From 7a5708a80806babefad28831dffef5c7d018a691 Mon Sep 17 00:00:00 2001 From: Darrien Rushing Date: Mon, 2 Nov 2026 17:03:39 -0600 Subject: [PATCH] Add a Clone locally button for repos only on GitHub or GitLab Repos that a host lists but no local clone has now open a detail page (/remotes/$provider/$) with a "Clone locally" button. The server looks up the clone URL in its own catalog and runs git clone without prompts, then rescans and sends you to the new repo. The destination is a new Clone directory setting on the Settings page, saved in the app home (clone-directory.json) and defaulting to ~/git. It must be inside a scanned root so the clone appears in the library. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/catalog-table.tsx | 19 +-- .../components/clone-directory-settings.tsx | 83 +++++++++++++ apps/web/src/lib/catalog.ts | 23 ++++ apps/web/src/routeTree.gen.ts | 21 ++++ apps/web/src/routes/remotes.$provider.$.tsx | 110 ++++++++++++++++++ apps/web/src/routes/settings.tsx | 3 + apps/web/src/server/catalog.server.ts | 48 ++++++++ apps/web/src/server/catalog.ts | 50 +++++++- apps/web/test/catalog.test.ts | 15 +++ packages/core/src/app.ts | 9 ++ packages/core/src/clone-directory.ts | 54 +++++++++ packages/core/src/config.ts | 4 + packages/core/src/index.ts | 1 + packages/core/test/chat.test.ts | 18 +++ 14 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/clone-directory-settings.tsx create mode 100644 apps/web/src/routes/remotes.$provider.$.tsx create mode 100644 packages/core/src/clone-directory.ts diff --git a/apps/web/src/components/catalog-table.tsx b/apps/web/src/components/catalog-table.tsx index 3c78423..e4d82b5 100644 --- a/apps/web/src/components/catalog-table.tsx +++ b/apps/web/src/components/catalog-table.tsx @@ -79,7 +79,11 @@ export function CatalogTable({ {entries.map((entry) => { const local = entry.locals[0]; const technologies = local ? (reposByPath.get(local)?.technologies ?? []) : []; - const external = entry.github?.url ?? entry.gitlab?.url; + const remote = entry.github + ? ({ provider: "github", link: entry.github } as const) + : entry.gitlab + ? ({ provider: "gitlab", link: entry.gitlab } as const) + : null; const flags = flagsOf(entry); return ( @@ -92,15 +96,16 @@ export function CatalogTable({ > {entry.name} - ) : ( - {entry.name} - + + ) : ( + {entry.name} )} {entry.fullName} diff --git a/apps/web/src/components/clone-directory-settings.tsx b/apps/web/src/components/clone-directory-settings.tsx new file mode 100644 index 0000000..4d7c27e --- /dev/null +++ b/apps/web/src/components/clone-directory-settings.tsx @@ -0,0 +1,83 @@ +import { useEffect, useId, useState } from "react"; +import { Button } from "#/components/ui/button"; +import { Input } from "#/components/ui/input"; +import { Label } from "#/components/ui/label"; +import { getCloneDirectory, saveCloneDirectory } from "#/server/catalog"; + +/** Settings for where "Clone locally" puts repositories. */ +export function CloneDirectorySettings() { + const inputId = useId(); + const [directory, setDirectory] = useState(""); + const [roots, setRoots] = useState([]); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + getCloneDirectory() + .then((current) => { + setDirectory(current.directory); + setRoots(current.roots); + }) + .catch(() => setError("Could not read the current clone directory.")); + }, []); + + const save = async () => { + setSaving(true); + setError(null); + setSaved(false); + try { + const result = await saveCloneDirectory({ data: { directory } }); + if (!result.ok) setError(result.error); + else { + setDirectory(result.directory); + setSaved(true); + } + } catch { + setError("Could not save. See the server log."); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

+ Clone directory +

+

+ Where "Clone locally" puts repositories that are only on GitHub or GitLab. Saved on the server, so it applies in every browser. +

+
+
+ + { + setDirectory(event.target.value); + setSaved(false); + }} + autoComplete="off" + spellCheck={false} + /> +

+ Must be inside a scanned root{roots.length > 0 ? ` (${roots.join(", ")})` : ""}, so the clone shows up in your library. +

+
+
+ + {saved ? Saved. : null} +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/lib/catalog.ts b/apps/web/src/lib/catalog.ts index 9c414d0..ccb3e0f 100644 --- a/apps/web/src/lib/catalog.ts +++ b/apps/web/src/lib/catalog.ts @@ -253,3 +253,26 @@ export function matchesQuery(entry: CatalogEntry, query: string | undefined): bo const haystack = `${entry.name} ${entry.fullName} ${entry.description}`.toLowerCase(); return words.every((word) => haystack.includes(word)); } + +/** The entry for a repo that only a hosting service has: listed (or named) by `provider`, with no local clone. */ +export function findRemoteOnly( + entries: readonly CatalogEntry[], + provider: RemoteProvider, + fullName: string, +): CatalogEntry | null { + const target = fullName.toLowerCase(); + return ( + entries.find((entry) => entry.locals.length === 0 && entry[provider]?.fullName.toLowerCase() === target) ?? null + ); +} + +/** The HTTPS URL `git clone` takes for a repo's landing page. */ +export function cloneUrlOf(landingPageUrl: string): string { + return landingPageUrl.replace(/\/+$/, "").replace(/\.git$/i, "") + ".git"; +} + +/** The folder a clone gets: the repo's own name, which is the last segment of its full name. */ +export function cloneFolderName(fullName: string): string | null { + const name = fullName.split("/").filter(Boolean).at(-1)?.replace(/\.git$/i, ""); + return name && name !== "." && name !== ".." && !/[\\:\0]/.test(name) ? name : null; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 5e90aca..b6e6ddd 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ProjectsIdRouteImport } from './routes/projects.$id' import { Route as ReposSplatRouteImport } from './routes/repos.$' import { Route as ApiDependabotStartRouteImport } from './routes/api.dependabot.start' import { Route as PlanningIssuesIdRouteImport } from './routes/planning.issues.$id' +import { Route as RemotesProviderSplatRouteImport } from './routes/remotes.$provider.$' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -76,6 +77,11 @@ const PlanningIssuesIdRoute = PlanningIssuesIdRouteImport.update({ path: '/planning/issues/$id', getParentRoute: () => rootRouteImport, } as any) +const RemotesProviderSplatRoute = RemotesProviderSplatRouteImport.update({ + id: '/remotes/$provider/$', + path: '/remotes/$provider/$', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -89,6 +95,7 @@ export interface FileRoutesByFullPath { '/projects/': typeof ProjectsIndexRoute '/api/dependabot/start': typeof ApiDependabotStartRoute '/planning/issues/$id': typeof PlanningIssuesIdRoute + '/remotes/$provider/$': typeof RemotesProviderSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -102,6 +109,7 @@ export interface FileRoutesByTo { '/projects': typeof ProjectsIndexRoute '/api/dependabot/start': typeof ApiDependabotStartRoute '/planning/issues/$id': typeof PlanningIssuesIdRoute + '/remotes/$provider/$': typeof RemotesProviderSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -116,6 +124,7 @@ export interface FileRoutesById { '/projects/': typeof ProjectsIndexRoute '/api/dependabot/start': typeof ApiDependabotStartRoute '/planning/issues/$id': typeof PlanningIssuesIdRoute + '/remotes/$provider/$': typeof RemotesProviderSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -131,6 +140,7 @@ export interface FileRouteTypes { | '/projects/' | '/api/dependabot/start' | '/planning/issues/$id' + | '/remotes/$provider/$' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -144,6 +154,7 @@ export interface FileRouteTypes { | '/projects' | '/api/dependabot/start' | '/planning/issues/$id' + | '/remotes/$provider/$' id: | '__root__' | '/' @@ -157,6 +168,7 @@ export interface FileRouteTypes { | '/projects/' | '/api/dependabot/start' | '/planning/issues/$id' + | '/remotes/$provider/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -171,6 +183,7 @@ export interface RootRouteChildren { ProjectsIndexRoute: typeof ProjectsIndexRoute ApiDependabotStartRoute: typeof ApiDependabotStartRoute PlanningIssuesIdRoute: typeof PlanningIssuesIdRoute + RemotesProviderSplatRoute: typeof RemotesProviderSplatRoute } declare module '@tanstack/react-router' { @@ -252,6 +265,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PlanningIssuesIdRouteImport parentRoute: typeof rootRouteImport } + '/remotes/$provider/$': { + id: '/remotes/$provider/$' + path: '/remotes/$provider/$' + fullPath: '/remotes/$provider/$' + preLoaderRoute: typeof RemotesProviderSplatRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -267,6 +287,7 @@ const rootRouteChildren: RootRouteChildren = { ProjectsIndexRoute: ProjectsIndexRoute, ApiDependabotStartRoute: ApiDependabotStartRoute, PlanningIssuesIdRoute: PlanningIssuesIdRoute, + RemotesProviderSplatRoute: RemotesProviderSplatRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/remotes.$provider.$.tsx b/apps/web/src/routes/remotes.$provider.$.tsx new file mode 100644 index 0000000..18f6732 --- /dev/null +++ b/apps/web/src/routes/remotes.$provider.$.tsx @@ -0,0 +1,110 @@ +import { Link, createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; +import { ArrowLeft, Download, ExternalLink, LoaderCircle } from "lucide-react"; +import { useState } from "react"; +import { TechBadge } from "#/components/tech-badge"; +import { Button } from "#/components/ui/button"; +import type { RemoteProvider } from "#/lib/catalog"; +import { useLibraryState } from "#/lib/library-state"; +import { cloneRepo, getRemoteRepo } from "#/server/catalog"; + +const LABELS: Record = { github: "GitHub", gitlab: "GitLab" }; + +export const Route = createFileRoute("/remotes/$provider/$")({ + // The splat holds the repo's full name on the host, which contains slashes. + loader: async ({ params }) => { + const provider = params.provider === "github" || params.provider === "gitlab" ? params.provider : null; + if (!provider) throw notFound(); + const entry = await getRemoteRepo({ data: { provider, fullName: params._splat ?? "" } }); + // Cloned in the meantime: the local detail page is the one to show. + if (!entry) throw notFound(); + return { provider: provider as RemoteProvider, entry }; + }, + head: ({ loaderData }) => ({ meta: [{ title: `${loaderData?.entry.name ?? "Repository"} · Gitlib` }] }), + notFoundComponent: RemoteNotFound, + component: RemoteRepoDetail, +}); + +function BackLink() { + const library = useLibraryState(); + return ( + + Repositories + + ); +} + +function RemoteNotFound() { + return ( +
+ +

+ That repository isn't listed as hosted-only. It may already be cloned, or the listing may have changed; try a refresh. +

+
+ ); +} + +function RemoteRepoDetail() { + const { provider, entry } = Route.useLoaderData(); + const link = entry[provider]!; + const navigate = useNavigate(); + const [cloning, setCloning] = useState(false); + const [error, setError] = useState(null); + const flags = [link.isPrivate && "private", link.isFork && "fork", link.isArchived && "archived"].filter(Boolean); + + async function clone() { + setCloning(true); + setError(null); + try { + const { relativePath } = await cloneRepo({ data: { provider, fullName: link.fullName } }); + await navigate({ to: "/repos/$", params: { _splat: relativePath } }); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Could not clone the repository."); + setCloning(false); + } + } + + return ( +
+ +
+
+

{entry.name}

+

+ {link.fullName} +

+ {entry.language ? ( +
+ +
+ ) : null} +
+
+ + +
+
+ {error ? ( +

+ {error} +

+ ) : null} +

+ 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(); + }); +});