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