diff --git a/README.md b/README.md index 59501c5..bce8f2d 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,28 @@ ghostq install ``` ghostq install set up the global post-checkout hook (core.hooksPath) ghostq apply [path] link overlay files into the checkout (idempotent) +ghostq adopt ... move existing gitignored files into the overlay and link them ghostq status [path] show link states and warnings without changing anything ghostq prune [path] remove dangling ghostq-managed links (idempotent) ghostq root print the overlay root ghostq uninstall remove the hook wiring ``` +### Adopting an existing file + +If a personal file already exists as a real file in your checkout (instead of +having been set up in the overlay first), `ghostq adopt ...` moves it +into this repo's overlay entry and replaces it with the same kind of symlink +`apply` would produce — one command instead of manually creating the overlay +directory and copying the file over. + +Each file passed to `adopt` must already be gitignored (`adopt` never adopts a +tracked file) and must be a real file, not a symlink or a directory — pass +individual files; ghostq never links whole directories (symlink-each). If the +overlay already has a file at the target path, or the checkout path is already +a symlink pointing elsewhere, `adopt` warns and skips it rather than +clobbering anything. + ## Overlay layout Default overlay root: `${XDG_CONFIG_HOME:-~/.config}/ghostq/overlay`, diff --git a/src/adopt.ts b/src/adopt.ts new file mode 100644 index 0000000..1d1134e --- /dev/null +++ b/src/adopt.ts @@ -0,0 +1,102 @@ +import { promises as fs } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { resolveContext } from "./context.ts"; +import { git } from "./git.ts"; + +export type AdoptOutcome = + | "adopted" + | "already-adopted" + | "skipped-not-ignored" + | "skipped-conflict" + | "skipped-directory" + | "error"; + +export interface AdoptReport { + path: string; + outcome: AdoptOutcome; + detail?: string; +} + +export async function adoptFiles(paths: string[], cwd: string): Promise { + const reports: AdoptReport[] = []; + for (const path of paths) { + reports.push(await adoptOne(path, cwd)); + } + return reports; +} + +async function adoptOne(path: string, cwd: string): Promise { + const requested = resolve(cwd, path); + + let st; + try { + st = await fs.lstat(requested); + } catch { + return { path, outcome: "error", detail: `no such file: ${requested}` }; + } + // Directories hold committed files next to overlay links (symlink-each); + // adopting one wholesale would either skip its committed siblings or + // require a recursive walk that duplicates apply's own traversal. Reject + // and let the caller pass the individual gitignored files instead. + if (st.isDirectory()) { + return { path, outcome: "skipped-directory", detail: "pass individual files, not a directory" }; + } + + // ctx.root comes from `git rev-parse --show-toplevel`, which resolves + // symlinks in the path; resolve the parent dir the same way so `relative()` + // below does not produce a bogus `../..` path when cwd is reached through + // a symlinked ancestor (e.g. macOS's /tmp -> /private/tmp). + const parent = await fs.realpath(dirname(requested)); + const abs = join(parent, requested.slice(dirname(requested).length + 1)); + + const res = await resolveContext(parent); + if (!res.ok) { + return { path, outcome: "error", detail: res.reason }; + } + const { ctx } = res; + + const rel = relative(ctx.root, abs); + const overlayPath = join(ctx.entryDir, rel); + + const ignored = git(["check-ignore", "-q", "--", rel], ctx.root); + if (ignored.status !== 0) { + return { path, outcome: "skipped-not-ignored", detail: "not gitignored in this repo (add it to .gitignore)" }; + } + + if (st.isSymbolicLink()) { + const target = resolve(dirname(abs), await fs.readlink(abs)); + if (target === resolve(overlayPath)) { + return { path, outcome: "already-adopted" }; + } + return { path, outcome: "skipped-conflict", detail: "already a symlink pointing elsewhere; will not clobber" }; + } + + let overlayExists = true; + try { + await fs.lstat(overlayPath); + } catch { + overlayExists = false; + } + if (overlayExists) { + return { path, outcome: "skipped-conflict", detail: `overlay already has a file at ${overlayPath}` }; + } + + await fs.mkdir(dirname(overlayPath), { recursive: true }); + await moveFile(abs, overlayPath); + await fs.symlink(overlayPath, abs); + + return { path, outcome: "adopted", detail: rel }; +} + +async function moveFile(src: string, dst: string): Promise { + try { + await fs.rename(src, dst); + } catch (err) { + // EXDEV: overlay root lives on a different filesystem than the + // checkout (e.g. overlay on a separate volume); rename cannot cross + // devices, so fall back to copy+unlink. + if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err; + await fs.copyFile(src, dst); + await fs.unlink(src); + } +} diff --git a/src/index.ts b/src/index.ts index ed062e9..c32fde7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import { type AdoptOutcome, adoptFiles } from "./adopt.ts"; import { applyFiles, inspectFiles, pruneFiles, type FileState } from "./apply.ts"; import { resolveContext } from "./context.ts"; import { install, uninstall } from "./install.ts"; @@ -9,6 +10,7 @@ const USAGE = `ghostq — re-link personal, gitignored, per-repo files on clone Usage: ghostq install set up the global post-checkout hook (core.hooksPath) ghostq apply [path] link overlay files into the checkout (idempotent) + ghostq adopt ... move existing gitignored files into the overlay and link them ghostq status [path] show link states and warnings without changing anything ghostq prune [path] remove dangling ghostq-managed links (idempotent) ghostq root print the overlay root @@ -57,6 +59,40 @@ async function prune(path: string): Promise { return 0; } +const ADOPT_NOTES: Record = { + adopted: "", + "already-adopted": "already linked into the overlay", + "skipped-not-ignored": "not gitignored in this repo (add it to .gitignore)", + "skipped-conflict": "", + "skipped-directory": "pass individual files, not a directory", + error: "", +}; + +async function adopt(paths: string[]): Promise { + const reports = await adoptFiles(paths, process.cwd()); + let failed = false; + for (const { path, outcome, detail } of reports) { + switch (outcome) { + case "adopted": + console.log(`adopted ${path}${detail ? ` -> ${detail}` : ""}`); + break; + case "already-adopted": + console.log(`ghostq: ${path}: ${ADOPT_NOTES[outcome]}`); + break; + case "error": + console.error(`ghostq: ${path}: ${detail}`); + failed = true; + break; + default: { + const note = detail || ADOPT_NOTES[outcome]; + console.error(`ghostq: skip ${path}: ${note}`); + break; + } + } + } + return failed ? 1 : 0; +} + const STATE_NOTES: Record = { linked: "", missing: "run `ghostq apply`", @@ -100,6 +136,12 @@ async function main(): Promise { return uninstall(); case "apply": return apply(rest[0] ?? process.cwd()); + case "adopt": + if (rest.length === 0) { + console.error("ghostq: adopt requires at least one file"); + return 1; + } + return adopt(rest); case "status": return status(rest[0] ?? process.cwd()); case "prune": diff --git a/test/adopt.test.ts b/test/adopt.test.ts new file mode 100644 index 0000000..e6eeeb2 --- /dev/null +++ b/test/adopt.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { adoptFiles } from "../src/adopt.ts"; +import { applyFiles } from "../src/apply.ts"; +import { resolveContext } from "../src/context.ts"; +import { makeRepo, makeTmpDir, writeOverlayEntry } from "./helpers.ts"; + +const IDENTITY = "github.com/testuser/testrepo"; +const REMOTE = "https://github.com/testuser/testrepo.git"; + +let tmp: string; +let repo: string; +let savedRoot: string | undefined; + +beforeEach(async () => { + tmp = await makeTmpDir("ghostq-adopt"); + savedRoot = process.env.GHOSTQ_ROOT; + process.env.GHOSTQ_ROOT = join(tmp, "overlay"); + repo = join(tmp, "repo"); + await makeRepo( + repo, + { + ".gitignore": ".env.local\n.claude/personal.md\n", + ".claude/settings.json": "{ \"committed\": true }\n", + "README.md": "hello\n", + }, + { remote: REMOTE }, + ); +}); + +afterEach(async () => { + if (savedRoot === undefined) delete process.env.GHOSTQ_ROOT; + else process.env.GHOSTQ_ROOT = savedRoot; + await fs.rm(tmp, { recursive: true, force: true }); +}); + +async function contextFor(dir: string) { + const res = await resolveContext(dir); + if (!res.ok) throw new Error(res.reason); + return res.ctx; +} + +describe("ghostq adopt", () => { + test("moves a gitignored real file into the overlay and replaces it with a symlink", async () => { + await fs.writeFile(join(repo, ".env.local"), "SECRET=1\n"); + + const [report] = await adoptFiles([".env.local"], repo); + + expect(report!.outcome).toBe("adopted"); + const overlayFile = join(process.env.GHOSTQ_ROOT!, IDENTITY, ".env.local"); + expect(await fs.readFile(overlayFile, "utf8")).toBe("SECRET=1\n"); + + const link = join(repo, ".env.local"); + expect((await fs.lstat(link)).isSymbolicLink()).toBe(true); + expect(await fs.readlink(link)).toBe(overlayFile); + expect(await fs.readFile(link, "utf8")).toBe("SECRET=1\n"); + }); + + test("after adopt, applyFiles reports the file as already linked", async () => { + await fs.writeFile(join(repo, ".env.local"), "SECRET=1\n"); + await adoptFiles([".env.local"], repo); + + const report = await applyFiles(await contextFor(repo)); + + expect(report.alreadyLinked).toEqual([".env.local"]); + expect(report.linked).toEqual([]); + expect(report.conflicts).toEqual([]); + }); + + test("a tracked (non-ignored) file is skipped and left as a real file", async () => { + const [report] = await adoptFiles(["README.md"], repo); + + expect(report!.outcome).toBe("skipped-not-ignored"); + const st = await fs.lstat(join(repo, "README.md")); + expect(st.isSymbolicLink()).toBe(false); + expect(await fs.readFile(join(repo, "README.md"), "utf8")).toBe("hello\n"); + }); + + test("a file already symlinked into the overlay is reported as already-adopted", async () => { + await fs.writeFile(join(repo, ".env.local"), "SECRET=1\n"); + await adoptFiles([".env.local"], repo); + + const [second] = await adoptFiles([".env.local"], repo); + + expect(second!.outcome).toBe("already-adopted"); + const link = join(repo, ".env.local"); + expect((await fs.lstat(link)).isSymbolicLink()).toBe(true); + }); + + test("adopting is skipped when an overlay file already exists at the target", async () => { + await writeOverlayEntry(process.env.GHOSTQ_ROOT!, IDENTITY, { + ".env.local": "overlay version\n", + }); + await fs.writeFile(join(repo, ".env.local"), "local version\n"); + + const [report] = await adoptFiles([".env.local"], repo); + + expect(report!.outcome).toBe("skipped-conflict"); + expect(await fs.readFile(join(repo, ".env.local"), "utf8")).toBe("local version\n"); + expect((await fs.lstat(join(repo, ".env.local"))).isSymbolicLink()).toBe(false); + expect(await fs.readFile(join(process.env.GHOSTQ_ROOT!, IDENTITY, ".env.local"), "utf8")).toBe( + "overlay version\n", + ); + }); + + test("a symlink pointing somewhere other than the overlay is skipped, not clobbered", async () => { + const elsewhere = join(tmp, "elsewhere.txt"); + await fs.writeFile(elsewhere, "not overlay content\n"); + await fs.symlink(elsewhere, join(repo, ".env.local")); + + const [report] = await adoptFiles([".env.local"], repo); + + expect(report!.outcome).toBe("skipped-conflict"); + expect(await fs.readlink(join(repo, ".env.local"))).toBe(elsewhere); + }); + + test("passing a directory is rejected cleanly", async () => { + const [report] = await adoptFiles([".claude"], repo); + + expect(report!.outcome).toBe("skipped-directory"); + expect((await fs.lstat(join(repo, ".claude"))).isDirectory()).toBe(true); + }); +});