Skip to main content

git_queue/
engine.rs

1//! The headless engine behind `git queue tui`.
2//!
3//! The engine owns the editing state of the current [queue line] — the ordered
4//! commit sequence (front/oldest → tip) and the branch [boundaries] over it —
5//! and (in later tickets) the undo/redo stack and the operations that mutate
6//! git. It is deliberately *headless*: it touches `git`/`meta` but never the
7//! terminal, so the interactive view sits in front of it and tests drive it
8//! in-process (via `lib.rs`) instead of spawning the binary.
9//!
10//! This ticket is the scaffold: load the current line into the model and guard
11//! the entry conditions. The operations-as-data type is defined even where its
12//! variants are not yet applied — later tickets fill in `apply`.
13//!
14//! [queue line]: crate::queue::Line
15//! [boundaries]: Boundary
16
17use crate::git;
18use crate::ident;
19use crate::meta;
20use crate::queue::Queue;
21use anyhow::{bail, Result};
22use std::collections::HashSet;
23
24/// One commit of the loaded queue line.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Commit {
27    /// The commit's current SHA. Churns on every rewrite; use `id` for a
28    /// stable handle.
29    pub sha: String,
30    /// The [Stable-Commit-Id](crate::ident) read from the commit's message,
31    /// where present. Unstamped commits (e.g. an untracked branch's) carry
32    /// `None`; any commit the TUI later creates is stamped so the queue never
33    /// leaves with untracked commits.
34    pub id: Option<String>,
35    /// The commit's subject line.
36    pub subject: String,
37    /// Whether the commit introduces no change (an empty commit). Rendered
38    /// `(empty)` when it still has a description; auto-dropped when it does not.
39    pub empty: bool,
40}
41
42impl Commit {
43    /// Whether the commit has a description (a non-empty subject). An empty,
44    /// description-less commit is auto-dropped; an empty *described* one is kept.
45    pub fn described(&self) -> bool {
46        !self.subject.trim().is_empty()
47    }
48}
49
50/// A branch [boundary](crate::queue) over the commit sequence.
51///
52/// `name` owns the contiguous run of commits ending just before index `end`
53/// (its exclusive upper bound into [`EditableLine::commits`]). Boundaries are
54/// front → tip, their `end`s strictly increase, and the last one's `end` equals
55/// the commit count.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Boundary {
58    /// The branch's full ref name (e.g. `queue/feature/api`).
59    pub name: String,
60    /// Exclusive upper index into [`EditableLine::commits`] where this branch's run
61    /// ends. The run is `commits[prev_end..end]`.
62    pub end: usize,
63}
64
65/// The loaded queue line: the commit sequence and the boundaries over it.
66#[derive(Debug, Clone)]
67pub struct EditableLine {
68    /// The branch the line sits on (its front boundary's parent). Usually
69    /// trunk, but any branch can be a base.
70    pub base: String,
71    /// Every commit of the line, front (oldest, merges first) → tip (newest).
72    pub commits: Vec<Commit>,
73    /// The branch boundaries over `commits`, front → tip. Never empty for a
74    /// successfully loaded line.
75    pub boundaries: Vec<Boundary>,
76}
77
78impl EditableLine {
79    /// The tip (last) branch's name. A loaded line always has at least one
80    /// branch, so this never panics.
81    #[allow(clippy::unwrap_used)] // invariant: a loaded line has ≥1 boundary
82    pub(crate) fn tip_branch(&self) -> &str {
83        &self.boundaries.last().unwrap().name
84    }
85
86    /// The run of commits owned by the boundary at `index`, front → tip.
87    pub fn commits_of(&self, index: usize) -> &[Commit] {
88        let start = if index == 0 {
89            0
90        } else {
91            self.boundaries[index - 1].end
92        };
93        &self.commits[start..self.boundaries[index].end]
94    }
95
96    /// The boundary (branch) that owns the commit at `commit_index`.
97    pub fn boundary_of(&self, commit_index: usize) -> usize {
98        self.boundaries
99            .iter()
100            .position(|b| commit_index < b.end)
101            .unwrap_or_else(|| self.boundaries.len().saturating_sub(1))
102    }
103
104    /// The queue pane's rows, front (top) → tip (bottom): each branch header
105    /// followed by the commits it owns. Pure, so ordering is unit-tested
106    /// without a repo.
107    pub fn rows(&self) -> Vec<Row> {
108        let mut rows = Vec::new();
109        for (b, boundary) in self.boundaries.iter().enumerate() {
110            rows.push(Row::Branch { boundary: b });
111            let start = if b == 0 {
112                0
113            } else {
114                self.boundaries[b - 1].end
115            };
116            for index in start..boundary.end {
117                rows.push(Row::Commit { index });
118            }
119        }
120        rows
121    }
122}
123
124/// A row of the queue pane, indexing back into an [`EditableLine`].
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum Row {
127    /// A branch header — the boundary at this index in `boundaries`.
128    Branch { boundary: usize },
129    /// A commit — at this index in `commits`.
130    Commit { index: usize },
131}
132
133/// The kind of a line in the split selector's rendering of a commit's diff.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum SplitKind {
136    /// A file/hunk header — shown, not selectable.
137    Meta,
138    /// An unchanged context line — shown, not selectable.
139    Context,
140    /// An added (`+`) line — selectable.
141    Added,
142    /// A removed (`-`) line — selectable.
143    Removed,
144}
145
146/// One display line of the split selector. Selectable lines (`Added`/`Removed`)
147/// carry a dense `change_index` used to express the selection.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct SplitLine {
150    pub kind: SplitKind,
151    pub text: String,
152    pub change_index: Option<usize>,
153}
154
155// --- internal diff model, shared by the selector and the split reconstruction ---
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158enum LineKind {
159    Context,
160    Added,
161    Removed,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165struct HunkLine {
166    kind: LineKind,
167    text: String,
168    /// Dense index over all selectable (added/removed) lines in the diff.
169    change_index: Option<usize>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173struct Hunk {
174    /// 0-based start line of this hunk's region within the *new* file version.
175    v_start: usize,
176    lines: Vec<HunkLine>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180struct FileDiff {
181    path: String,
182    hunks: Vec<Hunk>,
183    /// True when git rendered a binary change (no selectable lines).
184    binary: bool,
185}
186
187/// Parse a unified diff (from `git diff <parent> <commit>`) into per-file
188/// hunks, numbering the selectable (`+`/`-`) lines densely. Pure; unit-tested.
189fn parse_diff(diff: &str) -> Vec<FileDiff> {
190    let mut files: Vec<FileDiff> = Vec::new();
191    let mut change_counter = 0usize;
192    for raw in diff.lines() {
193        if let Some(rest) = raw.strip_prefix("diff --git ") {
194            // `a/<path> b/<path>` — take the b-side path.
195            let path = rest
196                .split_once(" b/")
197                .map_or_else(|| rest.to_string(), |(_, b)| b.to_string());
198            files.push(FileDiff {
199                path,
200                hunks: Vec::new(),
201                binary: false,
202            });
203        } else if raw.starts_with("Binary files") {
204            if let Some(f) = files.last_mut() {
205                f.binary = true;
206            }
207        } else if let Some(rest) = raw.strip_prefix("@@") {
208            // `@@ -a,b +c,d @@` — c is the 1-based new-file start line.
209            let v_start = rest
210                .split_once('+')
211                .and_then(|(_, r)| r.split([',', ' ']).next())
212                .and_then(|n| n.parse::<usize>().ok())
213                .map_or(0, |n| n.saturating_sub(1));
214            if let Some(f) = files.last_mut() {
215                f.hunks.push(Hunk {
216                    v_start,
217                    lines: Vec::new(),
218                });
219            }
220        } else if raw.starts_with("+++") || raw.starts_with("---") {
221            // File-header markers; the path came from the `diff --git` line.
222        } else if let Some(f) = files.last_mut() {
223            let Some(hunk) = f.hunks.last_mut() else {
224                continue;
225            };
226            let (kind, text) = if let Some(t) = raw.strip_prefix('+') {
227                (LineKind::Added, t)
228            } else if let Some(t) = raw.strip_prefix('-') {
229                (LineKind::Removed, t)
230            } else if let Some(t) = raw.strip_prefix(' ') {
231                (LineKind::Context, t)
232            } else {
233                continue; // "\ No newline at end of file" and blanks
234            };
235            let change_index = if kind == LineKind::Context {
236                None
237            } else {
238                let i = change_counter;
239                change_counter += 1;
240                Some(i)
241            };
242            hunk.lines.push(HunkLine {
243                kind,
244                text: text.to_string(),
245                change_index,
246            });
247        }
248    }
249    files
250}
251
252/// Reconstruct the intermediate version of a file (`M`) for a split: the new
253/// version with the *selected* changes reverted — selected additions removed,
254/// selected removals restored. `selected` holds the `change_index`es peeled
255/// into the second (newer) commit. Pure; unit-tested.
256fn reconstruct_middle(
257    file: &FileDiff,
258    v_content: &str,
259    selected: &HashSet<usize>,
260) -> Option<String> {
261    if file.binary {
262        return None; // binary changes stay whole in the older piece
263    }
264    let mut v_lines: Vec<String> = v_content.lines().map(str::to_string).collect();
265    // Splice each hunk's region bottom-up so earlier line numbers stay valid.
266    for hunk in file.hunks.iter().rev() {
267        // Lines present in the new version within this hunk (context + added).
268        let v_len = hunk
269            .lines
270            .iter()
271            .filter(|l| matches!(l.kind, LineKind::Context | LineKind::Added))
272            .count();
273        let mut region: Vec<String> = Vec::new();
274        for l in &hunk.lines {
275            let selected_line = l.change_index.is_some_and(|i| selected.contains(&i));
276            let keep = match l.kind {
277                LineKind::Context => true,
278                // A selected removal is still present in M (the newer piece
279                // removes it); an unselected removal is gone (the older piece).
280                LineKind::Removed => selected_line,
281                // A selected addition is not yet in M; an unselected one is.
282                LineKind::Added => !selected_line,
283            };
284            if keep {
285                region.push(l.text.clone());
286            }
287        }
288        let end = (hunk.v_start + v_len).min(v_lines.len());
289        let start = hunk.v_start.min(v_lines.len());
290        v_lines.splice(start..end, region);
291    }
292    let mut result = v_lines.join("\n");
293    // Preserve a trailing newline (git files usually end with one).
294    if v_content.ends_with('\n') && !result.is_empty() {
295        result.push('\n');
296    }
297    Some(result)
298}
299
300/// An edit expressed as data.
301///
302/// The view produces these from user input; the engine applies them. Every
303/// variant the TUI will ever perform is named here
304/// so undo becomes a snapshot↔operation pair and the engine stays directly
305/// testable — but this scaffolding ticket does not yet *apply* any of them
306/// (see [`Engine::apply`]). Later tickets fill the variants in, roughly in the
307/// spine order: boundary edits (ref-only) → reorder/reword/delete → squash →
308/// split → undo/redo.
309#[derive(Debug, Clone, PartialEq, Eq)]
310#[non_exhaustive]
311pub enum Operation {
312    /// Move the commit at `from` to sit at index `to` within the line,
313    /// reassigning it to whichever branch's run it lands in.
314    Reorder { from: usize, to: usize },
315    /// Squash the commit at `index` into its adjacent, older (front-ward)
316    /// neighbour. `message` is the combined description when the caller
317    /// prompted for one (both were non-empty); `None` lets the engine pick the
318    /// non-empty side or concatenate.
319    Squash {
320        index: usize,
321        message: Option<String>,
322    },
323    /// Split the commit at `index` into two: the `selected` diff-line
324    /// `change_index`es are peeled into a new, newer commit with `message` and
325    /// a fresh id, while the older piece keeps the original id and message.
326    /// Repeatable on the remainder for N pieces.
327    Split {
328        index: usize,
329        selected: HashSet<usize>,
330        message: String,
331    },
332    /// Rewrite the message of the commit at `index`, preserving its
333    /// Stable-Commit-Id.
334    Reword { index: usize, message: String },
335    /// Delete the commit at `index`.
336    Delete { index: usize },
337    /// Start a new branch `name` at the commit at `index`: that commit becomes
338    /// the new branch's front, and the new branch takes the tip-ward remainder
339    /// of its current branch (matching `git queue edit`).
340    AddBoundary { index: usize, name: String },
341    /// Remove the boundary at `boundary`, dissolving that branch into its
342    /// neighbour.
343    RemoveBoundary { boundary: usize },
344    /// Shift the boundary at `boundary` by `delta` commits, moving commits
345    /// between the two adjacent branches.
346    MoveBoundary { boundary: usize, delta: isize },
347    /// Rename the branch at `boundary`.
348    RenameBranch { boundary: usize, name: String },
349    /// Undo the last applied operation.
350    Undo,
351    /// Redo the last undone operation.
352    Redo,
353}
354
355/// The headless editing engine over the current queue line.
356#[derive(Debug)]
357pub struct Engine {
358    line: EditableLine,
359    /// The queue name new branches are recorded under and (when the line is
360    /// namespaced) prefixed with.
361    qname: String,
362    /// Whether short branch names are namespaced to `queue/<qname>/<short>`.
363    namespaced: bool,
364    /// The branch HEAD is on; it follows renames/dissolves and is the landing
365    /// point on exit.
366    current: String,
367    /// The branch the TUI launched from (for the exit summary).
368    launched_from: String,
369    /// Branches present at load with their cached PR numbers, so exit can warn
370    /// about dissolved branches that had an open PR.
371    original: Vec<(String, Option<u64>)>,
372    /// Snapshots taken before each applied operation; undo restores the top.
373    undo: Vec<Snapshot>,
374    /// Snapshots of undone operations, for redo.
375    redo: Vec<Snapshot>,
376    /// Whether any operation has mutated git this session (gates the exit
377    /// summary — a pure read-only session leaves the repo, and the output,
378    /// untouched).
379    touched: bool,
380    /// When a rewriting operation conflicts, the pre-operation snapshot is
381    /// parked here (and a rebase is left in progress) until the caller either
382    /// undoes the conflict or suspends to the shell to resolve it.
383    pending_conflict: Option<Snapshot>,
384    /// Cached PR number per boundary, aligned with `line.boundaries`. Recomputed
385    /// on load/reload — never per render — so the view can query it every frame
386    /// without shelling out to `git config`.
387    pr_cache: Vec<Option<u64>>,
388}
389
390/// The outcome of applying an operation.
391#[derive(Debug, PartialEq, Eq)]
392pub enum Applied {
393    /// The operation completed and the model is up to date.
394    Done,
395    /// The operation conflicted: the repo is in a mid-rebase state. The caller
396    /// must resolve it — [`Engine::undo_conflict`] to back out cleanly, or
397    /// suspend to the shell (the user finishes the rebase, then re-runs
398    /// `git queue tui`).
399    Conflict,
400}
401
402/// A branch's full restorable state: its ref and the queue metadata that moves
403/// with it. Captured before every operation so undo can put it back exactly —
404/// the rewritten commit *objects* survive in git until GC (ADR-0002), so
405/// restoring a ref to its old sha brings the old commit back too.
406#[derive(Debug, Clone)]
407struct BranchSnap {
408    name: String,
409    sha: String,
410    parent: String,
411    parent_sha: Option<String>,
412    queue: Option<String>,
413    pr: Option<u64>,
414    description: Option<String>,
415}
416
417/// A whole-line snapshot: every branch's state plus which branch HEAD was on.
418#[derive(Debug, Clone)]
419struct Snapshot {
420    branches: Vec<BranchSnap>,
421    head: String,
422}
423
424impl Engine {
425    /// Load the current queue line into the engine, applying the TUI's entry
426    /// guards. Reuses `edit`'s scoping: the current queue line (an untracked
427    /// branch opens as one provisional section over trunk). Refuses a forked
428    /// line, a dirty worktree, and an empty queue.
429    ///
430    /// This is headless — it never checks for a TTY. The non-TTY guard lives
431    /// in the command layer (`commands::tui`) so that tests can drive `load`
432    /// in-process without a terminal.
433    pub fn load() -> Result<Self> {
434        git::ensure_repo()?;
435        if git::rebase_in_progress() {
436            bail!(
437                "a rebase is in progress — finish resolving it (`git status`, then \
438                 `git rebase --continue` or `--abort`), then re-run `git queue tui`"
439            );
440        }
441        if !git::worktree_clean() {
442            bail!(
443                "working tree has uncommitted changes; commit or stash them before \
444                 opening the queue in the TUI"
445            );
446        }
447
448        let queue = Queue::load()?;
449        let branch = git::current_branch()?;
450        let (branches, base) = Self::scope(&queue, &branch)?;
451        let line = Self::build_line(base, &branches)?;
452        if line.commits.is_empty() {
453            bail!("the queue has no commits to edit");
454        }
455
456        let qname = branch_queue_name(&branches).unwrap_or_else(|| branch.replace('/', "-"));
457        let namespaced = branches.iter().any(|b| b.starts_with("queue/"));
458        let original: Vec<(String, Option<u64>)> =
459            branches.iter().map(|b| (b.clone(), meta::pr(b))).collect();
460        let pr_cache = line.boundaries.iter().map(|b| meta::pr(&b.name)).collect();
461
462        Ok(Self {
463            line,
464            qname,
465            namespaced,
466            current: branch.clone(),
467            launched_from: branch,
468            original,
469            undo: Vec::new(),
470            redo: Vec::new(),
471            touched: false,
472            pending_conflict: None,
473            pr_cache,
474        })
475    }
476
477    /// Resolve the current queue line's branches and base, refusing a forked
478    /// line. An untracked branch resolves to one provisional section over trunk.
479    fn scope(queue: &Queue, branch: &str) -> Result<(Vec<String>, String)> {
480        if queue.is_tracked(branch) {
481            let line = queue.line_through(branch)?;
482            // A fork is any branch in the editing range with more than one
483            // tracked child (covers a fork above *or* below the current
484            // branch, which `line.fork_at` alone would miss).
485            if line.branches.iter().any(|b| queue.children(b).len() > 1) {
486                bail!(
487                    "`{branch}` is on a forked queue line; the TUI cannot rewrite history \
488                     shared with a sibling line. Use `git queue edit` for ref-only \
489                     boundary changes, or checkout a single unforked line first"
490                );
491            }
492            Ok((line.branches, line.base))
493        } else {
494            Ok((vec![branch.to_string()], queue.trunk.clone()))
495        }
496    }
497
498    /// Build the commit sequence (front → tip) and the per-branch boundaries
499    /// over it, by walking each branch's run.
500    fn build_line(base: String, branches: &[String]) -> Result<EditableLine> {
501        let mut commits = Vec::new();
502        let mut boundaries = Vec::new();
503        let mut parent = base.clone();
504        for b in branches {
505            for (sha, id, subject) in git::commits_between_with_ids(&parent, b)? {
506                let empty = git::commit_is_empty(&sha);
507                commits.push(Commit {
508                    sha,
509                    id,
510                    subject,
511                    empty,
512                });
513            }
514            boundaries.push(Boundary {
515                name: b.clone(),
516                end: commits.len(),
517            });
518            parent = b.clone();
519        }
520        Ok(EditableLine {
521            base,
522            commits,
523            boundaries,
524        })
525    }
526
527    /// The loaded queue line: commit sequence and boundaries.
528    pub const fn line(&self) -> &EditableLine {
529        &self.line
530    }
531
532    /// The branch the TUI was launched from.
533    pub fn launched_from(&self) -> &str {
534        &self.launched_from
535    }
536
537    /// The branch HEAD is currently on (the exit landing point).
538    pub fn landing_branch(&self) -> &str {
539        &self.current
540    }
541
542    /// The queue name new branches are recorded under.
543    pub fn queue_name(&self) -> &str {
544        &self.qname
545    }
546
547    /// Whether any operation is on the undo stack (drives the quit-guard).
548    pub const fn has_pending(&self) -> bool {
549        !self.undo.is_empty()
550    }
551
552    /// Whether any operation has mutated git this session.
553    pub const fn changed(&self) -> bool {
554        self.touched
555    }
556
557    /// The final branch chain, front → tip, as `(parent, branch)` pairs.
558    pub fn layout(&self) -> Vec<(String, String)> {
559        let mut out = Vec::new();
560        let mut parent = self.line.base.clone();
561        for b in &self.line.boundaries {
562            out.push((parent.clone(), b.name.clone()));
563            parent = b.name.clone();
564        }
565        out
566    }
567
568    /// Branches that were present at load but have since been dissolved, and
569    /// that carried a cached PR number — the exit summary warns about these.
570    pub fn dissolved_with_prs(&self) -> Vec<(String, u64)> {
571        let present: HashSet<&str> = self
572            .line
573            .boundaries
574            .iter()
575            .map(|b| b.name.as_str())
576            .collect();
577        self.original
578            .iter()
579            .filter(|(name, _)| !present.contains(name.as_str()))
580            .filter_map(|(name, pr)| pr.map(|n| (name.clone(), n)))
581            .collect()
582    }
583
584    /// Apply an operation. Boundary edits, undo/redo, and reorder are handled
585    /// here; the remaining history-rewriting variants land in later tickets.
586    ///
587    /// Most operations return [`Applied::Done`]; a rewriting operation that
588    /// conflicts returns [`Applied::Conflict`] with a rebase in progress (see
589    /// [`Engine::undo_conflict`] / [`Engine::conflicted`]).
590    pub fn apply(&mut self, op: Operation) -> Result<Applied> {
591        if self.pending_conflict.is_some() {
592            bail!("a conflict is pending; resolve it in your shell or undo it first");
593        }
594        match op {
595            Operation::RenameBranch { boundary, name } => {
596                self.rename_branch(boundary, &name)?;
597                Ok(Applied::Done)
598            }
599            Operation::AddBoundary { index, name } => {
600                self.add_boundary(index, &name)?;
601                Ok(Applied::Done)
602            }
603            Operation::RemoveBoundary { boundary } => {
604                self.remove_boundary(boundary)?;
605                Ok(Applied::Done)
606            }
607            Operation::MoveBoundary { boundary, delta } => {
608                self.move_boundary(boundary, delta)?;
609                Ok(Applied::Done)
610            }
611            Operation::Reorder { from, to } => self.reorder(from, to),
612            Operation::Reword { index, message } => {
613                self.reword(index, &message)?;
614                Ok(Applied::Done)
615            }
616            Operation::Delete { index } => self.delete(index),
617            Operation::Squash { index, message } => self.squash(index, message.as_deref()),
618            Operation::Split {
619                index,
620                selected,
621                message,
622            } => self.split(index, &selected, &message),
623            Operation::Undo => {
624                self.undo()?;
625                Ok(Applied::Done)
626            }
627            Operation::Redo => {
628                self.redo()?;
629                Ok(Applied::Done)
630            }
631        }
632    }
633
634    /// Whether a conflicted operation is awaiting resolution.
635    pub const fn conflicted(&self) -> bool {
636        self.pending_conflict.is_some()
637    }
638
639    /// Back out a conflicted operation: abort the rebase and restore the
640    /// pre-operation snapshot exactly.
641    pub fn undo_conflict(&mut self) -> Result<()> {
642        let Some(snap) = self.pending_conflict.take() else {
643            bail!("no conflict to undo");
644        };
645        if git::rebase_in_progress() {
646            git::rebase_abort()?;
647        }
648        self.restore(&snap)?;
649        Ok(())
650    }
651
652    // ---- reorder (history-rewriting) ----
653
654    /// Move the commit at `from` to sit at index `to` within the line,
655    /// rebasing immediately. The commit keeps its Stable-Commit-Id; crossing a
656    /// boundary reassigns it to the branch whose run it lands in (via
657    /// `--update-refs`, exactly like `git queue move`).
658    fn reorder(&mut self, from: usize, to: usize) -> Result<Applied> {
659        let n = self.line.commits.len();
660        if from >= n || to >= n {
661            bail!("reorder index out of range");
662        }
663        if from == to {
664            return Ok(Applied::Done);
665        }
666
667        let todo = reorder_todo(&self.line, from, to);
668        let snap = self.snapshot()?;
669        let base = self.line.base.clone();
670        let top = self.line.tip_branch().to_string();
671        let land = self.current.clone();
672        match git::rebase_with_todo_stop(&base, &top, &todo)? {
673            git::Rewrite::Clean => {
674                self.finish_rewrite(&land)?;
675                self.undo.push(snap);
676                self.redo.clear();
677                self.touched = true;
678                // A reorder can leave a commit empty (two commits touching the
679                // same lines); auto-drop the undescribed ones (jj).
680                self.auto_drop_empties(&land)?;
681                Ok(Applied::Done)
682            }
683            git::Rewrite::Conflict => {
684                self.pending_conflict = Some(snap);
685                self.touched = true;
686                Ok(Applied::Conflict)
687            }
688        }
689    }
690
691    // ---- delete (history-rewriting) ----
692
693    /// Delete the commit at `index`: drop it from the line and replay
694    /// descendants (through the conflict escape hatch if needed). Explicit
695    /// delete always removes, empty or not. Afterwards, any commit left empty
696    /// *and* description-less is auto-dropped.
697    fn delete(&mut self, index: usize) -> Result<Applied> {
698        let n = self.line.commits.len();
699        if index >= n {
700            bail!("delete index out of range");
701        }
702        if n == 1 {
703            bail!("cannot delete the queue's only commit");
704        }
705        let mut drop = HashSet::new();
706        drop.insert(index);
707        let todo = drop_todo(&self.line, &drop);
708        let snap = self.snapshot()?;
709        let base = self.line.base.clone();
710        let top = self.line.tip_branch().to_string();
711        let land = self.current.clone();
712        match git::rebase_with_todo_stop(&base, &top, &todo)? {
713            git::Rewrite::Clean => {
714                self.finish_rewrite(&land)?;
715                // Record undo before the empty-cleanup, so a delete stays
716                // undoable even if the (rare) cleanup step fails.
717                self.undo.push(snap);
718                self.redo.clear();
719                self.touched = true;
720                self.auto_drop_empties(&land)?;
721                Ok(Applied::Done)
722            }
723            git::Rewrite::Conflict => {
724                self.pending_conflict = Some(snap);
725                self.touched = true;
726                Ok(Applied::Conflict)
727            }
728        }
729    }
730
731    /// Drop any commit that is empty *and* description-less (jj's rule). An
732    /// empty commit that still has a description is kept and rendered
733    /// `(empty)`. Dropping empty commits cannot conflict.
734    fn auto_drop_empties(&mut self, land: &str) -> Result<()> {
735        for _ in 0..64 {
736            let drop: HashSet<usize> = self
737                .line
738                .commits
739                .iter()
740                .enumerate()
741                .filter(|(_, c)| c.empty && !c.described())
742                .map(|(i, _)| i)
743                .collect();
744            if drop.is_empty() {
745                return Ok(());
746            }
747            let todo = drop_todo(&self.line, &drop);
748            let base = self.line.base.clone();
749            let top = self.line.tip_branch().to_string();
750            match git::rebase_with_todo_stop(&base, &top, &todo)? {
751                git::Rewrite::Clean => self.finish_rewrite(land)?,
752                git::Rewrite::Conflict => {
753                    // Empty drops shouldn't conflict; if one does, don't leave a
754                    // dangling rebase.
755                    git::rebase_abort()?;
756                    bail!("auto-dropping empty commits unexpectedly conflicted");
757                }
758            }
759        }
760        Ok(())
761    }
762
763    /// Boundary indices whose branch currently owns no commits (e.g. after a
764    /// delete emptied it) — the view offers to dissolve these.
765    pub fn empty_branches(&self) -> Vec<usize> {
766        (0..self.line.boundaries.len())
767            .filter(|&i| self.line.commits_of(i).is_empty())
768            .collect()
769    }
770
771    /// The cached PR number of the branch at `boundary`, if any. O(1) — no
772    /// git call — so the view can call it every frame.
773    pub fn pr_of(&self, boundary: usize) -> Option<u64> {
774        self.pr_cache.get(boundary).copied().flatten()
775    }
776
777    /// The branch name at `boundary`.
778    pub fn branch_name(&self, boundary: usize) -> &str {
779        &self.line.boundaries[boundary].name
780    }
781
782    // ---- split (history-rewriting) ----
783
784    /// The split selector's view of the commit at `index`: its diff flattened
785    /// into displayable lines, the `+`/`-` ones carrying a dense `change_index`.
786    pub fn split_lines(&self, index: usize) -> Result<Vec<SplitLine>> {
787        if index >= self.line.commits.len() {
788            bail!("split index out of range");
789        }
790        let diff = git::commit_diff(&self.line.commits[index].sha)?;
791        let mut out = Vec::new();
792        for f in parse_diff(&diff) {
793            out.push(SplitLine {
794                kind: SplitKind::Meta,
795                text: format!("── {} ──", f.path),
796                change_index: None,
797            });
798            if f.binary {
799                out.push(SplitLine {
800                    kind: SplitKind::Meta,
801                    text: "(binary — peeled into the new commit)".into(),
802                    change_index: None,
803                });
804                continue;
805            }
806            for h in &f.hunks {
807                out.push(SplitLine {
808                    kind: SplitKind::Meta,
809                    text: "@@".into(),
810                    change_index: None,
811                });
812                for l in &h.lines {
813                    let (kind, prefix) = match l.kind {
814                        LineKind::Added => (SplitKind::Added, '+'),
815                        LineKind::Removed => (SplitKind::Removed, '-'),
816                        LineKind::Context => (SplitKind::Context, ' '),
817                    };
818                    out.push(SplitLine {
819                        kind,
820                        text: format!("{prefix}{}", l.text),
821                        change_index: l.change_index,
822                    });
823                }
824            }
825        }
826        Ok(out)
827    }
828
829    /// The number of selectable (`+`/`-`) lines in the commit at `index`.
830    pub fn split_change_count(&self, index: usize) -> Result<usize> {
831        let diff = git::commit_diff(&self.line.commits[index].sha)?;
832        Ok(parse_diff(&diff)
833            .iter()
834            .flat_map(|f| &f.hunks)
835            .flat_map(|h| &h.lines)
836            .filter(|l| l.change_index.is_some())
837            .count())
838    }
839
840    /// Split the commit at `index` into two. The `selected` change-lines are
841    /// peeled into a new, newer commit (fresh id + `message`); the older piece
842    /// keeps the original id and message. Descendants replay cleanly (the new
843    /// tip has the same tree). One undo entry.
844    fn split(&mut self, index: usize, selected: &HashSet<usize>, message: &str) -> Result<Applied> {
845        if index >= self.line.commits.len() {
846            bail!("split index out of range");
847        }
848        let total = self.split_change_count(index)?;
849        if selected.is_empty() || selected.len() >= total {
850            bail!("select some — but not all — lines to peel into the new commit");
851        }
852
853        let c_sha = self.line.commits[index].sha.clone();
854        let parent_sha = if index == 0 {
855            git::rev_parse(&self.line.base)?
856        } else {
857            self.line.commits[index - 1].sha.clone()
858        };
859        let diff = git::commit_diff(&c_sha)?;
860        let files = parse_diff(&diff);
861
862        // Build the older piece's tree: parent + the *unselected* changes.
863        let parent_tree = git::tree_of(&parent_sha)?;
864        let mut changes: Vec<(String, Option<String>)> = Vec::new();
865        for f in &files {
866            let v = git::file_at(&c_sha, &f.path);
867            let Some(m) = reconstruct_middle(f, &v, selected) else {
868                continue; // binary: stays whole in the newer piece
869            };
870            let parent_has = !git::file_at(&parent_sha, &f.path).is_empty();
871            if m.is_empty() {
872                if parent_has {
873                    changes.push((f.path.clone(), None)); // removed in the older piece
874                }
875                // else: new file, fully peeled — absent from the older piece
876            } else {
877                changes.push((f.path.clone(), Some(m)));
878            }
879        }
880        let m_tree = git::build_tree(&parent_tree, &changes)?;
881
882        // Older piece keeps C's message (and id); newer piece is auto-stamped.
883        let older = git::commit_tree(&m_tree, &parent_sha, &git::commit_message(&c_sha)?)?;
884        let newer_msg = with_preserved_id(message, Some(&ident::new_id()));
885        let newer = git::commit_tree(&git::tree_of(&c_sha)?, &older, &newer_msg)?;
886
887        let snap = self.snapshot()?;
888        // Replay C's descendants onto the newer piece (clean: same tree).
889        let mut mapping: std::collections::HashMap<String, String> =
890            std::collections::HashMap::new();
891        let mut prev = newer.clone();
892        for c in &self.line.commits[index + 1..] {
893            prev = git::cherry_pick_onto(&prev, &c.sha)?;
894            mapping.insert(c.sha.clone(), prev.clone());
895        }
896
897        // Point every branch at its tip's new sha.
898        let boundaries = self.line.boundaries.clone();
899        let mut parent = self.line.base.clone();
900        for b in &boundaries {
901            let tip_old = &self.line.commits[b.end - 1].sha;
902            let new_tip = if *tip_old == c_sha {
903                newer.clone()
904            } else if let Some(n) = mapping.get(tip_old) {
905                n.clone()
906            } else {
907                tip_old.clone()
908            };
909            git::force_ref(&b.name, &new_tip)?;
910            meta::set_parent(&b.name, &parent)?;
911            meta::set_parent_sha(&b.name, &git::rev_parse(&parent)?)?;
912            parent = b.name.clone();
913        }
914
915        let land = self.current.clone();
916        git::checkout_quiet(&land)?;
917        git::reset_hard_head()?;
918        self.reload()?;
919        self.undo.push(snap);
920        self.redo.clear();
921        self.touched = true;
922        self.auto_drop_empties(&land)?;
923        Ok(Applied::Done)
924    }
925
926    // ---- squash (history-rewriting) ----
927
928    /// Whether squashing the commit at `index` into its older neighbour needs a
929    /// combined-message prompt — true only when *both* descriptions are
930    /// non-empty (otherwise the non-empty side is used, or the result is empty).
931    pub fn squash_needs_message(&self, index: usize) -> Result<bool> {
932        if index == 0 || index >= self.line.commits.len() {
933            bail!("no older commit to squash into");
934        }
935        let older = message_body(&self.line.commits[index - 1].sha)?;
936        let newer = message_body(&self.line.commits[index].sha)?;
937        Ok(!older.trim().is_empty() && !newer.trim().is_empty())
938    }
939
940    /// The default combined description shown in the squash prompt: the older
941    /// body then the newer, blank line between (each without its id trailer).
942    pub fn squash_default_message(&self, index: usize) -> Result<String> {
943        if index == 0 || index >= self.line.commits.len() {
944            bail!("no older commit to squash into");
945        }
946        Ok(combine_bodies(
947            &message_body(&self.line.commits[index - 1].sha)?,
948            &message_body(&self.line.commits[index].sha)?,
949        ))
950    }
951
952    /// Squash the commit at `index` into its adjacent older neighbour. The
953    /// older commit keeps its Stable-Commit-Id; the newer is absorbed. A
954    /// cross-boundary squash lands the result in the older branch and may empty
955    /// the newer one (see [`Engine::empty_branches`]).
956    fn squash(&mut self, index: usize, message: Option<&str>) -> Result<Applied> {
957        if index == 0 {
958            bail!("the front commit has no older neighbour to squash into");
959        }
960        if index >= self.line.commits.len() {
961            bail!("squash index out of range");
962        }
963        // Combined body: the caller's trimmed text, else the non-empty side or
964        // a concatenation; then the older commit's id, preserved.
965        let body = match message {
966            Some(m) => m.to_string(),
967            None => combine_bodies(
968                &message_body(&self.line.commits[index - 1].sha)?,
969                &message_body(&self.line.commits[index].sha)?,
970            ),
971        };
972        let final_message = with_preserved_id(&body, self.line.commits[index - 1].id.as_deref());
973
974        let todo = squash_todo(&self.line, index);
975        let snap = self.snapshot()?;
976        let base = self.line.base.clone();
977        let top = self.line.tip_branch().to_string();
978        let land = self.current.clone();
979        match git::rebase_squash_stop(&base, &top, &todo, &final_message)? {
980            git::Rewrite::Clean => {
981                self.finish_rewrite(&land)?;
982                self.undo.push(snap);
983                self.redo.clear();
984                self.touched = true;
985                self.auto_drop_empties(&land)?;
986                Ok(Applied::Done)
987            }
988            git::Rewrite::Conflict => {
989                self.pending_conflict = Some(snap);
990                self.touched = true;
991                Ok(Applied::Conflict)
992            }
993        }
994    }
995
996    // ---- reword (message-only rewrite) ----
997
998    /// Rewrite the message of the commit at `index`, preserving its
999    /// Stable-Commit-Id and rebasing descendants (message-only, so the replay
1000    /// is clean). One undo entry.
1001    fn reword(&mut self, index: usize, new_text: &str) -> Result<()> {
1002        if index >= self.line.commits.len() {
1003            bail!("reword index out of range");
1004        }
1005        let message = with_preserved_id(new_text, self.line.commits[index].id.as_deref());
1006        let todo = reword_todo(&self.line, index);
1007        let snap = self.snapshot()?;
1008        let base = self.line.base.clone();
1009        let top = self.line.tip_branch().to_string();
1010        let land = self.current.clone();
1011        git::rebase_with_todo_message(&base, &top, &todo, &message)?;
1012        self.finish_rewrite(&land)?;
1013        self.undo.push(snap);
1014        self.redo.clear();
1015        self.touched = true;
1016        Ok(())
1017    }
1018
1019    /// After a clean rewriting rebase: refresh each branch's rebase anchor to
1020    /// its parent's new tip, land HEAD on `land`, snap the worktree, reload.
1021    fn finish_rewrite(&mut self, land: &str) -> Result<()> {
1022        let names: Vec<String> = self
1023            .line
1024            .boundaries
1025            .iter()
1026            .map(|b| b.name.clone())
1027            .collect();
1028        let mut parent = self.line.base.clone();
1029        for b in &names {
1030            meta::set_parent_sha(b, &git::rev_parse(&parent)?)?;
1031            parent = b.clone();
1032        }
1033        git::checkout_quiet(land)?;
1034        git::reset_hard_head()?;
1035        self.reload()?;
1036        Ok(())
1037    }
1038
1039    // ---- boundary operations (ref-only; no commit is rewritten) ----
1040
1041    /// Rename the branch at `boundary`.
1042    fn rename_branch(&mut self, boundary: usize, new_name: &str) -> Result<()> {
1043        let resolved = self.resolve_name(new_name);
1044        let old = self.line.boundaries[boundary].name.clone();
1045        if resolved == old {
1046            return Ok(());
1047        }
1048        if git::branch_exists(&resolved) {
1049            bail!("branch `{resolved}` already exists; pick a different name");
1050        }
1051        let mut segs = self.line.boundaries.clone();
1052        segs[boundary].name = resolved.clone();
1053        let land = if self.current == old {
1054            resolved
1055        } else {
1056            self.current.clone()
1057        };
1058        self.commit_op(segs, land)
1059    }
1060
1061    /// Start a new branch `name` **at** commit `index`: the highlighted commit
1062    /// becomes the front of the new branch, which takes the tip-ward remainder
1063    /// of its current branch; the current branch keeps the commits before it.
1064    /// This matches `git queue edit`, where a header sits above the commits it
1065    /// owns — so the new branch's header lands on the highlighted commit.
1066    fn add_boundary(&mut self, index: usize, name: &str) -> Result<()> {
1067        let resolved = self.resolve_name(name);
1068        if git::branch_exists(&resolved) {
1069            bail!("branch `{resolved}` already exists; pick a different name");
1070        }
1071        let b = self.line.boundary_of(index);
1072        let start = if b == 0 {
1073            0
1074        } else {
1075            self.line.boundaries[b - 1].end
1076        };
1077        if index == start {
1078            bail!(
1079                "`{}` already starts at that commit; highlight a later commit to \
1080                 split off a new branch",
1081                self.line.boundaries[b].name
1082            );
1083        }
1084        let mut segs = self.line.boundaries.clone();
1085        let end = segs[b].end;
1086        // The existing branch keeps [start..index); the new branch owns
1087        // [index..end) with the highlighted commit as its front.
1088        segs[b].end = index;
1089        segs.insert(
1090            b + 1,
1091            Boundary {
1092                name: resolved,
1093                end,
1094            },
1095        );
1096        let land = self.current.clone();
1097        self.commit_op(segs, land)
1098    }
1099
1100    /// Remove the boundary at `boundary`, dissolving that branch into its
1101    /// neighbour (its child, or its parent when it is the tip).
1102    fn remove_boundary(&mut self, boundary: usize) -> Result<()> {
1103        if self.line.boundaries.len() < 2 {
1104            bail!("cannot dissolve the only branch of the line");
1105        }
1106        let removed = self.line.boundaries[boundary].name.clone();
1107        let is_tip = boundary == self.line.boundaries.len() - 1;
1108        let survivor = if is_tip {
1109            self.line.boundaries[boundary - 1].name.clone()
1110        } else {
1111            self.line.boundaries[boundary + 1].name.clone()
1112        };
1113        let mut segs = self.line.boundaries.clone();
1114        segs.remove(boundary);
1115        // Removing the tip boundary would leave its commits unowned; extend the
1116        // new last branch to cover them.
1117        if let Some(last) = segs.last_mut() {
1118            last.end = self.line.commits.len();
1119        }
1120        let land = if self.current == removed {
1121            survivor
1122        } else {
1123            self.current.clone()
1124        };
1125        self.commit_op(segs, land)
1126    }
1127
1128    /// Shift the boundary at `boundary` by `delta` commits, moving commits
1129    /// between it and the next branch.
1130    fn move_boundary(&mut self, boundary: usize, delta: isize) -> Result<()> {
1131        if boundary + 1 >= self.line.boundaries.len() {
1132            bail!("the last branch's boundary is fixed at the tip");
1133        }
1134        let lower = if boundary == 0 {
1135            0
1136        } else {
1137            self.line.boundaries[boundary - 1].end as isize
1138        };
1139        let upper = self.line.boundaries[boundary + 1].end as isize;
1140        let new_end = self.line.boundaries[boundary].end as isize + delta;
1141        if new_end <= lower || new_end >= upper {
1142            bail!("that shift would empty a branch");
1143        }
1144        let mut segs = self.line.boundaries.clone();
1145        segs[boundary].end = new_end as usize;
1146        let land = self.current.clone();
1147        self.commit_op(segs, land)
1148    }
1149
1150    // ---- undo / redo ----
1151
1152    fn undo(&mut self) -> Result<()> {
1153        let Some(snap) = self.undo.pop() else {
1154            bail!("nothing to undo");
1155        };
1156        let redo = self.snapshot()?;
1157        self.restore(&snap)?;
1158        self.redo.push(redo);
1159        Ok(())
1160    }
1161
1162    fn redo(&mut self) -> Result<()> {
1163        let Some(snap) = self.redo.pop() else {
1164            bail!("nothing to redo");
1165        };
1166        let undo = self.snapshot()?;
1167        self.restore(&snap)?;
1168        self.undo.push(undo);
1169        Ok(())
1170    }
1171
1172    // ---- shared mutation machinery ----
1173
1174    /// Namespace a short branch name to `queue/<qname>/<short>` when the line
1175    /// is namespaced; names with a `/`, existing line branches, and plain-named
1176    /// queues stay as-is (the same rule as `edit`/`create`).
1177    fn resolve_name(&self, n: &str) -> String {
1178        if n.contains('/') || self.line.boundaries.iter().any(|b| b.name == n) || !self.namespaced {
1179            n.to_string()
1180        } else {
1181            format!("queue/{}/{n}", self.qname)
1182        }
1183    }
1184
1185    /// Snapshot before an operation, apply it by materialising the new segment
1186    /// layout, land HEAD on `land`, reload the model, and push the undo entry.
1187    fn commit_op(&mut self, segments: Vec<Boundary>, land: String) -> Result<()> {
1188        let snap = self.snapshot()?;
1189        self.materialise(&segments)?;
1190        git::checkout_quiet(&land)?;
1191        self.reload()?;
1192        self.undo.push(snap);
1193        self.redo.clear();
1194        self.touched = true;
1195        Ok(())
1196    }
1197
1198    /// Rewrite branch refs and parent pointers to match `segments` over the
1199    /// current (fixed) commit sequence, and delete branches that dropped out.
1200    /// Ref-only: no commit object is rewritten. Leaves HEAD detached.
1201    fn materialise(&self, segments: &[Boundary]) -> Result<()> {
1202        git::detach_head()?;
1203        let target: HashSet<&str> = segments.iter().map(|s| s.name.as_str()).collect();
1204        let mut parent = self.line.base.clone();
1205        for s in segments {
1206            let tip_sha = self.line.commits[s.end - 1].sha.clone();
1207            if git::branch_exists(&s.name) {
1208                git::force_ref(&s.name, &tip_sha)?;
1209            } else {
1210                git::create_branch(&s.name, &tip_sha)?;
1211            }
1212            meta::set_parent(&s.name, &parent)?;
1213            meta::set_parent_sha(&s.name, &git::rev_parse(&parent)?)?;
1214            meta::set_branch_queue(&s.name, &self.qname)?;
1215            parent = s.name.clone();
1216        }
1217        for old in self.line.boundaries.iter().map(|b| b.name.clone()) {
1218            if !target.contains(old.as_str()) {
1219                meta::untrack(&old);
1220                git::run(&["branch", "-q", "-D", &old])?;
1221            }
1222        }
1223        meta::touch_queue(&self.qname);
1224        Ok(())
1225    }
1226
1227    /// Capture the whole line's restorable state.
1228    fn snapshot(&self) -> Result<Snapshot> {
1229        let mut branches = Vec::new();
1230        for b in self.line.boundaries.iter().map(|x| &x.name) {
1231            branches.push(BranchSnap {
1232                name: b.clone(),
1233                sha: git::rev_parse(b)?,
1234                parent: meta::parent(b).unwrap_or_else(|| self.line.base.clone()),
1235                parent_sha: meta::parent_sha(b),
1236                queue: meta::branch_queue(b),
1237                pr: meta::pr(b),
1238                description: meta::description(b),
1239            });
1240        }
1241        Ok(Snapshot {
1242            branches,
1243            head: self.current.clone(),
1244        })
1245    }
1246
1247    /// Restore a snapshot: delete branches the operation created, put every
1248    /// snapshotted branch's ref and metadata back, and return HEAD. Reloads.
1249    fn restore(&mut self, snap: &Snapshot) -> Result<()> {
1250        git::detach_head()?;
1251        let keep: HashSet<&str> = snap.branches.iter().map(|b| b.name.as_str()).collect();
1252        for b in self.line.boundaries.iter().map(|x| x.name.clone()) {
1253            if !keep.contains(b.as_str()) {
1254                meta::untrack(&b);
1255                git::run(&["branch", "-q", "-D", &b])?;
1256            }
1257        }
1258        for bs in &snap.branches {
1259            if git::branch_exists(&bs.name) {
1260                git::force_ref(&bs.name, &bs.sha)?;
1261            } else {
1262                git::create_branch(&bs.name, &bs.sha)?;
1263            }
1264            meta::set_parent(&bs.name, &bs.parent)?;
1265            if let Some(s) = &bs.parent_sha {
1266                meta::set_parent_sha(&bs.name, s)?;
1267            }
1268            if let Some(q) = &bs.queue {
1269                meta::set_branch_queue(&bs.name, q)?;
1270            }
1271            if let Some(pr) = bs.pr {
1272                meta::set_pr(&bs.name, pr)?;
1273            }
1274            if let Some(d) = &bs.description {
1275                meta::set_description(&bs.name, d)?;
1276            }
1277        }
1278        git::checkout_quiet(&snap.head)?;
1279        self.reload()?;
1280        Ok(())
1281    }
1282
1283    /// Rebuild the in-memory model from the current git state (after HEAD has
1284    /// been put on a valid line branch). Runs no guards.
1285    fn reload(&mut self) -> Result<()> {
1286        let queue = Queue::load()?;
1287        let branch = git::current_branch()?;
1288        let (branches, base) = Self::scope(&queue, &branch)?;
1289        self.line = Self::build_line(base, &branches)?;
1290        self.pr_cache = self
1291            .line
1292            .boundaries
1293            .iter()
1294            .map(|b| meta::pr(&b.name))
1295            .collect();
1296        self.current = branch;
1297        Ok(())
1298    }
1299}
1300
1301/// Build the interactive-rebase todo for `reorder(from, to)` over `line`.
1302///
1303/// Emits the picks in the current front → tip order with an `update-ref` line
1304/// after each **non-leaf** branch's tip (the leaf branch updates implicitly),
1305/// then relocates only the moved commit's `pick` line to directly follow the
1306/// commit that precedes its target position (or to the front). Moving just the
1307/// pick — and leaving every `update-ref` line where it sits — is what makes a
1308/// commit that crosses a boundary join the branch it lands in and shrink the
1309/// branch it left (mirrors the CLI's `reorder-todo`). Pure, so it is
1310/// unit-tested without a repo.
1311fn reorder_todo(line: &EditableLine, from: usize, to: usize) -> String {
1312    let commits = &line.commits;
1313    let pick = |i: usize| format!("pick {} {}", commits[i].sha, commits[i].subject);
1314
1315    // `update-ref` after each non-leaf branch's tip commit.
1316    let last = line.boundaries.len() - 1;
1317    let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
1318    for (bi, b) in line.boundaries.iter().enumerate() {
1319        if bi != last {
1320            ref_after.insert(b.end - 1, b.name.as_str());
1321        }
1322    }
1323
1324    // The full todo in current order.
1325    let mut lines: Vec<String> = Vec::new();
1326    for i in 0..commits.len() {
1327        lines.push(pick(i));
1328        if let Some(name) = ref_after.get(&i) {
1329            lines.push(format!("update-ref refs/heads/{name}"));
1330        }
1331    }
1332
1333    // Relocate the moved pick to follow the target's preceding commit.
1334    let moved_line = pick(from);
1335    lines.retain(|l| l != &moved_line);
1336    let mut order: Vec<usize> = (0..commits.len()).collect();
1337    let m = order.remove(from);
1338    order.insert(to, m);
1339    let idx = if to == 0 {
1340        0
1341    } else {
1342        let anchor = pick(order[to - 1]);
1343        lines.iter().position(|l| *l == anchor).map_or(0, |i| i + 1)
1344    };
1345    lines.insert(idx, moved_line);
1346    lines.join("\n") + "\n"
1347}
1348
1349/// Build the rebase todo for `reword(index)`: the line's picks front → tip with
1350/// an `update-ref` after each non-leaf branch tip, and the target commit's line
1351/// marked `reword` so git stops to take the new message. Pure; unit-tested.
1352fn reword_todo(line: &EditableLine, index: usize) -> String {
1353    let commits = &line.commits;
1354    let last = line.boundaries.len() - 1;
1355    let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
1356    for (bi, b) in line.boundaries.iter().enumerate() {
1357        if bi != last {
1358            ref_after.insert(b.end - 1, b.name.as_str());
1359        }
1360    }
1361    let mut lines: Vec<String> = Vec::new();
1362    for (i, c) in commits.iter().enumerate() {
1363        let verb = if i == index { "reword" } else { "pick" };
1364        lines.push(format!("{verb} {} {}", c.sha, c.subject));
1365        if let Some(name) = ref_after.get(&i) {
1366            lines.push(format!("update-ref refs/heads/{name}"));
1367        }
1368    }
1369    lines.join("\n") + "\n"
1370}
1371
1372/// Build the rebase todo that drops the commits in `drop`: every other pick in
1373/// front → tip order, with the `update-ref` lines kept in place (so dropping a
1374/// branch's tip shrinks it, and dropping a branch's only commit empties it).
1375/// Pure; unit-tested.
1376fn drop_todo(line: &EditableLine, drop: &HashSet<usize>) -> String {
1377    let commits = &line.commits;
1378    let last = line.boundaries.len() - 1;
1379    let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
1380    for (bi, b) in line.boundaries.iter().enumerate() {
1381        if bi != last {
1382            ref_after.insert(b.end - 1, b.name.as_str());
1383        }
1384    }
1385    let mut lines: Vec<String> = Vec::new();
1386    for (i, c) in commits.iter().enumerate() {
1387        if !drop.contains(&i) {
1388            lines.push(format!("pick {} {}", c.sha, c.subject));
1389        }
1390        if let Some(name) = ref_after.get(&i) {
1391            lines.push(format!("update-ref refs/heads/{name}"));
1392        }
1393    }
1394    lines.join("\n") + "\n"
1395}
1396
1397/// Build the rebase todo for `squash(index)`: the line's picks front → tip with
1398/// the target marked `squash` so it folds into the previous commit. For a
1399/// cross-boundary squash the older branch's `update-ref` is deferred to *after*
1400/// the squash line, so that branch captures the combined commit (and the newer
1401/// branch may end up empty). Pure; unit-tested.
1402fn squash_todo(line: &EditableLine, index: usize) -> String {
1403    let commits = &line.commits;
1404    let last = line.boundaries.len() - 1;
1405    let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
1406    for (bi, b) in line.boundaries.iter().enumerate() {
1407        if bi != last {
1408            ref_after.insert(b.end - 1, b.name.as_str());
1409        }
1410    }
1411    let cross = line.boundary_of(index - 1) != line.boundary_of(index);
1412
1413    let mut lines: Vec<String> = Vec::new();
1414    for (i, c) in commits.iter().enumerate() {
1415        let verb = if i == index { "squash" } else { "pick" };
1416        lines.push(format!("{verb} {} {}", c.sha, c.subject));
1417        if let Some(name) = ref_after.get(&i) {
1418            // Defer the older branch's ref past the squash so it lands there.
1419            if !(cross && i == index - 1) {
1420                lines.push(format!("update-ref refs/heads/{name}"));
1421            }
1422        }
1423        if cross && i == index {
1424            if let Some(name) = ref_after.get(&(index - 1)) {
1425                lines.push(format!("update-ref refs/heads/{name}"));
1426            }
1427        }
1428    }
1429    lines.join("\n") + "\n"
1430}
1431
1432/// A commit's message with its `Stable-Commit-Id` trailer lines removed and
1433/// surrounding whitespace trimmed — the human-authored description.
1434fn message_body(sha: &str) -> Result<String> {
1435    let prefix = format!("{}:", ident::TRAILER);
1436    let msg = git::commit_message(sha)?;
1437    Ok(msg
1438        .lines()
1439        .filter(|l| !l.trim_start().starts_with(&prefix))
1440        .collect::<Vec<_>>()
1441        .join("\n")
1442        .trim()
1443        .to_string())
1444}
1445
1446/// Combine two descriptions for a squash: the non-empty one when only one has
1447/// text, else both with a blank line between.
1448fn combine_bodies(older: &str, newer: &str) -> String {
1449    match (older.trim(), newer.trim()) {
1450        ("", n) => n.to_string(),
1451        (o, "") => o.to_string(),
1452        (o, n) => format!("{o}\n\n{n}"),
1453    }
1454}
1455
1456/// The final reword message: the user's text with any `Stable-Commit-Id` lines
1457/// stripped, then the original id re-appended as the sole trailer — so the
1458/// change keeps its identity no matter how the user edited the pane. A commit
1459/// that had no id keeps none.
1460fn with_preserved_id(new_text: &str, id: Option<&str>) -> String {
1461    let prefix = format!("{}:", ident::TRAILER);
1462    let body = new_text
1463        .lines()
1464        .filter(|l| !l.trim_start().starts_with(&prefix))
1465        .collect::<Vec<_>>()
1466        .join("\n");
1467    let body = body.trim_end();
1468    match id {
1469        Some(id) => format!("{body}\n\n{}: {id}\n", ident::TRAILER),
1470        None => format!("{body}\n"),
1471    }
1472}
1473
1474/// The queue name a set of line branches belongs to: an explicit `queueName`
1475/// membership wins, else a `queue/<name>/…` prefix. `None` for a plain,
1476/// unnamed (e.g. provisional untracked) line.
1477fn branch_queue_name(branches: &[String]) -> Option<String> {
1478    for b in branches {
1479        if let Some(n) = meta::branch_queue(b) {
1480            return Some(n);
1481        }
1482    }
1483    for b in branches {
1484        if let Some(rest) = b.strip_prefix("queue/") {
1485            if let Some((n, _)) = rest.split_once('/') {
1486                return Some(n.to_string());
1487            }
1488        }
1489    }
1490    None
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495    #![allow(clippy::unwrap_used, clippy::expect_used)]
1496    use super::*;
1497
1498    fn commit(subject: &str) -> Commit {
1499        Commit {
1500            sha: subject.into(),
1501            id: None,
1502            subject: subject.into(),
1503            empty: false,
1504        }
1505    }
1506
1507    /// Two branches: `a` owns commits 0,1; `b` owns commit 2.
1508    fn sample() -> EditableLine {
1509        EditableLine {
1510            base: "main".into(),
1511            commits: vec![commit("c0"), commit("c1"), commit("c2")],
1512            boundaries: vec![
1513                Boundary {
1514                    name: "a".into(),
1515                    end: 2,
1516                },
1517                Boundary {
1518                    name: "b".into(),
1519                    end: 3,
1520                },
1521            ],
1522        }
1523    }
1524
1525    #[test]
1526    fn rows_interleave_branch_headers_front_to_tip() {
1527        let line = sample();
1528        assert_eq!(
1529            line.rows(),
1530            vec![
1531                Row::Branch { boundary: 0 },
1532                Row::Commit { index: 0 },
1533                Row::Commit { index: 1 },
1534                Row::Branch { boundary: 1 },
1535                Row::Commit { index: 2 },
1536            ]
1537        );
1538    }
1539
1540    #[test]
1541    fn boundary_of_maps_commits_to_their_branch() {
1542        let line = sample();
1543        assert_eq!(line.boundary_of(0), 0);
1544        assert_eq!(line.boundary_of(1), 0);
1545        assert_eq!(line.boundary_of(2), 1);
1546    }
1547
1548    #[test]
1549    fn reorder_todo_moves_a_pick_across_a_boundary_update_ref() {
1550        // a owns c0,c1 (update-ref after c1); b (leaf) owns c2.
1551        // Moving c1 (index 1) to index 2 relocates only its pick, leaving
1552        // `update-ref a` after c0 — so a shrinks to [c0] and c1 joins b.
1553        let todo = reorder_todo(&sample(), 1, 2);
1554        assert_eq!(
1555            todo,
1556            "pick c0 c0\nupdate-ref refs/heads/a\npick c2 c2\npick c1 c1\n"
1557        );
1558    }
1559
1560    #[test]
1561    fn reorder_todo_to_the_front_puts_the_pick_first() {
1562        // Move the leaf's commit (index 2) to the front.
1563        let todo = reorder_todo(&sample(), 2, 0);
1564        assert_eq!(
1565            todo,
1566            "pick c2 c2\npick c0 c0\npick c1 c1\nupdate-ref refs/heads/a\n"
1567        );
1568    }
1569
1570    #[test]
1571    fn drop_todo_omits_the_dropped_pick_and_keeps_update_refs() {
1572        // Dropping c1 (a's tip) leaves `update-ref a` after c0 — a shrinks.
1573        let mut drop = HashSet::new();
1574        drop.insert(1usize);
1575        let todo = drop_todo(&sample(), &drop);
1576        assert_eq!(todo, "pick c0 c0\nupdate-ref refs/heads/a\npick c2 c2\n");
1577    }
1578
1579    #[test]
1580    fn squash_todo_same_branch_folds_into_the_previous_pick() {
1581        // Squash c1 into c0 (both in a): a's update-ref stays after the squash.
1582        let todo = squash_todo(&sample(), 1);
1583        assert_eq!(
1584            todo,
1585            "pick c0 c0\nsquash c1 c1\nupdate-ref refs/heads/a\npick c2 c2\n"
1586        );
1587    }
1588
1589    #[test]
1590    fn squash_todo_across_a_boundary_defers_the_older_ref() {
1591        // Squash c2 (b) into c1 (a's tip): a's update-ref moves past the squash
1592        // so a captures the combined commit; b (leaf) ends up empty.
1593        let todo = squash_todo(&sample(), 2);
1594        assert_eq!(
1595            todo,
1596            "pick c0 c0\npick c1 c1\nsquash c2 c2\nupdate-ref refs/heads/a\n"
1597        );
1598    }
1599
1600    #[test]
1601    fn combine_bodies_picks_the_nonempty_side_or_joins() {
1602        assert_eq!(combine_bodies("older", "newer"), "older\n\nnewer");
1603        assert_eq!(combine_bodies("", "newer"), "newer");
1604        assert_eq!(combine_bodies("older", ""), "older");
1605    }
1606
1607    #[test]
1608    fn parse_diff_numbers_selectable_lines() {
1609        let diff = [
1610            "diff --git a/f.txt b/f.txt",
1611            "--- a/f.txt",
1612            "+++ b/f.txt",
1613            "@@ -1,2 +1,2 @@",
1614            "-old",
1615            "+new",
1616            " ctx",
1617        ]
1618        .join("\n")
1619            + "\n";
1620        let files = parse_diff(&diff);
1621        assert_eq!(files.len(), 1);
1622        assert_eq!(files[0].path, "f.txt");
1623        let lines = &files[0].hunks[0].lines;
1624        assert_eq!(lines[0].kind, LineKind::Removed);
1625        assert_eq!(lines[0].change_index, Some(0));
1626        assert_eq!(lines[1].kind, LineKind::Added);
1627        assert_eq!(lines[1].change_index, Some(1));
1628        assert_eq!(lines[2].kind, LineKind::Context);
1629        assert_eq!(lines[2].change_index, None);
1630    }
1631
1632    #[test]
1633    fn reconstruct_middle_reverts_only_selected_changes() {
1634        let file = FileDiff {
1635            path: "f".into(),
1636            binary: false,
1637            hunks: vec![Hunk {
1638                v_start: 0,
1639                lines: vec![
1640                    HunkLine {
1641                        kind: LineKind::Added,
1642                        text: "A".into(),
1643                        change_index: Some(0),
1644                    },
1645                    HunkLine {
1646                        kind: LineKind::Added,
1647                        text: "B".into(),
1648                        change_index: Some(1),
1649                    },
1650                ],
1651            }],
1652        };
1653        // Peel B -> the middle keeps only A.
1654        assert_eq!(
1655            reconstruct_middle(&file, "A\nB\n", &HashSet::from([1])),
1656            Some("A\n".into())
1657        );
1658        // Peel A -> the middle keeps only B.
1659        assert_eq!(
1660            reconstruct_middle(&file, "A\nB\n", &HashSet::from([0])),
1661            Some("B\n".into())
1662        );
1663    }
1664
1665    #[test]
1666    fn reword_todo_marks_the_target_and_keeps_the_rest() {
1667        let todo = reword_todo(&sample(), 0);
1668        assert_eq!(
1669            todo,
1670            "reword c0 c0\npick c1 c1\nupdate-ref refs/heads/a\npick c2 c2\n"
1671        );
1672    }
1673
1674    #[test]
1675    fn with_preserved_id_reattaches_the_original_trailer() {
1676        // A fresh message gains the original id.
1677        assert_eq!(
1678            with_preserved_id("hello world", Some("q-abc")),
1679            "hello world\n\nStable-Commit-Id: q-abc\n"
1680        );
1681        // Any id line the user's text carried is replaced by the original.
1682        assert_eq!(
1683            with_preserved_id("subject\n\nStable-Commit-Id: q-typo", Some("q-abc")),
1684            "subject\n\nStable-Commit-Id: q-abc\n"
1685        );
1686        // An unstamped commit stays unstamped.
1687        assert_eq!(with_preserved_id("just text", None), "just text\n");
1688    }
1689}