Skip to content

Commit 6ee1200

Browse files
committed
feat(vcs): add GitButler virtual branch support to code review
Implements a GitButler VCS provider that integrates `but diff` into plannotator's review flow. Detected via `.git/gitbutler/but.sqlite`. - Lane selector shows each applied stack as a named diff option - Workspace view diffs from merge base (includes committed lane changes and untracked files via `but diff -j` enumeration) - Per-lane view combines uncommitted assigned changes (stack CLI ID) with committed branch changes (branch CLI ID), merging hunks by line number - File metadata badges show committed (C), staged (S), or mixed (M) status per file in lane view, with lane name inline (e.g. "S · test") - Workspace view shows lane attribution per file; multi-lane files show "N lanes" with per-lane breakdown on hover ("committed to x, staged to y") - FileMeta type added to shared/review-core.ts and re-exported through shared/types.ts (no duplicate interfaces) - Hunk expansion disabled for per-lane diffs to avoid @pierre/diffs trailing context mismatch when other lanes' hunks shift line counts
1 parent 4139999 commit 6ee1200

12 files changed

Lines changed: 478 additions & 19 deletions

File tree

.claude/settings.local.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(but --help)",
5+
"Bash(but diff:*)",
6+
"Bash(but branch:*)",
7+
"Bash(but status:*)",
8+
"Bash(but show:*)"
9+
]
10+
}
11+
}

apps/hook/server/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import {
6363
startAnnotateServer,
6464
handleAnnotateServerReady,
6565
} from "@plannotator/server/annotate";
66-
import { type DiffType, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs";
66+
import { type DiffType, type FileMeta, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs";
6767
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
6868
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
6969
import { urlToMarkdown } from "@plannotator/shared/url-to-markdown";
@@ -216,6 +216,7 @@ if (args[0] === "sessions") {
216216
let initialDiffType: DiffType | undefined;
217217
let agentCwd: string | undefined;
218218
let worktreeCleanup: (() => void | Promise<void>) | undefined;
219+
let initialFileMeta: Record<string, FileMeta> | undefined;
219220

220221
if (isPRMode) {
221222
// --- PR Review Mode ---
@@ -388,11 +389,12 @@ if (args[0] === "sessions") {
388389
} else {
389390
// --- Local Review Mode ---
390391
gitContext = await getVcsContext();
391-
initialDiffType = gitContext.vcsType === "p4" ? "p4-default" : resolveDefaultDiffType(loadConfig());
392+
initialDiffType = gitContext.vcsType === "p4" ? "p4-default" : gitContext.vcsType === "gitbutler" ? "gitbutler:workspace" : resolveDefaultDiffType(loadConfig());
392393
const diffResult = await runVcsDiff(initialDiffType, gitContext.defaultBranch);
393394
rawPatch = diffResult.patch;
394395
gitRef = diffResult.label;
395396
diffError = diffResult.error;
397+
initialFileMeta = diffResult.fileMeta;
396398
}
397399

398400
const reviewProject = (await detectProjectName()) ?? "_unknown";
@@ -402,6 +404,7 @@ if (args[0] === "sessions") {
402404
rawPatch,
403405
gitRef,
404406
error: diffError,
407+
fileMeta: initialFileMeta,
405408
origin: detectedOrigin,
406409
diffType: gitContext ? (initialDiffType ?? "unstaged") : undefined,
407410
gitContext,

bun.lock

Lines changed: 4 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/review-editor/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import {
5555
REVIEW_PR_COMMENTS_PANEL_ID,
5656
REVIEW_PR_CHECKS_PANEL_ID,
5757
} from './dock/reviewPanelTypes';
58-
import type { DiffFile } from './types';
58+
import type { DiffFile, FileMeta } from './types';
5959
import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types';
6060
import type { PRMetadata } from '@plannotator/shared/pr-provider';
6161
import { altKey } from '@plannotator/ui/utils/platform';
@@ -155,6 +155,7 @@ const ReviewApp: React.FC = () => {
155155
const [gitUser, setGitUser] = useState<string | undefined>();
156156
const [isWSL, setIsWSL] = useState(false);
157157
const [diffType, setDiffType] = useState<string>('uncommitted');
158+
const [fileMeta, setFileMeta] = useState<Record<string, FileMeta> | null>(null);
158159
const [gitContext, setGitContext] = useState<GitContext | null>(null);
159160
const [agentCwd, setAgentCwd] = useState<string | null>(null);
160161
const [isLoadingDiff, setIsLoadingDiff] = useState(false);
@@ -656,6 +657,7 @@ const ReviewApp: React.FC = () => {
656657
setFiles(apiFiles);
657658
if (data.origin) setOrigin(data.origin);
658659
if (data.diffType) setDiffType(data.diffType);
660+
if (data.fileMeta) setFileMeta(data.fileMeta);
659661
if (data.gitContext) setGitContext(data.gitContext);
660662
if (data.agentCwd) setAgentCwd(data.agentCwd);
661663
if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled);
@@ -912,6 +914,7 @@ const ReviewApp: React.FC = () => {
912914
setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef, diffType: data.diffType } : prev);
913915
setFiles(nextFiles);
914916
setDiffType(data.diffType);
917+
setFileMeta(data.fileMeta ?? null);
915918
setActiveFileIndex(0);
916919
setPendingSelection(null);
917920
setDiffError(data.error || null);
@@ -1662,6 +1665,8 @@ const ReviewApp: React.FC = () => {
16621665
activeWorktreePath={activeWorktreePath}
16631666
onSelectWorktree={handleWorktreeSwitch}
16641667
currentBranch={gitContext?.currentBranch}
1668+
vcsType={gitContext?.vcsType}
1669+
fileMeta={fileMeta ?? undefined}
16651670
stagedFiles={stagedFiles}
16661671
onCopyRawDiff={handleCopyDiff}
16671672
canCopyRawDiff={!!diffData?.rawPatch}

packages/review-editor/components/FileTree.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { DiffOption, WorktreeInfo } from '@plannotator/shared/types';
44
import { buildFileTree, getAncestorPaths, getAllFolderPaths } from '../utils/buildFileTree';
55
import { FileTreeNodeItem } from './FileTreeNode';
66
import { getReviewSearchSideLabel, type ReviewSearchFileGroup, type ReviewSearchMatch } from '../utils/reviewSearch';
7-
import type { DiffFile } from '../types';
7+
import type { DiffFile, FileMeta } from '../types';
88
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
99

1010
interface FileTreeProps {
@@ -27,6 +27,8 @@ interface FileTreeProps {
2727
activeWorktreePath?: string | null;
2828
onSelectWorktree?: (path: string | null) => void;
2929
currentBranch?: string;
30+
vcsType?: string;
31+
fileMeta?: Record<string, FileMeta>;
3032
stagedFiles?: Set<string>;
3133
onCopyRawDiff?: () => void;
3234
canCopyRawDiff?: boolean;
@@ -66,6 +68,8 @@ export const FileTree: React.FC<FileTreeProps> = ({
6668
activeWorktreePath,
6769
onSelectWorktree,
6870
currentBranch,
71+
vcsType,
72+
fileMeta,
6973
stagedFiles,
7074
onCopyRawDiff,
7175
canCopyRawDiff = false,
@@ -391,6 +395,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
391395
hideViewedFiles={hideViewedFiles}
392396
getAnnotationCount={getAnnotationCount}
393397
stagedFiles={stagedFiles}
398+
fileMeta={fileMeta}
394399
/>
395400
))
396401
)}

packages/review-editor/components/FileTreeNode.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import type { FileTreeNode as TreeNode } from '../utils/buildFileTree';
3+
import type { FileMeta } from '../types';
34

45
interface FileTreeNodeProps {
56
node: TreeNode;
@@ -13,6 +14,7 @@ interface FileTreeNodeProps {
1314
hideViewedFiles: boolean;
1415
getAnnotationCount: (filePath: string) => number;
1516
stagedFiles?: Set<string>;
17+
fileMeta?: Record<string, FileMeta>;
1618
}
1719

1820
function hasVisibleChildren(
@@ -44,6 +46,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
4446
hideViewedFiles,
4547
getAnnotationCount,
4648
stagedFiles,
49+
fileMeta,
4750
}) => {
4851
const paddingLeft = 4 + node.depth * 8;
4952

@@ -92,6 +95,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
9295
hideViewedFiles={hideViewedFiles}
9396
getAnnotationCount={getAnnotationCount}
9497
stagedFiles={stagedFiles}
98+
fileMeta={fileMeta}
9599
/>
96100
))}
97101
</>
@@ -103,6 +107,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
103107
const isViewed = viewedFiles.has(node.path);
104108
const isStaged = stagedFiles?.has(node.path) ?? false;
105109
const annotationCount = getAnnotationCount(node.path);
110+
const meta = fileMeta?.[node.path];
106111

107112
if (hideViewedFiles && isViewed && !isActive) {
108113
return null;
@@ -141,6 +146,27 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
141146
{isStaged && (
142147
<span className="text-primary font-medium" title="Staged (git add)">+</span>
143148
)}
149+
{meta && (meta.lane || meta.lanes) && (() => {
150+
const srcChar = meta.source === 'committed' ? 'C' : meta.source === 'uncommitted' ? 'S' : meta.source === 'mixed' ? 'M' : null;
151+
const srcWord = meta.source === 'committed' ? 'committed' : meta.source === 'uncommitted' ? 'staged' : meta.source === 'mixed' ? 'committed + staged' : null;
152+
const srcColor = meta.source === 'committed' ? 'text-blue-400' : meta.source === 'uncommitted' ? 'text-orange-400' : meta.source === 'mixed' ? 'text-purple-400' : 'text-muted-foreground/60';
153+
const laneLabel = meta.lanes ? `${meta.lanes.length} lanes` : meta.lane ?? null;
154+
const hoverTitle = (() => {
155+
if (meta.laneDetails && meta.laneDetails.length > 1) {
156+
return meta.laneDetails
157+
.map((d) => `${d.source === 'committed' ? 'committed' : 'staged'} to ${d.lane}`)
158+
.join(', ');
159+
}
160+
if (srcWord && laneLabel) return `${srcWord} to ${meta.lanes ? meta.lanes.join(', ') : laneLabel}`;
161+
if (srcWord) return srcWord;
162+
if (meta.lanes) return meta.lanes.join(', ');
163+
return undefined;
164+
})();
165+
const displayLabel = [srcChar, laneLabel].filter(Boolean).join(' · ');
166+
return displayLabel ? (
167+
<span title={hoverTitle} className={`font-medium leading-none ${srcColor}`}>{displayLabel}</span>
168+
) : null;
169+
})()}
144170
{annotationCount > 0 && (
145171
<span className="text-primary font-medium">{annotationCount}</span>
146172
)}

packages/review-editor/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ export interface DiffFile {
55
additions: number;
66
deletions: number;
77
}
8+
9+
export type { FileMeta } from "@plannotator/shared/types";

0 commit comments

Comments
 (0)