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`] → 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}