+ This repository is only on {LABELS[provider]}
+ {flags.length > 0 ? ` (${flags.join(", ")})` : ""}. Cloning puts a copy in the clone directory set on the Settings page.
+
+ {entry.description ?
{entry.description}
: null}
+
+ );
+}
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx
index 50dad12..c056135 100644
--- a/apps/web/src/routes/settings.tsx
+++ b/apps/web/src/routes/settings.tsx
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { Check } from "lucide-react";
+import { CloneDirectorySettings } from "#/components/clone-directory-settings";
import { OllamaSettings } from "#/components/ollama-settings";
import { cn } from "#/lib/utils";
import { OPTIONAL_REPO_TABS, useRepoTabs } from "#/lib/repo-tabs";
@@ -64,6 +65,8 @@ function Settings() {
+
+
);
diff --git a/apps/web/src/server/catalog.server.ts b/apps/web/src/server/catalog.server.ts
index d309f59..ad87446 100644
--- a/apps/web/src/server/catalog.server.ts
+++ b/apps/web/src/server/catalog.server.ts
@@ -1,5 +1,12 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { promisify } from "node:util";
import { classifyProvider, loadGitRemotes, selectPreferredRemote, utcNow } from "@gitlib/core";
import {
+ cloneFolderName,
+ cloneUrlOf,
+ findRemoteOnly,
mergeCatalog,
parseLandingPage,
parseStoredListing,
@@ -225,3 +232,44 @@ export async function refreshAllListings(): Promise {
await Promise.all(PROVIDERS.filter(isConfigured).map((provider) => startCheck(provider, true)));
return buildCatalog();
}
+
+export interface CloneResult {
+ /** relativePath of the new clone, for linking to its detail page. */
+ relativePath: string;
+}
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Clones a repo that only a host has into the clone directory from Settings, named after the repo. The URL comes from the
+ * catalog, never from the caller, and git never prompts (a private repo needs a credential helper or SSH-free
+ * token setup already in place), so a failure comes back as a message instead of a hung request.
+ */
+export async function cloneRemoteRepo(provider: RemoteProvider, fullName: string): Promise {
+ const entry = findRemoteOnly((await buildCatalog()).entries, provider, fullName);
+ const link = entry?.[provider];
+ if (!entry || !link) throw new Error("That repository is not on a host gitlib lists, or it is already cloned.");
+
+ const root = getApp().config.cloneDir;
+ const folder = cloneFolderName(link.fullName);
+ if (!folder) throw new Error("Could not work out a folder name for that repository.");
+ fs.mkdirSync(root, { recursive: true });
+ const destination = path.join(root, folder);
+ if (fs.existsSync(destination)) throw new Error(`${destination} already exists.`);
+
+ try {
+ await execFileAsync("git", ["clone", "--", cloneUrlOf(link.url), destination], {
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
+ timeout: 10 * 60_000,
+ });
+ } catch (caught) {
+ const stderr = (caught as { stderr?: unknown }).stderr;
+ const detail = typeof stderr === "string" && stderr.trim() ? stderr.trim() : caught instanceof Error ? caught.message : "";
+ throw new Error(`git clone failed: ${detail}`);
+ }
+
+ cachedClones = undefined;
+ const repo = (await loadAndIndexRepositories(true)).find((candidate) => path.resolve(candidate.absolutePath) === path.resolve(destination));
+ if (!repo) throw new Error(`Cloned to ${destination}, but it is not under a scanned root.`);
+ return { relativePath: repo.relativePath };
+}
diff --git a/apps/web/src/server/catalog.ts b/apps/web/src/server/catalog.ts
index 35e75d2..4790331 100644
--- a/apps/web/src/server/catalog.ts
+++ b/apps/web/src/server/catalog.ts
@@ -1,7 +1,10 @@
import { createServerFn } from "@tanstack/react-start";
-import { buildCatalog, refreshAllListings } from "./catalog.server";
+import { isGitLibError } from "@gitlib/core";
+import { findRemoteOnly, type CatalogEntry, type RemoteProvider } from "#/lib/catalog";
+import { getApp } from "./gitlib.server";
+import { buildCatalog, cloneRemoteRepo, refreshAllListings, type CloneResult } from "./catalog.server";
-export type { CatalogView, ProviderStatus } from "./catalog.server";
+export type { CatalogView, CloneResult, ProviderStatus } from "./catalog.server";
/**
* Local clones joined with the repositories GitHub and GitLab list for the configured accounts. The hosts'
@@ -12,3 +15,46 @@ export const getCatalog = createServerFn({ method: "GET" }).handler(() => buildC
/** Asks both hosts for their complete listings now, ignoring ETags, and rereads every clone's remotes. */
export const refreshCatalog = createServerFn({ method: "POST" }).handler(() => refreshAllListings());
+
+interface RemoteRepoInput {
+ provider: RemoteProvider;
+ fullName: string;
+}
+
+function validateRemoteRepo(input: RemoteRepoInput): RemoteRepoInput {
+ if ((input?.provider !== "github" && input?.provider !== "gitlab") || typeof input.fullName !== "string" || !input.fullName) {
+ throw new Error("Invalid repository request.");
+ }
+ return input;
+}
+
+/** A repo that GitHub or GitLab has and no local clone does; null when it is unknown or already cloned. */
+export const getRemoteRepo = createServerFn({ method: "GET" })
+ .validator(validateRemoteRepo)
+ .handler(async ({ data }): Promise => findRemoteOnly((await buildCatalog()).entries, data.provider, data.fullName));
+
+/** Clones a remote-only repo into the clone directory. */
+export const cloneRepo = createServerFn({ method: "POST" })
+ .validator(validateRemoteRepo)
+ .handler(({ data }): Promise => cloneRemoteRepo(data.provider, data.fullName));
+
+/** Where "Clone locally" puts repositories, and the roots it may be inside of. */
+export const getCloneDirectory = createServerFn({ method: "GET" }).handler(() => {
+ const { cloneDir, roots } = getApp().config;
+ return { directory: cloneDir, roots };
+});
+
+/** Saves the clone directory; a bad one comes back as a message to show beside the field. */
+export const saveCloneDirectory = createServerFn({ method: "POST" })
+ .validator((input: { directory: string }) => {
+ if (typeof input?.directory !== "string") throw new Error("Invalid directory.");
+ return input;
+ })
+ .handler(async ({ data }): Promise<{ ok: true; directory: string } | { ok: false; error: string }> => {
+ try {
+ return { ok: true, directory: getApp().setCloneDirectory(data.directory) };
+ } catch (error) {
+ if (isGitLibError(error)) return { ok: false, error: error.message };
+ throw error;
+ }
+ });
diff --git a/apps/web/test/catalog.test.ts b/apps/web/test/catalog.test.ts
index b86ff3e..6d8e484 100644
--- a/apps/web/test/catalog.test.ts
+++ b/apps/web/test/catalog.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
+ cloneFolderName,
+ cloneUrlOf,
entriesForSource,
matchesQuery,
mergeCatalog,
@@ -198,3 +200,16 @@ describe("parseStoredListing", () => {
expect(parseStoredListing(JSON.stringify({ scope: "github.com", pages: [null] }), "github.com")).toBeNull();
});
});
+
+describe("clone helpers", () => {
+ it("builds the clone URL from a landing page", () => {
+ expect(cloneUrlOf("https://github.com/me/app")).toBe("https://github.com/me/app.git");
+ expect(cloneUrlOf("https://gitlab.com/g/sub/app/")).toBe("https://gitlab.com/g/sub/app.git");
+ });
+
+ it("names the clone folder after the repo and refuses path tricks", () => {
+ expect(cloneFolderName("g/sub/app")).toBe("app");
+ expect(cloneFolderName("..")).toBeNull();
+ expect(cloneFolderName("")).toBeNull();
+ });
+});
diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts
index c07b84d..74059a5 100644
--- a/packages/core/src/app.ts
+++ b/packages/core/src/app.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { buildChatSystemPrompt, ModelLane, OllamaBackend, type ChatBackend } from "./ai/index.js";
import { DEFAULT_OLLAMA_HOST, loadConfig, type AppConfig, type Env, type OllamaConnectionMode } from "./config.js";
+import { normalizeCloneDirectory, writeCloneDirectory } from "./clone-directory.js";
import {
hostFor,
normalizeOllamaHost,
@@ -155,6 +156,14 @@ export class GitLibApp {
if (!this.chatInjected && ollama.enabled) this.chatBackend = new OllamaBackend(ollama);
}
+ /** Remembers where "Clone locally" puts repositories; takes effect at once. Throws if it is outside every root. */
+ setCloneDirectory(input: string): string {
+ const directory = normalizeCloneDirectory(input, this.config.roots);
+ writeCloneDirectory(this.config.appHome, directory);
+ this.config.cloneDir = directory;
+ return directory;
+ }
+
refresh(): RepoRecord[] {
const discovered = discoverRepos(this.config.roots, this.config.ignoreDirs);
const seenAt = utcNow();
diff --git a/packages/core/src/clone-directory.ts b/packages/core/src/clone-directory.ts
new file mode 100644
index 0000000..672be17
--- /dev/null
+++ b/packages/core/src/clone-directory.ts
@@ -0,0 +1,54 @@
+import fs from "node:fs";
+import path from "node:path";
+import { DEFAULT_ROOTS } from "./config.js";
+import { GitLibError } from "./errors.js";
+import { expandUser, resolvePath } from "./fsutil.js";
+
+export class CloneDirectoryError extends GitLibError {}
+
+export function cloneDirectoryPath(appHome: string): string {
+ return path.join(appHome, "clone-directory.json");
+}
+
+/** Where clones go until Settings says otherwise. */
+export const defaultCloneDirectory = (): string => resolvePath(expandUser(DEFAULT_ROOTS[0]));
+
+/** True when `target` is `root` or inside it. */
+function isWithin(root: string, target: string): boolean {
+ const relative = path.relative(root, target);
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
+}
+
+/**
+ * Tidies a typed directory into an absolute path. It must sit inside a scanned root, or a clone made there would
+ * never show up in the library.
+ */
+export function normalizeCloneDirectory(input: string, roots: readonly string[]): string {
+ const text = input.trim();
+ if (!text) throw new CloneDirectoryError("Enter the directory where cloned repositories should go, e.g. ~/git.");
+ const expanded = expandUser(text);
+ if (!path.isAbsolute(expanded)) throw new CloneDirectoryError("Use an absolute path, or one starting with ~/.");
+ const directory = resolvePath(expanded);
+ if (!roots.some((root) => isWithin(root, directory))) {
+ throw new CloneDirectoryError(
+ `${directory} is not inside a scanned root (${roots.join(", ") || "none configured"}). Add it to roots in config.toml first.`,
+ );
+ }
+ return directory;
+}
+
+/** The saved directory, or null if none was saved or the file is unreadable. */
+export function readCloneDirectory(appHome: string): string | null {
+ try {
+ const raw: unknown = JSON.parse(fs.readFileSync(cloneDirectoryPath(appHome), "utf-8"));
+ const directory = raw && typeof raw === "object" ? (raw as Record)["directory"] : null;
+ return typeof directory === "string" && path.isAbsolute(directory) ? directory : null;
+ } catch {
+ return null;
+ }
+}
+
+export function writeCloneDirectory(appHome: string, directory: string): void {
+ fs.mkdirSync(appHome, { recursive: true });
+ fs.writeFileSync(cloneDirectoryPath(appHome), `${JSON.stringify({ directory }, null, 2)}\n`, "utf-8");
+}
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index d01f291..c7a2a0a 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import { parse as parseToml } from "smol-toml";
import { GitLibError } from "./errors.js";
import { expandUser, resolvePath } from "./fsutil.js";
+import { defaultCloneDirectory, readCloneDirectory } from "./clone-directory.js";
import { hostFor, readOllamaConnection } from "./ollama-connection.js";
export const DEFAULT_ROOTS = ["~/git"] as const;
@@ -73,6 +74,8 @@ export interface AppConfig {
/** gitlib's own planning data; kept apart from `dbPath` because it cannot be rebuilt by rescanning. */
planningDbPath: string;
roots: string[];
+ /** Where "Clone locally" puts repositories; chosen on the Settings page, `~/git` until then. */
+ cloneDir: string;
vscodeCommand: string[];
ignoreDirs: ReadonlySet;
search: SearchConfig;
@@ -234,6 +237,7 @@ export function loadConfig(env: Env = process.env): AppConfig {
dbPath,
planningDbPath,
roots: rootsRaw.map((item) => normalizeRoot(item, configPath)),
+ cloneDir: readCloneDirectory(appHome) ?? defaultCloneDirectory(),
vscodeCommand: vscodeCommandRaw,
ignoreDirs: new Set(ignoreDirsRaw),
search: loadSearchConfig(data["search"], env, configPath),
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index d10dbd9..b35b9bd 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,7 @@
export * from "./ai/index.js";
export * from "./app.js";
export * from "./backup.js";
+export * from "./clone-directory.js";
export * from "./config.js";
export * from "./dependabot/index.js";
export * from "./discovery.js";
diff --git a/packages/core/test/chat.test.ts b/packages/core/test/chat.test.ts
index 94dc5e5..5d9fe3f 100644
--- a/packages/core/test/chat.test.ts
+++ b/packages/core/test/chat.test.ts
@@ -505,3 +505,21 @@ describe("setOllamaConnection", () => {
expect(() => pinned.setOllamaConnection({ mode: "local" })).toThrow(/GITLIB_OLLAMA_HOST/);
});
});
+
+describe("setCloneDirectory", () => {
+ it("defaults to ~/git, saves a directory inside a root and survives a restart", () => {
+ const env = fx.env();
+ const app = createApplication(env);
+ expect(app.config.cloneDir).toMatch(/git$/);
+ const inside = `${app.config.roots[0]}/clones`;
+ expect(app.setCloneDirectory(inside)).toBe(inside);
+ expect(loadConfig(env).cloneDir).toBe(inside);
+ });
+
+ it("rejects a directory outside every root, a relative path and an empty one", () => {
+ const app = createApplication(fx.env());
+ expect(() => app.setCloneDirectory("/definitely/not/a/root")).toThrow(/scanned root/);
+ expect(() => app.setCloneDirectory("relative/dir")).toThrow(/absolute/);
+ expect(() => app.setCloneDirectory(" ")).toThrow();
+ });
+});