diff --git a/AGENTS.md b/AGENTS.md index 21aebb2..4d11045 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,22 @@ Verify every change with `bun test` and `bun run typecheck` before finishing. ghostq binary must never be spawned on an ordinary `git switch` / `git checkout` — only when the previous HEAD is the null ref with the branch flag set (fresh clone / `git worktree add`). -2. **The dispatcher always chains** to the previously configured global - hooksPath and to the repo-local `.git/hooks/post-checkout` before its own - gate. `core.hooksPath` shadows both, so dropping the chaining silently - breaks lefthook/project hooks. +2. **Install via `init.templateDir`, never `core.hooksPath`.** ghostq seeds a + single `post-checkout` into `.git/hooks` at clone/init time and then stays out + of the hooks path, so lefthook/husky/pre-commit/plain scripts install into + `.git/hooks` and fire untouched — no shims, no forwarding. Setting + `core.hooksPath` would shadow every repo-local hook and trip those tools' + install guards; that is exactly the coexistence bug this design avoids. The + template must also seed git's default files (`info/exclude`, `description`, + sample hooks) so the override doesn't silently strip them from clones, and + preserve any foreign `post-checkout` it displaces as `post-checkout.ghostq-orig`, + chaining to it. Accepted limitation: `git worktree add` runs the one shared + `.git/hooks/post-checkout`, so a tool whose config also owns `post-checkout` + displaces ghostq for new worktrees of that repo (manual `ghostq apply`) — a + git single-slot limitation, not one to design around. `install`/`uninstall` + must also migrate off a legacy core.hooksPath install (ghostq before this + switch): a leftover global core.hooksPath keeps shadowing `.git/hooks` and + would silently defeat the template hook for upgraders. 3. **Zero runtime dependency on ghq.** Never import ghq, shell out to it, or read `~/ghq`. The `host/user/repo` layout is computed independently from the remote URL. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ff17114..afde773 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -25,9 +25,9 @@ src/ context.ts repo root + remote → identity → overlay entry resolution identity.ts remote URL → host/user/repo normalization (pure function) apply.ts per-file state inspection and symlink-each application - install.ts hook install/uninstall; generates the POSIX sh dispatcher + install.ts hook install/uninstall via init.templateDir; the POSIX sh hook git.ts spawnSync wrapper around the git CLI - paths.ts overlay root / hooks dir / XDG resolution + paths.ts overlay root / template dir / XDG resolution ``` Commands print from `index.ts` only; the modules underneath return data. That @@ -35,6 +35,31 @@ is what makes `apply` and `status` share one inspection pass (`inspectFiles`) and keeps the tests assertion-based instead of output-scraping. +## Hook installation (init.templateDir) + +`ghostq install` sets a global `init.templateDir`, not `core.hooksPath`. git +copies that template into `.git/` on every clone / `git init`, seeding a +`post-checkout` hook — and ghostq then stays out of the hooks path, so a repo's +own `.git/hooks` (lefthook, husky, pre-commit, plain scripts) keep working. A +global `core.hooksPath` would instead shadow every repo-local hook and trip +those tools' install guards; avoiding it is the whole point (AGENTS.md invariant +2 spells out why). + +- **Seeding.** Overriding `init.templateDir` would otherwise drop git's default + template, so install first reproduces the currently-effective template (the + user's own, or git's built-in) via a throwaway `git init` and copies its + `info/`, `description`, and sample hooks in — clones keep their usual + `.git/info/exclude` and friends. +- **The hook** is pure POSIX sh and gates on the null-ref fast path: only a + fresh clone / `git worktree add` reports the all-zeros previous HEAD with the + branch flag set, so an ordinary switch/checkout never spawns the binary. A + `post-checkout` the template already carried is preserved as + `post-checkout.ghostq-orig` and chained. +- **Migration.** install/uninstall detect a legacy `core.hooksPath` still + pointing at ghostq's old hooks dir, restore the hooksPath it had recorded (or + unset it), and remove the stale dir — otherwise that leftover config keeps + shadowing the template hook after an upgrade. + ## How the tests are structured - `test/identity.test.ts` — table-driven spec of URL normalization: https, diff --git a/README.md b/README.md index bce8f2d..2d13cdf 100644 --- a/README.md +++ b/README.md @@ -12,74 +12,29 @@ live in a separate **overlay tree** (a stable source of truth). `ghostq` symlinks them into the real checkout via a git `post-checkout` hook, so a fresh clone or `git worktree add` comes up with your personal files already in place. -## ghq-shaped, not ghq-bound - -The overlay tree mirrors the same `host/user/repo` layout that -[ghq](https://github.com/x-motemen/ghq) uses — ghq's directory-structure -philosophy is the direct inspiration for this tool, and credit goes to it. - -That said, **ghostq is standalone and has zero runtime dependency on ghq**. It -does not import ghq, shell out to it, or read `~/ghq`; ghq does not need to be -installed. ghostq arrives at the `host/user/repo` layout independently, by -normalizing the repo's remote URL — the same transform ghq performs, computed -on its own. - -## How it works - -1. `ghostq install` sets a global `core.hooksPath` pointing at a generated - `post-checkout` dispatcher. -2. The dispatcher is pure POSIX sh. On every checkout it first chains to any - hook it shadows (a previously configured global hooksPath, and the - repo-local `.git/hooks/post-checkout` used by lefthook and friends), then - gates on the null-ref fast path: only a fresh `git clone` or - `git worktree add` reports the all-zeros ref as the previous HEAD. Ordinary - `git switch` / `git checkout` exits right there — the `ghostq` binary is - never spawned on the hot path. -3. On a fresh clone / worktree, `ghostq apply` runs: - - resolves the repo's identity from `remote.origin.url`, normalized to - `host/user/repo` (https, scp-like SSH, trailing `.git`, ports and - credentials all handled; non-GitHub hosts keep their hostname in the - path). Worktrees live at arbitrary paths, so identity comes from the - remote, never from the filesystem location. - - looks up the overlay entry at `////`. - The entry's contents **are** the manifest — no separate include file. - - symlinks each file individually, mirroring the relative structure - (**symlink-each**): a directory like `.claude/` can hold committed files - and overlay-linked personal files side by side. Whole directories are - never symlinked. - -Repos with no `origin` remote, hostless remote URLs, or no overlay entry are -skipped quietly. - -### Safety - -- Before linking, each target path is checked with `git check-ignore`; a path - that is not gitignored is warned about and skipped, so ghostq never dirties - the working tree or sets up an accidental commit. -- Apply is idempotent: already-correct links are skipped, and an existing real - file that is not a ghostq-managed link is never clobbered — it is warned - about and left alone. -- `apply` only ever creates or repoints links; it never removes one, even - after the overlay file behind it is deleted. `ghostq prune` cleans up the - resulting dangling link — a symlink that still points into this repo's - overlay entry but whose target no longer exists — and removes any parent - directory left empty as a result. Live links and non-ghostq files are never - touched. - -## Install +## Getting started ghostq ships as a single self-contained binary — no runtime needed on the -machine that runs it. Build it once (see [DEVELOPMENT.md](./DEVELOPMENT.md)) -or grab a prebuilt binary, put it on your `PATH`, then: +machine that runs it. Build it once (see [DEVELOPMENT.md](./DEVELOPMENT.md)) or +grab a prebuilt binary, put it on your `PATH`, then: ```sh -ghostq install +ghostq install # one-time: hook future clones & worktrees + +# bring a personal file for a repo you already have under ghostq: +cd path/to/your/repo +echo "TOKEN=..." > .env.local # a gitignored, personal file +ghostq adopt .env.local # moves it into the overlay + symlinks it back ``` +Every fresh `git clone` or `git worktree add` of that repo now restores +`.env.local` automatically. For a repo you cloned **before** installing ghostq, +run `ghostq apply` in it once. + ## Usage ``` -ghostq install set up the global post-checkout hook (core.hooksPath) +ghostq install install the post-checkout hook globally (init.templateDir) 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 @@ -121,6 +76,61 @@ overridable with `GHOSTQ_ROOT`. Making the overlay root itself a git repo is recommended (not required) so your personal files are versioned and sync across machines. +## How it works + +- On a fresh `git clone` or `git worktree add`, ghostq looks up an overlay + entry by the repo's remote identity (`host/user/repo`) and symlinks its files + into the checkout. An ordinary `git switch` / `git checkout` does nothing. +- **symlink-each:** files are linked one by one, so a directory like `.claude/` + can hold committed files next to your overlay-linked personal files — whole + directories are never symlinked. +- Repos with no `origin` remote, or no matching overlay entry, are skipped + quietly. + +How the hook itself is wired (`init.templateDir`, the null-ref gate, migration +from older versions) is an implementation detail — see +[DEVELOPMENT.md](./DEVELOPMENT.md). + +## Coexisting with lefthook (and other hook managers) + +ghostq doesn't take over your hooks. Install lefthook, husky, pre-commit, or +your own `.git/hooks` scripts exactly as you normally would — there's nothing +special to do, and nothing to undo. Hooks like `pre-commit` and `pre-push` +coexist with no caveat. + +**Limitation.** `git worktree add` runs a single shared +`.git/hooks/post-checkout`. If your own hook config *also* uses `post-checkout` +(e.g. a `post-checkout:` block in `lefthook.yml`), it takes that slot and ghostq +won't auto-link **new worktrees** of that repo — run `ghostq apply` in the new +worktree by hand. Clones, and every other hook, are unaffected. + +## Safety + +- Before linking, each target path is checked with `git check-ignore`; a path + that is not gitignored is warned about and skipped, so ghostq never dirties + the working tree or sets up an accidental commit. +- Apply is idempotent: already-correct links are skipped, and an existing real + file that is not a ghostq-managed link is never clobbered — it is warned + about and left alone. +- `apply` only ever creates or repoints links; it never removes one, even + after the overlay file behind it is deleted. `ghostq prune` cleans up the + resulting dangling link — a symlink that still points into this repo's + overlay entry but whose target no longer exists — and removes any parent + directory left empty as a result. Live links and non-ghostq files are never + touched. + +## ghq-shaped, not ghq-bound + +The overlay tree mirrors the same `host/user/repo` layout that +[ghq](https://github.com/x-motemen/ghq) uses — ghq's directory-structure +philosophy is the direct inspiration for this tool, and credit goes to it. + +That said, **ghostq is standalone and has zero runtime dependency on ghq**. It +does not import ghq, shell out to it, or read `~/ghq`; ghq does not need to be +installed. ghostq arrives at the `host/user/repo` layout independently, by +normalizing the repo's remote URL — the same transform ghq performs, computed +on its own. + ## Non-goals - No dependency on or integration with ghq at runtime. diff --git a/src/index.ts b/src/index.ts index c32fde7..a088063 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { overlayRoot } from "./paths.ts"; const USAGE = `ghostq — re-link personal, gitignored, per-repo files on clone / worktree add Usage: - ghostq install set up the global post-checkout hook (core.hooksPath) + ghostq install install the post-checkout hook globally (init.templateDir) 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 diff --git a/src/install.ts b/src/install.ts index a4b49ea..ac08900 100644 --- a/src/install.ts +++ b/src/install.ts @@ -1,34 +1,74 @@ import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { git } from "./git.ts"; -import { hooksDir, samePath } from "./paths.ts"; +import { ghostqDir, samePath, templateDir } from "./paths.ts"; -const PREV_STATE_FILE = "prev-hookspath"; +const PREV_STATE_FILE = "prev-templatedir"; +// distinctive line in our shim; lets a re-install tell its own post-checkout +// apart from a foreign one carried by the seed +const SHIM_MARKER = "generated by `ghostq install`"; function shellQuote(s: string): string { return `'${s.replaceAll("'", "'\\''")}'`; } -function shimSource(prevHooksPath: string, binPath: string): string { - return `#!/bin/sh -# post-checkout dispatcher generated by \`ghostq install\`. -# Re-run \`ghostq install\` to regenerate, \`ghostq uninstall\` to remove. - -# When core.hooksPath is set, git looks ONLY here — so this dispatcher must -# chain to the hooks it shadows before doing its own work. +async function exists(p: string): Promise { + return fs.access(p).then( + () => true, + () => false, + ); +} -# 1) the global core.hooksPath that was configured before ghostq install -GHOSTQ_PREV_HOOKS=${shellQuote(prevHooksPath)} -if [ -n "$GHOSTQ_PREV_HOOKS" ] && [ -x "$GHOSTQ_PREV_HOOKS/post-checkout" ]; then - "$GHOSTQ_PREV_HOOKS/post-checkout" "$@" -fi +// Undo a legacy core.hooksPath-based install (ghostq before it moved to +// init.templateDir). That global core.hooksPath makes git look ONLY there, +// shadowing .git/hooks — so leaving it set would keep overriding the new +// template hook and silently preserve the old coexistence bug for upgraders. +// Restore the core.hooksPath ghostq had recorded, then drop the stale dir. +async function migrateLegacyHooksPath(): Promise { + const legacyHooks = join(ghostqDir(), "hooks"); + const chp = git(["config", "--global", "--get", "core.hooksPath"]); + if (chp.status !== 0 || !samePath(chp.stdout, legacyHooks)) return false; + + const legacyPrev = ( + await fs.readFile(join(legacyHooks, "prev-hookspath"), "utf8").catch(() => "") + ).trim(); + if (legacyPrev !== "") git(["config", "--global", "core.hooksPath", legacyPrev]); + else git(["config", "--global", "--unset", "core.hooksPath"]); + await fs.rm(legacyHooks, { recursive: true, force: true }); + return true; +} -# 2) repo-local hooks (lefthook, plain .git/hooks, ...) -GHOSTQ_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) -if [ -n "$GHOSTQ_COMMON_DIR" ] && [ -x "$GHOSTQ_COMMON_DIR/hooks/post-checkout" ]; then - "$GHOSTQ_COMMON_DIR/hooks/post-checkout" "$@" +// ghostq hooks via init.templateDir, NOT core.hooksPath. Why-not core.hooksPath: +// git honours only one hooks directory, so a global core.hooksPath shadows every +// repo-local .git/hooks and forces ghostq to multiplex every hook AND trips the +// guards in lefthook/husky (they refuse to install while it is set). templateDir +// instead seeds .git/hooks on clone/init and then steps out of the way — git +// uses the repo's own .git/hooks, so any hook manager coexists untouched. +// +// The one thing templateDir cannot cover: `git worktree add` runs the single +// shared .git/hooks/post-checkout, so a tool whose config also owns +// post-checkout displaces ghostq there (its clone was still linked; only later +// worktrees in that repo need a manual `ghostq apply`). Two tools cannot both +// own one hook slot — that is a git limitation, not one ghostq can design away. +function postCheckoutShim(binPath: string, chainOrig: boolean): string { + const chain = chainOrig + ? ` +# chain to a post-checkout this template already carried +GHOSTQ_ORIG="$(dirname "$0")/post-checkout.ghostq-orig" +if [ -x "$GHOSTQ_ORIG" ]; then + "$GHOSTQ_ORIG" "$@" fi - +` + : ""; + return `#!/bin/sh +# post-checkout hook ${SHIM_MARKER} via git's init.templateDir. +# Re-run \`ghostq install\` to regenerate, \`ghostq uninstall\` to remove. +# +# Copied into .git/hooks on every clone / \`git init\`, so a fresh checkout links +# your overlay files. ghostq never sets core.hooksPath, leaving the hooks path +# free for lefthook, husky, pre-commit, and friends. +${chain} # Fast path: only a fresh clone or \`git worktree add\` reports the null ref # as the previous HEAD together with the branch flag. Every ordinary # checkout/switch exits here without spawning ghostq. @@ -49,14 +89,15 @@ exit 0 } // process.execPath alone is not enough: when running from source it is the -// bun runtime, not ghostq, so the shim gets a wrapper that re-invokes bun -// with the entry script. -async function resolveBinPath(dir: string): Promise { +// bun runtime, not ghostq, so the shim gets a wrapper that re-invokes bun with +// the entry script. The wrapper lives outside the template dir so it is not +// copied into every clone. +async function resolveBinPath(): Promise { const script = process.argv[1] ?? ""; const compiled = script.startsWith("/$bunfs/") || !/\.(ts|js|mjs)$/.test(script); if (compiled) return process.execPath; - const wrapper = join(dir, "ghostq-dev"); + const wrapper = join(ghostqDir(), "ghostq-dev"); await fs.writeFile( wrapper, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(resolve(script))} "$@"\n`, @@ -65,56 +106,100 @@ async function resolveBinPath(dir: string): Promise { return wrapper; } +// Reproduce the template a normal clone would otherwise get — git's built-in +// default, or the user's own init.templateDir if they had one — so overriding +// init.templateDir does not silently drop .git/info/exclude, description, and +// the sample hooks. A throwaway `git init` materializes the currently-effective +// template portably, with no hard-coded system paths. +async function seedTemplate(dst: string): Promise { + const tmp = await fs.mkdtemp(join(tmpdir(), "ghostq-seed-")); + try { + const repo = join(tmp, "r"); + const r = git(["init", "-q", repo]); + if (r.status !== 0) return; + const gitDir = join(repo, ".git"); + for (const name of ["hooks", "info", "branches", "description"]) { + await fs.cp(join(gitDir, name), join(dst, name), { recursive: true }).catch(() => {}); + } + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +} + export async function install(): Promise { - const dir = hooksDir(); - await fs.mkdir(dir, { recursive: true }); - const stateFile = join(dir, PREV_STATE_FILE); + const dir = templateDir(); + const hooks = join(dir, "hooks"); + const stateFile = join(ghostqDir(), PREV_STATE_FILE); + + if (await migrateLegacyHooksPath()) { + console.log("migrated off the legacy core.hooksPath install"); + } - const current = git(["config", "--global", "--get", "core.hooksPath"]); + const current = git(["config", "--global", "--get", "init.templateDir"]); let prev = ""; if (current.status === 0 && current.stdout !== "" && !samePath(current.stdout, dir)) { prev = current.stdout; } else { - // on re-install core.hooksPath already points at us; recording that + // on re-install init.templateDir already points at us; recording that // would lose the original value uninstall needs to restore prev = (await fs.readFile(stateFile, "utf8").catch(() => "")).trim(); } + + await fs.mkdir(hooks, { recursive: true }); + await seedTemplate(dir); await fs.writeFile(stateFile, prev === "" ? "" : `${prev}\n`); - const binPath = await resolveBinPath(dir); - await fs.writeFile(join(dir, "post-checkout"), shimSource(prev, binPath), { mode: 0o755 }); + // preserve a foreign post-checkout the seed carried (a prior install's own + // shim, identified by the marker, is simply overwritten) + const shimPath = join(hooks, "post-checkout"); + const origPath = join(hooks, "post-checkout.ghostq-orig"); + if (await exists(shimPath)) { + const body = await fs.readFile(shimPath, "utf8").catch(() => ""); + if (!body.includes(SHIM_MARKER)) await fs.rename(shimPath, origPath); + } + const chainOrig = await exists(origPath); + + const binPath = await resolveBinPath(); + await fs.writeFile(shimPath, postCheckoutShim(binPath, chainOrig), { mode: 0o755 }); - const set = git(["config", "--global", "core.hooksPath", dir]); + const set = git(["config", "--global", "init.templateDir", dir]); if (set.status !== 0) { - console.error(`ghostq: failed to set core.hooksPath: ${set.stderr}`); + console.error(`ghostq: failed to set init.templateDir: ${set.stderr}`); return 1; } - console.log(`installed post-checkout hook: ${join(dir, "post-checkout")}`); - console.log(`set global core.hooksPath: ${dir}`); - if (prev !== "") console.log(`chaining to previous hooksPath: ${prev}`); + console.log(`installed post-checkout template: ${shimPath}`); + console.log(`set global init.templateDir: ${dir}`); + if (prev !== "") console.log(`preserved previous templateDir: ${prev}`); + console.log("new clones and worktrees link automatically; run `ghostq apply` once in repos cloned before this"); return 0; } export async function uninstall(): Promise { - const dir = hooksDir(); - const stateFile = join(dir, PREV_STATE_FILE); + const dir = templateDir(); + const stateFile = join(ghostqDir(), PREV_STATE_FILE); const prev = (await fs.readFile(stateFile, "utf8").catch(() => "")).trim(); - const current = git(["config", "--global", "--get", "core.hooksPath"]); + const current = git(["config", "--global", "--get", "init.templateDir"]); if (current.status === 0 && current.stdout !== "" && samePath(current.stdout, dir)) { if (prev !== "") { - git(["config", "--global", "core.hooksPath", prev]); - console.log(`restored global core.hooksPath: ${prev}`); + git(["config", "--global", "init.templateDir", prev]); + console.log(`restored global init.templateDir: ${prev}`); } else { - git(["config", "--global", "--unset", "core.hooksPath"]); - console.log("unset global core.hooksPath"); + git(["config", "--global", "--unset", "init.templateDir"]); + console.log("unset global init.templateDir"); } } else { - console.log("global core.hooksPath is not managed by ghostq; leaving it as is"); + console.log("global init.templateDir is not managed by ghostq; leaving it as is"); + } + + if (await migrateLegacyHooksPath()) { + console.log("also removed the legacy core.hooksPath install"); } await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(stateFile, { force: true }); + await fs.rm(join(ghostqDir(), "ghostq-dev"), { force: true }); console.log(`removed ${dir}`); return 0; } diff --git a/src/paths.ts b/src/paths.ts index f6ae272..9da4095 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -9,8 +9,12 @@ export function overlayRoot(): string { return process.env.GHOSTQ_ROOT || join(configHome(), "ghostq", "overlay"); } -export function hooksDir(): string { - return join(configHome(), "ghostq", "hooks"); +export function ghostqDir(): string { + return join(configHome(), "ghostq"); +} + +export function templateDir(): string { + return join(configHome(), "ghostq", "template"); } export function expandTilde(p: string): string { diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 85234d9..5736456 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -10,9 +10,10 @@ const bin = join(tmp, "bin", "ghostq"); const home = join(tmp, "home"); const xdg = join(home, ".config"); const gitConfigGlobal = join(home, ".gitconfig"); -const hooksDir = join(xdg, "ghostq", "hooks"); +const templateDir = join(xdg, "ghostq", "template"); +const shimPath = join(templateDir, "hooks", "post-checkout"); const overlayRoot = join(xdg, "ghostq", "overlay"); -const prevHooks = join(tmp, "prev-hooks"); +const prevTemplate = join(tmp, "prev-template"); const upstream = join(tmp, "upstream"); const REMOTE = "https://github.com/testuser/testrepo.git"; const clone1 = join(tmp, "clone1"); @@ -45,7 +46,7 @@ afterAll(async () => { }); describe("ghostq end-to-end", () => { - test("setup: upstream repo, overlay entry, and a pre-existing global hooksPath", async () => { + test("setup: upstream repo, overlay entry, and a pre-existing init.templateDir", async () => { await fs.mkdir(home, { recursive: true }); for (const [k, v] of Object.entries({ "user.email": "test@example.com", @@ -69,26 +70,41 @@ describe("ghostq end-to-end", () => { ".env.local": "SECRET=1\n", }); - // a hooksPath that predates ghostq install and must keep working - await fs.mkdir(prevHooks, { recursive: true }); + // a template that predates ghostq install: its info/exclude must survive + // the override, and its post-checkout must keep firing (chained) + await fs.mkdir(join(prevTemplate, "info"), { recursive: true }); + await fs.mkdir(join(prevTemplate, "hooks"), { recursive: true }); + await fs.writeFile(join(prevTemplate, "info", "exclude"), "# custom-prev-template\n"); await fs.writeFile( - join(prevHooks, "post-checkout"), - `#!/bin/sh\ntouch prev-global-hook-ran\n`, + join(prevTemplate, "hooks", "post-checkout"), + `#!/bin/sh\ntouch prev-template-hook-ran\n`, { mode: 0o755 }, ); - run("git", ["config", "--global", "core.hooksPath", prevHooks], { env: ENV }); + run("git", ["config", "--global", "init.templateDir", prevTemplate], { env: ENV }); }); - test("install sets core.hooksPath and records the previous one", async () => { + test("install sets init.templateDir, records the previous one, never touches core.hooksPath", async () => { const r = ghostq(["install"]); expect(r.status).toBe(0); - expect(gitConfig("core.hooksPath")).toBe(hooksDir); - const shim = await fs.readFile(join(hooksDir, "post-checkout"), "utf8"); + expect(gitConfig("init.templateDir")).toBe(templateDir); + // ghostq must stay out of the hooks path so lefthook/husky/... coexist + expect(gitConfig("core.hooksPath")).toBe(""); + const shim = await fs.readFile(shimPath, "utf8"); expect(shim.startsWith("#!/bin/sh")).toBe(true); - expect(shim).toContain(prevHooks); }); - test("git clone triggers apply: overlay files are linked, siblings intact", async () => { + test("install seeds the previous template and preserves its post-checkout as .ghostq-orig", async () => { + // the seed captured the pre-existing template so a clone keeps its files + expect(await fs.readFile(join(templateDir, "info", "exclude"), "utf8")).toContain( + "custom-prev-template", + ); + // the foreign post-checkout was set aside for the shim to chain to + const orig = await fs.readFile(join(templateDir, "hooks", "post-checkout.ghostq-orig"), "utf8"); + expect(orig).toContain("prev-template-hook-ran"); + expect(await fs.readFile(shimPath, "utf8")).toContain("post-checkout.ghostq-orig"); + }); + + test("git clone triggers apply: overlay linked, siblings intact, template seeded, prev hook chained", async () => { runGit(["clone", "-q", REMOTE, clone1], tmp, ENV); expect(runGit(["config", "remote.origin.url"], clone1, ENV)).toBe(REMOTE); @@ -97,8 +113,12 @@ describe("ghostq end-to-end", () => { expect(await fs.readFile(link, "utf8")).toBe("my notes\n"); expect((await fs.lstat(join(clone1, ".env.local"))).isSymbolicLink()).toBe(true); expect((await fs.lstat(join(clone1, ".claude", "settings.json"))).isFile()).toBe(true); - // the pre-existing global hook was chained during the clone checkout - expect((await fs.stat(join(clone1, "prev-global-hook-ran"))).isFile()).toBe(true); + // the seeded template gave the clone a normal .git/info/exclude ... + expect(await fs.readFile(join(clone1, ".git", "info", "exclude"), "utf8")).toContain( + "custom-prev-template", + ); + // ... and the pre-existing template's post-checkout was chained + expect((await fs.stat(join(clone1, "prev-template-hook-ran"))).isFile()).toBe(true); }); test("ordinary git switch does not re-apply (null-ref sh gate)", async () => { @@ -111,17 +131,24 @@ describe("ghostq end-to-end", () => { expect(fs.lstat(join(clone1, ".env.local"))).rejects.toThrow(); }); - test("the dispatcher chains to a repo-local post-checkout hook", async () => { + test("a repo-local hook manager coexists: .git/hooks/pre-commit fires (nothing is shadowed)", async () => { + // ghostq sets no core.hooksPath, so git uses the repo's own .git/hooks and + // any hook a manager installs there keeps working, ghostq's post-checkout + // sitting alongside it await fs.writeFile( - join(clone1, ".git", "hooks", "post-checkout"), - `#!/bin/sh\ntouch repo-local-hook-ran\n`, + join(clone1, ".git", "hooks", "pre-commit"), + `#!/bin/sh\ntouch repo-local-precommit-ran\n`, { mode: 0o755 }, ); + await fs.writeFile(join(clone1, "extra.txt"), "x\n"); + runGit(["add", "extra.txt"], clone1, ENV); + runGit(["commit", "-q", "-m", "extra"], clone1, ENV); - runGit(["switch", "-q", "feature"], clone1, ENV); + expect((await fs.stat(join(clone1, "repo-local-precommit-ran"))).isFile()).toBe(true); + // ghostq's own post-checkout is still present, unclobbered + expect((await fs.stat(join(clone1, ".git", "hooks", "post-checkout"))).isFile()).toBe(true); - expect((await fs.stat(join(clone1, "repo-local-hook-ran"))).isFile()).toBe(true); - runGit(["switch", "-q", "main"], clone1, ENV); + await fs.unlink(join(clone1, ".git", "hooks", "pre-commit")); }); test("git worktree add triggers apply in the new worktree", async () => { @@ -151,10 +178,58 @@ describe("ghostq end-to-end", () => { expect(second.stdout).not.toContain("linked "); }); - test("uninstall restores the previous core.hooksPath", async () => { + test("uninstall restores the previous init.templateDir", async () => { const r = ghostq(["uninstall"]); expect(r.status).toBe(0); - expect(gitConfig("core.hooksPath")).toBe(prevHooks); - expect(fs.stat(join(hooksDir, "post-checkout"))).rejects.toThrow(); + expect(gitConfig("init.templateDir")).toBe(prevTemplate); + expect(fs.stat(shimPath)).rejects.toThrow(); + }); +}); + +describe("ghostq migration from a legacy core.hooksPath install", () => { + // simulate a ghostq that predates the init.templateDir switch: it set a + // global core.hooksPath pointing at ~/.config/ghostq/hooks + async function setUpLegacy(name: string, legacyPrevHooksPath: string) { + const h = join(tmp, name); + const x = join(h, ".config"); + const env = { + HOME: h, + XDG_CONFIG_HOME: x, + GIT_CONFIG_GLOBAL: join(h, ".gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + GHOSTQ_ROOT: undefined, + }; + await fs.mkdir(h, { recursive: true }); + run("git", ["config", "--global", "user.email", "t@e.com"], { env }); + const legacyHooks = join(x, "ghostq", "hooks"); + await fs.mkdir(legacyHooks, { recursive: true }); + await fs.writeFile(join(legacyHooks, "post-checkout"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + await fs.writeFile(join(legacyHooks, "prev-hookspath"), legacyPrevHooksPath); + run("git", ["config", "--global", "core.hooksPath", legacyHooks], { env }); + return { env, x, legacyHooks }; + } + + const get = (env: Record, key: string) => + run("git", ["config", "--global", "--get", key], { env }).stdout.trim(); + + test("install unsets the legacy core.hooksPath and removes its dir", async () => { + const { env, x, legacyHooks } = await setUpLegacy("mig-plain", ""); + + const r = run(bin, ["install"], { env }); + expect(r.status).toBe(0); + // the shadowing core.hooksPath is gone, so templateDir hooks take effect + expect(get(env, "core.hooksPath")).toBe(""); + expect(get(env, "init.templateDir")).toBe(join(x, "ghostq", "template")); + expect(fs.stat(legacyHooks)).rejects.toThrow(); + }); + + test("install restores a core.hooksPath the legacy install had shadowed", async () => { + const userHooks = join(tmp, "mig-user-hooks"); + await fs.mkdir(userHooks, { recursive: true }); + const { env } = await setUpLegacy("mig-restore", `${userHooks}\n`); + + const r = run(bin, ["install"], { env }); + expect(r.status).toBe(0); + expect(get(env, "core.hooksPath")).toBe(userHooks); }); });