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