@@ -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