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