workon/stack.rs
1//! Stacked diff workflow support.
2//!
3//! This module provides the infrastructure for detecting and interacting with
4//! stacked-diff tools alongside git-workon's worktree management.
5//!
6//! ## Model
7//!
8//! Stack awareness is two-dimensional:
9//!
10//! - [`StackModel`] — which tool manages stacks (v1: Graphite; future: branchless, sapling, spr)
11//! - [`Granularity`] — how worktrees map to stacks (v1: [`Granularity::Stack`], one per stack)
12//!
13//! ## Default-on behavior
14//!
15//! When `workon.stackModel` resolves to anything other than [`StackModel::None`] (by explicit
16//! config or auto-detection), every command that has a meaningful stack-aware variant uses it
17//! by default. A `--no-stack` CLI flag (global across subcommands) downgrades any single
18//! invocation back to branch-flat behavior.
19//!
20//! ## Graphite (v1)
21//!
22//! Stack metadata is read directly from `refs/branch-metadata/*` git refs — blobs containing
23//! JSON written by the `gt` CLI. No `gt` process is needed for detection or visualization.
24//! `gt track` is invoked only when registering a new branch (in `workon new` when creating a
25//! fork off a stack-worktree's branch).
26//!
27//! ## Cross-worktree grouping
28//!
29//! [`group_by_stack`] partitions a parallel slice of [`Option<Stack>`] values (one per worktree)
30//! into [`StackGrouping`]: a list of [`StackGroup`]s (each covering one connected stack) plus an
31//! `ungrouped` list for trunk/untracked worktrees. The grouping key is `(trunk, sorted diff
32//! set)`, so two worktrees in the same connected stack always collapse into one group regardless
33//! of the [`Stack::diffs`] vector order returned by [`current_stack`].
34
35mod graphite;
36
37use std::collections::{BTreeMap, HashMap};
38
39use git2::Repository;
40
41use crate::error::Result;
42
43/// Which stacked-diff tool is managing stacks in this repository.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum StackModel {
46 /// No stack tool; today's branch-flat behavior.
47 None,
48 /// Graphite (`gt`) manages stacks via `refs/branch-metadata/*`.
49 Graphite,
50}
51
52impl StackModel {
53 /// Auto-detect the active stack model from the repository environment.
54 ///
55 /// Returns [`StackModel::Graphite`] when `gt` is on PATH **and** the repo has been
56 /// initialized with `gt init` (`.graphite_repo_config` exists). Otherwise returns
57 /// [`StackModel::None`].
58 pub fn detect(repo: &Repository) -> Self {
59 if graphite::detect_gt() && graphite::is_graphite_repo(repo) {
60 Self::Graphite
61 } else {
62 Self::None
63 }
64 }
65}
66
67/// How worktrees map to stacks.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Granularity {
70 /// One worktree hosts an entire stack. The user navigates between branches inside it
71 /// using the stack tool's own commands (e.g. `gt up` / `gt down`).
72 Stack,
73}
74
75/// A stack of branches rooted at a trunk branch.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Stack {
78 /// The trunk branch this stack is rooted on (e.g., `"main"`).
79 pub trunk: String,
80 /// All non-trunk diffs (branches) in the stack, in BFS order from bottom to top.
81 pub diffs: Vec<String>,
82 /// The branch that is currently HEAD in the worktree.
83 pub current: String,
84 /// Parent map for the diffs in this stack: `diff → parent_branch`.
85 ///
86 /// The parent may be another diff or the trunk itself. Only covers diffs in
87 /// [`Stack::diffs`]; the trunk's own parent is not recorded. Used to reconstruct
88 /// the branching tree structure (a flat [`diffs`] list cannot represent forks).
89 pub parents: HashMap<String, String>,
90}
91
92/// Return all stacks present in metadata, one per connected component.
93///
94/// Each returned [`Stack`] corresponds to one potential [`StackGroup`] — the same `(trunk,
95/// sorted diffs)` key used by [`group_by_stack`]. Used by the `list` command to surface
96/// stacks that have no checked-out worktrees.
97pub fn enumerate_stacks(repo: &Repository, model: StackModel) -> Result<Vec<Stack>> {
98 match model {
99 StackModel::None => Ok(vec![]),
100 StackModel::Graphite => graphite::enumerate_stacks(repo).map_err(Into::into),
101 }
102}
103
104/// Return the stack for the worktree whose HEAD is `head_branch`, or `None` if the branch
105/// is not part of a tracked stack under `model`.
106///
107/// The returned [`Stack`] includes all branches reachable from the same stack root, not just
108/// the ancestors of `head_branch`, so branching stacks are fully represented.
109pub fn current_stack(
110 repo: &Repository,
111 head_branch: &str,
112 model: StackModel,
113) -> Result<Option<Stack>> {
114 match model {
115 StackModel::None => Ok(None),
116 StackModel::Graphite => graphite::current_stack(repo, head_branch).map_err(Into::into),
117 }
118}
119
120/// Returns `true` if `gt` is on PATH and this repository has been Graphite-initialized.
121pub fn is_graphite_active(repo: &Repository) -> bool {
122 graphite::detect_gt() && graphite::is_graphite_repo(repo)
123}
124
125/// Return the first trunk branch name from `.graphite_repo_config`, or `None` if
126/// the file is missing, unparseable, or contains no trunk entries.
127///
128/// Use this when you need to pass `--parent <trunk>` to `gt track` and want to
129/// avoid hardcoding `"main"`. Returns `None` rather than a hardcoded fallback so
130/// callers can omit `--parent` entirely and let `gt` infer when the trunk is unknown.
131pub fn graphite_trunk(repo: &Repository) -> Option<String> {
132 graphite::graphite_trunk(repo)
133}
134
135/// One group of worktrees that share a connected stack, identified by trunk + diff set.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct StackGroup {
138 /// Representative stack (trunk + diffs in BFS bottom→top order) for the group.
139 pub stack: Stack,
140 /// Indices into the caller's worktree slice for members of this stack, ordered by
141 /// the member branch's position in `stack.diffs` (bottom→top). Members whose
142 /// branch isn't found in `stack.diffs` sort last, in original input order.
143 pub members: Vec<usize>,
144}
145
146/// Result of partitioning a worktree slice by stacks via [`group_by_stack`].
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct StackGrouping {
149 /// Groups of worktrees sharing a connected stack, ordered by `(trunk, sorted branch set)`.
150 pub groups: Vec<StackGroup>,
151 /// Indices of worktrees with no stack (trunk branches, untracked branches), in input order.
152 pub ungrouped: Vec<usize>,
153}
154
155/// Partition a slice of optional stack descriptors into groups sharing the same connected stack.
156///
157/// The grouping key is `(trunk, sorted diff set)`, so worktrees in the same connected stack
158/// always collapse into one group regardless of the [`Stack::diffs`] vector order returned
159/// by [`current_stack`]. Members within each group are ordered by their branch's position in
160/// the representative stack's `diffs` list (bottom→top).
161///
162/// Groups are returned in deterministic order: sorted by `(trunk, sorted branch set)`.
163///
164/// When all entries are `None` (stack model inactive or `--no-stack`), `groups` is empty and
165/// every index appears in `ungrouped` — callers get identical flat-list behavior.
166pub fn group_by_stack(stacks: &[Option<Stack>]) -> StackGrouping {
167 // BTreeMap provides sorted iteration: (trunk, branch_set) order automatically.
168 let mut map: BTreeMap<(String, Vec<String>), (Stack, Vec<usize>)> = BTreeMap::new();
169 let mut ungrouped: Vec<usize> = Vec::new();
170
171 for (i, opt) in stacks.iter().enumerate() {
172 match opt {
173 None => ungrouped.push(i),
174 Some(stack) => {
175 let mut key_branches = stack.diffs.clone();
176 key_branches.sort();
177 let key = (stack.trunk.clone(), key_branches);
178 let entry = map
179 .entry(key)
180 .or_insert_with(|| (stack.clone(), Vec::new()));
181 entry.1.push(i);
182 }
183 }
184 }
185
186 let groups = map
187 .into_values()
188 .map(|(rep_stack, mut members)| {
189 // Sort members by their branch's position in rep_stack.diffs (bottom→top).
190 members.sort_by_key(|&idx| {
191 let branch = stacks[idx]
192 .as_ref()
193 .map(|s| s.current.as_str())
194 .unwrap_or("");
195 rep_stack
196 .diffs
197 .iter()
198 .position(|b| b == branch)
199 .unwrap_or(usize::MAX)
200 });
201 StackGroup {
202 stack: rep_stack,
203 members,
204 }
205 })
206 .collect();
207
208 StackGrouping { groups, ungrouped }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn stack(trunk: &str, diffs: &[&str], current: &str) -> Stack {
216 Stack {
217 trunk: trunk.to_string(),
218 diffs: diffs.iter().map(|s| s.to_string()).collect(),
219 current: current.to_string(),
220 parents: HashMap::new(),
221 }
222 }
223
224 #[test]
225 fn empty_input_yields_empty_grouping() {
226 let result = group_by_stack(&[]);
227 assert!(result.groups.is_empty());
228 assert!(result.ungrouped.is_empty());
229 }
230
231 #[test]
232 fn all_none_yields_all_ungrouped() {
233 let stacks: Vec<Option<Stack>> = vec![None, None, None];
234 let result = group_by_stack(&stacks);
235 assert!(result.groups.is_empty());
236 assert_eq!(result.ungrouped, vec![0, 1, 2]);
237 }
238
239 #[test]
240 fn single_stacked_worktree_forms_one_group() {
241 let stacks = vec![Some(stack("main", &["feat-a"], "feat-a"))];
242 let result = group_by_stack(&stacks);
243 assert!(result.ungrouped.is_empty());
244 assert_eq!(result.groups.len(), 1);
245 assert_eq!(result.groups[0].members, vec![0]);
246 assert_eq!(result.groups[0].stack.trunk, "main");
247 }
248
249 #[test]
250 fn two_worktrees_same_stack_collapse_into_one_group_ordered_bottom_to_top() {
251 // Two worktrees in the same 2-branch stack: feat-b is bottom, feat-a is top.
252 // Input idx 0 has current="feat-a" (position 1), input idx 1 has current="feat-b" (position 0).
253 let stacks = vec![
254 Some(stack("main", &["feat-b", "feat-a"], "feat-a")), // top worktree
255 Some(stack("main", &["feat-b", "feat-a"], "feat-b")), // bottom worktree
256 ];
257 let result = group_by_stack(&stacks);
258 assert!(result.ungrouped.is_empty());
259 assert_eq!(result.groups.len(), 1);
260 // Members ordered by branch position: idx 1 (feat-b, pos 0) first, idx 0 (feat-a, pos 1) second.
261 assert_eq!(result.groups[0].members, vec![1, 0]);
262 }
263
264 #[test]
265 fn same_stack_different_branches_vector_order_still_collapses() {
266 // The two Stacks have the same set but different vector orderings — must collapse.
267 let stacks = vec![
268 Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
269 Some(stack("main", &["feat-b", "feat-a"], "feat-b")),
270 ];
271 let result = group_by_stack(&stacks);
272 assert_eq!(
273 result.groups.len(),
274 1,
275 "different branch vector order must still collapse"
276 );
277 assert!(result.ungrouped.is_empty());
278 }
279
280 #[test]
281 fn two_distinct_stacks_on_same_trunk_stay_separate() {
282 let stacks = vec![
283 Some(stack("main", &["stack1-a"], "stack1-a")),
284 Some(stack("main", &["stack2-a"], "stack2-a")),
285 ];
286 let result = group_by_stack(&stacks);
287 assert_eq!(result.groups.len(), 2);
288 assert!(result.ungrouped.is_empty());
289 }
290
291 #[test]
292 fn mixed_stacked_and_ungrouped() {
293 let stacks = vec![
294 None, // trunk worktree
295 Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
296 None, // another ungrouped
297 Some(stack("main", &["feat-a", "feat-b"], "feat-b")),
298 ];
299 let result = group_by_stack(&stacks);
300 assert_eq!(result.ungrouped, vec![0, 2]);
301 assert_eq!(result.groups.len(), 1);
302 // feat-a is position 0, feat-b is position 1 → member idx 1 (current=feat-a) first, then idx 3
303 assert_eq!(result.groups[0].members, vec![1, 3]);
304 }
305
306 #[test]
307 fn groups_ordered_deterministically_by_trunk_then_branch_set() {
308 let stacks = vec![
309 Some(stack("main", &["z-feat"], "z-feat")),
310 Some(stack("dev", &["d-feat"], "d-feat")),
311 Some(stack("main", &["a-feat"], "a-feat")),
312 ];
313 let result = group_by_stack(&stacks);
314 assert_eq!(result.groups.len(), 3);
315 // Ordered: (dev, [d-feat]), (main, [a-feat]), (main, [z-feat])
316 assert_eq!(result.groups[0].stack.trunk, "dev");
317 assert_eq!(result.groups[1].stack.diffs, vec!["a-feat"]);
318 assert_eq!(result.groups[2].stack.diffs, vec!["z-feat"]);
319 }
320}