Skip to main content

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}