Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/persist-review-comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add opt-in `--persist-comments` / `persist_comments` to mirror review notes to `<git-dir>/hunk/review-comments.json` so they survive the session ending.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <git-dir>/hunk/review-comments.json
prompt_save_view_preferences = true
transparent_background = false
```
Expand Down
1 change: 1 addition & 0 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/app/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ describe("parseCli", () => {
"--wrap",
"--no-hunk-headers",
"--agent-notes",
"--persist-comments",
"--transparent-bg",
"--watch",
"--experimental",
Expand All @@ -151,6 +152,7 @@ describe("parseCli", () => {
wrapLines: true,
hunkHeaders: false,
agentNotes: true,
persistComments: true,
transparentBackground: true,
},
});
Expand Down Expand Up @@ -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"],
Expand Down
8 changes: 8 additions & 0 deletions src/app/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
{
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <git-dir>/hunk/review-comments.json",
" --transparent-bg / --no-transparent-bg let terminal background show through Hunk surfaces",
" --theme <theme> named theme override",
" --extension <path> load an extension entry file or directory (repeatable)",
Expand Down
123 changes: 123 additions & 0 deletions src/app/session/persistedComments.test.ts
Original file line number Diff line number Diff line change
@@ -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<SessionReviewNoteSummary> = {}) {
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: [],
});
});
});
51 changes: 51 additions & 0 deletions src/app/session/persistedComments.ts
Original file line number Diff line number Diff line change
@@ -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);
}
44 changes: 44 additions & 0 deletions src/app/sessionBootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}
});
});
20 changes: 20 additions & 0 deletions src/app/sessionBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/core/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface AppBootstrap<ExtensionState = unknown> {
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<string, UserKeyBinding>;
/** App-owned extension state carried without coupling core to the extension host. */
Expand Down
2 changes: 2 additions & 0 deletions src/core/run/commandInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/core/run/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ describe("config resolution", () => {
'theme = "github-light-default"',
"wrap_lines = true",
"menu_bar = false",
"persist_comments = true",
"",
"[pager]",
"hunk_headers = false",
Expand All @@ -221,6 +222,7 @@ describe("config resolution", () => {
menuBar: false,
hunkHeaders: false,
agentNotes: true,
persistComments: true,
promptSaveViewPreferences: false,
transparentBackground: true,
colorMoved: true,
Expand Down
Loading