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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/
*.sqlite
.ai-sessions/
.cursor/
pnpm-lock.yaml

# Test artifacts
.test-tmp-*/
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Always run `bun run check` before committing to ensure typecheck, lint, and test
- Claude Code hooks for non-blocking capture (<50ms per hook)
- AI summarization via `claude -p` on session end (background process)
- Shared knowledge via `ghost/knowledge` orphan branch (no worktree impact)
- Worktree-aware: `mainRepoRoot()` resolves to the main repo via `--git-common-dir`, so sessions/QMD/hooks are always in the main repo root

## Important

Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,42 @@ git push origin refs/notes/ai-sessions

When QMD is installed, Ghost configures an MCP server so Claude Code can search past sessions directly during a conversation. The agent can ask things like "what did we decide about fee calculation?" and get answers from its own history.

## Git Worktrees

Ghost is fully worktree-aware. When running inside a git worktree, all session data (`.ai-sessions/`), QMD collections, git hooks, and knowledge files are stored in the main repository root — not the worktree directory. This means:

- Sessions are never lost when a worktree is cleaned up
- The QMD collection name (`ghost-<repo-name>`) is stable across all worktrees
- MCP search always queries the correct, shared collection
- `ghost enable` from a worktree places config files (`.claude/settings.json`, `.mcp.json`) in the worktree but stores everything else in the main repo

Ghost resolves the main repo root automatically via `git rev-parse --git-common-dir`.

### Getting hooks into worktrees

Ghost's hooks are registered in `.claude/settings.json` and the MCP server in `.mcp.json`. These are local config files — new worktrees won't have them unless you take one of these approaches:

**Option A: Commit the config files** (recommended for seamless worktree support)

```bash
git add .claude/settings.json .mcp.json
git commit -m "Add Ghost hook and MCP config"
```

Since worktrees share the same git history, any new worktree will have the hooks and MCP server configured automatically. This also means collaborators who clone the repo get Ghost hooks out of the box.

**Option B: Run `ghost enable` per worktree**

```bash
git worktree add ../my-feature feature-branch
cd ../my-feature
ghost enable
```

This creates `.claude/settings.json` and `.mcp.json` in the worktree. Session data still goes to the main repo. Use this if you prefer to keep config files untracked.

**Note for Claude Code's automatic worktrees:** When Claude Code creates worktrees via the Agent tool (`isolation: "worktree"`), there's no opportunity to run `ghost enable`. If you use this feature and want sessions captured from worktree agents, commit the config files (Option A).

## Troubleshooting

**Sessions not being captured**
Expand Down
53 changes: 53 additions & 0 deletions src/git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { $ } from "bun";
import { mainRepoRoot, repoRoot } from "./git.js";

let tmpDir: string;

beforeEach(async () => {
tmpDir = join(import.meta.dir, `../.test-tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
await $`git init ${tmpDir}`.quiet();
await $`git -C ${tmpDir} commit --allow-empty -m "init"`.quiet();
});

afterEach(() => {
if (existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
}
});

describe("repoRoot", () => {
test("returns repo root with cwd parameter", async () => {
const root = await repoRoot(tmpDir);
expect(root).toBe(tmpDir);
});
});

describe("mainRepoRoot", () => {
test("returns same path as repoRoot in a normal repo", async () => {
const main = await mainRepoRoot(tmpDir);
const top = await repoRoot(tmpDir);
expect(main).toBe(top);
});

test("returns main repo root when called from a worktree", async () => {
const wtDir = `${tmpDir}-worktree`;
try {
await $`git -C ${tmpDir} worktree add ${wtDir} -b test-wt`.quiet();

// repoRoot from worktree should return the worktree dir
const top = await repoRoot(wtDir);
expect(top).toBe(wtDir);

// mainRepoRoot from worktree should return the main repo dir
const main = await mainRepoRoot(wtDir);
expect(main).toBe(tmpDir);
} finally {
await $`git -C ${tmpDir} worktree remove ${wtDir} --force`.quiet().nothrow();
if (existsSync(wtDir)) rmSync(wtDir, { recursive: true, force: true });
}
});
});
19 changes: 16 additions & 3 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { dirname } from "node:path";
import { $ } from "bun";

// =============================================================================
Expand All @@ -6,12 +7,24 @@ import { $ } from "bun";

const NOTES_REF = "ai-sessions";

/** Get the git repository root directory */
export async function repoRoot(): Promise<string> {
const result = await $`git rev-parse --show-toplevel`.quiet();
/** Get the git repository root directory (worktree-aware — returns worktree path in worktrees) */
export async function repoRoot(cwd?: string): Promise<string> {
const result = cwd
? await $`git -C ${cwd} rev-parse --show-toplevel`.quiet()
: await $`git rev-parse --show-toplevel`.quiet();
return result.text().trim();
}

/** Get the main repository root, resolving through worktrees to the original repo.
* In a normal repo, this returns the same as repoRoot().
* In a worktree, this returns the main repo root (not the worktree directory). */
export async function mainRepoRoot(cwd?: string): Promise<string> {
const result = cwd
? await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`.quiet()
: await $`git rev-parse --path-format=absolute --git-common-dir`.quiet();
return dirname(result.text().trim());
}

/** Get the current branch name */
export async function currentBranch(): Promise<string> {
const result = await $`git branch --show-current`.quiet();
Expand Down
70 changes: 70 additions & 0 deletions src/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
handleStop,
} from "./hooks.js";
import { ACTIVE_DIR, COMPLETED_DIR, SESSION_DIR } from "./paths.js";
import { collectionName } from "./qmd.js";
import { getActiveSessionId, getSessionPathForHook, parseFrontmatter, readSessionMap } from "./session.js";

let tmpDir: string;
Expand Down Expand Up @@ -264,3 +265,72 @@ describe("concurrent sessions via hooks", () => {
expect(Object.keys(finalMap).length).toBe(0);
});
});

describe("worktree support", () => {
let mainDir: string;
let wtDir: string;

beforeEach(async () => {
mainDir = join(import.meta.dir, `../.test-tmp-main-${Date.now()}-${Math.random().toString(36).slice(2)}`);
wtDir = join(import.meta.dir, `../.test-tmp-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(mainDir, { recursive: true });
await $`git init ${mainDir}`.quiet();
await $`git -C ${mainDir} commit --allow-empty -m "init"`.quiet();
await $`git -C ${mainDir} worktree add ${wtDir} -b test-wt`.quiet();
});

afterEach(async () => {
await $`git -C ${mainDir} worktree remove ${wtDir} --force`.quiet().nothrow();
if (existsSync(wtDir)) rmSync(wtDir, { recursive: true, force: true });
if (existsSync(mainDir)) rmSync(mainDir, { recursive: true, force: true });
});

test("session created from worktree is stored in main repo", async () => {
await handleSessionStart({ session_id: "wt-test", cwd: wtDir });

// Session should be in mainDir, not wtDir
const id = getActiveSessionId(mainDir);
expect(id).not.toBeNull();
expect(existsSync(join(mainDir, SESSION_DIR, ACTIVE_DIR, `${id}.md`))).toBe(true);
expect(existsSync(join(wtDir, SESSION_DIR))).toBe(false);
});

test("session end from worktree moves to main repo completed dir", async () => {
await handleSessionStart({ session_id: "wt-test", cwd: wtDir });
const id = getActiveSessionId(mainDir)!;

await handleSessionEnd({ session_id: "wt-test", cwd: wtDir });

expect(existsSync(join(mainDir, SESSION_DIR, COMPLETED_DIR, `${id}.md`))).toBe(true);
expect(existsSync(join(wtDir, SESSION_DIR))).toBe(false);
});

test("prompts from worktree append to session in main repo", async () => {
await handleSessionStart({ session_id: "wt-test", cwd: wtDir });
await handlePrompt({ session_id: "wt-test", cwd: wtDir, prompt: "Fix from worktree" });

const id = getActiveSessionId(mainDir)!;
const content = readFileSync(join(mainDir, SESSION_DIR, ACTIVE_DIR, `${id}.md`), "utf8");
expect(content).toContain("Fix from worktree");
});

test("file writes from worktree append to session in main repo", async () => {
await handleSessionStart({ session_id: "wt-test", cwd: wtDir });
await handlePostWrite({
session_id: "wt-test",
cwd: wtDir,
tool_name: "Write",
tool_input: { file_path: "src/worktree-file.ts" },
});

const id = getActiveSessionId(mainDir)!;
const content = readFileSync(join(mainDir, SESSION_DIR, ACTIVE_DIR, `${id}.md`), "utf8");
expect(content).toContain("- Modified: src/worktree-file.ts");
});

test("collectionName is the same from main repo and worktree", async () => {
const nameFromMain = await collectionName(mainDir);
const nameFromWt = await collectionName(wtDir);
expect(nameFromMain).toBe(nameFromWt);
});
});
19 changes: 10 additions & 9 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import type { PostToolUseInput, SessionEndInput, SessionStartInput, StopInput, UserPromptInput } from "./env.js";
import { repoRoot } from "./git.js";
import { mainRepoRoot } from "./git.js";
import { completedSessionPath } from "./paths.js";
import {
appendFileModification,
Expand Down Expand Up @@ -31,7 +31,8 @@ import {
* Returns context string via stdout (Claude sees this).
*/
export async function handleSessionStart(input: SessionStartInput): Promise<string | undefined> {
const root = input.cwd || (await repoRoot());
const root = await mainRepoRoot(input.cwd);
const cwd = input.cwd || root; // worktree cwd for git diff
const _id = await createSession(root, input.session_id);

// Clean up orphaned sessions from previous runs that never got a session-end
Expand All @@ -57,8 +58,8 @@ export async function handleSessionStart(input: SessionStartInput): Promise<stri
// 2. Gather relevant files from git state + previous session
let relevantFiles: string[] = [];
try {
const unstaged = execSync("git diff --name-only HEAD", { cwd: root, encoding: "utf8", timeout: 3000 }).trim();
const staged = execSync("git diff --name-only --cached", { cwd: root, encoding: "utf8", timeout: 3000 }).trim();
const unstaged = execSync("git diff --name-only HEAD", { cwd, encoding: "utf8", timeout: 3000 }).trim();
const staged = execSync("git diff --name-only --cached", { cwd, encoding: "utf8", timeout: 3000 }).trim();
relevantFiles = [...new Set([...unstaged.split("\n").filter(Boolean), ...staged.split("\n").filter(Boolean)])];
} catch {
// No git changes or not in a git repo
Expand Down Expand Up @@ -160,7 +161,7 @@ export async function handleSessionStart(input: SessionStartInput): Promise<stri
* UserPromptSubmit: Append user prompt to active session.
*/
export async function handlePrompt(input: UserPromptInput): Promise<void> {
const root = input.cwd || (await repoRoot());
const root = await mainRepoRoot(input.cwd);
const prompt = input.prompt || "";
if (prompt) {
appendPrompt(root, input.session_id, prompt);
Expand All @@ -171,7 +172,7 @@ export async function handlePrompt(input: UserPromptInput): Promise<void> {
* PostToolUse(Write|Edit): Record file modification.
*/
export async function handlePostWrite(input: PostToolUseInput): Promise<void> {
const root = input.cwd || (await repoRoot());
const root = await mainRepoRoot(input.cwd);
const filePath = input.tool_input?.file_path as string | undefined;
if (filePath) {
appendFileModification(root, input.session_id, filePath);
Expand All @@ -182,7 +183,7 @@ export async function handlePostWrite(input: PostToolUseInput): Promise<void> {
* PostToolUse(Task): Record task completion.
*/
export async function handlePostTask(input: PostToolUseInput): Promise<void> {
const root = input.cwd || (await repoRoot());
const root = await mainRepoRoot(input.cwd);
const description = (input.tool_input?.description as string) || "subtask completed";
appendTaskNote(root, input.session_id, description);
}
Expand All @@ -191,7 +192,7 @@ export async function handlePostTask(input: PostToolUseInput): Promise<void> {
* Stop: Append turn delimiter with timestamp and diff stat.
*/
export async function handleStop(input: StopInput): Promise<void> {
const root = input.cwd || (await repoRoot());
const root = await mainRepoRoot(input.cwd);
await appendTurnDelimiter(root, input.session_id);
}

Expand All @@ -200,7 +201,7 @@ export async function handleStop(input: StopInput): Promise<void> {
* Exits immediately — background process handles summarization, git notes, QMD indexing.
*/
export async function handleSessionEnd(input: SessionEndInput): Promise<void> {
const root = input.cwd || (await repoRoot());
const root = await mainRepoRoot(input.cwd);

const result = finalizeSession(root, input.session_id);
if (!result) return;
Expand Down
Loading
Loading