From 878c7dd18939caf7abba464b66adfd23460ba796 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Wed, 13 May 2026 17:29:35 +0900 Subject: [PATCH 1/7] fix(init): support git worktree hook shims Why Git worktrees can run shared hooks with a worktree-specific git dir, so hooks that only looked for a local shim could miss the CLI after init from another checkout. User impact Commits made from bare or non-bare worktrees, including Agent View style directories, can now write Agent Note data through the common git dir shim while keeping session buffers isolated per worktree. Verification npm run build npm run typecheck npm run lint npm test Release note: Support Agent Note recording from git worktrees by sharing the hook shim through the common git directory. --- packages/cli/dist/cli.js | 91 ++++++++++++++++++++--------- packages/cli/src/commands/deinit.ts | 15 +++-- packages/cli/src/commands/init.ts | 40 ++++++++----- packages/cli/src/paths.ts | 21 ++++++- 4 files changed, 119 insertions(+), 48 deletions(-) diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index a85d6f88..e81e4ffa 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -4912,9 +4912,10 @@ async function ensureEmptyBlobInStore() { } // src/paths.ts -import { join as join7 } from "node:path"; +import { isAbsolute as isAbsolute2, join as join7 } from "node:path"; var _root = null; var _gitDir = null; +var _commonGitDir = null; async function root() { if (!_root) { try { @@ -4929,15 +4930,27 @@ async function root() { async function gitDir() { if (!_gitDir) { _gitDir = await git(["rev-parse", "--git-dir"]); - if (!_gitDir.startsWith("/")) { + if (!isAbsolute2(_gitDir)) { _gitDir = join7(await root(), _gitDir); } } return _gitDir; } +async function commonGitDir() { + if (!_commonGitDir) { + _commonGitDir = await git(["rev-parse", "--git-common-dir"]); + if (!isAbsolute2(_commonGitDir)) { + _commonGitDir = join7(await root(), _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); } @@ -5171,7 +5184,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 resolve5 } 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 +5321,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 +5343,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 +5370,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 +5429,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 +5544,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 : join10(repoRoot3, hookPath); } function shellSingleQuote(value) { return `'${value.replace(/'/g, `'"'"'`)}'`; @@ -5543,12 +5571,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 +5586,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 +5660,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 +5710,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 +5748,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 +5937,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 +5958,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 +7066,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 { isAbsolute as isAbsolute5, join as join16 } from "node:path"; var VERSION = "1.0.3"; var CAPABILITY_LABELS = { edits: "edits", @@ -7269,16 +7302,16 @@ async function readManagedGitHooks(repoRoot3) { async function resolveHookDir2(repoRoot3) { const hooksPathConfig = (await gitSafe(["config", "--get", "core.hooksPath"])).stdout.trim(); if (hooksPathConfig) { - return isAbsolute4(hooksPathConfig) ? hooksPathConfig : join16(repoRoot3, hooksPathConfig); + return isAbsolute5(hooksPathConfig) ? hooksPathConfig : join16(repoRoot3, hooksPathConfig); } const gitDir2 = (await gitSafe(["rev-parse", "--git-dir"])).stdout.trim(); - const resolvedGitDir = isAbsolute4(gitDir2) ? gitDir2 : join16(repoRoot3, gitDir2); + const resolvedGitDir = isAbsolute5(gitDir2) ? gitDir2 : join16(repoRoot3, gitDir2); return join16(resolvedGitDir, "hooks"); } // src/commands/why.ts import { existsSync as existsSync16, realpathSync } from "node:fs"; -import { isAbsolute as isAbsolute5, posix, relative as relative3, resolve as resolvePath } from "node:path"; +import { isAbsolute as isAbsolute6, posix, relative as relative3, resolve as resolvePath } from "node:path"; var ALL_ZERO_COMMIT_RE = /^0{40}$/; var BLAME_HEADER_RE = /^([0-9a-f]{40})\s+\d+\s+\d+(?:\s+\d+)?$/i; var COLON_COLUMN_TARGET_RE = /^(.+):(\d+):\d+$/; @@ -7397,7 +7430,7 @@ async function normalizeTargetPath(path) { PATH_PREFIX_RE, "" ); - if (!isAbsolute5(normalized)) return normalized; + if (!isAbsolute6(normalized)) return normalized; const root2 = await repoRoot(); return relative3(realpathIfExists(root2), realpathIfExists(normalized)).replaceAll("\\", "/"); } diff --git a/packages/cli/src/commands/deinit.ts b/packages/cli/src/commands/deinit.ts index 4ac43c0b..c1827250 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.ts b/packages/cli/src/commands/init.ts index 0abb1bc9..835591d0 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 : join(repoRoot, 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/paths.ts b/packages/cli/src/paths.ts index 2e395a7b..c40e85b3 100644 --- a/packages/cli/src/paths.ts +++ b/packages/cli/src/paths.ts @@ -1,9 +1,10 @@ -import { join } from "node:path"; +import { isAbsolute, join } 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 and cache the repository root, exiting for non-git directories. */ async function root(): Promise { @@ -23,18 +24,34 @@ async function gitDir(): Promise { if (!_gitDir) { _gitDir = await git(["rev-parse", "--git-dir"]); // Make absolute if relative. - if (!_gitDir.startsWith("/")) { + if (!isAbsolute(_gitDir)) { _gitDir = join(await root(), _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"]); + if (!isAbsolute(_commonGitDir)) { + _commonGitDir = join(await root(), _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); From 51c7fd03bcc8f7f95570ad02cb6cbf139041a3ff Mon Sep 17 00:00:00 2001 From: wasabeef Date: Wed, 13 May 2026 17:30:25 +0900 Subject: [PATCH 2/7] test(init): cover git worktree layouts Why Worktree behavior depends on Git's private git dir, common git dir, hook path, and user-chosen worktree directory layout. User impact The regression suite now covers bare and non-bare repositories, custom paths with spaces, detached and orphan worktrees, moved worktrees, duplicate basenames, and worktree-specific hooksPath. Verification npx tsx src/commands/init.test.ts npx tsx src/commands/deinit.test.ts npm test Release note: skip --- packages/cli/src/commands/deinit.test.ts | 53 ++- packages/cli/src/commands/init.test.ts | 527 ++++++++++++++++++++++- 2 files changed, 576 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/deinit.test.ts b/packages/cli/src/commands/deinit.test.ts index d07f3206..086624a6 100644 --- a/packages/cli/src/commands/deinit.test.ts +++ b/packages/cli/src/commands/deinit.test.ts @@ -2,10 +2,18 @@ import assert from "node:assert/strict"; import { 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 shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +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"); @@ -177,6 +185,49 @@ describe("agentnote 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 }); + execSync(`git worktree add -b cleanup-target ${shellSingleQuote(worktreeDir)}`, { + cwd: dir, + }); + + execSync(`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"); + + execSync(`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 }); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index f15e77f3..eae39c2c 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,77 @@ 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 }, + }); + + execSync(`git add ${shellSingleQuote(options.fileName)}`, { cwd }); + execSync(`git commit -m ${shellSingleQuote(options.commitMessage)}`, { + cwd, + env: withoutCodexThreadEnv(), + }); + + return JSON.parse( + execSync("git notes --ref=agentnote show HEAD", { + cwd, + encoding: "utf-8", + }), + ); + } + 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 +343,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 +361,449 @@ 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("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) { + execSync(`git init --bare ${shellSingleQuote(mainDir)}`); + const seedDir = join(dir, "seed"); + execSync(`git clone ${shellSingleQuote(mainDir)} ${shellSingleQuote(seedDir)}`); + execSync("git config user.email test@test.com", { cwd: seedDir }); + execSync("git config user.name Test", { cwd: seedDir }); + execSync("git commit --allow-empty -m 'init'", { cwd: seedDir }); + execSync("git push origin HEAD:main", { cwd: seedDir }); + rmSync(seedDir, { recursive: true, force: true }); + } else { + execSync(`git init ${shellSingleQuote(mainDir)}`); + execSync("git config user.email test@test.com", { cwd: mainDir }); + execSync("git config user.name Test", { cwd: mainDir }); + execSync("git commit --allow-empty -m 'init'", { cwd: mainDir }); + } + + const baseCwd = layout.bare ? mainDir : mainDir; + const worktreeDir = layout.worktreePath(dir); + mkdirSync(join(worktreeDir, ".."), { recursive: true }); + execSync(`git worktree add -b feature ${shellSingleQuote(worktreeDir)} main`, { + cwd: baseCwd, + }); + execSync("git config user.email test@test.com", { cwd: worktreeDir }); + execSync("git config user.name Test", { cwd: worktreeDir }); + + 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(), + ); + const hookPath = resolveGitPath( + worktreeDir, + execSync("git rev-parse --git-path hooks/post-commit", { + cwd: worktreeDir, + encoding: "utf-8", + }).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 }, + }); + + execSync(`git add ${shellSingleQuote(fileName)}`, { cwd: worktreeDir }); + execSync(`git commit -m ${shellSingleQuote(`feat: ${layout.name}`)}`, { + cwd: worktreeDir, + env: withoutCodexThreadEnv(), + }); + + const note = JSON.parse( + execSync("git notes --ref=agentnote show HEAD", { + cwd: worktreeDir, + encoding: "utf-8", + }), + ); + 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`, { From f84e6b856c8e4d9dfb6d9b9f6a5bd2fd811285ce Mon Sep 17 00:00:00 2001 From: wasabeef Date: Wed, 13 May 2026 17:31:21 +0900 Subject: [PATCH 3/7] docs: document git worktree support Why The worktree implementation relies on Git's distinction between worktree-local and common git directories. User impact Maintainers can understand why session data remains per-worktree while hooks and shims are resolved through Git's shared paths. Verification npm run lint npm test Release note: skip --- AGENTS.md | 1 + CLAUDE.md | 1 + docs/architecture.md | 16 ++++++++++++++++ docs/knowledge/investigations.md | 11 +++++++++++ 4 files changed, 29 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d8e34536..bb83e312 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 1fa7d559..0250ad68 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 584dea5c..35a1de02 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. diff --git a/docs/knowledge/investigations.md b/docs/knowledge/investigations.md index 1bb801aa..4ddc6577 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` From 39aac55a9b2064a3bb4ed578fc6fa2baa64e9951 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Wed, 13 May 2026 18:01:00 +0900 Subject: [PATCH 4/7] test(deinit): avoid shell-interpolated commands Use execFileSync argument arrays for deinit test invocations that pass dynamic paths. Also apply CodeRabbit's hyphenation feedback for Agent View-style worktree docs. Verification: npm run typecheck; npm run lint; npx tsx src/commands/deinit.test.ts; npm test Release note: skip --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/architecture.md | 2 +- packages/cli/src/commands/deinit.test.ts | 72 +++++++++++++----------- 4 files changed, 43 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb83e312..f17e0f22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +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. +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 0250ad68..682ac914 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +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. +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 35a1de02..4612076c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -405,7 +405,7 @@ Git hooks are installed into the hook directory reported by Git, not by assuming 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. +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. diff --git a/packages/cli/src/commands/deinit.test.ts b/packages/cli/src/commands/deinit.test.ts index 086624a6..0ae2e139 100644 --- a/packages/cli/src/commands/deinit.test.ts +++ b/packages/cli/src/commands/deinit.test.ts @@ -1,15 +1,11 @@ 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 { isAbsolute, join } from "node:path"; import { after, before, describe, it } from "node:test"; import { AGENTNOTE_DIR, NOTES_REF_FULL } from "../core/constants.js"; -function shellSingleQuote(value: string): string { - return `'${value.replace(/'/g, `'"'"'`)}'`; -} - function resolveGitPath(cwd: string, value: string): string { return isAbsolute(value) ? value : join(cwd, value); } @@ -34,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 }; @@ -46,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", @@ -62,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", @@ -80,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"); @@ -96,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"); @@ -149,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"); @@ -173,12 +177,12 @@ 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"); @@ -196,11 +200,13 @@ describe("agentnote deinit", () => { const worktreeDir = join(dir, "custom worktrees", "cleanup target"); mkdirSync(join(worktreeDir, ".."), { recursive: true }); - execSync(`git worktree add -b cleanup-target ${shellSingleQuote(worktreeDir)}`, { + execFileSync("git", ["worktree", "add", "-b", "cleanup-target", worktreeDir], { cwd: dir, }); - execSync(`node ${cliPath} init --agent claude --no-action`, { cwd: worktreeDir }); + execFileSync("node", [cliPath, "init", "--agent", "claude", "--no-action"], { + cwd: worktreeDir, + }); const worktreeGitDir = resolveGitPath( worktreeDir, @@ -219,7 +225,7 @@ describe("agentnote deinit", () => { assert.ok(existsSync(worktreeShimPath), "worktree-local shim should exist after init"); assert.ok(existsSync(commonShimPath), "common shim should exist after init"); - execSync(`node ${cliPath} deinit --agent claude`, { cwd: worktreeDir }); + 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"); @@ -236,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"); @@ -255,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, @@ -279,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 }); }); @@ -295,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"); @@ -320,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", }); From 83d0fbc0d74b8785383af07a75d01a0a3568d075 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Wed, 13 May 2026 18:35:23 +0900 Subject: [PATCH 5/7] test(init): avoid hardcoded worktree base branch Use the current branch for non-bare worktree fixture setup so CI runners whose git default branch is master still cover the same worktree layouts. Verification: npm run typecheck; npm run lint; mise x node@22.22.0 -- npx tsx src/commands/init.test.ts; mise x node@22.22.0 -- npm test Release note: skip --- packages/cli/src/commands/init.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index eae39c2c..d620f7e8 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -551,12 +551,21 @@ describe("agentnote init", () => { execSync("git commit --allow-empty -m 'init'", { cwd: mainDir }); } - const baseCwd = layout.bare ? mainDir : mainDir; + const baseCwd = mainDir; + const baseRef = layout.bare + ? "main" + : execSync("git branch --show-current", { + cwd: mainDir, + encoding: "utf-8", + }).trim(); const worktreeDir = layout.worktreePath(dir); mkdirSync(join(worktreeDir, ".."), { recursive: true }); - execSync(`git worktree add -b feature ${shellSingleQuote(worktreeDir)} main`, { - cwd: baseCwd, - }); + execSync( + `git worktree add -b feature ${shellSingleQuote(worktreeDir)} ${shellSingleQuote(baseRef)}`, + { + cwd: baseCwd, + }, + ); execSync("git config user.email test@test.com", { cwd: worktreeDir }); execSync("git config user.name Test", { cwd: worktreeDir }); From 12d1a9788289208affb5b3a17a7756818499d425 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Wed, 13 May 2026 18:37:40 +0900 Subject: [PATCH 6/7] docs: align hook directory guidance with git resolution Explain that Agent Note asks Git for the effective hook directory and uses worktree-local then common shims at runtime. Verification: rg 'git config get core\.hooksPath|echo "\.git/hooks"' docs/architecture.md AGENTS.md CLAUDE.md docs Release note: skip --- docs/architecture.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4612076c..380b6c26 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -418,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. From 3af909d3ed2c74fbbc78e07b7b1d8f8a6f7970fa Mon Sep 17 00:00:00 2001 From: wasabeef Date: Wed, 13 May 2026 18:48:31 +0900 Subject: [PATCH 7/7] test(init): avoid shell interpolation in worktree helper Use git argv calls inside the worktree commit helper so special paths and commit messages do not depend on shell parsing. Verification: npm run lint; mise x node@22.22.0 -- npx tsx src/commands/init.test.ts Release note: skip --- packages/cli/src/commands/init.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index d620f7e8..cdc96f91 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -242,16 +242,19 @@ describe("agentnote init", () => { tool_input: { file_path: filePath }, }); - execSync(`git add ${shellSingleQuote(options.fileName)}`, { cwd }); - execSync(`git commit -m ${shellSingleQuote(options.commitMessage)}`, { + execFileSync("git", ["add", "--", options.fileName], { cwd }); + execFileSync("git", ["commit", "-m", options.commitMessage], { cwd, + encoding: "utf-8", env: withoutCodexThreadEnv(), + stdio: "pipe", }); return JSON.parse( - execSync("git notes --ref=agentnote show HEAD", { + execFileSync("git", ["notes", "--ref=agentnote", "show", "HEAD"], { cwd, encoding: "utf-8", + stdio: "pipe", }), ); }