workon/changeset.rs
1//! Changeset assembly: turning a stack model + repository state into an ordered list of
2//! reviewable [`Changeset`]s for the worktree whose `HEAD` is a given branch.
3//!
4//! This is the substrate the review TUI (M2+) consumes. It stays **diff-free**: every
5//! [`Changeset`] carries resolved `git2::Oid` rev pairs (or the [`ChangesetSource::Uncommitted`]
6//! marker), never a parsed diff. Detecting uncommitted changes uses `repo.statuses`, never
7//! `repo.diff_*`.
8//!
9//! ## Assembly per [`StackModel`]
10//!
11//! - [`StackModel::None`] → always `Ok(vec![])`.
12//! - [`StackModel::Graphite`] → walks recorded stack metadata: ancestors of `head_branch`
13//! (bottom → just-below-head), then `head_branch` itself, then descendants (depth-first,
14//! siblings sorted lexically). Ghost nodes (a metadata row with no resolvable branch ref)
15//! are skipped from the output but still walked through, so live descendants of a ghost
16//! still appear. Falls back to the `Git` arm when `head_branch` is a trunk branch or has no
17//! metadata row at all (mirrors the nvim prototype's factory behavior).
18//! - [`StackModel::Git`] → no metadata; walks `upstream..head_branch` commit-by-commit
19//! (oldest first), one [`Changeset`] per commit.
20//!
21//! In both metadata-bearing arms, a non-empty `repo.statuses` result inserts a
22//! [`ChangesetSource::Uncommitted`] entry immediately after the current node, taking over
23//! `current`.
24
25use std::collections::{HashMap, HashSet};
26
27use git2::{BranchType, Oid, Repository, StatusOptions};
28
29use crate::error::{ChangesetError, Result};
30use crate::stack::{graphite, StackModel};
31
32/// What a [`Changeset`] spans: a resolved commit range, or the working tree + index.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ChangesetSource {
35 /// A committed range `base..head` — resolved OIDs only; the lib never diffs them itself.
36 Committed { base: Oid, head: Oid },
37 /// Uncommitted working-tree + index changes relative to the current branch's head.
38 Uncommitted,
39}
40
41/// One reviewable unit in an assembled changeset stack, ordered base → head by
42/// [`assemble_changesets`].
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Changeset {
45 /// Branch name for stack nodes; 8-hex abbreviated commit id for git-inference per-commit
46 /// changesets; the current branch name for [`ChangesetSource::Uncommitted`].
47 pub name: String,
48 /// The commit range (or uncommitted marker) this changeset covers.
49 pub source: ChangesetSource,
50 /// PR title (from `.graphite_pr_info`) for Graphite nodes; commit summary for
51 /// git-inference nodes; `None` for [`ChangesetSource::Uncommitted`].
52 pub title: Option<String>,
53 /// Exactly one entry in the returned `Vec` is current: the Uncommitted entry when
54 /// present, otherwise the current branch's node (Graphite) or tip commit (Git).
55 pub current: bool,
56 /// Graphite nodes only: the recorded parent revision is non-empty AND differs from the
57 /// parent branch's *live* tip. Always `false` for git-inference and Uncommitted entries.
58 pub needs_restack: bool,
59}
60
61/// Assemble the ordered (base → head) changesets for the worktree whose `HEAD` is
62/// `head_branch`, under the given [`StackModel`].
63///
64/// See the module docs for the per-model walk semantics. Errors distinguish a genuinely
65/// broken reference or stack-metadata snapshot (bad ref, unresolvable recorded revision, no
66/// upstream) from a valid empty result (`Ok(vec![])`, e.g. a trunk-only worktree under `Git`
67/// with a clean tree).
68pub fn assemble_changesets(
69 repo: &Repository,
70 head_branch: &str,
71 model: StackModel,
72) -> Result<Vec<Changeset>> {
73 match model {
74 StackModel::None => Ok(vec![]),
75 StackModel::Git => assemble_git(repo, head_branch),
76 StackModel::Graphite => assemble_graphite(repo, head_branch),
77 }
78}
79
80/// Graphite-metadata-driven assembly (see module docs for the walk).
81fn assemble_graphite(repo: &Repository, head_branch: &str) -> Result<Vec<Changeset>> {
82 let metadata = graphite::read_branch_metadata(repo)?;
83 let trunks: HashSet<String> = graphite::read_trunks(repo).into_iter().collect();
84
85 // Trunk or untracked head_branch: no stack metadata to walk, fall back to git-inference.
86 if trunks.contains(head_branch) || !metadata.contains_key(head_branch) {
87 return assemble_git(repo, head_branch);
88 }
89
90 // head_branch is tracked but its own branch ref is gone: a genuinely broken state, distinct
91 // from an empty result.
92 if repo.find_branch(head_branch, BranchType::Local).is_err() {
93 return Err(ChangesetError::UnresolvableBranch {
94 branch: head_branch.to_string(),
95 }
96 .into());
97 }
98
99 // Ancestors bottom → just-below-head, following recorded parent links. Stops at a trunk
100 // parent or a branch absent from the metadata map; cycle-guarded (an `a` <-> `b` cycle in
101 // metadata must terminate, not hang).
102 let mut ancestors_desc: Vec<String> = Vec::new();
103 {
104 let mut walk = head_branch.to_string();
105 let mut seen: HashSet<String> = HashSet::new();
106 seen.insert(walk.clone());
107 loop {
108 if trunks.contains(&walk) {
109 break;
110 }
111 let Some(entry) = metadata.get(&walk) else {
112 break;
113 };
114 let parent = entry.parent.clone();
115 if trunks.contains(&parent) {
116 break;
117 }
118 // An untracked parent (no metadata row) is outside the stack: stop without
119 // emitting it, matching the prototype walk. Every walked name therefore has a
120 // metadata row.
121 if !metadata.contains_key(&parent) {
122 break;
123 }
124 if !seen.insert(parent.clone()) {
125 break; // cycle guard
126 }
127 ancestors_desc.push(parent.clone());
128 walk = parent;
129 }
130 }
131 ancestors_desc.reverse();
132
133 // Descendants: depth-first from head_branch, siblings sorted lexically, cycle-guarded.
134 let mut reverse_map: HashMap<String, Vec<String>> = HashMap::new();
135 for (branch, entry) in &metadata {
136 reverse_map
137 .entry(entry.parent.clone())
138 .or_default()
139 .push(branch.clone());
140 }
141 for children in reverse_map.values_mut() {
142 children.sort();
143 }
144
145 fn visit_descendants(
146 branch: &str,
147 reverse_map: &HashMap<String, Vec<String>>,
148 visited: &mut HashSet<String>,
149 out: &mut Vec<String>,
150 ) {
151 if let Some(children) = reverse_map.get(branch) {
152 for child in children {
153 if visited.insert(child.clone()) {
154 out.push(child.clone());
155 visit_descendants(child, reverse_map, visited, out);
156 }
157 }
158 }
159 }
160
161 let mut descendants: Vec<String> = Vec::new();
162 let mut visited: HashSet<String> = HashSet::new();
163 visited.insert(head_branch.to_string());
164 visit_descendants(head_branch, &reverse_map, &mut visited, &mut descendants);
165
166 let titles = graphite::read_pr_titles(repo);
167
168 let ordered_names: Vec<String> = ancestors_desc
169 .into_iter()
170 .chain(std::iter::once(head_branch.to_string()))
171 .chain(descendants)
172 .collect();
173
174 let mut changesets: Vec<Changeset> = Vec::new();
175 let mut current_index: Option<usize> = None;
176 for name in ordered_names {
177 // Ghost node: metadata row lingers, no branch exists anywhere. Skip from output —
178 // its children were already reached by visit_descendants regardless.
179 if !crate::resolve::branch_exists(repo, &name) {
180 continue;
181 }
182 // branch_exists also matches remote-only branches; a stack member with no LOCAL
183 // ref has no live head to span a changeset to, and that is an error, not a ghost.
184 let branch_ref = repo.find_branch(&name, BranchType::Local).map_err(|_| {
185 ChangesetError::UnresolvableBranch {
186 branch: name.clone(),
187 }
188 })?;
189 let head_oid =
190 branch_ref
191 .get()
192 .target()
193 .ok_or_else(|| ChangesetError::UnresolvableBranch {
194 branch: name.clone(),
195 })?;
196
197 // Every walked name has a metadata row: the ancestors walk stops at untracked
198 // parents, head_branch was checked on entry, and descendants come from the map.
199 let entry = &metadata[&name];
200 let (base_oid, needs_restack) = resolve_graphite_base(
201 repo,
202 &metadata,
203 &trunks,
204 &entry.parent,
205 entry.parent_revision.as_deref(),
206 head_oid,
207 &name,
208 )?;
209
210 let is_current = name == head_branch;
211 if is_current {
212 current_index = Some(changesets.len());
213 }
214 changesets.push(Changeset {
215 title: titles.get(&name).cloned(),
216 source: ChangesetSource::Committed {
217 base: base_oid,
218 head: head_oid,
219 },
220 current: is_current,
221 needs_restack,
222 name,
223 });
224 }
225
226 insert_uncommitted_layer(repo, head_branch, current_index, &mut changesets)?;
227
228 Ok(changesets)
229}
230
231/// Resolve `(base, needs_restack)` for one Graphite stack node.
232///
233/// `parent_revision`, when present, is the recorded snapshot — verified against the odb and
234/// used as `base` directly (no need to resolve the parent's live ref: this keeps a stale
235/// recorded revision spanning a ghost parent computable). `needs_restack` compares it against
236/// the parent's *live* tip when that tip is resolvable; an unresolvable parent (e.g. a ghost)
237/// with a recorded revision present is not a restack question — `needs_restack` is `false`.
238///
239/// When `parent_revision` is missing, `base` falls back to `merge_base(ancestor_tip, head)`,
240/// where `ancestor_tip` is the nearest *live* branch (or trunk) reached by walking through
241/// `metadata` past any ghost parents — a live node hanging off a ghost whose own recorded
242/// parent revision never resolved (because the ghost's branch ref never existed to resolve a
243/// tip from) still needs a computable base. Only when that walk finds nothing resolvable at
244/// all is it a genuine error.
245fn resolve_graphite_base(
246 repo: &Repository,
247 metadata: &HashMap<String, graphite::BranchMetadata>,
248 trunks: &HashSet<String>,
249 parent: &str,
250 parent_revision: Option<&str>,
251 head: Oid,
252 branch: &str,
253) -> Result<(Oid, bool)> {
254 match parent_revision {
255 Some(rev) => {
256 let oid = Oid::from_str(rev)
257 .ok()
258 .filter(|oid| repo.find_commit(*oid).is_ok())
259 .ok_or_else(|| ChangesetError::InvalidParentRevision {
260 branch: branch.to_string(),
261 revision: rev.to_string(),
262 })?;
263 let parent_live_tip = repo
264 .find_branch(parent, BranchType::Local)
265 .ok()
266 .and_then(|b| b.get().target());
267 let needs_restack = match parent_live_tip {
268 Some(tip) => oid != tip,
269 None => false,
270 };
271 Ok((oid, needs_restack))
272 }
273 None => {
274 let tip =
275 resolve_live_ancestor_tip(repo, metadata, trunks, parent).ok_or_else(|| {
276 ChangesetError::UnresolvableBranch {
277 branch: parent.to_string(),
278 }
279 })?;
280 let base = repo.merge_base(tip, head)?;
281 Ok((base, false))
282 }
283 }
284}
285
286/// Walk from `start` through `metadata`'s parent links (cycle-guarded) until a branch that
287/// resolves to a live ref is found, returning its tip. `start` itself is checked first, so a
288/// live `start` resolves immediately; a ghost `start` walks to its recorded parent, and so on.
289fn resolve_live_ancestor_tip(
290 repo: &Repository,
291 metadata: &HashMap<String, graphite::BranchMetadata>,
292 trunks: &HashSet<String>,
293 start: &str,
294) -> Option<Oid> {
295 let mut walk = start.to_string();
296 let mut seen: HashSet<String> = HashSet::new();
297 seen.insert(walk.clone());
298 loop {
299 if let Some(tip) = repo
300 .find_branch(&walk, BranchType::Local)
301 .ok()
302 .and_then(|b| b.get().target())
303 {
304 return Some(tip);
305 }
306 if trunks.contains(&walk) {
307 return None;
308 }
309 match metadata.get(&walk) {
310 Some(entry) if seen.insert(entry.parent.clone()) => walk = entry.parent.clone(),
311 _ => return None, // no metadata entry, or cycle
312 }
313 }
314}
315
316/// Git-inference assembly: one [`Changeset`] per commit in `upstream(head_branch)..head_branch`,
317/// oldest first.
318fn assemble_git(repo: &Repository, head_branch: &str) -> Result<Vec<Changeset>> {
319 let branch = repo.find_branch(head_branch, BranchType::Local)?;
320 let upstream = branch.upstream().map_err(|_| ChangesetError::NoUpstream {
321 branch: head_branch.to_string(),
322 })?;
323 let head_oid = branch
324 .get()
325 .target()
326 .ok_or_else(|| ChangesetError::NoUpstream {
327 branch: head_branch.to_string(),
328 })?;
329 let upstream_oid = upstream
330 .get()
331 .target()
332 .ok_or_else(|| ChangesetError::NoUpstream {
333 branch: head_branch.to_string(),
334 })?;
335
336 let mut revwalk = repo.revwalk()?;
337 revwalk.push(head_oid)?;
338 revwalk.hide(upstream_oid)?;
339 revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
340 // Follow only the branch's own first-parent line: without this, a merge into the
341 // branch enumerates every merged-in commit as its own changeset AND the merge commit
342 // (whose base is its first parent) spans the same content again — double-counted.
343 revwalk.simplify_first_parent()?;
344
345 let mut changesets: Vec<Changeset> = Vec::new();
346 for oid in revwalk {
347 let oid = oid?;
348 let commit = repo.find_commit(oid)?;
349 // Merges use the first parent; a parentless (root) commit has no base to diff
350 // against, so it emits base == head — the whole commit is its own changeset.
351 let base = commit.parent_id(0).unwrap_or(oid);
352 changesets.push(Changeset {
353 name: short_id(oid),
354 source: ChangesetSource::Committed { base, head: oid },
355 title: commit.summary()?.map(str::to_string),
356 current: false,
357 needs_restack: false,
358 });
359 }
360
361 let current_index = if changesets.is_empty() {
362 None
363 } else {
364 let last = changesets.len() - 1;
365 changesets[last].current = true;
366 Some(last)
367 };
368
369 insert_uncommitted_layer(repo, head_branch, current_index, &mut changesets)?;
370
371 Ok(changesets)
372}
373
374/// 8-hex abbreviated commit id, per the [`Changeset::name`] doc for git-inference nodes.
375fn short_id(oid: Oid) -> String {
376 oid.to_string()[..8].to_string()
377}
378
379/// Insert a [`ChangesetSource::Uncommitted`] entry immediately after `current_index` (or at
380/// the end, if there is no committed current node) when `repo.statuses` reports any working
381/// tree or index changes. Demotes the previous current node's `current` flag. No-op on a
382/// clean tree.
383fn insert_uncommitted_layer(
384 repo: &Repository,
385 current_branch: &str,
386 current_index: Option<usize>,
387 changesets: &mut Vec<Changeset>,
388) -> Result<()> {
389 let mut opts = StatusOptions::new();
390 opts.include_untracked(true);
391 opts.include_ignored(false);
392 let statuses = repo.statuses(Some(&mut opts))?;
393 if statuses.is_empty() {
394 return Ok(());
395 }
396
397 if let Some(idx) = current_index {
398 changesets[idx].current = false;
399 }
400 let insert_at = current_index.map_or(changesets.len(), |i| i + 1);
401 changesets.insert(
402 insert_at,
403 Changeset {
404 name: current_branch.to_string(),
405 source: ChangesetSource::Uncommitted,
406 title: None,
407 current: true,
408 needs_restack: false,
409 },
410 );
411 Ok(())
412}