a very good jj gui
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: improve branch ordering in revision graph by trunk merge-base

Reorder branches so that edges from branch commits to their trunk merge-base
don't visually cross through unrelated branches. Branches are now sorted by:
1. Working copy's branch always first
2. Trunk merge-base position (earlier = first)
3. Recency (most recently touched first)
4. Change ID (stable tiebreaker)

Also simplifies DiffPanel component and updates atoms.

+377 -168
+9 -1
apps/desktop/src/atoms.ts
··· 9 9 export const stackViewChangeIdAtom = Atom.make<string | null>(null); 10 10 // View mode: 1 = overview (only revisions), 2 = split (revisions + diff panel) 11 11 export type ViewMode = 1 | 2; 12 - export const viewModeAtom = Atom.make<ViewMode>(2); 12 + export const viewModeAtom = Atom.make<ViewMode>(1); 13 13 // Tracks which revision stacks are expanded (by stack ID) 14 14 export const expandedStacksAtom = Atom.make(new Set<string>()); 15 + 16 + // Diff panel state 17 + export type DiffStyle = "unified" | "split"; 18 + export const diffStyleAtom = Atom.make<DiffStyle>("unified"); 19 + // Tracks expanded files in diff panel (null = not initialized, will default to first file) 20 + export const expandedDiffFilesAtom = Atom.make<Set<string> | null>(null); 21 + // Per-file diff style overrides (file path -> style) 22 + export const fileDiffStyleOverridesAtom = Atom.make<Map<string, DiffStyle>>(new Map());
+9 -3
apps/desktop/src/components/ChangedFilesList.tsx
··· 62 62 showSelection?: boolean; 63 63 }) { 64 64 return ( 65 - <button 66 - type="button" 65 + <div 67 66 className={cn( 68 67 "flex items-center gap-2 w-full px-3 py-1.5 text-left transition-colors cursor-pointer group", 69 68 "hover:bg-muted/50", 70 69 isFocused && "bg-muted text-foreground", 71 70 )} 72 71 onClick={onClick} 72 + onKeyDown={(e) => { 73 + if (e.key === "Enter" || e.key === " ") { 74 + onClick(); 75 + } 76 + }} 77 + role="button" 78 + tabIndex={0} 73 79 > 74 80 {showSelection && ( 75 81 <button ··· 99 105 > 100 106 {file.path} 101 107 </span> 102 - </button> 108 + </div> 103 109 ); 104 110 } 105 111
+127 -121
apps/desktop/src/components/DiffPanel.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 1 2 import { PatchDiff } from "@pierre/diffs/react"; 2 3 import { useLiveQuery } from "@tanstack/react-db"; 3 4 import { useSearch } from "@tanstack/react-router"; ··· 6 7 ChevronRightIcon, 7 8 ChevronsDownUpIcon, 8 9 ChevronsUpDownIcon, 9 - ColumnsIcon, 10 + Columns2Icon, 10 11 RowsIcon, 11 12 } from "lucide-react"; 12 - import { useEffect, useRef, useState } from "react"; 13 + import { useEffect, useRef } from "react"; 14 + import { 15 + type DiffStyle, 16 + diffStyleAtom, 17 + expandedDiffFilesAtom, 18 + fileDiffStyleOverridesAtom, 19 + } from "@/atoms"; 13 20 import { Button } from "@/components/ui/button"; 21 + import { Separator } from "@/components/ui/separator"; 14 22 import { emptyDiffCollection, getRevisionDiffCollection } from "@/db"; 15 23 import type { Revision } from "@/tauri-commands"; 16 24 ··· 24 32 const commitIdShort = revision.commit_id.substring(0, 12); 25 33 26 34 return ( 27 - <div className="border border-border rounded-lg mb-4 bg-muted/50"> 28 - <div className="px-3 py-2 font-mono text-sm space-y-2"> 35 + <div className="border border-border rounded-lg bg-card"> 36 + <div className="px-3 py-2 font-mono text-xs space-y-1.5"> 29 37 <div className="flex gap-4"> 30 38 <div> 31 39 <span className="text-muted-foreground">Change ID:</span>{" "} ··· 43 51 <span className="text-foreground">{revision.timestamp}</span> 44 52 </div> 45 53 {revision.description && ( 46 - <div className="mt-3 pt-3 border-t border-border"> 47 - <pre className="text-sm text-foreground whitespace-pre-wrap font-sans"> 54 + <div className="mt-2 pt-2 border-t border-border"> 55 + <pre className="text-xs text-foreground whitespace-pre-wrap font-sans"> 48 56 {revision.description} 49 57 </pre> 50 58 </div> ··· 59 67 return match ? match[1] : "unknown"; 60 68 } 61 69 62 - type DiffStyle = "unified" | "split"; 63 - 64 70 function FileDiffSection({ 65 71 patch, 66 - defaultCollapsed = false, 67 72 isSelected = false, 68 73 fileRef, 69 - globalDiffStyle, 70 - onCollapseChange, 71 74 }: { 72 75 patch: string; 73 - defaultCollapsed?: boolean; 74 76 isSelected?: boolean; 75 77 fileRef?: React.RefObject<HTMLDivElement | null>; 76 - globalDiffStyle: DiffStyle; 77 - onCollapseChange?: (collapsed: boolean) => void; 78 78 }) { 79 - const [isCollapsedByUser, setIsCollapsedByUser] = useState(defaultCollapsed); 80 - const [localDiffStyle, setLocalDiffStyle] = useState<DiffStyle | null>(null); 81 - const filePath = extractFilePath(patch); 82 - 83 - // Derived state: auto-expand when selected 84 - const isCollapsed = isSelected ? false : isCollapsedByUser; 79 + const [globalDiffStyle] = useAtom(diffStyleAtom); 80 + const [expandedFiles, setExpandedFiles] = useAtom(expandedDiffFilesAtom); 81 + const [styleOverrides, setStyleOverrides] = useAtom(fileDiffStyleOverridesAtom); 85 82 83 + const filePath = extractFilePath(patch); 84 + const isExpanded = expandedFiles?.has(filePath) ?? false; 85 + // Auto-expand when selected 86 + const isCollapsed = isSelected ? false : !isExpanded; 86 87 // Use local override if set, otherwise use global 87 - const effectiveDiffStyle = localDiffStyle ?? globalDiffStyle; 88 + const effectiveDiffStyle = styleOverrides.get(filePath) ?? globalDiffStyle; 88 89 89 90 function handleToggleCollapse() { 90 - const newCollapsed = !isCollapsed; 91 - setIsCollapsedByUser(newCollapsed); 92 - onCollapseChange?.(newCollapsed); 91 + setExpandedFiles((prev) => { 92 + const next = new Set(prev ?? []); 93 + if (isCollapsed) { 94 + next.add(filePath); 95 + } else { 96 + next.delete(filePath); 97 + } 98 + return next; 99 + }); 100 + } 101 + 102 + function handleSetLocalStyle(style: DiffStyle) { 103 + setStyleOverrides((prev) => { 104 + const next = new Map(prev); 105 + next.set(filePath, style); 106 + return next; 107 + }); 93 108 } 94 109 95 110 return ( ··· 117 132 <ChevronDownIcon className="size-4" /> 118 133 )} 119 134 </span> 120 - <code className="font-mono text-sm text-foreground text-left flex-1 truncate"> 135 + <code className="font-mono text-xs text-foreground text-left flex-1 truncate"> 121 136 {filePath} 122 137 </code> 123 138 </button> ··· 127 142 <Button 128 143 variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 129 144 size="icon-xs" 130 - onClick={() => setLocalDiffStyle("unified")} 145 + onClick={() => handleSetLocalStyle("unified")} 131 146 title="Unified diff" 147 + className="h-6 w-6" 132 148 > 133 149 <RowsIcon className="size-3" /> 134 150 </Button> 135 151 <Button 136 152 variant={effectiveDiffStyle === "split" ? "secondary" : "ghost"} 137 153 size="icon-xs" 138 - onClick={() => setLocalDiffStyle("split")} 154 + onClick={() => handleSetLocalStyle("split")} 139 155 title="Split diff" 156 + className="h-6 w-6" 140 157 > 141 - <ColumnsIcon className="size-3" /> 158 + <Columns2Icon className="size-3" /> 142 159 </Button> 143 160 </div> 144 161 </div> ··· 201 218 : null; 202 219 203 220 return <DiffPanel repoPath={repoPath} changeId={selectedChangeId} revision={selectedRevision} />; 204 - } 205 - 206 - function DiffToolbar({ 207 - allCollapsed, 208 - onToggleAllFolds, 209 - diffStyle, 210 - onDiffStyleChange, 211 - }: { 212 - allCollapsed: boolean; 213 - onToggleAllFolds: () => void; 214 - diffStyle: DiffStyle; 215 - onDiffStyleChange: (style: DiffStyle) => void; 216 - }) { 217 - return ( 218 - <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30 sticky top-0 z-10"> 219 - <div className="flex items-center gap-2"> 220 - <Button 221 - variant="ghost" 222 - size="xs" 223 - onClick={onToggleAllFolds} 224 - title={allCollapsed ? "Expand all files" : "Collapse all files"} 225 - > 226 - {allCollapsed ? ( 227 - <ChevronsUpDownIcon className="size-3.5" /> 228 - ) : ( 229 - <ChevronsDownUpIcon className="size-3.5" /> 230 - )} 231 - <span>{allCollapsed ? "Expand all" : "Collapse all"}</span> 232 - </Button> 233 - </div> 234 - <div className="flex items-center gap-1"> 235 - <span className="text-xs text-muted-foreground mr-1">View:</span> 236 - <Button 237 - variant={diffStyle === "unified" ? "secondary" : "ghost"} 238 - size="icon-xs" 239 - onClick={() => onDiffStyleChange("unified")} 240 - title="Unified diff view" 241 - > 242 - <RowsIcon className="size-3" /> 243 - </Button> 244 - <Button 245 - variant={diffStyle === "split" ? "secondary" : "ghost"} 246 - size="icon-xs" 247 - onClick={() => onDiffStyleChange("split")} 248 - title="Split diff view" 249 - > 250 - <ColumnsIcon className="size-3" /> 251 - </Button> 252 - </div> 253 - </div> 254 - ); 255 221 } 256 222 257 223 export function DiffPanel({ repoPath, changeId, revision }: DiffPanelProps) { 258 224 const { file: selectedFilePath } = useSearch({ strict: false }); 259 225 const fileRefsMap = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map()); 260 - const [globalDiffStyle, setGlobalDiffStyle] = useState<DiffStyle>("unified"); 261 - const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set()); 262 - const [lastChangeId, setLastChangeId] = useState<string | null>(null); 263 - 264 - // Reset collapsed state when revision changes 265 - if (changeId !== lastChangeId) { 266 - setLastChangeId(changeId); 267 - if (collapsedFiles.size > 0) { 268 - setCollapsedFiles(new Set()); 269 - } 270 - } 226 + const [expandedFiles, setExpandedFiles] = useAtom(expandedDiffFilesAtom); 227 + const [, setStyleOverrides] = useAtom(fileDiffStyleOverridesAtom); 228 + const lastChangeIdRef = useRef<string | null>(null); 271 229 272 230 // Always fetch all diffs 273 231 const diffCollection = ··· 278 236 const fileDiffs = splitMultiFileDiff(revisionDiff); 279 237 const filePaths = fileDiffs.map(extractFilePath); 280 238 239 + // Reset state when revision changes 240 + const firstFilePath = filePaths[0] ?? null; 241 + useEffect(() => { 242 + if (changeId !== lastChangeIdRef.current) { 243 + lastChangeIdRef.current = changeId; 244 + // Reset to first file expanded 245 + if (firstFilePath) { 246 + setExpandedFiles(new Set([firstFilePath])); 247 + } else { 248 + setExpandedFiles(new Set()); 249 + } 250 + // Clear per-file style overrides 251 + setStyleOverrides(new Map()); 252 + } 253 + }, [changeId, firstFilePath, setExpandedFiles, setStyleOverrides]); 254 + 255 + // Initialize expanded files on first load 256 + useEffect(() => { 257 + if (expandedFiles === null && firstFilePath) { 258 + setExpandedFiles(new Set([firstFilePath])); 259 + } 260 + }, [expandedFiles, firstFilePath, setExpandedFiles]); 261 + 281 262 // Get or create ref for each file 282 263 const getFileRef = (filePath: string): React.RefObject<HTMLDivElement | null> => { 283 264 if (!fileRefsMap.current.has(filePath)) { ··· 287 268 return fileRefsMap.current.get(filePath)!; 288 269 }; 289 270 290 - // Track collapse state for toggle all 291 - const allCollapsed = filePaths.length > 0 && filePaths.every((p) => collapsedFiles.has(p)); 271 + // Toggle all folds 272 + const allExpanded = filePaths.length > 0 && filePaths.every((p) => expandedFiles?.has(p)); 292 273 293 274 function handleToggleAllFolds() { 294 - if (allCollapsed) { 295 - // Expand all 296 - setCollapsedFiles(new Set()); 275 + if (allExpanded) { 276 + setExpandedFiles(new Set()); 297 277 } else { 298 - // Collapse all 299 - setCollapsedFiles(new Set(filePaths)); 278 + setExpandedFiles(new Set(filePaths)); 300 279 } 301 - } 302 - 303 - function handleFileCollapseChange(filePath: string, collapsed: boolean) { 304 - setCollapsedFiles((prev) => { 305 - const next = new Set(prev); 306 - if (collapsed) { 307 - next.add(filePath); 308 - } else { 309 - next.delete(filePath); 310 - } 311 - return next; 312 - }); 313 280 } 314 281 315 282 // Scroll to selected file when it changes ··· 352 319 return ( 353 320 <div className="h-full overflow-auto bg-background"> 354 321 {revision && ( 355 - <div className="pt-6 px-4 pb-0"> 322 + <div className="p-4 pb-0"> 356 323 <RevisionHeader revision={revision} /> 357 324 </div> 358 325 )} 359 - <DiffToolbar 360 - allCollapsed={allCollapsed} 361 - onToggleAllFolds={handleToggleAllFolds} 362 - diffStyle={globalDiffStyle} 363 - onDiffStyleChange={setGlobalDiffStyle} 364 - /> 365 326 <div className="p-4 space-y-4"> 366 - {fileDiffs.map((patch, idx) => { 327 + <div className="flex items-center h-8 px-2 text-xs text-muted-foreground sticky top-0 z-10 bg-background -mt-2 pt-2"> 328 + <span className="font-medium"> 329 + {fileDiffs.length} {fileDiffs.length === 1 ? "file" : "files"} 330 + </span> 331 + <Separator orientation="vertical" className="h-4 mx-3" /> 332 + <Button 333 + variant="ghost" 334 + size="icon-xs" 335 + onClick={handleToggleAllFolds} 336 + title={allExpanded ? "Collapse all files" : "Expand all files"} 337 + className="h-6 w-6" 338 + > 339 + {allExpanded ? ( 340 + <ChevronsDownUpIcon className="size-3.5" /> 341 + ) : ( 342 + <ChevronsUpDownIcon className="size-3.5" /> 343 + )} 344 + </Button> 345 + <div className="flex items-center gap-0.5 ml-auto"> 346 + <DiffStyleToggle /> 347 + </div> 348 + </div> 349 + {fileDiffs.map((patch) => { 367 350 const filePath = extractFilePath(patch); 368 351 const fileRef = getFileRef(filePath); 369 352 const isSelected = selectedFilePath === filePath; 370 - const isCollapsed = collapsedFiles.has(filePath); 371 353 372 354 return ( 373 355 <FileDiffSection 374 356 key={filePath} 375 357 patch={patch} 376 - defaultCollapsed={isCollapsed || (idx > 0 && !isSelected)} 377 358 isSelected={isSelected} 378 359 fileRef={fileRef} 379 - globalDiffStyle={globalDiffStyle} 380 - onCollapseChange={(collapsed) => handleFileCollapseChange(filePath, collapsed)} 381 360 /> 382 361 ); 383 362 })} ··· 385 364 </div> 386 365 ); 387 366 } 367 + 368 + function DiffStyleToggle() { 369 + const [globalDiffStyle, setGlobalDiffStyle] = useAtom(diffStyleAtom); 370 + 371 + return ( 372 + <> 373 + <Button 374 + variant={globalDiffStyle === "unified" ? "secondary" : "ghost"} 375 + size="icon-xs" 376 + onClick={() => setGlobalDiffStyle("unified")} 377 + title="Unified diff view" 378 + className="h-6 w-6" 379 + > 380 + <RowsIcon className="size-3" /> 381 + </Button> 382 + <Button 383 + variant={globalDiffStyle === "split" ? "secondary" : "ghost"} 384 + size="icon-xs" 385 + onClick={() => setGlobalDiffStyle("split")} 386 + title="Split diff view" 387 + className="h-6 w-6" 388 + > 389 + <Columns2Icon className="size-3" /> 390 + </Button> 391 + </> 392 + ); 393 + }
+232 -43
apps/desktop/src/components/revision-graph-utils.ts
··· 311 311 return stacks; 312 312 } 313 313 314 + /** 315 + * Reorders revisions for optimal graph rendering in a 2-lane layout. 316 + * 317 + * The graph uses lane 0 for trunk commits and lane 1 for all feature branches. 318 + * This creates a challenge: edges from branch commits to their trunk merge-base 319 + * are drawn as vertical lines in lane 1, then elbows to lane 0. If branches are 320 + * ordered poorly, these vertical segments can pass through unrelated branch nodes, 321 + * creating a false visual impression that the branches are connected. 322 + * 323 + * ## Algorithm 324 + * 325 + * 1. **Identify trunk vs branch commits** using the `is_trunk` flag from backend 326 + * (commits in `::trunk()`) 327 + * 328 + * 2. **Compute trunk merge-base for each branch** - the first trunk commit 329 + * encountered when walking down from the branch head 330 + * 331 + * 3. **Sort branches by merge-base position in trunk** - branches connecting to 332 + * similar areas of trunk are grouped together. This prevents edge crossings 333 + * where a branch with a deep merge-base would have its edge pass through 334 + * other branches. 335 + * 336 + * 4. **Output branches in sorted order**, each branch fully (head to merge-base) 337 + * before moving to the next 338 + * 339 + * 5. **Output trunk commits last** in topological order 340 + * 341 + * ## Sort Priority 342 + * - Working copy's branch always first 343 + * - Then by trunk merge-base position (earlier = first) 344 + * - Then by recency (most recently touched first) 345 + * - Then by change_id (stable tiebreaker) 346 + * 347 + * @param revisions - All revisions to display 348 + * @param recency - Optional map of commit_id -> timestamp for recency sorting 349 + * @returns Reordered revisions for graph rendering 350 + */ 314 351 export function reorderForGraph(revisions: Revision[], recency?: CommitRecency): Revision[] { 315 352 if (revisions.length === 0) return []; 316 353 ··· 332 369 } 333 370 parentMap.set(rev.commit_id, parents); 334 371 } 372 + 373 + // Identify trunk commits (is_trunk flag from backend) 374 + const trunkCommitIds = new Set( 375 + revisions.filter((r) => r.is_trunk).map((r) => r.commit_id), 376 + ); 335 377 336 378 // Find the head that contains the working copy in its ancestry 337 379 const workingCopy = revisions.find((r) => r.is_working_copy); ··· 374 416 return maxRecency; 375 417 } 376 418 377 - // Find heads 419 + // Find all branch commits for a head (commits from head down to but not including trunk) 420 + // Also returns the trunk merge-base (first trunk commit encountered) 421 + function getBranchCommitsAndMergeBase(headCommitId: string): { commits: string[]; mergeBase: string | null } { 422 + const branchCommits: string[] = []; 423 + const visited = new Set<string>(); 424 + const stack = [headCommitId]; 425 + let mergeBase: string | null = null; 426 + 427 + while (stack.length > 0) { 428 + const id = stack.pop()!; 429 + if (visited.has(id)) continue; 430 + visited.add(id); 431 + 432 + // Stop at trunk commits - record as merge base 433 + if (trunkCommitIds.has(id)) { 434 + if (!mergeBase) mergeBase = id; 435 + continue; 436 + } 437 + 438 + branchCommits.push(id); 439 + 440 + // Add parents to continue traversal 441 + const parents = parentMap.get(id) ?? []; 442 + for (const parentId of parents) { 443 + if (!visited.has(parentId)) { 444 + stack.push(parentId); 445 + } 446 + } 447 + } 448 + 449 + return { commits: branchCommits, mergeBase }; 450 + } 451 + 452 + // Wrapper for backward compatibility 453 + function getBranchCommits(headCommitId: string): string[] { 454 + return getBranchCommitsAndMergeBase(headCommitId).commits; 455 + } 456 + 457 + // Find heads (commits with no children in our revset) 378 458 const heads = revisions.filter((r) => { 379 459 const children = childrenMap.get(r.commit_id) ?? []; 380 460 return children.filter((c) => commitIds.has(c)).length === 0; 381 461 }); 382 462 383 - // Sort heads: WC's branch first, then by recency (most recent first), then stable tiebreaker 384 - heads.sort((a, b) => { 463 + // Separate branch heads from trunk heads 464 + const branchHeads = heads.filter((h) => !trunkCommitIds.has(h.commit_id)); 465 + 466 + // First, compute trunk order (topological sort of trunk commits) 467 + // We need this to sort branches by their merge-base position 468 + const trunkOrder = new Map<string, number>(); 469 + { 470 + const trunkCommits = revisions.filter((r) => trunkCommitIds.has(r.commit_id)); 471 + const trunkChildCount = new Map<string, number>(); 472 + 473 + for (const rev of trunkCommits) { 474 + const parents = parentMap.get(rev.commit_id) ?? []; 475 + for (const parentId of parents) { 476 + if (trunkCommitIds.has(parentId)) { 477 + trunkChildCount.set(parentId, (trunkChildCount.get(parentId) ?? 0) + 1); 478 + } 479 + } 480 + } 481 + 482 + // Topological sort 483 + const ready = trunkCommits.filter((r) => (trunkChildCount.get(r.commit_id) ?? 0) === 0); 484 + let orderIdx = 0; 485 + const visited = new Set<string>(); 486 + 487 + while (ready.length > 0) { 488 + const rev = ready.shift()!; 489 + if (visited.has(rev.commit_id)) continue; 490 + visited.add(rev.commit_id); 491 + 492 + trunkOrder.set(rev.commit_id, orderIdx++); 493 + 494 + const parents = parentMap.get(rev.commit_id) ?? []; 495 + for (const parentId of parents) { 496 + if (trunkCommitIds.has(parentId) && !visited.has(parentId)) { 497 + const newCount = (trunkChildCount.get(parentId) ?? 1) - 1; 498 + trunkChildCount.set(parentId, newCount); 499 + if (newCount === 0) { 500 + const parentRev = commitMap.get(parentId); 501 + if (parentRev) ready.push(parentRev); 502 + } 503 + } 504 + } 505 + } 506 + } 507 + 508 + // Compute merge-base for each branch head 509 + const branchMergeBase = new Map<string, string | null>(); 510 + for (const head of branchHeads) { 511 + const { mergeBase } = getBranchCommitsAndMergeBase(head.commit_id); 512 + branchMergeBase.set(head.commit_id, mergeBase); 513 + } 514 + 515 + // Sort branch heads by: 516 + // 1. WC's branch first 517 + // 2. Trunk merge-base position (branches connecting to same trunk area are grouped) 518 + // 3. Recency as tiebreaker within same merge-base area 519 + // 4. Stable tiebreaker: change_id 520 + branchHeads.sort((a, b) => { 385 521 // WC's branch always first 386 522 const aIsWcBranch = a.commit_id === wcAncestorHeadId; 387 523 const bIsWcBranch = b.commit_id === wcAncestorHeadId; 388 524 if (aIsWcBranch && !bIsWcBranch) return -1; 389 525 if (!aIsWcBranch && bIsWcBranch) return 1; 390 526 391 - // Sort by recency (higher timestamp = more recent = should come first) 527 + // Sort by merge-base position in trunk (earlier merge-base = higher in graph = first) 528 + const aMergeBase = branchMergeBase.get(a.commit_id); 529 + const bMergeBase = branchMergeBase.get(b.commit_id); 530 + const aMergeBaseOrder = aMergeBase ? (trunkOrder.get(aMergeBase) ?? Infinity) : Infinity; 531 + const bMergeBaseOrder = bMergeBase ? (trunkOrder.get(bMergeBase) ?? Infinity) : Infinity; 532 + if (aMergeBaseOrder !== bMergeBaseOrder) { 533 + return aMergeBaseOrder - bMergeBaseOrder; // Ascending (earlier merge-base first) 534 + } 535 + 536 + // Within same merge-base area, sort by recency (most recent first) 392 537 if (recency) { 393 538 const aRecency = getBranchRecency(a.commit_id); 394 539 const bRecency = getBranchRecency(b.commit_id); ··· 401 546 return a.change_id.localeCompare(b.change_id); 402 547 }); 403 548 404 - // Track which commits have been output 549 + const result: Revision[] = []; 405 550 const output = new Set<string>(); 406 - // Track remaining children count for each commit 407 - const remainingChildren = new Map<string, number>(); 408 - for (const rev of revisions) { 409 - const children = childrenMap.get(rev.commit_id) ?? []; 410 - const childCount = children.filter((c) => commitIds.has(c)).length; 411 - remainingChildren.set(rev.commit_id, childCount); 412 - } 413 551 414 - const result: Revision[] = []; 552 + // Phase 1: Output each branch fully (head to merge-base, excluding trunk) 553 + // This groups all commits of a branch together 554 + // Branches are sorted by merge-base position, so branches connecting to similar 555 + // trunk areas are adjacent, preventing edge crossings 556 + for (const head of branchHeads) { 557 + if (output.has(head.commit_id)) continue; 415 558 416 - // Process each head's branch using DFS 417 - // This ensures we exhaust a branch before moving to the next 418 - function processBranch(startId: string) { 419 - const stack = [startId]; 559 + // Get all commits in this branch 560 + const branchCommits = getBranchCommits(head.commit_id); 420 561 421 - while (stack.length > 0) { 422 - const id = stack[stack.length - 1]; // Peek 562 + // Sort branch commits topologically (children before parents) 563 + // We need to respect the parent-child ordering within the branch 564 + const branchSet = new Set(branchCommits); 565 + const branchChildCount = new Map<string, number>(); 423 566 424 - if (output.has(id)) { 425 - stack.pop(); 426 - continue; 567 + for (const id of branchCommits) { 568 + const parents = parentMap.get(id) ?? []; 569 + for (const parentId of parents) { 570 + if (branchSet.has(parentId)) { 571 + branchChildCount.set(parentId, (branchChildCount.get(parentId) ?? 0) + 1); 572 + } 427 573 } 574 + } 428 575 429 - const remaining = remainingChildren.get(id) ?? 0; 430 - if (remaining > 0) { 431 - // This commit still has unprocessed children 432 - // They must be from other branches - we'll come back to this commit 433 - stack.pop(); 434 - continue; 576 + // Topological sort within the branch 577 + const sorted: string[] = []; 578 + const ready = branchCommits.filter((id) => (branchChildCount.get(id) ?? 0) === 0); 579 + 580 + while (ready.length > 0) { 581 + const id = ready.shift()!; 582 + if (output.has(id)) continue; 583 + 584 + sorted.push(id); 585 + output.add(id); 586 + 587 + const parents = parentMap.get(id) ?? []; 588 + for (const parentId of parents) { 589 + if (branchSet.has(parentId) && !output.has(parentId)) { 590 + const newCount = (branchChildCount.get(parentId) ?? 1) - 1; 591 + branchChildCount.set(parentId, newCount); 592 + if (newCount === 0) { 593 + ready.push(parentId); 594 + } 595 + } 435 596 } 597 + } 436 598 437 - // All children processed, we can output this commit 438 - output.add(id); 599 + // Add sorted branch commits to result 600 + for (const id of sorted) { 439 601 const rev = commitMap.get(id); 440 602 if (rev) result.push(rev); 441 - stack.pop(); 603 + } 604 + } 442 605 443 - // Decrease remaining children count for parents and add to stack 444 - const parents = parentMap.get(id) ?? []; 445 - for (const parentId of parents) { 446 - if (!output.has(parentId)) { 447 - const newRemaining = (remainingChildren.get(parentId) ?? 1) - 1; 448 - remainingChildren.set(parentId, newRemaining); 449 - // Add parent to stack to continue DFS 450 - stack.push(parentId); 606 + // Phase 2: Output trunk commits (shared ancestors) 607 + // Sort trunk commits topologically as well 608 + const trunkCommits = revisions.filter((r) => trunkCommitIds.has(r.commit_id) && !output.has(r.commit_id)); 609 + 610 + // Build child counts for trunk commits 611 + const trunkChildCount = new Map<string, number>(); 612 + for (const rev of trunkCommits) { 613 + const parents = parentMap.get(rev.commit_id) ?? []; 614 + for (const parentId of parents) { 615 + if (trunkCommitIds.has(parentId) && !output.has(parentId)) { 616 + trunkChildCount.set(parentId, (trunkChildCount.get(parentId) ?? 0) + 1); 617 + } 618 + } 619 + } 620 + 621 + // Start with trunk heads or commits with no unprocessed children 622 + const trunkReady = trunkCommits.filter((r) => (trunkChildCount.get(r.commit_id) ?? 0) === 0); 623 + 624 + while (trunkReady.length > 0) { 625 + const rev = trunkReady.shift()!; 626 + if (output.has(rev.commit_id)) continue; 627 + 628 + result.push(rev); 629 + output.add(rev.commit_id); 630 + 631 + const parents = parentMap.get(rev.commit_id) ?? []; 632 + for (const parentId of parents) { 633 + if (trunkCommitIds.has(parentId) && !output.has(parentId)) { 634 + const newCount = (trunkChildCount.get(parentId) ?? 1) - 1; 635 + trunkChildCount.set(parentId, newCount); 636 + if (newCount === 0) { 637 + const parentRev = commitMap.get(parentId); 638 + if (parentRev) trunkReady.push(parentRev); 451 639 } 452 640 } 453 641 } 454 642 } 455 643 456 - // Process heads in priority order 457 - for (const head of heads) { 458 - if (!output.has(head.commit_id)) { 459 - processBranch(head.commit_id); 644 + // Phase 3: Any remaining commits (shouldn't happen, but safety net) 645 + for (const rev of revisions) { 646 + if (!output.has(rev.commit_id)) { 647 + result.push(rev); 648 + output.add(rev.commit_id); 460 649 } 461 650 } 462 651