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::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}