···11import type { Revision } from "@/tauri-commands";
2233-export function reorderForGraph(revisions: Revision[]): Revision[] {
33+/** Recency map: commit_id (hex) -> timestamp_millis when last WC */
44+export type CommitRecency = Record<string, number>;
55+66+/**
77+ * Ancestry information for a revision within the visible revset.
88+ * Used to determine graph edges and lane allocation.
99+ */
1010+export interface RevisionAncestry {
1111+ /** commit_id -> Set of ancestor commit_ids within the visible revset */
1212+ ancestors: Map<string, Set<string>>;
1313+ /** commit_id -> Set of descendant commit_ids within the visible revset */
1414+ descendants: Map<string, Set<string>>;
1515+ /** commit_id -> direct parent commit_ids within the visible revset */
1616+ parents: Map<string, string[]>;
1717+ /** commit_id -> direct child commit_ids within the visible revset */
1818+ children: Map<string, string[]>;
1919+}
2020+2121+/**
2222+ * Computes ancestor/descendant relationships for all revisions within the visible revset.
2323+ * This is used to determine which revisions are actually related and should be connected by edges.
2424+ */
2525+export function computeRevisionAncestry(revisions: Revision[]): RevisionAncestry {
2626+ if (revisions.length === 0) {
2727+ return {
2828+ ancestors: new Map(),
2929+ descendants: new Map(),
3030+ parents: new Map(),
3131+ children: new Map(),
3232+ };
3333+ }
3434+3535+ const commitIds = new Set(revisions.map((r) => r.commit_id));
3636+3737+ // Build direct parent/child relationships (only within visible revset)
3838+ const parents = new Map<string, string[]>();
3939+ const children = new Map<string, string[]>();
4040+4141+ for (const rev of revisions) {
4242+ const visibleParents: string[] = [];
4343+ for (const edge of rev.parent_edges) {
4444+ // Only consider non-missing edges where parent is in visible set
4545+ if (edge.edge_type === "missing") continue;
4646+ if (!commitIds.has(edge.parent_id)) continue;
4747+ visibleParents.push(edge.parent_id);
4848+4949+ // Build children map
5050+ const parentChildren = children.get(edge.parent_id) ?? [];
5151+ parentChildren.push(rev.commit_id);
5252+ children.set(edge.parent_id, parentChildren);
5353+ }
5454+ parents.set(rev.commit_id, visibleParents);
5555+ }
5656+5757+ // Ensure all commits have entries even if they have no children/parents
5858+ for (const rev of revisions) {
5959+ if (!children.has(rev.commit_id)) {
6060+ children.set(rev.commit_id, []);
6161+ }
6262+ }
6363+6464+ // Compute transitive ancestors for each commit using BFS
6565+ const ancestors = new Map<string, Set<string>>();
6666+ for (const rev of revisions) {
6767+ const ancestorSet = new Set<string>();
6868+ const queue = [...(parents.get(rev.commit_id) ?? [])];
6969+7070+ while (queue.length > 0) {
7171+ const parentId = queue.shift()!;
7272+ if (ancestorSet.has(parentId)) continue;
7373+ ancestorSet.add(parentId);
7474+ queue.push(...(parents.get(parentId) ?? []));
7575+ }
7676+7777+ ancestors.set(rev.commit_id, ancestorSet);
7878+ }
7979+8080+ // Compute transitive descendants for each commit using BFS
8181+ const descendants = new Map<string, Set<string>>();
8282+ for (const rev of revisions) {
8383+ const descendantSet = new Set<string>();
8484+ const queue = [...(children.get(rev.commit_id) ?? [])];
8585+8686+ while (queue.length > 0) {
8787+ const childId = queue.shift()!;
8888+ if (descendantSet.has(childId)) continue;
8989+ descendantSet.add(childId);
9090+ queue.push(...(children.get(childId) ?? []));
9191+ }
9292+9393+ descendants.set(rev.commit_id, descendantSet);
9494+ }
9595+9696+ return { ancestors, descendants, parents, children };
9797+}
9898+9999+/**
100100+ * Checks if two revisions are related (one is ancestor/descendant of the other).
101101+ */
102102+export function areRevisionsRelated(
103103+ commitIdA: string,
104104+ commitIdB: string,
105105+ ancestry: RevisionAncestry,
106106+): boolean {
107107+ if (commitIdA === commitIdB) return true;
108108+ const ancestorsA = ancestry.ancestors.get(commitIdA);
109109+ const ancestorsB = ancestry.ancestors.get(commitIdB);
110110+ if (ancestorsA?.has(commitIdB)) return true;
111111+ if (ancestorsB?.has(commitIdA)) return true;
112112+ return false;
113113+}
114114+115115+/**
116116+ * Groups revisions into connected components based on ancestry relationships.
117117+ * Each component contains revisions that are related (share ancestor/descendant relationships).
118118+ */
119119+export function groupIntoConnectedComponents(
120120+ revisions: Revision[],
121121+ ancestry: RevisionAncestry,
122122+): Map<string, string[]> {
123123+ const components = new Map<string, string[]>(); // componentId -> commit_ids
124124+ const commitToComponent = new Map<string, string>();
125125+126126+ for (const rev of revisions) {
127127+ if (commitToComponent.has(rev.commit_id)) continue;
128128+129129+ // Start a new component with this revision as the root
130130+ const componentId = rev.commit_id;
131131+ const componentMembers: string[] = [];
132132+ const queue = [rev.commit_id];
133133+134134+ while (queue.length > 0) {
135135+ const commitId = queue.shift()!;
136136+ if (commitToComponent.has(commitId)) continue;
137137+138138+ commitToComponent.set(commitId, componentId);
139139+ componentMembers.push(commitId);
140140+141141+ // Add all ancestors and descendants to the component
142142+ const ancestorSet = ancestry.ancestors.get(commitId) ?? new Set();
143143+ const descendantSet = ancestry.descendants.get(commitId) ?? new Set();
144144+145145+ for (const ancestorId of ancestorSet) {
146146+ if (!commitToComponent.has(ancestorId)) {
147147+ queue.push(ancestorId);
148148+ }
149149+ }
150150+ for (const descendantId of descendantSet) {
151151+ if (!commitToComponent.has(descendantId)) {
152152+ queue.push(descendantId);
153153+ }
154154+ }
155155+ }
156156+157157+ components.set(componentId, componentMembers);
158158+ }
159159+160160+ return components;
161161+}
162162+163163+/**
164164+ * A linear stack of revisions that can be collapsed.
165165+ * Stacks are detected when revisions form a linear chain without branches.
166166+ */
167167+export interface RevisionStack {
168168+ /** Unique ID for this stack (based on top revision's change_id) */
169169+ id: string;
170170+ /** All change_ids in this stack, from top (newest) to bottom (oldest) */
171171+ changeIds: string[];
172172+ /** The top revision of the stack (most recent) */
173173+ topChangeId: string;
174174+ /** The bottom revision of the stack (oldest, often has a bookmark) */
175175+ bottomChangeId: string;
176176+ /** Intermediate revisions that can be hidden when collapsed */
177177+ intermediateChangeIds: string[];
178178+}
179179+180180+/**
181181+ * Detects linear stacks in the revision graph.
182182+ * A stack is a linear sequence where:
183183+ * - Each revision has exactly one "linear" parent in the sequence (a parent with only 1 child)
184184+ * - Merge commits ("merge main into branch") are allowed as intermediates - they have multiple
185185+ * parents but only one forms the linear chain (the branch parent has 1 child, main has many)
186186+ * - Top revision can have any children (or none)
187187+ * - Bottom revision can have any parents
188188+ * - Minimum 3 revisions to form a collapsible stack (so we have at least 1 hidden)
189189+ */
190190+export function detectStacks(revisions: Revision[]): RevisionStack[] {
191191+ if (revisions.length < 3) return [];
192192+193193+ const commitIds = new Set(revisions.map((r) => r.commit_id));
194194+ const changeIdByCommitId = new Map(revisions.map((r) => [r.commit_id, r.change_id]));
195195+ const revisionByChangeId = new Map(revisions.map((r) => [r.change_id, r]));
196196+197197+ // Build parent/children maps (only for edges within our revset)
198198+ const childrenMap = new Map<string, string[]>(); // commit_id -> child commit_ids
199199+ const parentMap = new Map<string, string[]>(); // commit_id -> parent commit_ids
200200+201201+ for (const rev of revisions) {
202202+ const parents: string[] = [];
203203+ for (const edge of rev.parent_edges) {
204204+ if (edge.edge_type === "missing") continue;
205205+ if (!commitIds.has(edge.parent_id)) continue;
206206+ parents.push(edge.parent_id);
207207+ const children = childrenMap.get(edge.parent_id) ?? [];
208208+ children.push(rev.commit_id);
209209+ childrenMap.set(edge.parent_id, children);
210210+ }
211211+ parentMap.set(rev.commit_id, parents);
212212+ }
213213+214214+ // Check if a revision should NOT be collapsed (needs to stay visible)
215215+ function shouldRemainVisible(rev: Revision): boolean {
216216+ if (rev.is_working_copy) return true;
217217+ if (rev.is_trunk) return true;
218218+ if (rev.bookmarks.length > 0) return true;
219219+ if (rev.is_divergent) return true;
220220+ return false;
221221+ }
222222+223223+ const stacks: RevisionStack[] = [];
224224+ const usedInStack = new Set<string>();
225225+226226+ // Walk through revisions and find stack starts
227227+ for (const rev of revisions) {
228228+ if (usedInStack.has(rev.change_id)) continue;
229229+ if (rev.is_immutable) continue; // Don't collapse immutable commits
230230+231231+ const commitId = rev.commit_id;
232232+233233+ // A stack starts at a revision that:
234234+ // - Is not immutable
235235+ // - Has at least one parent in our view that forms a linear chain
236236+ // (i.e., that parent has exactly 1 child - this revision)
237237+ // This handles merge commits: we follow the "linear" parent
238238+ const parents = parentMap.get(commitId) ?? [];
239239+ if (parents.length === 0) continue;
240240+241241+ // Find the linear parent (has exactly 1 child - this revision)
242242+ const linearParents = parents.filter((parentId) => {
243243+ const parentChildren = childrenMap.get(parentId) ?? [];
244244+ return parentChildren.length === 1;
245245+ });
246246+ // Need exactly one linear parent to start a chain
247247+ if (linearParents.length !== 1) continue;
248248+249249+ // Walk down the chain to find all linear descendants
250250+ const chain: string[] = [rev.change_id];
251251+ let current = rev;
252252+253253+ while (true) {
254254+ const currentParents = parentMap.get(current.commit_id) ?? [];
255255+ if (currentParents.length === 0) break;
256256+257257+ // Find the "linear" parent - the one that has exactly 1 child (current)
258258+ // This handles merge commits: the branch parent has 1 child, while
259259+ // the "main being merged in" parent typically has multiple children
260260+ let linearParentId: string | null = null;
261261+ for (const parentId of currentParents) {
262262+ const parentChildren = childrenMap.get(parentId) ?? [];
263263+ if (parentChildren.length === 1) {
264264+ if (linearParentId !== null) {
265265+ // Multiple parents with single child - ambiguous, stop
266266+ linearParentId = null;
267267+ break;
268268+ }
269269+ linearParentId = parentId;
270270+ }
271271+ }
272272+273273+ if (!linearParentId) break;
274274+275275+ const parentChangeId = changeIdByCommitId.get(linearParentId);
276276+ if (!parentChangeId) break;
277277+278278+ const parentRev = revisionByChangeId.get(parentChangeId);
279279+ if (!parentRev) break;
280280+281281+ // Stop if parent is immutable
282282+ if (parentRev.is_immutable) break;
283283+284284+ chain.push(parentChangeId);
285285+ current = parentRev;
286286+287287+ // If parent should remain visible and we have enough in chain, stop
288288+ if (shouldRemainVisible(parentRev) && chain.length >= 2) break;
289289+ }
290290+291291+ // Only create stack if we have at least 3 revisions (1+ intermediate)
292292+ if (chain.length >= 3) {
293293+ const topChangeId = chain[0];
294294+ const bottomChangeId = chain[chain.length - 1];
295295+ const intermediateChangeIds = chain.slice(1, -1);
296296+297297+ stacks.push({
298298+ id: topChangeId,
299299+ changeIds: chain,
300300+ topChangeId,
301301+ bottomChangeId,
302302+ intermediateChangeIds,
303303+ });
304304+305305+ for (const changeId of chain) {
306306+ usedInStack.add(changeId);
307307+ }
308308+ }
309309+ }
310310+311311+ return stacks;
312312+}
313313+314314+export function reorderForGraph(
315315+ revisions: Revision[],
316316+ recency?: CommitRecency,
317317+): Revision[] {
4318 if (revisions.length === 0) return [];
53196320 const commitMap = new Map(revisions.map((r) => [r.commit_id, r]));
···22336 parentMap.set(rev.commit_id, parents);
23337 }
243382525- // Priority score for a revision (lower = higher priority)
2626- function getPriority(rev: Revision): number {
2727- if (rev.is_working_copy) return 0;
2828- if (rev.is_mine && !rev.is_immutable) return 1;
2929- if (rev.bookmarks.length > 0 && !rev.is_immutable) return 2;
3030- if (!rev.is_immutable) return 3;
3131- if (rev.bookmarks.length > 0) return 4;
3232- return 5;
339339+ // Find the head that contains the working copy in its ancestry
340340+ const workingCopy = revisions.find((r) => r.is_working_copy);
341341+ const wcAncestorHeadId = (() => {
342342+ if (!workingCopy) return null;
343343+ // Walk up from WC to find which head contains it
344344+ const visited = new Set<string>();
345345+ const queue = [workingCopy.commit_id];
346346+ while (queue.length > 0) {
347347+ const id = queue.shift()!;
348348+ if (visited.has(id)) continue;
349349+ visited.add(id);
350350+ const children = childrenMap.get(id) ?? [];
351351+ const validChildren = children.filter((c) => commitIds.has(c));
352352+ if (validChildren.length === 0) {
353353+ // This is a head
354354+ return id;
355355+ }
356356+ queue.push(...validChildren);
357357+ }
358358+ return null;
359359+ })();
360360+361361+ // Get max recency for a branch (walk down from head to find most recent touch)
362362+ function getBranchRecency(headCommitId: string): number {
363363+ if (!recency) return 0;
364364+ let maxRecency = recency[headCommitId] ?? 0;
365365+ // Walk ancestors to find any that were touched more recently
366366+ const visited = new Set<string>();
367367+ const queue = [headCommitId];
368368+ while (queue.length > 0) {
369369+ const id = queue.shift()!;
370370+ if (visited.has(id)) continue;
371371+ visited.add(id);
372372+ const ts = recency[id] ?? 0;
373373+ if (ts > maxRecency) maxRecency = ts;
374374+ const parents = parentMap.get(id) ?? [];
375375+ queue.push(...parents);
376376+ }
377377+ return maxRecency;
33378 }
343793535- // Find heads and sort by priority
3636- const heads = revisions
3737- .filter((r) => {
3838- const children = childrenMap.get(r.commit_id) ?? [];
3939- return children.filter((c) => commitIds.has(c)).length === 0;
4040- })
4141- .sort((a, b) => getPriority(a) - getPriority(b));
380380+ // Find heads
381381+ const heads = revisions.filter((r) => {
382382+ const children = childrenMap.get(r.commit_id) ?? [];
383383+ return children.filter((c) => commitIds.has(c)).length === 0;
384384+ });
385385+386386+ // Sort heads: WC's branch first, then by recency (most recent first), then stable tiebreaker
387387+ heads.sort((a, b) => {
388388+ // WC's branch always first
389389+ const aIsWcBranch = a.commit_id === wcAncestorHeadId;
390390+ const bIsWcBranch = b.commit_id === wcAncestorHeadId;
391391+ if (aIsWcBranch && !bIsWcBranch) return -1;
392392+ if (!aIsWcBranch && bIsWcBranch) return 1;
393393+394394+ // Sort by recency (higher timestamp = more recent = should come first)
395395+ if (recency) {
396396+ const aRecency = getBranchRecency(a.commit_id);
397397+ const bRecency = getBranchRecency(b.commit_id);
398398+ if (aRecency !== bRecency) {
399399+ return bRecency - aRecency; // Descending (most recent first)
400400+ }
401401+ }
402402+403403+ // Stable tiebreaker: use change_id
404404+ return a.change_id.localeCompare(b.change_id);
405405+ });
4240643407 // Track which commits have been output
44408 const output = new Set<string>();