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
20 changes: 16 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 27 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,41 @@ 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
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,
Expand Down
128 changes: 69 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<overlay-root>/<host>/<user>/<repo>/`.
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 <file>... move existing gitignored files into the overlay and link them
ghostq status [path] show link states and warnings without changing anything
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>... move existing gitignored files into the overlay and link them
ghostq status [path] show link states and warnings without changing anything
Expand Down
Loading
Loading