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