Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>... 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 <file>...` 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`,
Expand Down
102 changes: 102 additions & 0 deletions src/adopt.ts
Original file line number Diff line number Diff line change
@@ -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<AdoptReport[]> {
const reports: AdoptReport[] = [];
for (const path of paths) {
reports.push(await adoptOne(path, cwd));
}
return reports;
}

async function adoptOne(path: string, cwd: string): Promise<AdoptReport> {
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<void> {
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);
}
}
42 changes: 42 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <file>... 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
Expand Down Expand Up @@ -57,6 +59,40 @@ async function prune(path: string): Promise<number> {
return 0;
}

const ADOPT_NOTES: Record<AdoptOutcome, string> = {
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<number> {
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<FileState, string> = {
linked: "",
missing: "run `ghostq apply`",
Expand Down Expand Up @@ -100,6 +136,12 @@ async function main(): Promise<number> {
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":
Expand Down
124 changes: 124 additions & 0 deletions test/adopt.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading