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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ The e2e suite encodes these invariants; a change that makes

## Conventions

- Runtime dependencies: none, and keep it that way. git is invoked via
`spawnSync`.
- Runtime dependencies: only `commander`, for CLI argument parsing in
`src/index.ts`. It is bundled into the compiled binary, so the distributed
artifact still needs no external runtime. Keep the dependency list minimal —
don't add more without good reason. git is invoked via `spawnSync`.
- Only `src/index.ts` prints; other modules return data structures.
- Where information belongs: code says How, tests say What (spec-style test
names), commit bodies say Why, code comments say only Why-not (why the
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and the test suite drives the real git on your machine.
## Commands

```sh
bun install # dependencies (dev-only; the tool itself has none)
bun install # dependencies (commander for CLI parsing, bundled into the binary)
bun test # unit + end-to-end suite
bun run typecheck # tsc --noEmit
bun run build # bun build --compile → dist/ghostq (single binary)
Expand Down
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,23 @@ run `ghostq apply` in it once.

## 📖 Commands

<!-- Pasted verbatim from `ghostq --help`. Re-run it and update this block whenever the CLI changes. -->

```
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
ghostq prune [path] remove dangling ghostq-managed links (idempotent)
ghostq root print the overlay root
ghostq uninstall remove the hook wiring
Usage: ghostq [options] [command]

re-link personal, gitignored, per-repo files on clone / worktree add

Commands:
install install the post-checkout hook globally (init.templateDir)
uninstall remove the hook wiring
apply [path] link overlay files into the checkout (idempotent)
adopt <files...> move existing gitignored files into the overlay and link
them
status [path] show link states and warnings without changing anything
prune [path] remove dangling ghostq-managed links (idempotent)
root print the overlay root
help [command] display help for command
```

### Adopting an existing file
Expand Down
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 12 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
{
"name": "@simochee/ghostq",
"version": "1.1.0",
"description": "Restore your gitignored per-repo files automatically on every clone and worktree via a git hook",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/simochee/ghostq.git"
},
"type": "module",
"engines": {
"bun": "1.3"
"devDependencies": {
"@tsconfig/bun": "^1.0.10",
"@types/bun": "^1.2.0",
"typescript": "^7"
},
"bin": {
"ghostq": "./src/index.ts"
},
"description": "Restore your gitignored per-repo files automatically on every clone and worktree via a git hook",
"engines": {
"bun": "1.3"
},
"license": "MIT",
"scripts": {
"build": "bun build --compile ./src/index.ts --outfile dist/ghostq",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@tsconfig/bun": "^1.0.10",
"@types/bun": "^1.2.0",
"typescript": "^7"
"type": "module",
"dependencies": {
"commander": "^15.0.0"
}
}
113 changes: 63 additions & 50 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
#!/usr/bin/env bun
import { Command } from "commander";
import pkg from "../package.json" with { type: "json" };
import { type AdoptOutcome, adoptFiles } from "./adopt.ts";
import { applyFiles, inspectFiles, pruneFiles, type FileState } from "./apply.ts";
import { applyFiles, type FileState, inspectFiles, pruneFiles } from "./apply.ts";
import { resolveContext } from "./context.ts";
import { install, uninstall } from "./install.ts";
import { overlayRoot } from "./paths.ts";

const USAGE = `ghostq — re-link personal, gitignored, per-repo files on clone / worktree add

Usage:
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
ghostq prune [path] remove dangling ghostq-managed links (idempotent)
ghostq root print the overlay root
ghostq uninstall remove the hook wiring

Overlay root: $GHOSTQ_ROOT or \${XDG_CONFIG_HOME:-~/.config}/ghostq/overlay,
laid out as <root>/<host>/<user>/<repo>/ mirroring each repo's remote URL.`;

async function apply(path: string): Promise<number> {
const res = await resolveContext(path);
if (!res.ok) {
Expand Down Expand Up @@ -127,39 +115,64 @@ async function status(path: string): Promise<number> {
return 0;
}

async function main(): Promise<number> {
const [cmd, ...rest] = process.argv.slice(2);
switch (cmd) {
case "install":
return install();
case "uninstall":
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":
return prune(rest[0] ?? process.cwd());
case "root":
console.log(overlayRoot());
return 0;
case undefined:
case "help":
case "-h":
case "--help":
console.log(USAGE);
return cmd === undefined ? 1 : 0;
default:
console.error(`ghostq: unknown command: ${cmd}`);
console.error(USAGE);
return 1;
}
}
const program = new Command();

program
.name("ghostq")
.description("re-link personal, gitignored, per-repo files on clone / worktree add")
.version(pkg.version, "-v, --version", "print the version");

program
.command("install")
.description("install the post-checkout hook globally (init.templateDir)")
.action(async () => {
process.exit(await install());
});

program
.command("uninstall")
.description("remove the hook wiring")
.action(async () => {
process.exit(await uninstall());
});

program
.command("apply")
.description("link overlay files into the checkout (idempotent)")
.argument("[path]", "repo path (defaults to cwd)")
.action(async (path?: string) => {
process.exit(await apply(path ?? process.cwd()));
});

program
.command("adopt")
.description("move existing gitignored files into the overlay and link them")
.argument("<files...>", "one or more files to adopt")
.action(async (files: string[]) => {
process.exit(await adopt(files));
});

program
.command("status")
.description("show link states and warnings without changing anything")
.argument("[path]", "repo path (defaults to cwd)")
.action(async (path?: string) => {
process.exit(await status(path ?? process.cwd()));
});

program
.command("prune")
.description("remove dangling ghostq-managed links (idempotent)")
.argument("[path]", "repo path (defaults to cwd)")
.action(async (path?: string) => {
process.exit(await prune(path ?? process.cwd()));
});

program
.command("root")
.description("print the overlay root")
.action(() => {
console.log(overlayRoot());
});

process.exit(await main());
await program.parseAsync();
Loading