diff --git a/.changeset/persist-review-comments.md b/.changeset/persist-review-comments.md new file mode 100644 index 000000000..2a53ae343 --- /dev/null +++ b/.changeset/persist-review-comments.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add opt-in `--persist-comments` / `persist_comments` to mirror review notes to `/hunk/review-comments.json` so they survive the session ending. diff --git a/README.md b/README.md index d327d7a3f..d69dbeb45 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ wrap_lines = false menu_bar = true sidebar = "auto" # "auto", true, false agent_notes = false +persist_comments = false # mirror review notes to /hunk/review-comments.json prompt_save_view_preferences = true transparent_background = false ``` diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 3148599cb..e302de29f 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -140,6 +140,7 @@ printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tig - Pass `--focus` when you want to jump to the new note or the first note in a batch - `comment list` and `comment clear` accept optional `--file` - Quote `--summary` and `--rationale` defensively in the shell +- When no live session answers, a review launched with `--persist-comments` (or `persist_comments = true` in config) leaves its notes at `$(git rev-parse --absolute-git-dir)/hunk/review-comments.json`; its `reviewNotes` array matches `review --include-notes --json` and `updatedAt` says when it was last written ### Attention marks diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index dd9333691..3d304567e 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -131,6 +131,7 @@ describe("parseCli", () => { "--wrap", "--no-hunk-headers", "--agent-notes", + "--persist-comments", "--transparent-bg", "--watch", "--experimental", @@ -151,6 +152,7 @@ describe("parseCli", () => { wrapLines: true, hunkHeaders: false, agentNotes: true, + persistComments: true, transparentBackground: true, }, }); @@ -326,6 +328,8 @@ describe("parseCli", () => { ["--no-sidebar", "sidebar"], ["--agent-notes", "agentNotes"], ["--no-agent-notes", "agentNotes"], + ["--persist-comments", "persistComments"], + ["--no-persist-comments", "persistComments"], ["--transparent-bg", "transparentBackground"], ["--no-transparent-bg", "transparentBackground"], ["--extensions", "extensions"], diff --git a/src/app/cli.ts b/src/app/cli.ts index 4c6d4483b..f89395f6b 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -131,6 +131,8 @@ export const COMMON_REVIEW_OPTIONS = [ { flag: "--no-sidebar", description: "hide files pane" }, { flag: "--agent-notes", description: "show agent notes by default" }, { flag: "--no-agent-notes", description: "hide agent notes by default" }, + AUXILIARY_AGENT_OPTIONS.persistComments, + { flag: "--no-persist-comments", description: "keep review notes in memory only" }, { flag: "--transparent-bg", description: "let terminal background show through Hunk surfaces" }, { flag: "--no-transparent-bg", description: "paint Hunk surfaces with the active theme" }, { @@ -417,6 +419,11 @@ function buildCommonOptions( hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"), sidebar: resolveBooleanFlag(argv, "--sidebar", "--no-sidebar"), agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"), + persistComments: resolveBooleanFlag( + argv, + AUXILIARY_AGENT_OPTIONS.persistComments.flag, + `--no-${AUXILIARY_AGENT_OPTIONS.persistComments.flag.slice(2)}`, + ), transparentBackground: resolveBooleanFlag(argv, "--transparent-bg", "--no-transparent-bg"), // Read straight from argv so the absence of the flag stays undefined rather than // becoming Commander's implicit `true` default for a negatable option. @@ -543,6 +550,7 @@ function renderCliHelp() { " --hunk-headers / --no-hunk-headers show or hide hunk metadata rows", " --sidebar / --no-sidebar show or hide files pane by default", " --agent-notes / --no-agent-notes show or hide agent notes by default", + " --persist-comments / --no-persist-comments mirror review notes to /hunk/review-comments.json", " --transparent-bg / --no-transparent-bg let terminal background show through Hunk surfaces", " --theme named theme override", " --extension load an extension entry file or directory (repeatable)", diff --git a/src/app/session/persistedComments.test.ts b/src/app/session/persistedComments.test.ts new file mode 100644 index 000000000..f07342f29 --- /dev/null +++ b/src/app/session/persistedComments.test.ts @@ -0,0 +1,123 @@ +import { afterAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { + resolvePersistedReviewCommentsPath, + writePersistedReviewComments, +} from "./persistedComments"; +import type { SessionReviewNoteSummary } from "../../session/types"; + +const tempDirs: string[] = []; + +// Hosted Windows runners can spend several seconds starting each real Git process. +setDefaultTimeout(30_000); + +function git(cwd: string, ...cmd: string[]) { + const proc = Bun.spawnSync(["git", ...cmd], { + cwd, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + if (proc.exitCode !== 0) { + const stderr = Buffer.from(proc.stderr).toString("utf8"); + throw new Error(stderr.trim() || `git ${cmd.join(" ")} failed`); + } + + return Buffer.from(proc.stdout).toString("utf8"); +} + +function createTempDir(prefix: string) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function createTempRepo(prefix: string) { + const dir = createTempDir(prefix); + git(dir, "init"); + git(dir, "config", "user.name", "Test User"); + git(dir, "config", "user.email", "test@example.com"); + git(dir, "config", "commit.gpgSign", "false"); + return dir; +} + +afterAll(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createNoteSummary(overrides: Partial = {}) { + return { + noteId: "user:1-1", + source: "user", + filePath: "src/example.ts", + hunkIndex: 0, + newRange: [10, 10], + body: "Rename this variable.", + author: "user", + createdAt: "2026-09-01T00:00:00.000Z", + editable: true, + ...overrides, + } satisfies SessionReviewNoteSummary; +} + +describe("resolvePersistedReviewCommentsPath", () => { + test("resolves under the repository's git directory", () => { + const repo = createTempRepo("hunk-persist-repo-"); + + const path = resolvePersistedReviewCommentsPath(repo); + + expect(path).toBe( + join(git(repo, "rev-parse", "--absolute-git-dir").trim(), "hunk", "review-comments.json"), + ); + }); + + test("resolves a linked worktree to its own metadata directory", () => { + const repo = createTempRepo("hunk-persist-main-"); + writeFileSync(join(repo, "file.txt"), "one\n"); + git(repo, "add", "file.txt"); + git(repo, "commit", "-m", "initial"); + const linked = mkdtempSync(join(tmpdir(), "hunk-persist-linked-")); + tempDirs.push(linked); + git(repo, "worktree", "add", linked, "-b", "linked-test"); + + const path = resolvePersistedReviewCommentsPath(linked); + + expect(path?.split(sep)).toContain("worktrees"); + expect(path).not.toBe(resolvePersistedReviewCommentsPath(repo)); + }); + + test("returns undefined outside a git repository", () => { + const dir = createTempDir("hunk-persist-plain-"); + + expect(resolvePersistedReviewCommentsPath(dir)).toBeUndefined(); + }); +}); + +describe("writePersistedReviewComments", () => { + test("creates the file with the session's notes and replaces it on later writes", () => { + const dir = createTempDir("hunk-persist-write-"); + const filePath = join(dir, "hunk", "review-comments.json"); + + writePersistedReviewComments(filePath, { + updatedAt: "2026-09-01T00:00:00.000Z", + sourceLabel: "git diff", + reviewNotes: [createNoteSummary()], + }); + writePersistedReviewComments(filePath, { + updatedAt: "2026-09-01T00:01:00.000Z", + sourceLabel: "git diff", + reviewNotes: [], + }); + + expect(JSON.parse(readFileSync(filePath, "utf8"))).toEqual({ + updatedAt: "2026-09-01T00:01:00.000Z", + sourceLabel: "git diff", + reviewNotes: [], + }); + }); +}); diff --git a/src/app/session/persistedComments.ts b/src/app/session/persistedComments.ts new file mode 100644 index 000000000..9e9399f6b --- /dev/null +++ b/src/app/session/persistedComments.ts @@ -0,0 +1,51 @@ +/** + * Persists the live session's review notes to a per-worktree file so they survive the + * TUI exiting — including a SIGKILL from a closed terminal pane. + * + * The file lives under the worktree's Git metadata directory (`rev-parse + * --absolute-git-dir`), so it never appears in `git status` and linked worktrees each + * keep their own copy. Its `reviewNotes` array is the exact projection + * `hunk session review --include-notes --json` publishes; the file mirrors the most + * recent session that changed its notes and is export-only — sessions never read it back. + */ +import { join } from "node:path"; +import { normalizePathForOS } from "../../lib/osPath"; +import { writeAppStateRecord } from "../../core/process/appStateFile"; +import type { SessionReviewNoteSummary } from "../../session/types"; + +export type PersistedReviewComments = { + updatedAt: string; + sourceLabel: string; + reviewNotes: SessionReviewNoteSummary[]; +}; + +/** + * Resolve the persisted-comments path for one worktree, or undefined when the + * directory is not inside a Git repository. + */ +export function resolvePersistedReviewCommentsPath(cwd: string): string | undefined { + let gitDir: string; + try { + const result = Bun.spawnSync(["git", "rev-parse", "--absolute-git-dir"], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + return undefined; + } + gitDir = result.stdout.toString().trim(); + } catch { + return undefined; + } + + return gitDir ? join(normalizePathForOS(gitDir), "hunk", "review-comments.json") : undefined; +} + +/** Atomically replace the persisted-comments file with the session's current notes. */ +export function writePersistedReviewComments( + filePath: string, + payload: PersistedReviewComments, +): void { + writeAppStateRecord(filePath, payload); +} diff --git a/src/app/sessionBootstrap.test.ts b/src/app/sessionBootstrap.test.ts index cad35584f..5f077c0d3 100644 --- a/src/app/sessionBootstrap.test.ts +++ b/src/app/sessionBootstrap.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileLanguageForPath } from "../core/changeset/fileLanguageLookup"; import { replaceExtensionFileLanguages } from "../core/changeset/fileLanguage"; import type { HunkConfigResolution } from "../core/run/config"; @@ -89,4 +92,45 @@ describe("loadConfiguredSessionBootstrap", () => { expect(fileLanguageForPath("ReplacementHunkfile")).toBe("text"); replaceExtensionFileLanguages([]); }); + + test("resolves the persisted-comments path only when the option is on and a git dir exists", async () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-bootstrap-repo-")); + const plain = mkdtempSync(join(tmpdir(), "hunk-bootstrap-plain-")); + try { + expect(Bun.spawnSync(["git", "init"], { cwd: repo, stderr: "ignore" }).exitCode).toBe(0); + const load = async (repoCwd: string, persistComments: boolean) => { + const input: CliInput = { + kind: "vcs", + staged: false, + options: { vcs: "git", persistComments }, + }; + return loadConfiguredSessionBootstrap({ + configured: createTestConfig(input), + cwd: repoCwd, + loadAppBootstrapImpl: async (resolvedInput) => ({ + ...createTestBootstrap(resolvedInput), + reloadContext: { cwd: repoCwd }, + }), + }); + }; + + const persisted = await load(repo, true); + expect(persisted.bootstrap.persistedCommentsPath).toBe( + join(realpathSync.native(repo), ".git", "hunk", "review-comments.json"), + ); + expect(persisted.bootstrap.startupNotices ?? []).toEqual([]); + + const disabled = await load(repo, false); + expect(disabled.bootstrap.persistedCommentsPath).toBeUndefined(); + + const outsideRepo = await load(plain, true); + expect(outsideRepo.bootstrap.persistedCommentsPath).toBeUndefined(); + expect(outsideRepo.bootstrap.startupNotices).toMatchObject([ + { key: "persist-comments:unavailable" }, + ]); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(plain, { recursive: true, force: true }); + } + }); }); diff --git a/src/app/sessionBootstrap.ts b/src/app/sessionBootstrap.ts index eb36b7ed7..5d2c832d2 100644 --- a/src/app/sessionBootstrap.ts +++ b/src/app/sessionBootstrap.ts @@ -19,6 +19,14 @@ import { type AppliedExtensionRegistrations, } from "../extensions/apply"; import type { ExtensionLoadResult } from "../extensions/types"; +import type { StartupNotice } from "../core/process/startupNotice"; +import { resolvePersistedReviewCommentsPath } from "./session/persistedComments"; + +/** Warn when `--persist-comments` was requested outside a Git repository. */ +const PERSIST_COMMENTS_UNAVAILABLE_NOTICE: StartupNotice = { + key: "persist-comments:unavailable", + message: "persist_comments is on, but no Git directory was found • comments stay in memory", +}; export interface SessionBootstrapOptions { configured: HunkConfigResolution; @@ -91,6 +99,18 @@ export async function loadConfiguredSessionBootstrap({ bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; bootstrap.keybindings = configured.keybindings; + if (input.options.persistComments) { + const persistedCommentsPath = resolvePersistedReviewCommentsPath(bootstrap.reloadContext.cwd); + if (persistedCommentsPath) { + bootstrap.persistedCommentsPath = persistedCommentsPath; + } else { + bootstrap.startupNotices = [ + ...(bootstrap.startupNotices ?? []), + PERSIST_COMMENTS_UNAVAILABLE_NOTICE, + ]; + } + } + return { applied, bootstrap, input, previousFileLanguages, sessionThemes, sessionVcs }; } catch (error) { restoreFileLanguageRegistrations(previousFileLanguages); diff --git a/src/core/bootstrap.ts b/src/core/bootstrap.ts index 76e47f73e..40a635997 100644 --- a/src/core/bootstrap.ts +++ b/src/core/bootstrap.ts @@ -56,6 +56,8 @@ export interface AppBootstrap { initialCursorLine?: CursorLine; startupNotices?: readonly StartupNotice[]; viewPreferencesConfigPath?: string; + /** Where review notes are mirrored on change; absent unless `--persist-comments` resolved a Git dir. */ + persistedCommentsPath?: string; /** The user's `[keybindings]` table, resolved against command defaults in App. */ keybindings?: Record; /** App-owned extension state carried without coupling core to the extension host. */ diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index 8dca48432..3d3f194a4 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -44,6 +44,8 @@ export interface CommonOptions { menuBar?: boolean; sidebar?: SidebarVisibility; agentNotes?: boolean; + /** Mirror review notes to the worktree's Git metadata directory as they change. */ + persistComments?: boolean; copyDecorations?: boolean; promptSaveViewPreferences?: boolean; transparentBackground?: boolean; diff --git a/src/core/run/config.test.ts b/src/core/run/config.test.ts index 9b5f312e7..da1d72285 100644 --- a/src/core/run/config.test.ts +++ b/src/core/run/config.test.ts @@ -195,6 +195,7 @@ describe("config resolution", () => { 'theme = "github-light-default"', "wrap_lines = true", "menu_bar = false", + "persist_comments = true", "", "[pager]", "hunk_headers = false", @@ -221,6 +222,7 @@ describe("config resolution", () => { menuBar: false, hunkHeaders: false, agentNotes: true, + persistComments: true, promptSaveViewPreferences: false, transparentBackground: true, colorMoved: true, diff --git a/src/core/run/config.ts b/src/core/run/config.ts index 68ebc02b2..2c969ffcb 100644 --- a/src/core/run/config.ts +++ b/src/core/run/config.ts @@ -428,6 +428,15 @@ export const CONFIG_REFERENCE_OPTIONS: readonly ConfigReferenceOption[] = [ runtimeDefault: DEFAULT_VIEW_PREFERENCES.showAgentNotes, description: "Show agent notes when a review opens.", }, + { + key: "persist_comments", + property: "persistComments", + type: "boolean", + accepted: "`true` or `false`", + runtimeDefault: false, + description: + "Mirror review notes to `/hunk/review-comments.json` as they change, so they survive the session ending.", + }, { key: "copy_decorations", property: "copyDecorations", @@ -1029,6 +1038,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti menuBar: overrides.menuBar ?? base.menuBar, sidebar: overrides.sidebar ?? base.sidebar, agentNotes: overrides.agentNotes ?? base.agentNotes, + persistComments: overrides.persistComments ?? base.persistComments, copyDecorations: overrides.copyDecorations ?? base.copyDecorations, promptSaveViewPreferences: overrides.promptSaveViewPreferences ?? base.promptSaveViewPreferences, @@ -1305,6 +1315,7 @@ export function resolveConfiguredCliInput( menuBar: resolvedOptions.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar, sidebar: resolvedOptions.sidebar ?? "auto", agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, + persistComments: resolvedOptions.persistComments ?? false, copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, promptSaveViewPreferences: resolvedOptions.promptSaveViewPreferences ?? true, transparentBackground: resolvedOptions.transparentBackground ?? false, diff --git a/src/hunk-review/skillDocument.test.ts b/src/hunk-review/skillDocument.test.ts index e45f4175e..ae4a88232 100644 --- a/src/hunk-review/skillDocument.test.ts +++ b/src/hunk-review/skillDocument.test.ts @@ -17,8 +17,8 @@ const DOCUMENTED_AGENT_FLAGS = new Set([ ...Object.values(AUXILIARY_AGENT_OPTIONS).map(agentOptionFlagName), ]); -/** Flags of non-hunk shell tools that appear inside doc examples (e.g. curl). */ -const NON_HUNK_SHELL_FLAGS = new Set(["--data"]); +/** Flags of non-hunk shell tools that appear inside doc examples (e.g. curl, git). */ +const NON_HUNK_SHELL_FLAGS = new Set(["--data", "--absolute-git-dir"]); /** Normalize checkout line endings so the comparison stays portable on Windows. */ function normalizeNewlines(text: string) { @@ -44,6 +44,9 @@ describe("hunk-review skill document", () => { expect(mentioned.length).toBeGreaterThan(0); for (const flag of mentioned) { + if (NON_HUNK_SHELL_FLAGS.has(flag)) { + continue; + } expect(DOCUMENTED_AGENT_FLAGS).toContain(flag); } }); diff --git a/src/hunk-review/skillDocument.ts b/src/hunk-review/skillDocument.ts index 79694d612..0a31b8df9 100644 --- a/src/hunk-review/skillDocument.ts +++ b/src/hunk-review/skillDocument.ts @@ -166,6 +166,7 @@ const COMMENTS_SECTION = [ "- Pass `--focus` when you want to jump to the new note or the first note in a batch", "- `comment list` and `comment clear` accept optional `--file`", "- Quote `--summary` and `--rationale` defensively in the shell", + "- When no live session answers, a review launched with `--persist-comments` (or `persist_comments = true` in config) leaves its notes at `$(git rev-parse --absolute-git-dir)/hunk/review-comments.json`; its `reviewNotes` array matches `review --include-notes --json` and `updatedAt` says when it was last written", ]; const HIGHLIGHTS_SECTION = [ diff --git a/src/session/agent/surface.ts b/src/session/agent/surface.ts index 80007bc85..38ecc9cfd 100644 --- a/src/session/agent/surface.ts +++ b/src/session/agent/surface.ts @@ -139,6 +139,10 @@ export const AUXILIARY_AGENT_OPTIONS = { flag: "--width ", description: "layout width in columns", }, + persistComments: { + flag: "--persist-comments", + description: "mirror review notes to /hunk/review-comments.json", + }, } as const satisfies Record; /** Selector notation shared by every synopsis line that targets one live session. */ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 9c5cdd1d1..496d5ed71 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -692,7 +692,9 @@ export function App({ liveCommentSummaries: review.liveCommentSummaries, navigateToLocation: review.navigateToLocation, noteMarkupWidth: stmlEnabled ? noteMarkupWidth : undefined, + onPersistedCommentsError: showSessionNotice, openAgentNotes, + persistedCommentsPath: bootstrap.persistedCommentsPath, reloadSession: onReloadSession, removeLiveComment: review.removeLiveComment, reviewProducer, @@ -703,6 +705,7 @@ export function App({ selectedHunk: review.selectedHunk, selectedHunkIndex, showAgentNotes, + sourceLabel: bootstrap.changeset.sourceLabel, }); const maxVisibleLineNumber = useMemo( () => diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/src/ui/hooks/useHunkSessionBridge.ts index 727255d90..8b20cd5f4 100644 --- a/src/ui/hooks/useHunkSessionBridge.ts +++ b/src/ui/hooks/useHunkSessionBridge.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import type { ReviewProducer } from "../../app/review/producer"; import type { DiffFile } from "../../core/changeset/model"; import type { CliInput } from "../../core/run/commandInputs"; @@ -12,8 +12,12 @@ import type { SessionReviewNoteSummary, } from "../../session/types"; import type { TerminalReview } from "./useTerminalReview"; +import { writePersistedReviewComments } from "../../app/session/persistedComments"; -/** Bridge one live Hunk review session to the local session daemon. */ +/** + * Bridge one live Hunk review session to the local session daemon, and mirror the + * published review notes to the persisted-comments file when one is configured. + */ export function useHunkSessionBridge({ addAgentLineHighlight, addLiveComment, @@ -25,7 +29,9 @@ export function useHunkSessionBridge({ liveCommentSummaries, navigateToLocation, noteMarkupWidth, + onPersistedCommentsError, openAgentNotes, + persistedCommentsPath, reloadSession, removeLiveComment, reviewNoteCount, @@ -36,6 +42,7 @@ export function useHunkSessionBridge({ selectedHunk, selectedHunkIndex, showAgentNotes, + sourceLabel, }: { addAgentLineHighlight: TerminalReview["addAgentLineHighlight"]; addLiveComment: TerminalReview["addLiveComment"]; @@ -48,7 +55,10 @@ export function useHunkSessionBridge({ navigateToLocation: TerminalReview["navigateToLocation"]; /** Width STML note markup currently renders at (see agentNoteMarkupWidth). */ noteMarkupWidth?: number; + onPersistedCommentsError?: (message: string) => void; openAgentNotes: () => void; + /** Mirror `reviewNoteSummaries` to this file as notes change; absent when persistence is off. */ + persistedCommentsPath?: string; reloadSession: ( nextInput: CliInput, options?: ReloadSessionOptions, @@ -64,6 +74,8 @@ export function useHunkSessionBridge({ selectedHunk: DiffFile["metadata"]["hunks"][number] | undefined; selectedHunkIndex: number; showAgentNotes: boolean; + /** Where this review came from (`git diff`, a patch file, …), recorded in the persisted file. */ + sourceLabel: string; }) { const bridge = useMemo( () => @@ -154,4 +166,34 @@ export function useHunkSessionBridge({ selectedHunkIndex, showAgentNotes, ]); + + // The mount's first summaries are recorded, not written: a session that never touches + // its notes must not clobber what an earlier session persisted before an agent reads it. + const persistedBaselineRef = useRef(null); + + useEffect(() => { + if (!persistedCommentsPath) { + return; + } + if (persistedBaselineRef.current === null) { + persistedBaselineRef.current = reviewNoteSummaries; + return; + } + if (persistedBaselineRef.current === reviewNoteSummaries) { + return; + } + persistedBaselineRef.current = reviewNoteSummaries; + + try { + writePersistedReviewComments(persistedCommentsPath, { + updatedAt: new Date().toISOString(), + sourceLabel, + reviewNotes: reviewNoteSummaries, + }); + } catch (error) { + onPersistedCommentsError?.( + `Persisting comments failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, [onPersistedCommentsError, persistedCommentsPath, reviewNoteSummaries, sourceLabel]); } diff --git a/test/session/cli.test.ts b/test/session/cli.test.ts index afdc2a68e..266f85d5d 100644 --- a/test/session/cli.test.ts +++ b/test/session/cli.test.ts @@ -49,6 +49,7 @@ async function reserveLoopbackPort() { interface SessionListJson { sessions: Array<{ sessionId: string; + pid: number; files: Array<{ path: string; }>; @@ -109,8 +110,15 @@ function createFixtureFiles(name: string, beforeLines: string[], afterLines: str return { dir, before, after, transcript, afterName }; } -function spawnHunkSession(fixture: ReturnType, port: number) { - const innerCommand = `bun run ${shellQuote(sourceEntrypoint)} diff --files ${shellQuote(fixture.before)} ${shellQuote(fixture.after)}`; +function spawnHunkSession( + fixture: ReturnType, + port: number, + extraArgs: string[] = [], +) { + const innerCommand = [ + `bun run ${shellQuote(sourceEntrypoint)} diff --files ${shellQuote(fixture.before)} ${shellQuote(fixture.after)}`, + ...extraArgs.map(shellQuote), + ].join(" "); return Bun.spawn(["script", "-q", "-f", "-e", "-c", innerCommand, fixture.transcript], { cwd: fixture.dir, @@ -976,4 +984,76 @@ sessionDescribe("session CLI integration", () => { await cleanupHunkSession(session, fixture, port); } }, 20_000); + + test("mirrors comments to the git directory as they change, surviving SIGKILL", async () => { + const port = await reserveLoopbackPort(); + const fixture = createFixtureFiles( + "persist", + ["export const value = 1;", "console.log(value);"], + ["export const value = 2;", "console.log(value * 2);"], + ); + const init = Bun.spawnSync(["git", "init"], { + cwd: fixture.dir, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + expect(init.exitCode).toBe(0); + const commentsPath = join(fixture.dir, ".git", "hunk", "review-comments.json"); + const session = spawnHunkSession(fixture, port, ["--persist-comments"]); + + try { + const listed = await waitForRegisteredSessions(port); + const sessionId = listed[0]!.sessionId; + const sessionPid = listed[0]!.pid; + + const comment = runSessionCli( + [ + "comment", + "add", + sessionId, + "--file", + fixture.afterName, + "--new-line", + "1", + "--summary", + "Persisted note", + "--json", + ], + port, + ); + expect(comment.proc.exitCode).toBe(0); + expect(comment.stderr).toBe(""); + + await waitUntil("comment mirrored to disk", () => { + if (!existsSync(commentsPath)) { + return null; + } + const parsed = JSON.parse(readFileSync(commentsPath, "utf8")) as { + reviewNotes?: Array<{ body?: string }>; + }; + return parsed.reviewNotes?.some((note) => note.body === "Persisted note") ? parsed : null; + }); + + // A closed terminal pane dies exactly like this: no teardown hook ever runs. + process.kill(sessionPid, "SIGKILL"); + await session.exited.catch(() => undefined); + + const persisted = JSON.parse(readFileSync(commentsPath, "utf8")) as { + updatedAt: string; + sourceLabel: string; + reviewNotes: Array<{ filePath: string; body: string; newRange?: [number, number] }>; + }; + expect(persisted.sourceLabel.length).toBeGreaterThan(0); + expect(persisted.reviewNotes).toMatchObject([ + { filePath: fixture.afterName, body: "Persisted note", newRange: [1, 1] }, + ]); + } finally { + if (session.exitCode === null) { + session.kill(); + await session.exited.catch(() => undefined); + } + await stopTestDaemon(port); + } + }, 20_000); }); diff --git a/website/public/docs/hunk-review-skill.md b/website/public/docs/hunk-review-skill.md index 3148599cb..e302de29f 100644 --- a/website/public/docs/hunk-review-skill.md +++ b/website/public/docs/hunk-review-skill.md @@ -140,6 +140,7 @@ printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tig - Pass `--focus` when you want to jump to the new note or the first note in a batch - `comment list` and `comment clear` accept optional `--file` - Quote `--summary` and `--rationale` defensively in the shell +- When no live session answers, a review launched with `--persist-comments` (or `persist_comments = true` in config) leaves its notes at `$(git rev-parse --absolute-git-dir)/hunk/review-comments.json`; its `reviewNotes` array matches `review --include-notes --json` and `updatedAt` says when it was last written ### Attention marks diff --git a/website/src/content/docs/docs/configure/configuration.md b/website/src/content/docs/docs/configure/configuration.md index ed5235b11..8ced956bf 100644 --- a/website/src/content/docs/docs/configure/configuration.md +++ b/website/src/content/docs/docs/configure/configuration.md @@ -27,6 +27,7 @@ hunk_headers = true menu_bar = true sidebar = "auto" agent_notes = false +persist_comments = false transparent_background = false ``` diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index ea51eaee5..ca8ae2cb2 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -16,32 +16,34 @@ This reference is generated from the command metadata used by Hunk itself. Run ` ## Common review options -| Option | Description | -| --------------------------- | --------------------------------------------------------------- | -| `--mode ` | layout mode: auto, split, stack | -| `--cursor-line