Skip to content

Commit a30a1c1

Browse files
backnotpropclaude
andcommitted
feat: worktree mode switch with full diff options (#196)
Selecting a worktree now enters "worktree mode" — the dropdown replaces its contents with standard diff options (uncommitted, last-commit, vs main) scoped to that worktree. A "Back to main repo" entry returns to the original view. - Add parseWorktreeDiffType() for compound types like worktree:/path:last-commit - Add getWorktreeDiffOptions() to build worktree-mode dropdown entries - Update runGitDiff() to handle worktree sub-types - Update /api/diff/switch to return diffOptions on mode transitions - Update App.tsx to consume updated diffOptions and refresh dropdown - Add test sandbox with 4 worktrees for manual testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0d676d5 commit a30a1c1

File tree

6 files changed

+1227
-15
lines changed

6 files changed

+1227
-15
lines changed

packages/review-editor/App.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,14 +368,22 @@ const ReviewApp: React.FC = () => {
368368
rawPatch: string;
369369
gitRef: string;
370370
diffType: string;
371+
diffOptions?: { id: string; label: string }[];
372+
error?: string;
371373
};
372374

373375
const newFiles = parseDiffToFiles(data.rawPatch);
374376
setFiles(newFiles);
375377
setDiffType(data.diffType);
376378
setActiveFileIndex(0);
377379
setPendingSelection(null);
378-
setDiffError((data as { error?: string }).error || null);
380+
setDiffError(data.error || null);
381+
382+
// Update dropdown options on worktree mode transitions
383+
if (data.diffOptions) {
384+
setGitContext(prev => prev ? { ...prev, diffOptions: data.diffOptions! } : prev);
385+
}
386+
379387
// Note: We keep existing annotations - they may still be relevant
380388
// or user can clear them manually
381389
} catch (err) {
@@ -837,7 +845,11 @@ const ReviewApp: React.FC = () => {
837845
{diffType === 'unstaged' && "No unstaged changes. All changes are staged."}
838846
{diffType === 'last-commit' && "No changes in the last commit."}
839847
{diffType === 'branch' && `No changes between this branch and ${gitContext?.defaultBranch || 'main'}.`}
840-
{diffType?.startsWith('worktree:') && "No uncommitted changes in this worktree."}
848+
{diffType?.startsWith('worktree:') && (
849+
diffType.endsWith(':last-commit') ? "No changes in the last commit in this worktree." :
850+
diffType.endsWith(':branch') ? `No changes vs ${gitContext?.defaultBranch || 'main'} in this worktree.` :
851+
"No uncommitted changes in this worktree."
852+
)}
841853
</p>
842854
</>
843855
)}

packages/server/git.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,40 @@ async function getUntrackedFileDiffs(srcPrefix = 'a/', dstPrefix = 'b/', cwd?: s
195195
}
196196
}
197197

198+
/**
199+
* Parse a worktree diff type like `worktree:/path:last-commit` into path + sub-type.
200+
* Falls back to `uncommitted` if no sub-type suffix (backwards compatible).
201+
*/
202+
const WORKTREE_SUB_TYPES = new Set(["uncommitted", "last-commit", "branch"]);
203+
204+
function parseWorktreeDiffType(diffType: string): { path: string; subType: string } | null {
205+
if (!diffType.startsWith("worktree:")) return null;
206+
const rest = diffType.slice("worktree:".length);
207+
const lastColon = rest.lastIndexOf(":");
208+
if (lastColon !== -1) {
209+
const maybeSub = rest.slice(lastColon + 1);
210+
if (WORKTREE_SUB_TYPES.has(maybeSub)) {
211+
return { path: rest.slice(0, lastColon), subType: maybeSub };
212+
}
213+
}
214+
return { path: rest, subType: "uncommitted" };
215+
}
216+
217+
/**
218+
* Build diff options for worktree mode: back-to-main, separator, then
219+
* standard diff types with the worktree path baked into each id.
220+
*/
221+
export function getWorktreeDiffOptions(worktreePath: string, defaultBranch: string): DiffOption[] {
222+
const prefix = `worktree:${worktreePath}:`;
223+
return [
224+
{ id: "back-to-main" as DiffType, label: "\u2190 Back to main repo" },
225+
{ id: "separator", label: "" },
226+
{ id: `${prefix}uncommitted` as DiffType, label: "Uncommitted changes" },
227+
{ id: `${prefix}last-commit` as DiffType, label: "Last commit" },
228+
{ id: `${prefix}branch` as DiffType, label: `vs ${defaultBranch}` },
229+
];
230+
}
231+
198232
/**
199233
* Run git diff with the specified type
200234
*/
@@ -207,22 +241,46 @@ export async function runGitDiff(
207241

208242
// Handle worktree diffs — run git commands in the worktree's directory
209243
if (diffType.startsWith("worktree:")) {
210-
const worktreePath = diffType.slice("worktree:".length);
244+
const parsed = parseWorktreeDiffType(diffType);
245+
if (!parsed) {
246+
return { patch: "", label: "Worktree error", error: "Could not parse worktree diff type" };
247+
}
248+
249+
const { path: wtPath, subType } = parsed;
250+
211251
try {
212-
const trackedDiff = (await $`git diff HEAD --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(worktreePath)).text();
213-
const untrackedDiff = await getUntrackedFileDiffs('a/', 'b/', worktreePath);
214-
patch = trackedDiff + untrackedDiff;
252+
switch (subType) {
253+
case "uncommitted": {
254+
const trackedDiff = (await $`git diff HEAD --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
255+
const untrackedDiff = await getUntrackedFileDiffs('a/', 'b/', wtPath);
256+
patch = trackedDiff + untrackedDiff;
257+
label = "Uncommitted changes";
258+
break;
259+
}
260+
case "last-commit":
261+
patch = (await $`git diff HEAD~1..HEAD --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
262+
label = "Last commit";
263+
break;
264+
case "branch":
265+
patch = (await $`git diff ${defaultBranch}..HEAD --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
266+
label = `Changes vs ${defaultBranch}`;
267+
break;
268+
default:
269+
patch = "";
270+
label = "Unknown worktree diff type";
271+
}
215272

216-
// Derive label from branch name in the worktree
273+
// Prefix label with worktree branch name for context
217274
try {
218-
const branch = (await $`git rev-parse --abbrev-ref HEAD`.quiet().cwd(worktreePath)).text().trim();
219-
label = `Worktree: ${branch}`;
275+
const branch = (await $`git rev-parse --abbrev-ref HEAD`.quiet().cwd(wtPath)).text().trim();
276+
label = `${branch}: ${label}`;
220277
} catch {
221-
label = `Worktree: ${worktreePath.split("/").pop()}`;
278+
label = `${wtPath.split("/").pop()}: ${label}`;
222279
}
223280

224281
return { patch, label };
225282
} catch (error) {
283+
console.error(`Git diff error for ${diffType}:`, error);
226284
const errorMessage = error instanceof Error ? error.message : String(error);
227285
return { patch: "", label: "Worktree error", error: errorMessage };
228286
}

packages/server/review.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import { isRemoteSession, getServerPort } from "./remote";
13-
import { type DiffType, type GitContext, runGitDiff } from "./git";
13+
import { type DiffType, type GitContext, runGitDiff, getWorktreeDiffOptions } from "./git";
1414
import { getRepoInfo } from "./repo";
1515
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers";
1616
import { contentHash, deleteDraft } from "./draft";
@@ -141,7 +141,7 @@ export async function startReviewServer(
141141
if (url.pathname === "/api/diff/switch" && req.method === "POST") {
142142
try {
143143
const body = (await req.json()) as { diffType: DiffType };
144-
const newDiffType = body.diffType;
144+
let newDiffType = body.diffType;
145145

146146
if (!newDiffType) {
147147
return Response.json(
@@ -150,8 +150,16 @@ export async function startReviewServer(
150150
);
151151
}
152152

153-
// Run the new diff
154153
const defaultBranch = gitContext?.defaultBranch || "main";
154+
const previousDiffType = currentDiffType;
155+
156+
// Handle "back to main repo" sentinel
157+
const isBackToMain = newDiffType === ("back-to-main" as DiffType);
158+
if (isBackToMain) {
159+
newDiffType = "uncommitted" as DiffType;
160+
}
161+
162+
// Run the new diff
155163
const result = await runGitDiff(newDiffType, defaultBranch);
156164

157165
// Update state
@@ -160,12 +168,27 @@ export async function startReviewServer(
160168
currentDiffType = newDiffType;
161169
currentError = result.error;
162170

163-
return Response.json({
171+
// Build response — include diffOptions on worktree mode transitions
172+
const response: Record<string, unknown> = {
164173
rawPatch: currentPatch,
165174
gitRef: currentGitRef,
166175
diffType: currentDiffType,
167176
...(currentError && { error: currentError }),
168-
});
177+
};
178+
179+
if (isBackToMain) {
180+
// Exiting worktree mode: restore original dropdown options
181+
response.diffOptions = gitContext?.diffOptions;
182+
} else if (
183+
newDiffType.startsWith("worktree:") &&
184+
!previousDiffType.startsWith("worktree:")
185+
) {
186+
// Entering worktree mode: send worktree-scoped dropdown options
187+
const wtPath = newDiffType.slice("worktree:".length).replace(/:(?:uncommitted|last-commit|branch)$/, "");
188+
response.diffOptions = getWorktreeDiffOptions(wtPath, defaultBranch);
189+
}
190+
191+
return Response.json(response);
169192
} catch (err) {
170193
const message =
171194
err instanceof Error ? err.message : "Failed to switch diff";

tests/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ These scripts test the UI components and require a browser.
1717

1818
```bash
1919
./tests/manual/local/test-opencode-review.sh # Code review UI test
20+
./tests/manual/local/test-worktree-review.sh # Worktree support test (creates sandbox with 4 worktrees)
2021
```
2122

2223
See [UI-TESTING.md](../docs/UI-TESTING.md) for detailed UI testing documentation.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/bin/bash
2+
# Test script for worktree support in code review
3+
#
4+
# Usage:
5+
# ./test-worktree-review.sh [--keep]
6+
#
7+
# Options:
8+
# --keep Don't clean up the temp repo on exit (for debugging)
9+
#
10+
# What it does:
11+
# 1. Builds the review app (ensures latest code)
12+
# 2. Creates a temp git repo with 4 worktrees:
13+
# - feature-auth: new file + modified file
14+
# - fix-parser: modified file + untracked file
15+
# - empty-branch: no changes (tests empty state)
16+
# - detached HEAD: new file on detached HEAD
17+
# 3. Launches review server — browser opens automatically
18+
# 4. You test the worktree dropdown and diff switching
19+
# 5. Cleans up on exit (unless --keep)
20+
21+
set -e
22+
23+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
24+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
25+
26+
echo "=== Plannotator Worktree Review Test ==="
27+
echo ""
28+
29+
# Build first to ensure latest code
30+
echo "Building review app..."
31+
cd "$PROJECT_ROOT"
32+
bun run build:review
33+
34+
echo ""
35+
echo "Setting up sandbox with worktrees..."
36+
echo ""
37+
38+
# Forward args to the TypeScript test server
39+
bun run "$PROJECT_ROOT/tests/manual/test-worktree-review.ts" "$@"
40+
41+
echo ""
42+
echo "=== Test Complete ==="

0 commit comments

Comments
 (0)