diff --git a/AGENTS.md b/AGENTS.md index d8e3453..f17e0f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ Gemini-specific event handling: - **`pre-push`**: Auto-pushes `refs/notes/agentnote` to remote. Uses `AGENTNOTE_PUSHING` recursion guard. Existing hooks are backed up and chained. Compatible with husky/lefthook. +Git worktrees are supported by keeping session buffers in each worktree's own git dir while sharing the repo-local CLI shim from the common git dir. This must work for bare and non-bare repositories, arbitrary worktree directory layouts, and Agent View-style worktree commits after init from the main checkout. ### Core modules diff --git a/CLAUDE.md b/CLAUDE.md index 1fa7d55..682ac91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,7 @@ Gemini-specific event handling: - **`pre-push`**: Auto-pushes `refs/notes/agentnote` to remote. Uses `AGENTNOTE_PUSHING` recursion guard. Existing hooks are backed up and chained. Compatible with husky/lefthook. +Git worktrees are supported by keeping session buffers in each worktree's own git dir while sharing the repo-local CLI shim from the common git dir. This must work for bare and non-bare repositories, arbitrary worktree directory layouts, and Agent View-style worktree commits after init from the main checkout. ### Core modules diff --git a/docs/architecture.md b/docs/architecture.md index 584dea5..380b6c2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -184,6 +184,14 @@ Append-only JSONL files, accumulated during a session, rotated after each commit └── pre_blobs-.jsonl # archived at next turn boundary ``` +In git worktrees, this local temp layer intentionally lives under that +worktree's own git dir (`.git/worktrees//agentnote` for a non-bare +repository, or the equivalent worktree git dir for a bare repository). This +keeps active session pointers, heartbeats, and uncommitted JSONL buffers +isolated per worktree regardless of where the user chooses to place the +worktree directory, while git notes remain shared through the repository's +common git database. + **Layer 2 — Git notes (`refs/notes/agentnote`)** The permanent record. One JSON note per commit, written at commit time. Pushable, fetchable, shareable with the team. @@ -391,6 +399,14 @@ Three git hooks handle commit integration and notes sharing: | `post-commit` | After commit succeeds | Reads session ID from the finalized trailer on HEAD, calls `agent-note record ` to write git note. If `prepare-commit-msg` explicitly marked a stale-heartbeat fallback, calls `agent-note record --fallback-head`, which only records when a session post-edit blob matches a committed HEAD blob. If the current process exposes an adapter-supported session environment such as `CODEX_THREAD_ID`, it may also call `agent-note record --fallback-env` when HEAD still has no Agent Note after the trailer/head attempt; fresh mutating transcript work can become commit-level attribution even when exact `files_touched` is unavailable. Direct file-matched env fallback rows may pull in bounded preceding decision-context prompts for display, but only the matched rows affect attribution. Idempotent — skips if note already exists. | | `pre-push` | Before push to remote | Pushes `refs/notes/agentnote` to the actual remote (`$1`) and waits for `push-notes` to finish. Recursion-guarded via `AGENTNOTE_PUSHING` env var. | +Git hooks are installed into the hook directory reported by Git, not by assuming +`.git/hooks`. For worktrees, the hook script may run with a worktree-specific +`$GIT_DIR`, so `post-commit` and `pre-push` first try that worktree's local +Agent Note shim and then fall back to the common git dir shim shared by all +worktrees. This works for both bare and non-bare repositories, including custom +worktree directory layouts. It lets a main checkout `agent-note init` support +commits made inside Claude Agent View-style worktrees. + Session freshness is verified via per-session heartbeat file (`sessions//heartbeat`). Heartbeat is refreshed by normalized hook events during long turns. `Stop` does NOT invalidate the heartbeat — it fires when the AI finishes responding, not when the session ends. Gemini `SessionEnd` is a real session termination and removes the heartbeat. Missing heartbeat in `prepare-commit-msg` skips trailer injection. Stale heartbeat writes a one-shot fallback marker for brand-new commits only; `post-commit` consumes that marker and records only if the active session has post-edit blob evidence that matches the committed HEAD blobs. Agent-hosted terminals may also expose the current session through adapter-specific environment variables. Today, Codex exposes `CODEX_THREAD_ID`, which lets `post-commit` recover a fresh Codex transcript even when `.git/agentnote/session` points at a stale or unrelated session. Plain git hook trailer injection also requires file evidence. File-change records or pre-edit blobs count as safe evidence because they can be matched back to committed files. Prompts alone are not enough for plain git hooks: a fresh prompt-only active session might belong to another agent or terminal workflow. Agent hook trailer injection can still preserve prompt-only work because the commit command itself was observed inside the agent. Transcript paths are supporting metadata, not recordable data by themselves. Heartbeat, `SessionStart`, and `transcript_path` metadata alone do not receive dangling `Agentnote-Session` trailers. @@ -402,11 +418,22 @@ Environment fallback is narrower than trailer injection. It does not trust `.git `agent-note init` installs git hooks respecting the repository's hook directory: ```bash -# Determine hook directory -HOOK_DIR=$(git config get core.hooksPath || echo ".git/hooks") +# Determine the effective hook directory. Git resolves this for normal +# checkouts, bare repositories, custom core.hooksPath, and worktrees. +HOOK_DIR=$(git rev-parse --git-path hooks) ``` -If `core.hooksPath` is set (e.g., by husky, lefthook, or custom configuration), hooks are installed there instead of `.git/hooks/`. This ensures compatibility with any hook manager. +Git owns hook path resolution. Agent Note therefore asks Git for the effective +hook directory instead of reconstructing it from `.git/` paths or +`core.hooksPath`. This keeps hook installation correct for hook managers, +bare repositories, custom worktree layouts, and Claude Agent View-style +worktrees. + +At runtime, hook scripts first try the worktree-local Agent Note shim under the +Git-reported `$GIT_DIR`. If that shim does not exist, they fall back to the +common git-dir shim shared by all worktrees. This lets `agent-note init` run +from either the main checkout or a linked worktree while still supporting +commits made from any related worktree. When an existing hook file is found, agent-note chains to it — the original hook runs first, then agent-note's logic runs. This avoids overwriting user or tool-managed hooks. diff --git a/docs/knowledge/investigations.md b/docs/knowledge/investigations.md index 1bb801a..4ddc657 100644 --- a/docs/knowledge/investigations.md +++ b/docs/knowledge/investigations.md @@ -67,6 +67,17 @@ PR #72 で `--fallback-env` を導入し、PR #74 で stale trailer retry と bo ## Resolved Investigations +### Claude Agent View / git worktree commit で note が落ちる可能性 + +- 対象: `agent-note init` が生成する `post-commit` / `pre-push` hook、repo-local CLI shim、worktree 上の plain git commit。 +- 背景: Claude Agent View は background Agent の編集前に `.claude/worktrees/...` 配下の git worktree へ移動する。`git worktree` では `git rev-parse --git-dir` が main repository の `.git` ではなく `.git/worktrees/` を返す。一方、hooks は common git dir 側の hooks を使う。 +- 過去対応: `4c08e7f fix: support git worktree by using git rev-parse --git-dir` により、`.git` が file になる worktree でも実 git dir を解決できるようになっていた。これは session buffer を worktree ごとに分けるためには正しい対応だった。 +- 残っていた問題: main checkout で `agent-note init` した場合、repo-local shim は main 側の `.git/agentnote/bin/agent-note` に作られる。しかし worktree commit の hook 実行時の `$GIT_DIR` は `.git/worktrees/` なので、`$GIT_DIR/agentnote/bin/agent-note` だけを見ると shim が見つからない。結果として trailer があっても `post-commit` が `record` を実行できず、git note が作られない可能性があった。 +- Entire から得た判断: Entire は git worktree を session 分離境界として扱う設計を採っている。Agent Note でも session buffer / active pointer / heartbeat は worktree ごとに分離する方が正しい。ただし CLI shim と hooks は common git dir を共有できるため、session data と executable discovery は分けて考える必要がある。 +- 修正: `agentnoteDir()` は従来通り worktree-specific git dir を使い、session buffer を worktree ごとに分ける。一方で `commonAgentnoteDir()` を追加し、`agent-note init` は current worktree git dir と common git dir の両方に deterministic CLI shim を作る。生成 hook は `$GIT_DIR/agentnote/bin/agent-note` を優先し、見つからなければ `git rev-parse --git-common-dir` の shim を使う。`resolveHookDir()` は fallback 時に `git rev-parse --git-path hooks` を使い、Git が実際に使う hook path に揃える。 +- 追加修正: worktree 対応で hook path が絶対パスになるケースが増えたことで、既存 hook chaining の `String.replace()` replacement 文字列に含まれる `$` / `$'` が JavaScript の replacement token として展開されるバグが表面化した。function replacement に変更し、shell-special path でも backup hook path を壊さないようにした。 +- Regression coverage: `packages/cli/src/commands/init.test.ts` で、main checkout で `agent-note init` した後に `.claude/worktrees/agent-view` worktree を作り、Claude hook event で file evidence を作成し、worktree 内の plain `git commit` から git note が作られることを確認する。この test は worktree-local shim が存在しないことも確認し、common shim fallback を必ず通す。追加 matrix では bare repository (`repo.bare/branch/...`) と non-bare repository の両方、repo 内 nested path、repo 外 custom sibling path、space を含む arbitrary worktree path を通し、Git worktree のディレクトリ命名ルールに依存しないことを固定する。さらに `git worktree add` の主要 mode (`--detach`, `--orphan`, `--lock`, `--relative-paths`)、duplicate basename worktree、`git worktree move` 後の path、worktree-specific `core.hooksPath` を通し、Git worktree の private git dir / common git dir / hook path が変わる仕様境界を regression test に含める。 + ### PR #76 deletion-only AI Ratio が 0% になる - 対象 PR: `#76` diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index a85d6f8..44e688c 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -1382,7 +1382,7 @@ async function waitForTranscriptReady(transcriptPath) { } } catch { } - await new Promise((resolve6) => setTimeout(resolve6, TRANSCRIPT_POLL_MS)); + await new Promise((resolve7) => setTimeout(resolve7, TRANSCRIPT_POLL_MS)); } return false; } @@ -4459,13 +4459,13 @@ function escapeRegExp3(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } async function readCommittedFilePrefix(commitSha, file, maxBytes = 2048) { - return new Promise((resolve6) => { + return new Promise((resolve7) => { const child = spawn("git", ["show", `${commitSha}:${file}`], { stdio: ["ignore", "pipe", "ignore"] }); const stdout = child.stdout; if (!stdout) { - resolve6(null); + resolve7(null); return; } const chunks = []; @@ -4476,7 +4476,7 @@ async function readCommittedFilePrefix(commitSha, file, maxBytes = 2048) { const finish = (value) => { if (settled) return; settled = true; - resolve6(value); + resolve7(value); }; child.on("error", () => finish(null)); stdout.on("data", (chunk) => { @@ -4912,9 +4912,13 @@ async function ensureEmptyBlobInStore() { } // src/paths.ts -import { join as join7 } from "node:path"; +import { isAbsolute as isAbsolute2, join as join7, resolve as resolve5 } from "node:path"; var _root = null; var _gitDir = null; +var _commonGitDir = null; +function resolveGitPath(value) { + return isAbsolute2(value) ? value : resolve5(process.cwd(), value); +} async function root() { if (!_root) { try { @@ -4929,15 +4933,23 @@ async function root() { async function gitDir() { if (!_gitDir) { _gitDir = await git(["rev-parse", "--git-dir"]); - if (!_gitDir.startsWith("/")) { - _gitDir = join7(await root(), _gitDir); - } + _gitDir = resolveGitPath(_gitDir); } return _gitDir; } +async function commonGitDir() { + if (!_commonGitDir) { + _commonGitDir = await git(["rev-parse", "--git-common-dir"]); + _commonGitDir = resolveGitPath(_commonGitDir); + } + return _commonGitDir; +} async function agentnoteDir() { return join7(await gitDir(), AGENTNOTE_DIR); } +async function commonAgentnoteDir() { + return join7(await commonGitDir(), AGENTNOTE_DIR); +} async function sessionFile() { return join7(await agentnoteDir(), SESSION_FILE); } @@ -5139,8 +5151,8 @@ async function commit(args2) { stdio: "inherit", cwd: process.cwd() }); - const exitCode = await new Promise((resolve6) => { - child.on("close", (code) => resolve6(code ?? 1)); + const exitCode = await new Promise((resolve7) => { + child.on("close", (code) => resolve7(code ?? 1)); }); if (exitCode !== 0) { process.exit(exitCode); @@ -5171,7 +5183,7 @@ import { join as join11 } from "node:path"; // src/commands/init.ts import { existsSync as existsSync10 } from "node:fs"; import { chmod, mkdir as mkdir6, readFile as readFile10, writeFile as writeFile7 } from "node:fs/promises"; -import { isAbsolute as isAbsolute2, join as join10, resolve as resolve5 } from "node:path"; +import { isAbsolute as isAbsolute3, join as join10, resolve as resolve6 } from "node:path"; var PR_REPORT_WORKFLOW_FILENAME = "agentnote-pr-report.yml"; var DASHBOARD_WORKFLOW_FILENAME = "agentnote-dashboard.yml"; var [PREPARE_COMMIT_MSG_HOOK, POST_COMMIT_HOOK, PRE_PUSH_HOOK] = GIT_HOOK_NAMES; @@ -5308,6 +5320,7 @@ ${AGENTNOTE_HOOK_MARKER} # injected because the session heartbeat was stale, the CLI may use a strict # HEAD fallback that only records when session file evidence matches HEAD. GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)" +COMMON_GIT_DIR="$(git rev-parse --git-common-dir 2>/dev/null)" SESSION_ID=$(git log -1 --format='%(trailers:key=${TRAILER_KEY},valueonly)' HEAD 2>/dev/null | tr -d '\\n') if [ -z "$SESSION_ID" ]; then FALLBACK_FILE="$GIT_DIR/agentnote/${POST_COMMIT_FALLBACK_FILE}" @@ -5329,6 +5342,12 @@ record_agentnote() { "$GIT_DIR/agentnote/bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null || true return fi + # Git worktrees use their own $GIT_DIR but share hooks through the common git + # dir. Fall back to the common shim when init ran from another worktree. + if [ -n "$COMMON_GIT_DIR" ] && [ "$COMMON_GIT_DIR" != "$GIT_DIR" ] && [ -x "$COMMON_GIT_DIR/agentnote/bin/agent-note" ]; then + "$COMMON_GIT_DIR/agentnote/bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null || true + return + fi # Fall back to stable local/global binaries only. REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" if [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ]; then @@ -5350,10 +5369,15 @@ ${AGENTNOTE_HOOK_MARKER} # PR workflows can fetch the latest notes ref, but never block the main push on failure. if [ -n "$AGENTNOTE_PUSHING" ]; then exit 0; fi GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)" +COMMON_GIT_DIR="$(git rev-parse --git-common-dir 2>/dev/null)" if [ -x "$GIT_DIR/agentnote/bin/agent-note" ]; then "$GIT_DIR/agentnote/bin/agent-note" push-notes "$1" 2>/dev/null || true exit 0 fi +if [ -n "$COMMON_GIT_DIR" ] && [ "$COMMON_GIT_DIR" != "$GIT_DIR" ] && [ -x "$COMMON_GIT_DIR/agentnote/bin/agent-note" ]; then + "$COMMON_GIT_DIR/agentnote/bin/agent-note" push-notes "$1" 2>/dev/null || true + exit 0 +fi REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" if [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ]; then "$REPO_ROOT/node_modules/.bin/agent-note" push-notes "$1" 2>/dev/null || true @@ -5404,7 +5428,10 @@ async function init(args2) { } } if (!skipGitHooks && !actionOnly) { - await installLocalCliShim(await agentnoteDir()); + const shimDirs = /* @__PURE__ */ new Set([await agentnoteDir(), await commonAgentnoteDir()]); + for (const shimDir of shimDirs) { + await installLocalCliShim(shimDir); + } const hookDir = await resolveHookDir(repoRoot3); await mkdir6(hookDir, { recursive: true }); const installed = await installGitHook( @@ -5516,11 +5543,11 @@ async function init(args2) { async function resolveHookDir(repoRoot3) { try { const hooksPath = await git(["config", "--get", "core.hooksPath"]); - if (hooksPath) return isAbsolute2(hooksPath) ? hooksPath : join10(repoRoot3, hooksPath); + if (hooksPath) return isAbsolute3(hooksPath) ? hooksPath : join10(repoRoot3, hooksPath); } catch { } - const gitDir2 = await git(["rev-parse", "--git-dir"]); - return join10(gitDir2, "hooks"); + const hookPath = await git(["rev-parse", "--git-path", "hooks"]); + return isAbsolute3(hookPath) ? hookPath : resolve6(process.cwd(), hookPath); } function shellSingleQuote(value) { return `'${value.replace(/'/g, `'"'"'`)}'`; @@ -5529,7 +5556,7 @@ async function installLocalCliShim(agentnoteDirPath) { if (!process.argv[1]) return; const shimDir = join10(agentnoteDirPath, "bin"); const shimPath = join10(shimDir, "agent-note"); - const cliPath = resolve5(process.argv[1]); + const cliPath = resolve6(process.argv[1]); const shim = `#!/bin/sh exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(cliPath)} "$@" `; @@ -5543,12 +5570,11 @@ async function installGitHook(hookDir, name, script) { const existing = await readFile10(hookPath, TEXT_ENCODING); if (existing.includes(AGENTNOTE_HOOK_MARKER)) { const backupPath2 = `${hookPath}.agentnote-backup`; - const target = existsSync10(backupPath2) ? script.replace( - "#!/bin/sh", - `#!/bin/sh + const target = existsSync10(backupPath2) ? script.replace("#!/bin/sh", () => { + return `#!/bin/sh # Chain to original hook \u2014 preserve exit status. -if [ -f ${shellSingleQuote(backupPath2)} ]; then ${shellSingleQuote(backupPath2)} "$@" || exit $?; fi` - ) : script; +if [ -f ${shellSingleQuote(backupPath2)} ]; then ${shellSingleQuote(backupPath2)} "$@" || exit $?; fi`; + }) : script; if (existing.trim() === target.trim()) return false; await writeFile7(hookPath, target); await chmod(hookPath, 493); @@ -5559,12 +5585,11 @@ if [ -f ${shellSingleQuote(backupPath2)} ]; then ${shellSingleQuote(backupPath2) await writeFile7(backupPath, existing); await chmod(backupPath, 493); } - const chainedScript = script.replace( - "#!/bin/sh", - `#!/bin/sh + const chainedScript = script.replace("#!/bin/sh", () => { + return `#!/bin/sh # Chain to original hook \u2014 preserve exit status. -if [ -f ${shellSingleQuote(backupPath)} ]; then ${shellSingleQuote(backupPath)} "$@" || exit $?; fi` - ); +if [ -f ${shellSingleQuote(backupPath)} ]; then ${shellSingleQuote(backupPath)} "$@" || exit $?; fi`; + }); await writeFile7(hookPath, chainedScript); await chmod(hookPath, 493); return true; @@ -5634,10 +5659,17 @@ async function deinit(args2) { results.push(` \xB7 git hook: ${name} (not found or not managed by agentnote)`); } } - const binDir = join11(await agentnoteDir(), "bin"); - const shimPath = join11(binDir, "agent-note"); - if (existsSync11(shimPath)) { + const shimPaths = /* @__PURE__ */ new Set([ + join11(await agentnoteDir(), "bin", "agent-note"), + join11(await commonAgentnoteDir(), "bin", "agent-note") + ]); + let removedShim = false; + for (const shimPath of shimPaths) { + if (!existsSync11(shimPath)) continue; await unlink2(shimPath); + removedShim = true; + } + if (removedShim) { results.push(" \u2713 removed local CLI shim"); } if (removeWorkflow) { @@ -5677,7 +5709,7 @@ async function deinit(args2) { import { randomUUID } from "node:crypto"; import { existsSync as existsSync13 } from "node:fs"; import { mkdir as mkdir7, readFile as readFile12, realpath, unlink as unlink3, writeFile as writeFile8 } from "node:fs/promises"; -import { isAbsolute as isAbsolute3, join as join13, relative as relative2 } from "node:path"; +import { isAbsolute as isAbsolute4, join as join13, relative as relative2 } from "node:path"; // src/core/rotate.ts import { existsSync as existsSync12 } from "node:fs"; @@ -5715,7 +5747,7 @@ function isSynchronousHookEvent(value) { return SYNCHRONOUS_HOOK_EVENTS.has(value.hook_event_name); } async function normalizeToRepoRelative(filePath) { - if (!isAbsolute3(filePath)) return filePath; + if (!isAbsolute4(filePath)) return filePath; try { const rawRoot = (await git(["rev-parse", "--show-toplevel"])).trim(); const repoRoot3 = await realpath(rawRoot); @@ -5904,7 +5936,7 @@ async function hook(args2 = []) { const filePath = await normalizeToRepoRelative(absPath); const turn = await readCurrentTurn2(sessionDir); const promptId = await readCurrentPromptId(sessionDir); - const preBlob = isAbsolute3(absPath) ? await blobHash(absPath) : EMPTY_BLOB; + const preBlob = isAbsolute4(absPath) ? await blobHash(absPath) : EMPTY_BLOB; await appendJsonl(join13(sessionDir, PRE_BLOBS_FILE), { event: PRE_BLOB_EVENT, turn, @@ -5925,7 +5957,7 @@ async function hook(args2 = []) { const filePath = await normalizeToRepoRelative(absPath); const turn = await readCurrentTurn2(sessionDir); const promptId = await readCurrentPromptId(sessionDir); - const postBlob = isAbsolute3(absPath) ? await blobHash(absPath) : EMPTY_BLOB; + const postBlob = isAbsolute4(absPath) ? await blobHash(absPath) : EMPTY_BLOB; const changeId = adapter.name === AGENT_NAMES.cursor ? `${event.timestamp}:${event.tool ?? NORMALIZED_EVENT_KINDS.fileChange}:${filePath}:${postBlob}` : null; await appendJsonl(join13(sessionDir, CHANGES_FILE), { event: NORMALIZED_EVENT_KINDS.fileChange, @@ -7033,7 +7065,7 @@ function truncateLines(text, maxLen) { // src/commands/status.ts import { existsSync as existsSync15 } from "node:fs"; import { readFile as readFile13 } from "node:fs/promises"; -import { isAbsolute as isAbsolute4, join as join16 } from "node:path"; +import { join as join16 } from "node:path"; var VERSION = "1.0.3"; var CAPABILITY_LABELS = { edits: "edits", @@ -7251,7 +7283,7 @@ async function readGeminiCaptureCapabilities(repoRoot3) { } } async function readManagedGitHooks(repoRoot3) { - const hookDir = await resolveHookDir2(repoRoot3); + const hookDir = await resolveHookDir(repoRoot3); const active = []; for (const name of GIT_HOOK_NAMES) { const hookPath = join16(hookDir, name); @@ -7266,15 +7298,6 @@ async function readManagedGitHooks(repoRoot3) { } return active; } -async function resolveHookDir2(repoRoot3) { - const hooksPathConfig = (await gitSafe(["config", "--get", "core.hooksPath"])).stdout.trim(); - if (hooksPathConfig) { - return isAbsolute4(hooksPathConfig) ? hooksPathConfig : join16(repoRoot3, hooksPathConfig); - } - const gitDir2 = (await gitSafe(["rev-parse", "--git-dir"])).stdout.trim(); - const resolvedGitDir = isAbsolute4(gitDir2) ? gitDir2 : join16(repoRoot3, gitDir2); - return join16(resolvedGitDir, "hooks"); -} // src/commands/why.ts import { existsSync as existsSync16, realpathSync } from "node:fs"; diff --git a/packages/cli/src/commands/deinit.test.ts b/packages/cli/src/commands/deinit.test.ts index d07f320..0ae2e13 100644 --- a/packages/cli/src/commands/deinit.test.ts +++ b/packages/cli/src/commands/deinit.test.ts @@ -1,11 +1,15 @@ import assert from "node:assert/strict"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { after, before, describe, it } from "node:test"; import { AGENTNOTE_DIR, NOTES_REF_FULL } from "../core/constants.js"; +function resolveGitPath(cwd: string, value: string): string { + return isAbsolute(value) ? value : join(cwd, value); +} + describe("agentnote deinit", () => { let testDir: string; const cliPath = join(process.cwd(), "dist", "cli.js"); @@ -26,7 +30,11 @@ describe("agentnote deinit", () => { it("requires --agent flag", () => { let threw = false; try { - execSync(`node ${cliPath} deinit`, { cwd: testDir, encoding: "utf-8", stdio: "pipe" }); + execFileSync("node", [cliPath, "deinit"], { + cwd: testDir, + encoding: "utf-8", + stdio: "pipe", + }); } catch (err: unknown) { threw = true; const e = err as { stderr: string }; @@ -38,7 +46,7 @@ describe("agentnote deinit", () => { it("rejects unknown agent", () => { let threw = false; try { - execSync(`node ${cliPath} deinit --agent unknownagent`, { + execFileSync("node", [cliPath, "deinit", "--agent", "unknownagent"], { cwd: testDir, encoding: "utf-8", stdio: "pipe", @@ -54,7 +62,7 @@ describe("agentnote deinit", () => { it("rejects repeated --agent flags", () => { let threw = false; try { - execSync(`node ${cliPath} deinit --agent claude --agent cursor`, { + execFileSync("node", [cliPath, "deinit", "--agent", "claude", "--agent", "cursor"], { cwd: testDir, encoding: "utf-8", stdio: "pipe", @@ -72,7 +80,7 @@ describe("agentnote deinit", () => { it("removes agent hooks, git hooks, workflow, and notes config after init", () => { // First, init - execSync(`node ${cliPath} init --agent claude`, { cwd: testDir }); + execFileSync("node", [cliPath, "init", "--agent", "claude"], { cwd: testDir }); const settingsPath = join(testDir, ".claude", "settings.json"); assert.ok(existsSync(settingsPath), "settings.json should exist after init"); @@ -88,10 +96,14 @@ describe("agentnote deinit", () => { assert.ok(fetchBefore.includes(NOTES_REF_FULL), "notes fetch should be configured after init"); // Now deinit (with --remove-workflow to opt into workflow deletion) - const output = execSync(`node ${cliPath} deinit --agent claude --remove-workflow`, { - cwd: testDir, - encoding: "utf-8", - }); + const output = execFileSync( + "node", + [cliPath, "deinit", "--agent", "claude", "--remove-workflow"], + { + cwd: testDir, + encoding: "utf-8", + }, + ); assert.ok(output.includes("✓"), "should show success markers"); @@ -141,13 +153,13 @@ describe("agentnote deinit", () => { writeFileSync(join(hookDir, "post-commit"), originalHookContent, { mode: 0o755 }); // init should chain: backup original and install agent-note hook - execSync(`node ${cliPath} init --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "init", "--agent", "claude"], { cwd: dir }); const backupPath = join(hookDir, "post-commit.agentnote-backup"); assert.ok(existsSync(backupPath), "backup should exist after init"); // deinit should restore the original hook - execSync(`node ${cliPath} deinit --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "deinit", "--agent", "claude"], { cwd: dir }); assert.ok(existsSync(join(hookDir, "post-commit")), "post-commit should be restored"); const restoredContent = readFileSync(join(hookDir, "post-commit"), "utf-8"); @@ -165,18 +177,63 @@ describe("agentnote deinit", () => { execSync("git remote add origin https://example.com/repo.git", { cwd: dir }); execSync("git commit --allow-empty -m 'init'", { cwd: dir }); - execSync(`node ${cliPath} init --agent claude --no-action`, { cwd: dir }); + execFileSync("node", [cliPath, "init", "--agent", "claude", "--no-action"], { cwd: dir }); const shimPath = join(dir, ".git", AGENTNOTE_DIR, "bin", "agent-note"); assert.ok(existsSync(shimPath), "shim should exist after init"); - execSync(`node ${cliPath} deinit --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "deinit", "--agent", "claude"], { cwd: dir }); assert.ok(!existsSync(shimPath), "shim should be removed after deinit"); rmSync(dir, { recursive: true, force: true }); }); + it("removes worktree-local and common CLI shims when run inside a worktree", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-deinit-worktree-shim-")); + try { + execSync("git init", { cwd: dir }); + execSync("git config user.email test@test.com", { cwd: dir }); + execSync("git config user.name Test", { cwd: dir }); + execSync("git remote add origin https://example.com/repo.git", { cwd: dir }); + execSync("git commit --allow-empty -m 'init'", { cwd: dir }); + + const worktreeDir = join(dir, "custom worktrees", "cleanup target"); + mkdirSync(join(worktreeDir, ".."), { recursive: true }); + execFileSync("git", ["worktree", "add", "-b", "cleanup-target", worktreeDir], { + cwd: dir, + }); + + execFileSync("node", [cliPath, "init", "--agent", "claude", "--no-action"], { + cwd: worktreeDir, + }); + + const worktreeGitDir = resolveGitPath( + worktreeDir, + execSync("git rev-parse --git-dir", { cwd: worktreeDir, encoding: "utf-8" }).trim(), + ); + const commonGitDir = resolveGitPath( + worktreeDir, + execSync("git rev-parse --git-common-dir", { + cwd: worktreeDir, + encoding: "utf-8", + }).trim(), + ); + const worktreeShimPath = join(worktreeGitDir, AGENTNOTE_DIR, "bin", "agent-note"); + const commonShimPath = join(commonGitDir, AGENTNOTE_DIR, "bin", "agent-note"); + + assert.ok(existsSync(worktreeShimPath), "worktree-local shim should exist after init"); + assert.ok(existsSync(commonShimPath), "common shim should exist after init"); + + execFileSync("node", [cliPath, "deinit", "--agent", "claude"], { cwd: worktreeDir }); + + assert.ok(!existsSync(worktreeShimPath), "worktree-local shim should be removed"); + assert.ok(!existsSync(commonShimPath), "common shim should be removed"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("preserves workflow by default (requires --remove-workflow to delete)", () => { const dir = mkdtempSync(join(tmpdir(), "agentnote-deinit-kwf-")); execSync("git init", { cwd: dir }); @@ -185,11 +242,11 @@ describe("agentnote deinit", () => { execSync("git remote add origin https://example.com/repo.git", { cwd: dir }); execSync("git commit --allow-empty -m 'init'", { cwd: dir }); - execSync(`node ${cliPath} init --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "init", "--agent", "claude"], { cwd: dir }); const workflowPath = join(dir, ".github", "workflows", "agentnote-pr-report.yml"); assert.ok(existsSync(workflowPath), "workflow should exist after init"); - execSync(`node ${cliPath} deinit --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "deinit", "--agent", "claude"], { cwd: dir }); assert.ok(existsSync(workflowPath), "workflow should be preserved without --remove-workflow"); @@ -204,9 +261,11 @@ describe("agentnote deinit", () => { execSync("git remote add origin https://example.com/repo.git", { cwd: dir }); execSync("git commit --allow-empty -m 'init'", { cwd: dir }); - execSync(`node ${cliPath} init --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "init", "--agent", "claude"], { cwd: dir }); - execSync(`node ${cliPath} deinit --agent claude --keep-notes`, { cwd: dir }); + execFileSync("node", [cliPath, "deinit", "--agent", "claude", "--keep-notes"], { + cwd: dir, + }); const fetchResult = execSync("git config --get-all remote.origin.fetch", { cwd: dir, @@ -228,10 +287,10 @@ describe("agentnote deinit", () => { execSync("git remote add origin https://example.com/repo.git", { cwd: dir }); execSync("git commit --allow-empty -m 'init'", { cwd: dir }); - execSync(`node ${cliPath} init --agent claude`, { cwd: dir }); - execSync(`node ${cliPath} deinit --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "init", "--agent", "claude"], { cwd: dir }); + execFileSync("node", [cliPath, "deinit", "--agent", "claude"], { cwd: dir }); // Second deinit should not throw - execSync(`node ${cliPath} deinit --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "deinit", "--agent", "claude"], { cwd: dir }); rmSync(dir, { recursive: true, force: true }); }); @@ -244,11 +303,11 @@ describe("agentnote deinit", () => { execSync("git remote add origin https://example.com/repo.git", { cwd: dir }); execSync("git commit --allow-empty -m 'init'", { cwd: dir }); - execSync(`node ${cliPath} init --agent claude`, { cwd: dir }); - execSync(`node ${cliPath} deinit --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "init", "--agent", "claude"], { cwd: dir }); + execFileSync("node", [cliPath, "deinit", "--agent", "claude"], { cwd: dir }); // Re-init should succeed and install hooks again - execSync(`node ${cliPath} init --agent claude`, { cwd: dir }); + execFileSync("node", [cliPath, "init", "--agent", "claude"], { cwd: dir }); const settingsPath = join(dir, ".claude", "settings.json"); assert.ok(existsSync(settingsPath), "settings.json should exist after re-init"); @@ -269,14 +328,14 @@ describe("agentnote deinit", () => { execSync("git remote add origin https://example.com/repo.git", { cwd: dir }); execSync("git commit --allow-empty -m 'init'", { cwd: dir }); - execSync(`node ${cliPath} init --agent claude --dashboard`, { cwd: dir }); + execFileSync("node", [cliPath, "init", "--agent", "claude", "--dashboard"], { cwd: dir }); const prWorkflowPath = join(dir, ".github", "workflows", "agentnote-pr-report.yml"); const dashboardWorkflowPath = join(dir, ".github", "workflows", "agentnote-dashboard.yml"); assert.ok(existsSync(prWorkflowPath), "PR workflow should exist after init"); assert.ok(existsSync(dashboardWorkflowPath), "dashboard workflow should exist after init"); - execSync(`node ${cliPath} deinit --agent claude --remove-workflow`, { + execFileSync("node", [cliPath, "deinit", "--agent", "claude", "--remove-workflow"], { cwd: dir, encoding: "utf-8", }); diff --git a/packages/cli/src/commands/deinit.ts b/packages/cli/src/commands/deinit.ts index 4ac43c0..c182725 100644 --- a/packages/cli/src/commands/deinit.ts +++ b/packages/cli/src/commands/deinit.ts @@ -9,7 +9,7 @@ import { TEXT_ENCODING, } from "../core/constants.js"; import { gitSafe } from "../git.js"; -import { agentnoteDir, root } from "../paths.js"; +import { agentnoteDir, commonAgentnoteDir, root } from "../paths.js"; import { DASHBOARD_WORKFLOW_FILENAME, PR_REPORT_WORKFLOW_FILENAME, @@ -106,10 +106,17 @@ export async function deinit(args: string[]): Promise { } // Local CLI shim - const binDir = join(await agentnoteDir(), "bin"); - const shimPath = join(binDir, "agent-note"); - if (existsSync(shimPath)) { + const shimPaths = new Set([ + join(await agentnoteDir(), "bin", "agent-note"), + join(await commonAgentnoteDir(), "bin", "agent-note"), + ]); + let removedShim = false; + for (const shimPath of shimPaths) { + if (!existsSync(shimPath)) continue; await unlink(shimPath); + removedShim = true; + } + if (removedShim) { results.push(" ✓ removed local CLI shim"); } diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index f15e77f..4471ed0 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -10,7 +10,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { after, before, describe, it } from "node:test"; import { AGENTNOTE_DIR, @@ -29,12 +29,22 @@ function shellSingleQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } +function resolveGitPath(cwd: string, value: string): string { + return isAbsolute(value) ? value : join(cwd, value); +} + function withoutCodexThreadEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.CODEX_THREAD_ID; return env; } +type WorktreeLayout = { + name: string; + bare: boolean; + worktreePath: (dir: string) => string; +}; + function writeCodexTranscript( codexHome: string, sessionId: string, @@ -181,13 +191,80 @@ describe("agentnote init", () => { let originalCodexThreadId: string | undefined; const cliPath = join(process.cwd(), "dist", "cli.js"); + function configureUser(cwd: string): void { + execSync("git config user.email test@test.com", { cwd }); + execSync("git config user.name Test", { cwd }); + } + + function runClaudeHook(cwd: string, payload: Record): void { + execFileSync(process.execPath, [cliPath, "hook", "--agent", "claude"], { + cwd, + input: JSON.stringify(payload), + encoding: "utf-8", + env: withoutCodexThreadEnv(), + }); + } + + function recordClaudeWorktreeCommit( + cwd: string, + options: { + sessionId: string; + prompt: string; + fileName: string; + commitMessage: string; + content?: string; + }, + ): Record { + const filePath = join(cwd, options.fileName); + runClaudeHook(cwd, { + hook_event_name: "SessionStart", + session_id: options.sessionId, + model: "claude-opus-4-6", + }); + runClaudeHook(cwd, { + hook_event_name: "UserPromptSubmit", + session_id: options.sessionId, + prompt: options.prompt, + }); + runClaudeHook(cwd, { + hook_event_name: "PreToolUse", + session_id: options.sessionId, + tool_name: "Write", + tool_use_id: `tool-${options.sessionId}`, + tool_input: { file_path: filePath }, + }); + writeFileSync(filePath, options.content ?? `${options.prompt}\n`); + runClaudeHook(cwd, { + hook_event_name: "PostToolUse", + session_id: options.sessionId, + tool_name: "Write", + tool_use_id: `tool-${options.sessionId}`, + tool_input: { file_path: filePath }, + }); + + execFileSync("git", ["add", "--", options.fileName], { cwd }); + execFileSync("git", ["commit", "-m", options.commitMessage], { + cwd, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + + return JSON.parse( + execFileSync("git", ["notes", "--ref=agentnote", "show", "HEAD"], { + cwd, + encoding: "utf-8", + stdio: "pipe", + }), + ); + } + before(() => { originalCodexThreadId = process.env.CODEX_THREAD_ID; delete process.env.CODEX_THREAD_ID; testDir = mkdtempSync(join(tmpdir(), "agentnote-init-")); execSync("git init", { cwd: testDir }); - execSync("git config user.email test@test.com", { cwd: testDir }); - execSync("git config user.name Test", { cwd: testDir }); + configureUser(testDir); execSync("git remote add origin https://example.com/repo.git", { cwd: testDir, }); @@ -269,6 +346,10 @@ describe("agentnote init", () => { postCommitHook.includes('"$GIT_DIR/agentnote/bin/agent-note"'), "post-commit should prefer the repo-local shim", ); + assert.ok( + postCommitHook.includes('"$COMMON_GIT_DIR/agentnote/bin/agent-note"'), + "post-commit should fall back to the shared worktree shim", + ); assert.ok( !postCommitHook.includes("npx --yes agent-note record"), "post-commit should not resolve an unpinned package at commit time", @@ -283,6 +364,577 @@ describe("agentnote init", () => { ); }); + it("records plain commits made from a git worktree using the common shim", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-worktree-")); + try { + execSync("git init", { cwd: dir }); + execSync("git config user.email test@test.com", { cwd: dir }); + execSync("git config user.name Test", { cwd: dir }); + execSync("git commit --allow-empty -m 'init'", { cwd: dir }); + execSync(`node ${cliPath} init --agent claude --no-action`, { + cwd: dir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + }); + + const commonGitDir = resolveGitPath( + dir, + execSync("git rev-parse --git-common-dir", { cwd: dir, encoding: "utf-8" }).trim(), + ); + const commonShim = join(commonGitDir, AGENTNOTE_DIR, "bin", "agent-note"); + assert.ok(existsSync(commonShim), "init should create a common shim for all worktrees"); + + const worktreeDir = join(dir, ".claude", "worktrees", "agent-view"); + mkdirSync(join(dir, ".claude", "worktrees"), { recursive: true }); + execSync(`git worktree add -b agent-view-test ${shellSingleQuote(worktreeDir)}`, { + cwd: dir, + }); + + const worktreeGitDir = resolveGitPath( + worktreeDir, + execSync("git rev-parse --git-dir", { cwd: worktreeDir, encoding: "utf-8" }).trim(), + ); + assert.ok( + !existsSync(join(worktreeGitDir, AGENTNOTE_DIR, "bin", "agent-note")), + "the regression must exercise the common shim, not a worktree-local shim", + ); + + const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const runClaudeHook = (payload: Record) => { + execFileSync(process.execPath, [cliPath, "hook", "--agent", "claude"], { + cwd: worktreeDir, + input: JSON.stringify(payload), + encoding: "utf-8", + env: withoutCodexThreadEnv(), + }); + }; + + runClaudeHook({ + hook_event_name: "SessionStart", + session_id: sessionId, + model: "claude-opus-4-6", + }); + runClaudeHook({ + hook_event_name: "UserPromptSubmit", + session_id: sessionId, + prompt: "Create the Agent View worktree fixture.", + }); + + const filePath = join(worktreeDir, "agent-view-worktree.txt"); + runClaudeHook({ + hook_event_name: "PreToolUse", + session_id: sessionId, + tool_name: "Write", + tool_use_id: "tool-worktree-write", + tool_input: { file_path: filePath }, + }); + writeFileSync(filePath, "Agent View worktree support\n"); + runClaudeHook({ + hook_event_name: "PostToolUse", + session_id: sessionId, + tool_name: "Write", + tool_use_id: "tool-worktree-write", + tool_input: { file_path: filePath }, + }); + + execSync("git add agent-view-worktree.txt", { cwd: worktreeDir }); + execSync("git commit -m 'feat: worktree agent note'", { + cwd: worktreeDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + }); + + const note = JSON.parse( + execSync("git notes --ref=agentnote show HEAD", { + cwd: worktreeDir, + encoding: "utf-8", + }), + ); + assert.equal(note.agent, "claude"); + assert.equal(note.session_id, sessionId); + assert.equal(note.interactions[0].prompt, "Create the Agent View worktree fixture."); + assert.deepEqual(note.interactions[0].files_touched, ["agent-view-worktree.txt"]); + assert.equal(note.files[0].path, "agent-view-worktree.txt"); + assert.equal(note.files[0].by_ai, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("installs shared hooks and shims when init runs inside a git worktree", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-worktree-init-")); + try { + execSync("git init", { cwd: dir }); + execSync("git config user.email test@test.com", { cwd: dir }); + execSync("git config user.name Test", { cwd: dir }); + execSync("git commit --allow-empty -m 'init'", { cwd: dir }); + + const worktreeDir = join(dir, ".claude", "worktrees", "init-agent"); + mkdirSync(join(dir, ".claude", "worktrees"), { recursive: true }); + execSync(`git worktree add -b init-agent-test ${shellSingleQuote(worktreeDir)}`, { + cwd: dir, + }); + + execSync(`node ${cliPath} init --agent claude --no-action`, { + cwd: worktreeDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + }); + + const worktreeGitDir = resolveGitPath( + worktreeDir, + execSync("git rev-parse --git-dir", { cwd: worktreeDir, encoding: "utf-8" }).trim(), + ); + const commonGitDir = resolveGitPath( + worktreeDir, + execSync("git rev-parse --git-common-dir", { + cwd: worktreeDir, + encoding: "utf-8", + }).trim(), + ); + + assert.ok( + existsSync(join(worktreeGitDir, AGENTNOTE_DIR, "bin", "agent-note")), + "worktree init should create a worktree-local shim", + ); + assert.ok( + existsSync(join(commonGitDir, AGENTNOTE_DIR, "bin", "agent-note")), + "worktree init should also create a common shim for sibling worktrees", + ); + assert.ok( + existsSync(join(commonGitDir, "hooks", "post-commit")), + "worktree init should install hooks in the shared git hook directory", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves normal repository hook paths when init and status run from a subdirectory", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-subdir-init-")); + try { + execSync("git init", { cwd: dir }); + execSync("git config user.email test@test.com", { cwd: dir }); + execSync("git config user.name Test", { cwd: dir }); + execSync("git commit --allow-empty -m 'init'", { cwd: dir }); + + const nestedDir = join(dir, "src", "nested"); + mkdirSync(nestedDir, { recursive: true }); + execFileSync(process.execPath, [cliPath, "init", "--agent", "claude", "--no-action"], { + cwd: nestedDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + + assert.ok( + existsSync(join(dir, ".git", "hooks", "post-commit")), + "subdirectory init should install hooks in the repository git dir", + ); + + const statusOutput = execFileSync(process.execPath, [cliPath, "status"], { + cwd: nestedDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + assert.ok( + statusOutput.includes("git: active (prepare-commit-msg, post-commit, pre-push)"), + "status from a repository subdirectory should read Git's effective hook directory", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves worktree common git paths when init and status run from a subdirectory", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-worktree-subdir-")); + try { + execSync("git init", { cwd: dir }); + execSync("git config user.email test@test.com", { cwd: dir }); + execSync("git config user.name Test", { cwd: dir }); + execSync("git commit --allow-empty -m 'init'", { cwd: dir }); + + const worktreeDir = join(dir, ".claude", "worktrees", "subdir-agent"); + mkdirSync(join(dir, ".claude", "worktrees"), { recursive: true }); + execFileSync("git", ["worktree", "add", "-b", "subdir-agent-test", worktreeDir], { + cwd: dir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + + const nestedDir = join(worktreeDir, "src", "nested"); + mkdirSync(nestedDir, { recursive: true }); + execFileSync(process.execPath, [cliPath, "init", "--agent", "claude", "--no-action"], { + cwd: nestedDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + + const commonGitDir = resolveGitPath( + nestedDir, + execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: nestedDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }).trim(), + ); + assert.ok( + existsSync(join(commonGitDir, AGENTNOTE_DIR, "bin", "agent-note")), + "subdirectory init should create the common shim at Git's cwd-relative common dir", + ); + + const statusOutput = execFileSync(process.execPath, [cliPath, "status"], { + cwd: nestedDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + assert.ok( + statusOutput.includes("git: active (prepare-commit-msg, post-commit, pre-push)"), + "status from a worktree subdirectory should read Git's effective hook directory", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("supports common-shim worktree commits across bare and non-bare layouts", () => { + const layouts: WorktreeLayout[] = [ + { + name: "non-bare nested Agent View path", + bare: false, + worktreePath: (dir) => join(dir, "repo", ".claude", "worktrees", "agent-view"), + }, + { + name: "non-bare custom sibling path with spaces", + bare: false, + worktreePath: (dir) => join(dir, "custom worktrees", "feature one"), + }, + { + name: "bare branch directory path", + bare: true, + worktreePath: (dir) => join(dir, "repo.bare", "branch", "feature"), + }, + { + name: "bare external custom path with spaces", + bare: true, + worktreePath: (dir) => join(dir, "external worktrees", "feature one"), + }, + ]; + + for (const layout of layouts) { + const dir = mkdtempSync(join(tmpdir(), "agentnote-worktree-matrix-")); + try { + const mainDir = layout.bare ? join(dir, "repo.bare") : join(dir, "repo"); + if (layout.bare) { + execFileSync("git", ["init", "--bare", mainDir], { + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + const seedDir = join(dir, "seed"); + execFileSync("git", ["clone", mainDir, seedDir], { + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: seedDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: seedDir }); + execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: seedDir }); + execFileSync("git", ["push", "origin", "HEAD:main"], { cwd: seedDir }); + rmSync(seedDir, { recursive: true, force: true }); + } else { + execFileSync("git", ["init", mainDir], { + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: mainDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: mainDir }); + execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: mainDir }); + } + + const baseCwd = mainDir; + const baseRef = layout.bare + ? "main" + : execFileSync("git", ["branch", "--show-current"], { + cwd: mainDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }).trim(); + const worktreeDir = layout.worktreePath(dir); + mkdirSync(join(worktreeDir, ".."), { recursive: true }); + execFileSync("git", ["worktree", "add", "-b", "feature", worktreeDir, baseRef], { + cwd: baseCwd, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: worktreeDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: worktreeDir }); + + execFileSync(process.execPath, [cliPath, "init", "--agent", "claude", "--no-action"], { + cwd: worktreeDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + + const worktreeGitDir = resolveGitPath( + worktreeDir, + execFileSync("git", ["rev-parse", "--git-dir"], { + cwd: worktreeDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }).trim(), + ); + const commonGitDir = resolveGitPath( + worktreeDir, + execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: worktreeDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }).trim(), + ); + const hookPath = resolveGitPath( + worktreeDir, + execFileSync("git", ["rev-parse", "--git-path", "hooks/post-commit"], { + cwd: worktreeDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }).trim(), + ); + + assert.ok( + existsSync(join(worktreeGitDir, AGENTNOTE_DIR, "bin", "agent-note")), + `${layout.name}: worktree-local shim should exist`, + ); + assert.ok( + existsSync(join(commonGitDir, AGENTNOTE_DIR, "bin", "agent-note")), + `${layout.name}: common shim should exist`, + ); + assert.ok(existsSync(hookPath), `${layout.name}: shared post-commit hook should exist`); + + rmSync(join(worktreeGitDir, AGENTNOTE_DIR, "bin"), { recursive: true, force: true }); + assert.ok( + !existsSync(join(worktreeGitDir, AGENTNOTE_DIR, "bin", "agent-note")), + `${layout.name}: regression should force common-shim fallback`, + ); + + const sessionId = "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"; + const runClaudeHook = (payload: Record) => { + execFileSync(process.execPath, [cliPath, "hook", "--agent", "claude"], { + cwd: worktreeDir, + input: JSON.stringify(payload), + encoding: "utf-8", + env: withoutCodexThreadEnv(), + }); + }; + + runClaudeHook({ + hook_event_name: "SessionStart", + session_id: sessionId, + model: "claude-opus-4-6", + }); + runClaudeHook({ + hook_event_name: "UserPromptSubmit", + session_id: sessionId, + prompt: `${layout.name}: create worktree fixture.`, + }); + + const fileName = "worktree-matrix.txt"; + const filePath = join(worktreeDir, fileName); + runClaudeHook({ + hook_event_name: "PreToolUse", + session_id: sessionId, + tool_name: "Write", + tool_use_id: `tool-${layout.name}`, + tool_input: { file_path: filePath }, + }); + writeFileSync(filePath, `${layout.name}\n`); + runClaudeHook({ + hook_event_name: "PostToolUse", + session_id: sessionId, + tool_name: "Write", + tool_use_id: `tool-${layout.name}`, + tool_input: { file_path: filePath }, + }); + + execFileSync("git", ["add", "--", fileName], { cwd: worktreeDir }); + execFileSync("git", ["commit", "-m", `feat: ${layout.name}`], { + cwd: worktreeDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + + const note = JSON.parse( + execFileSync("git", ["notes", "--ref=agentnote", "show", "HEAD"], { + cwd: worktreeDir, + encoding: "utf-8", + stdio: "pipe", + }), + ); + assert.equal(note.session_id, sessionId, `${layout.name}: note should use hook session`); + assert.deepEqual( + note.interactions[0].files_touched, + [fileName], + `${layout.name}: prompt should keep file evidence`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it("records worktree commits across git worktree add modes and path mutations", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-worktree-modes-")); + try { + const repoDir = join(dir, "repo"); + execSync(`git init ${shellSingleQuote(repoDir)}`); + configureUser(repoDir); + execSync("git commit --allow-empty -m 'init'", { cwd: repoDir }); + execSync(`node ${cliPath} init --agent claude --no-action`, { + cwd: repoDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + }); + + const addWorktree = (args: string[]) => { + execFileSync("git", ["worktree", "add", ...args], { + cwd: repoDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + }; + const recordAndAssert = (worktreeDir: string, name: string, index: number) => { + const sessionId = `cccccccc-dddd-4eee-8fff-${String(index).padStart(12, "0")}`; + const fileName = `worktree-mode-${index}.txt`; + const prompt = `${name}: record worktree mode.`; + const note = recordClaudeWorktreeCommit(worktreeDir, { + sessionId, + prompt, + fileName, + commitMessage: `feat: ${name}`, + }) as { + session_id?: string; + interactions?: Array<{ prompt?: string; files_touched?: string[] }>; + }; + + assert.equal(note.session_id, sessionId, `${name}: note should use the worktree session`); + assert.equal(note.interactions?.[0]?.prompt, prompt, `${name}: prompt should be recorded`); + assert.deepEqual( + note.interactions?.[0]?.files_touched, + [fileName], + `${name}: file evidence should be preserved`, + ); + }; + + const detachedDir = join(dir, "detached worktree"); + addWorktree(["--detach", detachedDir, "HEAD"]); + recordAndAssert(detachedDir, "detached HEAD worktree", 1); + + const lockedDir = join(dir, "locked worktree"); + addWorktree([ + "--lock", + "--reason", + "agentnote regression", + "-b", + "locked-mode", + lockedDir, + "HEAD", + ]); + recordAndAssert(lockedDir, "locked worktree", 2); + execFileSync("git", ["worktree", "unlock", lockedDir], { cwd: repoDir }); + + const relativeDir = join(dir, "relative paths", "feature"); + mkdirSync(join(relativeDir, ".."), { recursive: true }); + addWorktree(["--relative-paths", "-b", "relative-mode", relativeDir, "HEAD"]); + recordAndAssert(relativeDir, "relative-paths worktree", 3); + + const orphanDir = join(dir, "orphan worktree"); + addWorktree(["--orphan", "-b", "orphan-mode", orphanDir]); + recordAndAssert(orphanDir, "orphan worktree", 4); + + const firstDuplicateDir = join(dir, "duplicate-a", "feature"); + const secondDuplicateDir = join(dir, "duplicate-b", "feature"); + mkdirSync(join(firstDuplicateDir, ".."), { recursive: true }); + mkdirSync(join(secondDuplicateDir, ".."), { recursive: true }); + addWorktree(["-b", "duplicate-a-mode", firstDuplicateDir, "HEAD"]); + addWorktree(["-b", "duplicate-b-mode", secondDuplicateDir, "HEAD"]); + recordAndAssert(secondDuplicateDir, "duplicate basename worktree", 5); + + const moveSourceDir = join(dir, "move source"); + const moveTargetDir = join(dir, "moved worktrees", "move target"); + mkdirSync(join(moveTargetDir, ".."), { recursive: true }); + addWorktree(["-b", "moved-mode", moveSourceDir, "HEAD"]); + execFileSync("git", ["worktree", "move", moveSourceDir, moveTargetDir], { + cwd: repoDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + stdio: "pipe", + }); + recordAndAssert(moveTargetDir, "moved worktree", 6); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("respects worktree-specific hooksPath configuration", () => { + const dir = mkdtempSync(join(tmpdir(), "agentnote-worktree-hooks-path-")); + try { + const repoDir = join(dir, "repo"); + execSync(`git init ${shellSingleQuote(repoDir)}`); + configureUser(repoDir); + execSync("git commit --allow-empty -m 'init'", { cwd: repoDir }); + + const worktreeDir = join(dir, "configured worktree"); + execSync(`git worktree add -b configured-hooks ${shellSingleQuote(worktreeDir)} HEAD`, { + cwd: repoDir, + }); + execSync("git config extensions.worktreeConfig true", { cwd: repoDir }); + execSync("git config --worktree core.hooksPath .custom-hooks", { cwd: worktreeDir }); + + execSync(`node ${cliPath} init --agent claude --no-action`, { + cwd: worktreeDir, + encoding: "utf-8", + env: withoutCodexThreadEnv(), + }); + + const postCommitHook = join(worktreeDir, ".custom-hooks", "post-commit"); + assert.ok( + existsSync(postCommitHook), + "init should install hooks into the worktree-specific hooksPath", + ); + + const sessionId = "dddddddd-eeee-4fff-8aaa-000000000001"; + const prompt = "configured hooksPath: record worktree commit."; + const note = recordClaudeWorktreeCommit(worktreeDir, { + sessionId, + prompt, + fileName: "configured-hooks-path.txt", + commitMessage: "feat: configured hooks path worktree", + }) as { + session_id?: string; + interactions?: Array<{ prompt?: string }>; + }; + + assert.equal(note.session_id, sessionId); + assert.equal(note.interactions?.[0]?.prompt, prompt); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("is idempotent", () => { execSync(`node ${cliPath} init --agent claude`, { cwd: testDir }); const output = execSync(`node ${cliPath} init --agent claude`, { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0abb1bc..b255404 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -17,7 +17,7 @@ import { TRAILER_SESSION_FILES, } from "../core/constants.js"; import { git, gitSafe } from "../git.js"; -import { agentnoteDir, root } from "../paths.js"; +import { agentnoteDir, commonAgentnoteDir, root } from "../paths.js"; /** Default workflow filename generated for PR Report mode. */ export const PR_REPORT_WORKFLOW_FILENAME = "agentnote-pr-report.yml"; @@ -169,6 +169,7 @@ ${AGENTNOTE_HOOK_MARKER} # injected because the session heartbeat was stale, the CLI may use a strict # HEAD fallback that only records when session file evidence matches HEAD. GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)" +COMMON_GIT_DIR="$(git rev-parse --git-common-dir 2>/dev/null)" SESSION_ID=$(git log -1 --format='%(trailers:key=${TRAILER_KEY},valueonly)' HEAD 2>/dev/null | tr -d '\\n') if [ -z "$SESSION_ID" ]; then FALLBACK_FILE="$GIT_DIR/agentnote/${POST_COMMIT_FALLBACK_FILE}" @@ -190,6 +191,12 @@ record_agentnote() { "$GIT_DIR/agentnote/bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null || true return fi + # Git worktrees use their own $GIT_DIR but share hooks through the common git + # dir. Fall back to the common shim when init ran from another worktree. + if [ -n "$COMMON_GIT_DIR" ] && [ "$COMMON_GIT_DIR" != "$GIT_DIR" ] && [ -x "$COMMON_GIT_DIR/agentnote/bin/agent-note" ]; then + "$COMMON_GIT_DIR/agentnote/bin/agent-note" record "$RECORD_SESSION_ID" 2>/dev/null || true + return + fi # Fall back to stable local/global binaries only. REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" if [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ]; then @@ -212,10 +219,15 @@ ${AGENTNOTE_HOOK_MARKER} # PR workflows can fetch the latest notes ref, but never block the main push on failure. if [ -n "$AGENTNOTE_PUSHING" ]; then exit 0; fi GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)" +COMMON_GIT_DIR="$(git rev-parse --git-common-dir 2>/dev/null)" if [ -x "$GIT_DIR/agentnote/bin/agent-note" ]; then "$GIT_DIR/agentnote/bin/agent-note" push-notes "$1" 2>/dev/null || true exit 0 fi +if [ -n "$COMMON_GIT_DIR" ] && [ "$COMMON_GIT_DIR" != "$GIT_DIR" ] && [ -x "$COMMON_GIT_DIR/agentnote/bin/agent-note" ]; then + "$COMMON_GIT_DIR/agentnote/bin/agent-note" push-notes "$1" 2>/dev/null || true + exit 0 +fi REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" if [ -f "$REPO_ROOT/node_modules/.bin/agent-note" ]; then "$REPO_ROOT/node_modules/.bin/agent-note" push-notes "$1" 2>/dev/null || true @@ -278,7 +290,10 @@ export async function init(args: string[]): Promise { // Git hooks (prepare-commit-msg, post-commit, pre-push) if (!skipGitHooks && !actionOnly) { - await installLocalCliShim(await agentnoteDir()); + const shimDirs = new Set([await agentnoteDir(), await commonAgentnoteDir()]); + for (const shimDir of shimDirs) { + await installLocalCliShim(shimDir); + } const hookDir = await resolveHookDir(repoRoot); await mkdir(hookDir, { recursive: true }); @@ -412,7 +427,6 @@ export async function init(args: string[]): Promise { // ─── Git hook helpers ─── -/** Resolve the git hooks directory (respects core.hooksPath). */ /** Resolve the effective git hooks directory, respecting `core.hooksPath`. */ export async function resolveHookDir(repoRoot: string): Promise { try { @@ -421,8 +435,8 @@ export async function resolveHookDir(repoRoot: string): Promise { } catch { // No custom hooksPath set. } - const gitDir = await git(["rev-parse", "--git-dir"]); - return join(gitDir, "hooks"); + const hookPath = await git(["rev-parse", "--git-path", "hooks"]); + return isAbsolute(hookPath) ? hookPath : resolve(process.cwd(), hookPath); } function shellSingleQuote(value: string): string { @@ -459,10 +473,10 @@ async function installGitHook(hookDir: string, name: string, script: string): Pr const backupPath = `${hookPath}.agentnote-backup`; // Regenerate chained variant if a backup exists, otherwise bare script. const target = existsSync(backupPath) - ? script.replace( - "#!/bin/sh", - `#!/bin/sh\n# Chain to original hook — preserve exit status.\nif [ -f ${shellSingleQuote(backupPath)} ]; then ${shellSingleQuote(backupPath)} "$@" || exit $?; fi`, - ) + ? script.replace("#!/bin/sh", () => { + // Function replacement avoids `$` expansion for shell-special paths. + return `#!/bin/sh\n# Chain to original hook — preserve exit status.\nif [ -f ${shellSingleQuote(backupPath)} ]; then ${shellSingleQuote(backupPath)} "$@" || exit $?; fi`; + }) : script; if (existing.trim() === target.trim()) return false; // already up-to-date await writeFile(hookPath, target); @@ -478,10 +492,10 @@ async function installGitHook(hookDir: string, name: string, script: string): Pr } // Chain: run original hook first, preserve its exit status. // If the original hook fails, abort — don't override repo protections. - const chainedScript = script.replace( - "#!/bin/sh", - `#!/bin/sh\n# Chain to original hook — preserve exit status.\nif [ -f ${shellSingleQuote(backupPath)} ]; then ${shellSingleQuote(backupPath)} "$@" || exit $?; fi`, - ); + const chainedScript = script.replace("#!/bin/sh", () => { + // Function replacement avoids `$` expansion for shell-special paths. + return `#!/bin/sh\n# Chain to original hook — preserve exit status.\nif [ -f ${shellSingleQuote(backupPath)} ]; then ${shellSingleQuote(backupPath)} "$@" || exit $?; fi`; + }); await writeFile(hookPath, chainedScript); await chmod(hookPath, 0o755); return true; diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index db7e9fe..e2e3384 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { isAbsolute, join } from "node:path"; +import { join } from "node:path"; import { isAgentNoteHookCommand } from "../agents/hook-command.js"; import { getAgent, listAgents } from "../agents/index.js"; import { AGENT_NAMES, type AgentName } from "../agents/types.js"; @@ -19,6 +19,7 @@ import { readSessionAgent } from "../core/session.js"; import { readNote } from "../core/storage.js"; import { gitSafe } from "../git.js"; import { agentnoteDir, root, sessionFile } from "../paths.js"; +import { resolveHookDir } from "./init.js"; import { normalizeEntry } from "./normalize.js"; declare const __VERSION__: string; @@ -340,14 +341,3 @@ async function readManagedGitHooks(repoRoot: string): Promise { return active; } - -async function resolveHookDir(repoRoot: string): Promise { - const hooksPathConfig = (await gitSafe(["config", "--get", "core.hooksPath"])).stdout.trim(); - if (hooksPathConfig) { - return isAbsolute(hooksPathConfig) ? hooksPathConfig : join(repoRoot, hooksPathConfig); - } - - const gitDir = (await gitSafe(["rev-parse", "--git-dir"])).stdout.trim(); - const resolvedGitDir = isAbsolute(gitDir) ? gitDir : join(repoRoot, gitDir); - return join(resolvedGitDir, "hooks"); -} diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts index 2e395a7..e7b159d 100644 --- a/packages/cli/src/paths.ts +++ b/packages/cli/src/paths.ts @@ -1,9 +1,15 @@ -import { join } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import { AGENTNOTE_DIR, SESSION_FILE } from "./core/constants.js"; import { git, repoRoot } from "./git.js"; let _root: string | null = null; let _gitDir: string | null = null; +let _commonGitDir: string | null = null; + +/** Resolve git paths that are reported relative to the current process cwd. */ +function resolveGitPath(value: string): string { + return isAbsolute(value) ? value : resolve(process.cwd(), value); +} /** Resolve and cache the repository root, exiting for non-git directories. */ async function root(): Promise { @@ -22,19 +28,30 @@ async function root(): Promise { async function gitDir(): Promise { if (!_gitDir) { _gitDir = await git(["rev-parse", "--git-dir"]); - // Make absolute if relative. - if (!_gitDir.startsWith("/")) { - _gitDir = join(await root(), _gitDir); - } + _gitDir = resolveGitPath(_gitDir); } return _gitDir; } +/** Resolve the shared git directory used by all worktrees in this repository. */ +async function commonGitDir(): Promise { + if (!_commonGitDir) { + _commonGitDir = await git(["rev-parse", "--git-common-dir"]); + _commonGitDir = resolveGitPath(_commonGitDir); + } + return _commonGitDir; +} + /** Path to the agentnote data directory inside the git dir. */ export async function agentnoteDir(): Promise { return join(await gitDir(), AGENTNOTE_DIR); } +/** Path to the shared agentnote directory visible from every git worktree. */ +export async function commonAgentnoteDir(): Promise { + return join(await commonGitDir(), AGENTNOTE_DIR); +} + /** Path to the active session ID file. */ export async function sessionFile(): Promise { return join(await agentnoteDir(), SESSION_FILE);