Skip to content

Commit 2bbfa53

Browse files
committed
feat(vcs): improve GitButler stacked diff navigation and lane attribution (#2)
1 parent 6ee1200 commit 2bbfa53

File tree

4 files changed

+101
-25
lines changed

4 files changed

+101
-25
lines changed

packages/review-editor/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1667,6 +1667,7 @@ const ReviewApp: React.FC = () => {
16671667
currentBranch={gitContext?.currentBranch}
16681668
vcsType={gitContext?.vcsType}
16691669
fileMeta={fileMeta ?? undefined}
1670+
hideLaneLabel={diffType.startsWith('gitbutler:') && (diffType.match(/:/g)?.length ?? 0) >= 2}
16701671
stagedFiles={stagedFiles}
16711672
onCopyRawDiff={handleCopyDiff}
16721673
canCopyRawDiff={!!diffData?.rawPatch}

packages/review-editor/components/FileTree.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface FileTreeProps {
2929
currentBranch?: string;
3030
vcsType?: string;
3131
fileMeta?: Record<string, FileMeta>;
32+
hideLaneLabel?: boolean;
3233
stagedFiles?: Set<string>;
3334
onCopyRawDiff?: () => void;
3435
canCopyRawDiff?: boolean;
@@ -70,6 +71,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
7071
currentBranch,
7172
vcsType,
7273
fileMeta,
74+
hideLaneLabel,
7375
stagedFiles,
7476
onCopyRawDiff,
7577
canCopyRawDiff = false,
@@ -396,6 +398,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
396398
getAnnotationCount={getAnnotationCount}
397399
stagedFiles={stagedFiles}
398400
fileMeta={fileMeta}
401+
hideLaneLabel={hideLaneLabel}
399402
/>
400403
))
401404
)}

packages/review-editor/components/FileTreeNode.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ interface FileTreeNodeProps {
1515
getAnnotationCount: (filePath: string) => number;
1616
stagedFiles?: Set<string>;
1717
fileMeta?: Record<string, FileMeta>;
18+
hideLaneLabel?: boolean;
1819
}
1920

2021
function hasVisibleChildren(
@@ -47,6 +48,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
4748
getAnnotationCount,
4849
stagedFiles,
4950
fileMeta,
51+
hideLaneLabel,
5052
}) => {
5153
const paddingLeft = 4 + node.depth * 8;
5254

@@ -96,6 +98,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
9698
getAnnotationCount={getAnnotationCount}
9799
stagedFiles={stagedFiles}
98100
fileMeta={fileMeta}
101+
hideLaneLabel={hideLaneLabel}
99102
/>
100103
))}
101104
</>
@@ -162,7 +165,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
162165
if (meta.lanes) return meta.lanes.join(', ');
163166
return undefined;
164167
})();
165-
const displayLabel = [srcChar, laneLabel].filter(Boolean).join(' · ');
168+
const displayLabel = [srcChar, hideLaneLabel ? null : laneLabel].filter(Boolean).join(' · ');
166169
return displayLabel ? (
167170
<span title={hoverTitle} className={`font-medium leading-none ${srcColor}`}>{displayLabel}</span>
168171
) : null;

packages/server/gitbutler.ts

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,27 @@ export async function getGitButlerContext(cwd?: string): Promise<GitContext> {
8888
const status = JSON.parse(statusResult.stdout) as ButStatusJson;
8989
for (const stack of status.stacks ?? []) {
9090
if (!stack.cliId) continue;
91-
// Use the topmost branch name as the display label for the stack
92-
const label = stack.branches?.[0]?.name ?? stack.cliId;
93-
virtualBranches.push({ id: stack.cliId, name: label });
94-
diffOptions.push({ id: `gitbutler:${stack.cliId}`, label });
91+
const branches = stack.branches ?? [];
92+
const topBranchName = branches[0]?.name ?? stack.cliId;
93+
virtualBranches.push({ id: stack.cliId, name: topBranchName });
94+
95+
if (branches.length > 1) {
96+
// Multi-branch stack: show a combined stack option + individual branch options
97+
diffOptions.push({
98+
id: `gitbutler:${stack.cliId}`,
99+
label: `Stack: ${topBranchName} (${branches.length})`,
100+
});
101+
for (const branch of branches) {
102+
if (!branch.cliId || !branch.name) continue;
103+
diffOptions.push({
104+
id: `gitbutler:${stack.cliId}:${branch.cliId}`,
105+
label: ` › ${branch.name}`,
106+
});
107+
}
108+
} else {
109+
// Single-branch stack: show as a plain branch option
110+
diffOptions.push({ id: `gitbutler:${stack.cliId}`, label: topBranchName });
111+
}
95112
}
96113
} catch {
97114
// ignore JSON parse errors — workspace option still available
@@ -205,12 +222,12 @@ export async function runGitButlerDiff(
205222
diffType: DiffType,
206223
cwd?: string,
207224
): Promise<DiffResult> {
208-
const target =
225+
const rawTarget =
209226
diffType === "gitbutler:workspace"
210227
? null
211228
: (diffType as string).slice("gitbutler:".length);
212229

213-
if (!target) {
230+
if (!rawTarget) {
214231
// Workspace: diff from merge base (includes committed lane changes + working tree changes)
215232
const status = await getButStatus(cwd);
216233
const mergeBase = status.mergeBase?.commitId;
@@ -234,15 +251,16 @@ export async function runGitButlerDiff(
234251
.filter((c) => c.changeType === "added")
235252
.map((c) => c.filePath);
236253

237-
// Committed files per branch (for lane attribution)
238-
const committedByLane = await Promise.all(
239-
(status.stacks ?? []).map(async (stack) => {
240-
const laneName = stack.branches?.[0]?.name ?? stack.cliId ?? "unknown";
241-
const committed = (
242-
await Promise.all((stack.branches ?? []).map((b) => b.cliId ? butDiffChanges(b.cliId, cwd) : Promise.resolve([])))
243-
).flat();
244-
return { laneName, paths: committed.map((c) => c.path) };
245-
}),
254+
// Committed files per individual branch (for accurate lane attribution)
255+
const committedByBranch = await Promise.all(
256+
(status.stacks ?? []).flatMap((stack) =>
257+
(stack.branches ?? [])
258+
.filter((b): b is ButBranch & { cliId: string; name: string } => Boolean(b.cliId && b.name))
259+
.map(async (branch) => {
260+
const changes = await butDiffChanges(branch.cliId, cwd);
261+
return { branchName: branch.name, paths: changes.map((c) => c.path) };
262+
})
263+
),
246264
);
247265

248266
const [untrackedPatches] = await Promise.all([
@@ -278,9 +296,9 @@ export async function runGitButlerDiff(
278296
addDetail(c.filePath, { lane: laneName, source: "uncommitted" });
279297
}
280298
}
281-
for (const { laneName, paths } of committedByLane) {
299+
for (const { branchName, paths } of committedByBranch) {
282300
for (const p of paths) {
283-
addDetail(p, { lane: laneName, source: "committed" });
301+
addDetail(p, { lane: branchName, source: "committed" });
284302
}
285303
}
286304
const fileMeta: Record<string, FileMeta> = {};
@@ -299,7 +317,30 @@ export async function runGitButlerDiff(
299317
return { patch, label: "Workspace (all changes)", fileMeta };
300318
}
301319

302-
// Per-lane: combine uncommitted (stack) + committed (branch) diffs for full picture
320+
// Determine whether this is a per-stack or individual-branch diff
321+
const colonIdx = rawTarget.indexOf(":");
322+
const isIndividualBranch = colonIdx > -1;
323+
324+
if (isIndividualBranch) {
325+
// Individual branch: gitbutler:{stackId}:{branchId} — show only that branch's commits
326+
const stackId = rawTarget.slice(0, colonIdx);
327+
const branchId = rawTarget.slice(colonIdx + 1);
328+
329+
const status = await getButStatus(cwd);
330+
const stack = status.stacks?.find((s) => s.cliId === stackId);
331+
const branch = stack?.branches?.find((b) => b.cliId === branchId);
332+
const branchLabel = branch?.name ?? branchId;
333+
334+
const committedChanges = await butDiffChanges(branchId, cwd);
335+
336+
const fileMeta: Record<string, FileMeta> = {};
337+
for (const c of committedChanges) fileMeta[c.path] = { source: "committed", lane: branchLabel };
338+
339+
return { patch: buildUnifiedPatch(committedChanges), label: branchLabel, fileMeta };
340+
}
341+
342+
// Per-stack: combine uncommitted (stack) + committed (all branches) diffs for full picture
343+
const target = rawTarget;
303344
const status = await getButStatus(cwd);
304345
const stack = status.stacks?.find((s) => s.cliId === target);
305346
const branchCliIds = stack?.branches?.map((b) => b.cliId).filter(Boolean) as string[] ?? [];
@@ -312,13 +353,41 @@ export async function runGitButlerDiff(
312353
const allCommittedChanges = committedChangeSets.flat();
313354
const merged = mergeChanges(uncommittedChanges, allCommittedChanges);
314355

315-
const stackLabel = stack?.branches?.[0]?.name ?? target;
356+
const branches = stack?.branches ?? [];
357+
const stackLabel = branches.length > 1
358+
? `Stack: ${branches[0]?.name ?? target} (${branches.length})`
359+
: (branches[0]?.name ?? target);
360+
361+
const branchNameById = new Map<string, string>(
362+
branches
363+
.filter((b): b is ButBranch & { cliId: string; name: string } => Boolean(b.cliId && b.name))
364+
.map((b) => [b.cliId, b.name])
365+
);
366+
367+
type LaneDetail = { lane: string; source: "committed" | "uncommitted" };
368+
const stackFileDetails = new Map<string, LaneDetail[]>();
369+
const addStackDetail = (path: string, detail: LaneDetail) => {
370+
const arr = stackFileDetails.get(path) ?? [];
371+
if (!arr.some((d) => d.lane === detail.lane && d.source === detail.source)) arr.push(detail);
372+
stackFileDetails.set(path, arr);
373+
};
374+
375+
for (const c of uncommittedChanges) addStackDetail(c.path, { lane: stackLabel, source: "uncommitted" });
376+
377+
for (let i = 0; i < branchCliIds.length; i++) {
378+
const branchName = branchNameById.get(branchCliIds[i]) ?? branchCliIds[i];
379+
for (const c of committedChangeSets[i]) addStackDetail(c.path, { lane: branchName, source: "committed" });
380+
}
381+
316382
const fileMeta: Record<string, FileMeta> = {};
317-
for (const c of uncommittedChanges) fileMeta[c.path] = { source: "uncommitted", lane: stackLabel };
318-
for (const c of allCommittedChanges) {
319-
fileMeta[c.path] = fileMeta[c.path]
320-
? { source: "mixed", lane: stackLabel }
321-
: { source: "committed", lane: stackLabel };
383+
for (const [path, details] of stackFileDetails) {
384+
const lanes = [...new Set(details.map((d) => d.lane))];
385+
const sources = [...new Set(details.map((d) => d.source))];
386+
const source: FileMeta["source"] =
387+
sources.length === 1 ? (sources[0] as "committed" | "uncommitted") : "mixed";
388+
fileMeta[path] = lanes.length > 1
389+
? { source, lanes, laneDetails: details }
390+
: { source, lane: lanes[0] };
322391
}
323392

324393
return { patch: buildUnifiedPatch(merged), label: stackLabel, fileMeta };

0 commit comments

Comments
 (0)