Skip to content

Commit e0b2fdd

Browse files
committed
fix(review): harden jj diff and vcs detection
1 parent dec94db commit e0b2fdd

6 files changed

Lines changed: 90 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ User runs /plannotator-review command
152152
Claude Code: plannotator review subcommand runs
153153
OpenCode: event handler intercepts command
154154
155-
git diff captures unstaged changes
155+
VCS diff captures local changes (git diff or jj diff)
156156
157157
Review server starts, opens browser with diff viewer
158158

apps/marketing/src/content/docs/guides/ai-code-review.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ claude -p \
189189
--no-session-persistence \
190190
--model sonnet \
191191
--tools Agent,Bash,Read,Glob,Grep \
192-
--allowedTools Agent,Read,Glob,Grep,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh api repos/*/*/pulls/*),Bash(gh api repos/*/*/pulls/*/files*),Bash(gh api repos/*/*/pulls/*/comments*),Bash(gh api repos/*/*/issues/*/comments*),Bash(glab mr view:*),Bash(glab mr diff:*),Bash(glab mr list:*),Bash(glab api:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git branch:*),Bash(git grep:*),Bash(git ls-remote:*),Bash(git ls-tree:*),Bash(git merge-base:*),Bash(git remote:*),Bash(git rev-parse:*),Bash(git show-ref:*),Bash(jj status:*),Bash(jj diff:*),Bash(jj log:*),Bash(jj show:*),Bash(jj bookmark list:*),Bash(wc:*) \
192+
--allowedTools Agent,Read,Glob,Grep,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh api repos/*/*/pulls/*),Bash(gh api repos/*/*/pulls/*/files*),Bash(gh api repos/*/*/pulls/*/comments*),Bash(gh api repos/*/*/issues/*/comments*),Bash(glab mr view:*),Bash(glab mr diff:*),Bash(glab mr list:*),Bash(glab api:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git branch:*),Bash(git grep:*),Bash(git ls-remote:*),Bash(git ls-tree:*),Bash(git merge-base:*),Bash(git remote:*),Bash(git rev-parse:*),Bash(git show-ref:*),Bash(jj status:*),Bash(jj diff:*),Bash(jj log:*),Bash(jj show:*),Bash(jj file show:*),Bash(jj cat:*),Bash(jj bookmark list:*),Bash(wc:*) \
193193
--disallowedTools Edit,Write,NotebookEdit,WebFetch,WebSearch,Bash(python:*),Bash(python3:*),Bash(node:*),Bash(npx:*),Bash(bun:*),Bash(bunx:*),Bash(sh:*),Bash(bash:*),Bash(zsh:*),Bash(curl:*),Bash(wget:*)
194194
```
195195

packages/shared/jj-core.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
jjLineBaseRevset,
55
parseJjBookmarkList,
66
parseJjRemoteBookmarkList,
7+
type ReviewJjRuntime,
8+
runJjDiff,
79
selectDefaultJjCompareTarget,
810
} from "./jj-core";
911

@@ -40,6 +42,45 @@ describe("jj diff args", () => {
4042
expect(getJjDiffArgs("jj-all", "trunk()", { hideWhitespace: true })?.args)
4143
.toEqual(["diff", "--git", "-w", "--from", "root()", "--to", "@"]);
4244
});
45+
46+
test("drops hunk-less file chunks after hide-whitespace filtering", async () => {
47+
const runtimeForPatch = (stdout: string): ReviewJjRuntime => ({
48+
async runJj() {
49+
return {
50+
stdout,
51+
stderr: "",
52+
exitCode: 0,
53+
};
54+
},
55+
});
56+
57+
const hunklessChunk = [
58+
"diff --git a/spacey.ts b/spacey.ts",
59+
"index 1111111..2222222 100644",
60+
"--- a/spacey.ts",
61+
"+++ b/spacey.ts",
62+
"",
63+
].join("\n");
64+
const realChunk = [
65+
"diff --git a/real.ts b/real.ts",
66+
"index 3333333..4444444 100644",
67+
"--- a/real.ts",
68+
"+++ b/real.ts",
69+
"@@ -1 +1 @@",
70+
"-old",
71+
"+new",
72+
"",
73+
].join("\n");
74+
75+
const result = await runJjDiff(runtimeForPatch(hunklessChunk + realChunk), "jj-current", "trunk()", undefined, { hideWhitespace: true });
76+
77+
expect(result.patch).not.toContain("spacey.ts");
78+
expect(result.patch).toContain("real.ts");
79+
expect(result.patch).toContain("@@ -1 +1 @@");
80+
81+
const emptyResult = await runJjDiff(runtimeForPatch(hunklessChunk), "jj-current", "trunk()", undefined, { hideWhitespace: true });
82+
expect(emptyResult.patch).toBe("");
83+
});
4384
});
4485

4586
describe("jj compare targets", () => {

packages/shared/jj-core.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,26 @@ export async function runJjDiff(
8787
return { patch: "", label: args.label, error: firstErrorLine(result.stderr) };
8888
}
8989

90-
return { patch: result.stdout, label: args.label };
90+
const patch = options?.hideWhitespace ? dropHunklessGitDiffChunks(result.stdout) : result.stdout;
91+
return { patch, label: args.label };
92+
}
93+
94+
function dropHunklessGitDiffChunks(patch: string): string {
95+
if (!patch.includes("diff --git ")) return patch;
96+
97+
const chunks = patch.split(/^diff --git /m);
98+
const prefix = chunks.shift() ?? "";
99+
const filtered = chunks
100+
.filter(hasReviewableGitDiffChunk)
101+
.map((chunk) => `diff --git ${chunk}`)
102+
.join("");
103+
104+
return `${prefix}${filtered}`;
105+
}
106+
107+
function hasReviewableGitDiffChunk(chunk: string): boolean {
108+
if (/^@@@? /m.test(chunk)) return true;
109+
return /^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|copy from|copy to|GIT binary patch|Binary files |similarity index|dissimilarity index)/m.test(chunk);
91110
}
92111

93112
export async function getJjFileContentsForDiff(

packages/shared/vcs-core.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ describe("createVcsApi", () => {
9494
await expect(api.getVcsContext("/repo/packages/tool")).resolves.toMatchObject({ vcsType: "jj" });
9595
});
9696

97+
test("continues probing providers when root detection throws", async () => {
98+
const brokenGit = {
99+
...provider("git", true, ["uncommitted"]),
100+
async getRoot() {
101+
throw new Error("git failed");
102+
},
103+
};
104+
const p4 = provider("p4", true, ["p4-default"], {}, "/repo");
105+
const api = createVcsApi([brokenGit, p4]);
106+
107+
await expect(api.detectVcs("/repo")).resolves.toBe(p4);
108+
await expect(api.getVcsContext("/repo")).resolves.toMatchObject({ vcsType: "p4" });
109+
});
110+
97111
test("routes operations by diff type before falling back to detection", async () => {
98112
const jj = provider("jj", false, ["jj-current"]);
99113
const git = provider("git", true, ["uncommitted"]);

packages/shared/vcs-core.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,19 @@ export function createVcsApi(providers: readonly VcsProvider[]): VcsApi {
242242

243243
const candidates: Array<{ provider: VcsProvider; root: string | null; order: number }> = [];
244244
for (const provider of providerList) {
245-
const root = provider.getRoot ? await provider.getRoot(cwd) : null;
246-
if (root || (!provider.getRoot && await provider.detect(cwd))) {
245+
let root: string | null = null;
246+
let detected = false;
247+
try {
248+
if (provider.getRoot) {
249+
root = await provider.getRoot(cwd);
250+
detected = root !== null;
251+
} else {
252+
detected = await provider.detect(cwd);
253+
}
254+
} catch {
255+
continue;
256+
}
257+
if (detected) {
247258
candidates.push({ provider, root, order: candidates.length });
248259
}
249260
}

0 commit comments

Comments
 (0)