From 44dd51f5f0775d95061c1237ea1ecdf665183bff Mon Sep 17 00:00:00 2001 From: Ryoya Tamura Date: Fri, 17 Jul 2026 23:51:23 +0900 Subject: [PATCH] feat: add adopt command to move existing files into the overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting up a new overlay file today is manual: mkdir the entry, cp/mv the file over, then run apply. That friction discourages moving files into the overlay after the fact, even though the overlay is meant to be the source of truth for gitignored personal files. adopt collapses this into one step per file: verify the target is gitignored (never touch tracked files) and not already a symlink elsewhere or shadowing existing overlay content, move it into the mirrored overlay path, and symlink it back — leaving the file in the exact "linked" state apply would produce, so a subsequent apply is a no-op. Directories are rejected rather than recursed, matching symlink-each: ghostq never links whole directories, and a directory can hold committed files alongside overlay candidates that need individual gitignore review. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013mAzjNvVWGBJT81cfTSJJN --- README.md | 16 ++++++ src/adopt.ts | 102 +++++++++++++++++++++++++++++++++++++ src/index.ts | 42 +++++++++++++++ test/adopt.test.ts | 124 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 src/adopt.ts create mode 100644 test/adopt.test.ts 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); + }); +});