use crate::git;
use crate::ident;
use crate::meta;
use crate::queue::Queue;
use anyhow::{bail, Result};
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
pub sha: String,
pub id: Option<String>,
pub subject: String,
pub empty: bool,
}
impl Commit {
pub fn described(&self) -> bool {
!self.subject.trim().is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Boundary {
pub name: String,
pub end: usize,
}
#[derive(Debug, Clone)]
pub struct EditableLine {
pub base: String,
pub commits: Vec<Commit>,
pub boundaries: Vec<Boundary>,
}
impl EditableLine {
#[allow(clippy::unwrap_used)] pub(crate) fn tip_branch(&self) -> &str {
&self.boundaries.last().unwrap().name
}
pub fn commits_of(&self, index: usize) -> &[Commit] {
let start = if index == 0 {
0
} else {
self.boundaries[index - 1].end
};
&self.commits[start..self.boundaries[index].end]
}
pub fn boundary_of(&self, commit_index: usize) -> usize {
self.boundaries
.iter()
.position(|b| commit_index < b.end)
.unwrap_or_else(|| self.boundaries.len().saturating_sub(1))
}
pub fn rows(&self) -> Vec<Row> {
let mut rows = Vec::new();
for (b, boundary) in self.boundaries.iter().enumerate() {
rows.push(Row::Branch { boundary: b });
let start = if b == 0 {
0
} else {
self.boundaries[b - 1].end
};
for index in start..boundary.end {
rows.push(Row::Commit { index });
}
}
rows
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Row {
Branch { boundary: usize },
Commit { index: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitKind {
Meta,
Context,
Added,
Removed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SplitLine {
pub kind: SplitKind,
pub text: String,
pub change_index: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum LineKind {
Context,
Added,
Removed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct HunkLine {
kind: LineKind,
text: String,
change_index: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Hunk {
v_start: usize,
lines: Vec<HunkLine>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FileDiff {
path: String,
hunks: Vec<Hunk>,
binary: bool,
}
fn parse_diff(diff: &str) -> Vec<FileDiff> {
let mut files: Vec<FileDiff> = Vec::new();
let mut change_counter = 0usize;
for raw in diff.lines() {
if let Some(rest) = raw.strip_prefix("diff --git ") {
let path = rest
.split_once(" b/")
.map_or_else(|| rest.to_string(), |(_, b)| b.to_string());
files.push(FileDiff {
path,
hunks: Vec::new(),
binary: false,
});
} else if raw.starts_with("Binary files") {
if let Some(f) = files.last_mut() {
f.binary = true;
}
} else if let Some(rest) = raw.strip_prefix("@@") {
let v_start = rest
.split_once('+')
.and_then(|(_, r)| r.split([',', ' ']).next())
.and_then(|n| n.parse::<usize>().ok())
.map_or(0, |n| n.saturating_sub(1));
if let Some(f) = files.last_mut() {
f.hunks.push(Hunk {
v_start,
lines: Vec::new(),
});
}
} else if raw.starts_with("+++") || raw.starts_with("---") {
} else if let Some(f) = files.last_mut() {
let Some(hunk) = f.hunks.last_mut() else {
continue;
};
let (kind, text) = if let Some(t) = raw.strip_prefix('+') {
(LineKind::Added, t)
} else if let Some(t) = raw.strip_prefix('-') {
(LineKind::Removed, t)
} else if let Some(t) = raw.strip_prefix(' ') {
(LineKind::Context, t)
} else {
continue; };
let change_index = if kind == LineKind::Context {
None
} else {
let i = change_counter;
change_counter += 1;
Some(i)
};
hunk.lines.push(HunkLine {
kind,
text: text.to_string(),
change_index,
});
}
}
files
}
fn reconstruct_middle(
file: &FileDiff,
v_content: &str,
selected: &HashSet<usize>,
) -> Option<String> {
if file.binary {
return None; }
let mut v_lines: Vec<String> = v_content.lines().map(str::to_string).collect();
for hunk in file.hunks.iter().rev() {
let v_len = hunk
.lines
.iter()
.filter(|l| matches!(l.kind, LineKind::Context | LineKind::Added))
.count();
let mut region: Vec<String> = Vec::new();
for l in &hunk.lines {
let selected_line = l.change_index.is_some_and(|i| selected.contains(&i));
let keep = match l.kind {
LineKind::Context => true,
LineKind::Removed => selected_line,
LineKind::Added => !selected_line,
};
if keep {
region.push(l.text.clone());
}
}
let end = (hunk.v_start + v_len).min(v_lines.len());
let start = hunk.v_start.min(v_lines.len());
v_lines.splice(start..end, region);
}
let mut result = v_lines.join("\n");
if v_content.ends_with('\n') && !result.is_empty() {
result.push('\n');
}
Some(result)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Operation {
Reorder { from: usize, to: usize },
Squash {
index: usize,
message: Option<String>,
},
Split {
index: usize,
selected: HashSet<usize>,
message: String,
},
Reword { index: usize, message: String },
Delete { index: usize },
AddBoundary { index: usize, name: String },
RemoveBoundary { boundary: usize },
MoveBoundary { boundary: usize, delta: isize },
RenameBranch { boundary: usize, name: String },
Undo,
Redo,
}
#[derive(Debug)]
pub struct Engine {
line: EditableLine,
qname: String,
namespaced: bool,
current: String,
launched_from: String,
original: Vec<(String, Option<u64>)>,
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
touched: bool,
pending_conflict: Option<Snapshot>,
pr_cache: Vec<Option<u64>>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Applied {
Done,
Conflict,
}
#[derive(Debug, Clone)]
struct BranchSnap {
name: String,
sha: String,
parent: String,
parent_sha: Option<String>,
queue: Option<String>,
pr: Option<u64>,
description: Option<String>,
}
#[derive(Debug, Clone)]
struct Snapshot {
branches: Vec<BranchSnap>,
head: String,
}
impl Engine {
pub fn load() -> Result<Self> {
git::ensure_repo()?;
if git::rebase_in_progress() {
bail!(
"a rebase is in progress — finish resolving it (`git status`, then \
`git rebase --continue` or `--abort`), then re-run `git queue tui`"
);
}
if !git::worktree_clean() {
bail!(
"working tree has uncommitted changes; commit or stash them before \
opening the queue in the TUI"
);
}
let queue = Queue::load()?;
let branch = git::current_branch()?;
let (branches, base) = Self::scope(&queue, &branch)?;
let line = Self::build_line(base, &branches)?;
if line.commits.is_empty() {
bail!("the queue has no commits to edit");
}
let qname = branch_queue_name(&branches).unwrap_or_else(|| branch.replace('/', "-"));
let namespaced = branches.iter().any(|b| b.starts_with("queue/"));
let original: Vec<(String, Option<u64>)> =
branches.iter().map(|b| (b.clone(), meta::pr(b))).collect();
let pr_cache = line.boundaries.iter().map(|b| meta::pr(&b.name)).collect();
Ok(Self {
line,
qname,
namespaced,
current: branch.clone(),
launched_from: branch,
original,
undo: Vec::new(),
redo: Vec::new(),
touched: false,
pending_conflict: None,
pr_cache,
})
}
fn scope(queue: &Queue, branch: &str) -> Result<(Vec<String>, String)> {
if queue.is_tracked(branch) {
let line = queue.line_through(branch)?;
if line.branches.iter().any(|b| queue.children(b).len() > 1) {
bail!(
"`{branch}` is on a forked queue line; the TUI cannot rewrite history \
shared with a sibling line. Use `git queue edit` for ref-only \
boundary changes, or checkout a single unforked line first"
);
}
Ok((line.branches, line.base))
} else {
Ok((vec![branch.to_string()], queue.trunk.clone()))
}
}
fn build_line(base: String, branches: &[String]) -> Result<EditableLine> {
let mut commits = Vec::new();
let mut boundaries = Vec::new();
let mut parent = base.clone();
for b in branches {
for (sha, id, subject) in git::commits_between_with_ids(&parent, b)? {
let empty = git::commit_is_empty(&sha);
commits.push(Commit {
sha,
id,
subject,
empty,
});
}
boundaries.push(Boundary {
name: b.clone(),
end: commits.len(),
});
parent = b.clone();
}
Ok(EditableLine {
base,
commits,
boundaries,
})
}
pub const fn line(&self) -> &EditableLine {
&self.line
}
pub fn launched_from(&self) -> &str {
&self.launched_from
}
pub fn landing_branch(&self) -> &str {
&self.current
}
pub fn queue_name(&self) -> &str {
&self.qname
}
pub const fn has_pending(&self) -> bool {
!self.undo.is_empty()
}
pub const fn changed(&self) -> bool {
self.touched
}
pub fn layout(&self) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut parent = self.line.base.clone();
for b in &self.line.boundaries {
out.push((parent.clone(), b.name.clone()));
parent = b.name.clone();
}
out
}
pub fn dissolved_with_prs(&self) -> Vec<(String, u64)> {
let present: HashSet<&str> = self
.line
.boundaries
.iter()
.map(|b| b.name.as_str())
.collect();
self.original
.iter()
.filter(|(name, _)| !present.contains(name.as_str()))
.filter_map(|(name, pr)| pr.map(|n| (name.clone(), n)))
.collect()
}
pub fn apply(&mut self, op: Operation) -> Result<Applied> {
if self.pending_conflict.is_some() {
bail!("a conflict is pending; resolve it in your shell or undo it first");
}
match op {
Operation::RenameBranch { boundary, name } => {
self.rename_branch(boundary, &name)?;
Ok(Applied::Done)
}
Operation::AddBoundary { index, name } => {
self.add_boundary(index, &name)?;
Ok(Applied::Done)
}
Operation::RemoveBoundary { boundary } => {
self.remove_boundary(boundary)?;
Ok(Applied::Done)
}
Operation::MoveBoundary { boundary, delta } => {
self.move_boundary(boundary, delta)?;
Ok(Applied::Done)
}
Operation::Reorder { from, to } => self.reorder(from, to),
Operation::Reword { index, message } => {
self.reword(index, &message)?;
Ok(Applied::Done)
}
Operation::Delete { index } => self.delete(index),
Operation::Squash { index, message } => self.squash(index, message.as_deref()),
Operation::Split {
index,
selected,
message,
} => self.split(index, &selected, &message),
Operation::Undo => {
self.undo()?;
Ok(Applied::Done)
}
Operation::Redo => {
self.redo()?;
Ok(Applied::Done)
}
}
}
pub const fn conflicted(&self) -> bool {
self.pending_conflict.is_some()
}
pub fn undo_conflict(&mut self) -> Result<()> {
let Some(snap) = self.pending_conflict.take() else {
bail!("no conflict to undo");
};
if git::rebase_in_progress() {
git::rebase_abort()?;
}
self.restore(&snap)?;
Ok(())
}
fn reorder(&mut self, from: usize, to: usize) -> Result<Applied> {
let n = self.line.commits.len();
if from >= n || to >= n {
bail!("reorder index out of range");
}
if from == to {
return Ok(Applied::Done);
}
let todo = reorder_todo(&self.line, from, to);
let snap = self.snapshot()?;
let base = self.line.base.clone();
let top = self.line.tip_branch().to_string();
let land = self.current.clone();
match git::rebase_with_todo_stop(&base, &top, &todo)? {
git::Rewrite::Clean => {
self.finish_rewrite(&land)?;
self.undo.push(snap);
self.redo.clear();
self.touched = true;
self.auto_drop_empties(&land)?;
Ok(Applied::Done)
}
git::Rewrite::Conflict => {
self.pending_conflict = Some(snap);
self.touched = true;
Ok(Applied::Conflict)
}
}
}
fn delete(&mut self, index: usize) -> Result<Applied> {
let n = self.line.commits.len();
if index >= n {
bail!("delete index out of range");
}
if n == 1 {
bail!("cannot delete the queue's only commit");
}
let mut drop = HashSet::new();
drop.insert(index);
let todo = drop_todo(&self.line, &drop);
let snap = self.snapshot()?;
let base = self.line.base.clone();
let top = self.line.tip_branch().to_string();
let land = self.current.clone();
match git::rebase_with_todo_stop(&base, &top, &todo)? {
git::Rewrite::Clean => {
self.finish_rewrite(&land)?;
self.undo.push(snap);
self.redo.clear();
self.touched = true;
self.auto_drop_empties(&land)?;
Ok(Applied::Done)
}
git::Rewrite::Conflict => {
self.pending_conflict = Some(snap);
self.touched = true;
Ok(Applied::Conflict)
}
}
}
fn auto_drop_empties(&mut self, land: &str) -> Result<()> {
for _ in 0..64 {
let drop: HashSet<usize> = self
.line
.commits
.iter()
.enumerate()
.filter(|(_, c)| c.empty && !c.described())
.map(|(i, _)| i)
.collect();
if drop.is_empty() {
return Ok(());
}
let todo = drop_todo(&self.line, &drop);
let base = self.line.base.clone();
let top = self.line.tip_branch().to_string();
match git::rebase_with_todo_stop(&base, &top, &todo)? {
git::Rewrite::Clean => self.finish_rewrite(land)?,
git::Rewrite::Conflict => {
git::rebase_abort()?;
bail!("auto-dropping empty commits unexpectedly conflicted");
}
}
}
Ok(())
}
pub fn empty_branches(&self) -> Vec<usize> {
(0..self.line.boundaries.len())
.filter(|&i| self.line.commits_of(i).is_empty())
.collect()
}
pub fn pr_of(&self, boundary: usize) -> Option<u64> {
self.pr_cache.get(boundary).copied().flatten()
}
pub fn branch_name(&self, boundary: usize) -> &str {
&self.line.boundaries[boundary].name
}
pub fn split_lines(&self, index: usize) -> Result<Vec<SplitLine>> {
if index >= self.line.commits.len() {
bail!("split index out of range");
}
let diff = git::commit_diff(&self.line.commits[index].sha)?;
let mut out = Vec::new();
for f in parse_diff(&diff) {
out.push(SplitLine {
kind: SplitKind::Meta,
text: format!("── {} ──", f.path),
change_index: None,
});
if f.binary {
out.push(SplitLine {
kind: SplitKind::Meta,
text: "(binary — peeled into the new commit)".into(),
change_index: None,
});
continue;
}
for h in &f.hunks {
out.push(SplitLine {
kind: SplitKind::Meta,
text: "@@".into(),
change_index: None,
});
for l in &h.lines {
let (kind, prefix) = match l.kind {
LineKind::Added => (SplitKind::Added, '+'),
LineKind::Removed => (SplitKind::Removed, '-'),
LineKind::Context => (SplitKind::Context, ' '),
};
out.push(SplitLine {
kind,
text: format!("{prefix}{}", l.text),
change_index: l.change_index,
});
}
}
}
Ok(out)
}
pub fn split_change_count(&self, index: usize) -> Result<usize> {
let diff = git::commit_diff(&self.line.commits[index].sha)?;
Ok(parse_diff(&diff)
.iter()
.flat_map(|f| &f.hunks)
.flat_map(|h| &h.lines)
.filter(|l| l.change_index.is_some())
.count())
}
fn split(&mut self, index: usize, selected: &HashSet<usize>, message: &str) -> Result<Applied> {
if index >= self.line.commits.len() {
bail!("split index out of range");
}
let total = self.split_change_count(index)?;
if selected.is_empty() || selected.len() >= total {
bail!("select some — but not all — lines to peel into the new commit");
}
let c_sha = self.line.commits[index].sha.clone();
let parent_sha = if index == 0 {
git::rev_parse(&self.line.base)?
} else {
self.line.commits[index - 1].sha.clone()
};
let diff = git::commit_diff(&c_sha)?;
let files = parse_diff(&diff);
let parent_tree = git::tree_of(&parent_sha)?;
let mut changes: Vec<(String, Option<String>)> = Vec::new();
for f in &files {
let v = git::file_at(&c_sha, &f.path);
let Some(m) = reconstruct_middle(f, &v, selected) else {
continue; };
let parent_has = !git::file_at(&parent_sha, &f.path).is_empty();
if m.is_empty() {
if parent_has {
changes.push((f.path.clone(), None)); }
} else {
changes.push((f.path.clone(), Some(m)));
}
}
let m_tree = git::build_tree(&parent_tree, &changes)?;
let older = git::commit_tree(&m_tree, &parent_sha, &git::commit_message(&c_sha)?)?;
let newer_msg = with_preserved_id(message, Some(&ident::new_id()));
let newer = git::commit_tree(&git::tree_of(&c_sha)?, &older, &newer_msg)?;
let snap = self.snapshot()?;
let mut mapping: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut prev = newer.clone();
for c in &self.line.commits[index + 1..] {
prev = git::cherry_pick_onto(&prev, &c.sha)?;
mapping.insert(c.sha.clone(), prev.clone());
}
let boundaries = self.line.boundaries.clone();
let mut parent = self.line.base.clone();
for b in &boundaries {
let tip_old = &self.line.commits[b.end - 1].sha;
let new_tip = if *tip_old == c_sha {
newer.clone()
} else if let Some(n) = mapping.get(tip_old) {
n.clone()
} else {
tip_old.clone()
};
git::force_ref(&b.name, &new_tip)?;
meta::set_parent(&b.name, &parent)?;
meta::set_parent_sha(&b.name, &git::rev_parse(&parent)?)?;
parent = b.name.clone();
}
let land = self.current.clone();
git::checkout_quiet(&land)?;
git::reset_hard_head()?;
self.reload()?;
self.undo.push(snap);
self.redo.clear();
self.touched = true;
self.auto_drop_empties(&land)?;
Ok(Applied::Done)
}
pub fn squash_needs_message(&self, index: usize) -> Result<bool> {
if index == 0 || index >= self.line.commits.len() {
bail!("no older commit to squash into");
}
let older = message_body(&self.line.commits[index - 1].sha)?;
let newer = message_body(&self.line.commits[index].sha)?;
Ok(!older.trim().is_empty() && !newer.trim().is_empty())
}
pub fn squash_default_message(&self, index: usize) -> Result<String> {
if index == 0 || index >= self.line.commits.len() {
bail!("no older commit to squash into");
}
Ok(combine_bodies(
&message_body(&self.line.commits[index - 1].sha)?,
&message_body(&self.line.commits[index].sha)?,
))
}
fn squash(&mut self, index: usize, message: Option<&str>) -> Result<Applied> {
if index == 0 {
bail!("the front commit has no older neighbour to squash into");
}
if index >= self.line.commits.len() {
bail!("squash index out of range");
}
let body = match message {
Some(m) => m.to_string(),
None => combine_bodies(
&message_body(&self.line.commits[index - 1].sha)?,
&message_body(&self.line.commits[index].sha)?,
),
};
let final_message = with_preserved_id(&body, self.line.commits[index - 1].id.as_deref());
let todo = squash_todo(&self.line, index);
let snap = self.snapshot()?;
let base = self.line.base.clone();
let top = self.line.tip_branch().to_string();
let land = self.current.clone();
match git::rebase_squash_stop(&base, &top, &todo, &final_message)? {
git::Rewrite::Clean => {
self.finish_rewrite(&land)?;
self.undo.push(snap);
self.redo.clear();
self.touched = true;
self.auto_drop_empties(&land)?;
Ok(Applied::Done)
}
git::Rewrite::Conflict => {
self.pending_conflict = Some(snap);
self.touched = true;
Ok(Applied::Conflict)
}
}
}
fn reword(&mut self, index: usize, new_text: &str) -> Result<()> {
if index >= self.line.commits.len() {
bail!("reword index out of range");
}
let message = with_preserved_id(new_text, self.line.commits[index].id.as_deref());
let todo = reword_todo(&self.line, index);
let snap = self.snapshot()?;
let base = self.line.base.clone();
let top = self.line.tip_branch().to_string();
let land = self.current.clone();
git::rebase_with_todo_message(&base, &top, &todo, &message)?;
self.finish_rewrite(&land)?;
self.undo.push(snap);
self.redo.clear();
self.touched = true;
Ok(())
}
fn finish_rewrite(&mut self, land: &str) -> Result<()> {
let names: Vec<String> = self
.line
.boundaries
.iter()
.map(|b| b.name.clone())
.collect();
let mut parent = self.line.base.clone();
for b in &names {
meta::set_parent_sha(b, &git::rev_parse(&parent)?)?;
parent = b.clone();
}
git::checkout_quiet(land)?;
git::reset_hard_head()?;
self.reload()?;
Ok(())
}
fn rename_branch(&mut self, boundary: usize, new_name: &str) -> Result<()> {
let resolved = self.resolve_name(new_name);
let old = self.line.boundaries[boundary].name.clone();
if resolved == old {
return Ok(());
}
if git::branch_exists(&resolved) {
bail!("branch `{resolved}` already exists; pick a different name");
}
let mut segs = self.line.boundaries.clone();
segs[boundary].name = resolved.clone();
let land = if self.current == old {
resolved
} else {
self.current.clone()
};
self.commit_op(segs, land)
}
fn add_boundary(&mut self, index: usize, name: &str) -> Result<()> {
let resolved = self.resolve_name(name);
if git::branch_exists(&resolved) {
bail!("branch `{resolved}` already exists; pick a different name");
}
let b = self.line.boundary_of(index);
let start = if b == 0 {
0
} else {
self.line.boundaries[b - 1].end
};
if index == start {
bail!(
"`{}` already starts at that commit; highlight a later commit to \
split off a new branch",
self.line.boundaries[b].name
);
}
let mut segs = self.line.boundaries.clone();
let end = segs[b].end;
segs[b].end = index;
segs.insert(
b + 1,
Boundary {
name: resolved,
end,
},
);
let land = self.current.clone();
self.commit_op(segs, land)
}
fn remove_boundary(&mut self, boundary: usize) -> Result<()> {
if self.line.boundaries.len() < 2 {
bail!("cannot dissolve the only branch of the line");
}
let removed = self.line.boundaries[boundary].name.clone();
let is_tip = boundary == self.line.boundaries.len() - 1;
let survivor = if is_tip {
self.line.boundaries[boundary - 1].name.clone()
} else {
self.line.boundaries[boundary + 1].name.clone()
};
let mut segs = self.line.boundaries.clone();
segs.remove(boundary);
if let Some(last) = segs.last_mut() {
last.end = self.line.commits.len();
}
let land = if self.current == removed {
survivor
} else {
self.current.clone()
};
self.commit_op(segs, land)
}
fn move_boundary(&mut self, boundary: usize, delta: isize) -> Result<()> {
if boundary + 1 >= self.line.boundaries.len() {
bail!("the last branch's boundary is fixed at the tip");
}
let lower = if boundary == 0 {
0
} else {
self.line.boundaries[boundary - 1].end as isize
};
let upper = self.line.boundaries[boundary + 1].end as isize;
let new_end = self.line.boundaries[boundary].end as isize + delta;
if new_end <= lower || new_end >= upper {
bail!("that shift would empty a branch");
}
let mut segs = self.line.boundaries.clone();
segs[boundary].end = new_end as usize;
let land = self.current.clone();
self.commit_op(segs, land)
}
fn undo(&mut self) -> Result<()> {
let Some(snap) = self.undo.pop() else {
bail!("nothing to undo");
};
let redo = self.snapshot()?;
self.restore(&snap)?;
self.redo.push(redo);
Ok(())
}
fn redo(&mut self) -> Result<()> {
let Some(snap) = self.redo.pop() else {
bail!("nothing to redo");
};
let undo = self.snapshot()?;
self.restore(&snap)?;
self.undo.push(undo);
Ok(())
}
fn resolve_name(&self, n: &str) -> String {
if n.contains('/') || self.line.boundaries.iter().any(|b| b.name == n) || !self.namespaced {
n.to_string()
} else {
format!("queue/{}/{n}", self.qname)
}
}
fn commit_op(&mut self, segments: Vec<Boundary>, land: String) -> Result<()> {
let snap = self.snapshot()?;
self.materialise(&segments)?;
git::checkout_quiet(&land)?;
self.reload()?;
self.undo.push(snap);
self.redo.clear();
self.touched = true;
Ok(())
}
fn materialise(&self, segments: &[Boundary]) -> Result<()> {
git::detach_head()?;
let target: HashSet<&str> = segments.iter().map(|s| s.name.as_str()).collect();
let mut parent = self.line.base.clone();
for s in segments {
let tip_sha = self.line.commits[s.end - 1].sha.clone();
if git::branch_exists(&s.name) {
git::force_ref(&s.name, &tip_sha)?;
} else {
git::create_branch(&s.name, &tip_sha)?;
}
meta::set_parent(&s.name, &parent)?;
meta::set_parent_sha(&s.name, &git::rev_parse(&parent)?)?;
meta::set_branch_queue(&s.name, &self.qname)?;
parent = s.name.clone();
}
for old in self.line.boundaries.iter().map(|b| b.name.clone()) {
if !target.contains(old.as_str()) {
meta::untrack(&old);
git::run(&["branch", "-q", "-D", &old])?;
}
}
meta::touch_queue(&self.qname);
Ok(())
}
fn snapshot(&self) -> Result<Snapshot> {
let mut branches = Vec::new();
for b in self.line.boundaries.iter().map(|x| &x.name) {
branches.push(BranchSnap {
name: b.clone(),
sha: git::rev_parse(b)?,
parent: meta::parent(b).unwrap_or_else(|| self.line.base.clone()),
parent_sha: meta::parent_sha(b),
queue: meta::branch_queue(b),
pr: meta::pr(b),
description: meta::description(b),
});
}
Ok(Snapshot {
branches,
head: self.current.clone(),
})
}
fn restore(&mut self, snap: &Snapshot) -> Result<()> {
git::detach_head()?;
let keep: HashSet<&str> = snap.branches.iter().map(|b| b.name.as_str()).collect();
for b in self.line.boundaries.iter().map(|x| x.name.clone()) {
if !keep.contains(b.as_str()) {
meta::untrack(&b);
git::run(&["branch", "-q", "-D", &b])?;
}
}
for bs in &snap.branches {
if git::branch_exists(&bs.name) {
git::force_ref(&bs.name, &bs.sha)?;
} else {
git::create_branch(&bs.name, &bs.sha)?;
}
meta::set_parent(&bs.name, &bs.parent)?;
if let Some(s) = &bs.parent_sha {
meta::set_parent_sha(&bs.name, s)?;
}
if let Some(q) = &bs.queue {
meta::set_branch_queue(&bs.name, q)?;
}
if let Some(pr) = bs.pr {
meta::set_pr(&bs.name, pr)?;
}
if let Some(d) = &bs.description {
meta::set_description(&bs.name, d)?;
}
}
git::checkout_quiet(&snap.head)?;
self.reload()?;
Ok(())
}
fn reload(&mut self) -> Result<()> {
let queue = Queue::load()?;
let branch = git::current_branch()?;
let (branches, base) = Self::scope(&queue, &branch)?;
self.line = Self::build_line(base, &branches)?;
self.pr_cache = self
.line
.boundaries
.iter()
.map(|b| meta::pr(&b.name))
.collect();
self.current = branch;
Ok(())
}
}
fn reorder_todo(line: &EditableLine, from: usize, to: usize) -> String {
let commits = &line.commits;
let pick = |i: usize| format!("pick {} {}", commits[i].sha, commits[i].subject);
let last = line.boundaries.len() - 1;
let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
for (bi, b) in line.boundaries.iter().enumerate() {
if bi != last {
ref_after.insert(b.end - 1, b.name.as_str());
}
}
let mut lines: Vec<String> = Vec::new();
for i in 0..commits.len() {
lines.push(pick(i));
if let Some(name) = ref_after.get(&i) {
lines.push(format!("update-ref refs/heads/{name}"));
}
}
let moved_line = pick(from);
lines.retain(|l| l != &moved_line);
let mut order: Vec<usize> = (0..commits.len()).collect();
let m = order.remove(from);
order.insert(to, m);
let idx = if to == 0 {
0
} else {
let anchor = pick(order[to - 1]);
lines.iter().position(|l| *l == anchor).map_or(0, |i| i + 1)
};
lines.insert(idx, moved_line);
lines.join("\n") + "\n"
}
fn reword_todo(line: &EditableLine, index: usize) -> String {
let commits = &line.commits;
let last = line.boundaries.len() - 1;
let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
for (bi, b) in line.boundaries.iter().enumerate() {
if bi != last {
ref_after.insert(b.end - 1, b.name.as_str());
}
}
let mut lines: Vec<String> = Vec::new();
for (i, c) in commits.iter().enumerate() {
let verb = if i == index { "reword" } else { "pick" };
lines.push(format!("{verb} {} {}", c.sha, c.subject));
if let Some(name) = ref_after.get(&i) {
lines.push(format!("update-ref refs/heads/{name}"));
}
}
lines.join("\n") + "\n"
}
fn drop_todo(line: &EditableLine, drop: &HashSet<usize>) -> String {
let commits = &line.commits;
let last = line.boundaries.len() - 1;
let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
for (bi, b) in line.boundaries.iter().enumerate() {
if bi != last {
ref_after.insert(b.end - 1, b.name.as_str());
}
}
let mut lines: Vec<String> = Vec::new();
for (i, c) in commits.iter().enumerate() {
if !drop.contains(&i) {
lines.push(format!("pick {} {}", c.sha, c.subject));
}
if let Some(name) = ref_after.get(&i) {
lines.push(format!("update-ref refs/heads/{name}"));
}
}
lines.join("\n") + "\n"
}
fn squash_todo(line: &EditableLine, index: usize) -> String {
let commits = &line.commits;
let last = line.boundaries.len() - 1;
let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
for (bi, b) in line.boundaries.iter().enumerate() {
if bi != last {
ref_after.insert(b.end - 1, b.name.as_str());
}
}
let cross = line.boundary_of(index - 1) != line.boundary_of(index);
let mut lines: Vec<String> = Vec::new();
for (i, c) in commits.iter().enumerate() {
let verb = if i == index { "squash" } else { "pick" };
lines.push(format!("{verb} {} {}", c.sha, c.subject));
if let Some(name) = ref_after.get(&i) {
if !(cross && i == index - 1) {
lines.push(format!("update-ref refs/heads/{name}"));
}
}
if cross && i == index {
if let Some(name) = ref_after.get(&(index - 1)) {
lines.push(format!("update-ref refs/heads/{name}"));
}
}
}
lines.join("\n") + "\n"
}
fn message_body(sha: &str) -> Result<String> {
let prefix = format!("{}:", ident::TRAILER);
let msg = git::commit_message(sha)?;
Ok(msg
.lines()
.filter(|l| !l.trim_start().starts_with(&prefix))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string())
}
fn combine_bodies(older: &str, newer: &str) -> String {
match (older.trim(), newer.trim()) {
("", n) => n.to_string(),
(o, "") => o.to_string(),
(o, n) => format!("{o}\n\n{n}"),
}
}
fn with_preserved_id(new_text: &str, id: Option<&str>) -> String {
let prefix = format!("{}:", ident::TRAILER);
let body = new_text
.lines()
.filter(|l| !l.trim_start().starts_with(&prefix))
.collect::<Vec<_>>()
.join("\n");
let body = body.trim_end();
match id {
Some(id) => format!("{body}\n\n{}: {id}\n", ident::TRAILER),
None => format!("{body}\n"),
}
}
fn branch_queue_name(branches: &[String]) -> Option<String> {
for b in branches {
if let Some(n) = meta::branch_queue(b) {
return Some(n);
}
}
for b in branches {
if let Some(rest) = b.strip_prefix("queue/") {
if let Some((n, _)) = rest.split_once('/') {
return Some(n.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn commit(subject: &str) -> Commit {
Commit {
sha: subject.into(),
id: None,
subject: subject.into(),
empty: false,
}
}
fn sample() -> EditableLine {
EditableLine {
base: "main".into(),
commits: vec![commit("c0"), commit("c1"), commit("c2")],
boundaries: vec![
Boundary {
name: "a".into(),
end: 2,
},
Boundary {
name: "b".into(),
end: 3,
},
],
}
}
#[test]
fn rows_interleave_branch_headers_front_to_tip() {
let line = sample();
assert_eq!(
line.rows(),
vec![
Row::Branch { boundary: 0 },
Row::Commit { index: 0 },
Row::Commit { index: 1 },
Row::Branch { boundary: 1 },
Row::Commit { index: 2 },
]
);
}
#[test]
fn boundary_of_maps_commits_to_their_branch() {
let line = sample();
assert_eq!(line.boundary_of(0), 0);
assert_eq!(line.boundary_of(1), 0);
assert_eq!(line.boundary_of(2), 1);
}
#[test]
fn reorder_todo_moves_a_pick_across_a_boundary_update_ref() {
let todo = reorder_todo(&sample(), 1, 2);
assert_eq!(
todo,
"pick c0 c0\nupdate-ref refs/heads/a\npick c2 c2\npick c1 c1\n"
);
}
#[test]
fn reorder_todo_to_the_front_puts_the_pick_first() {
let todo = reorder_todo(&sample(), 2, 0);
assert_eq!(
todo,
"pick c2 c2\npick c0 c0\npick c1 c1\nupdate-ref refs/heads/a\n"
);
}
#[test]
fn drop_todo_omits_the_dropped_pick_and_keeps_update_refs() {
let mut drop = HashSet::new();
drop.insert(1usize);
let todo = drop_todo(&sample(), &drop);
assert_eq!(todo, "pick c0 c0\nupdate-ref refs/heads/a\npick c2 c2\n");
}
#[test]
fn squash_todo_same_branch_folds_into_the_previous_pick() {
let todo = squash_todo(&sample(), 1);
assert_eq!(
todo,
"pick c0 c0\nsquash c1 c1\nupdate-ref refs/heads/a\npick c2 c2\n"
);
}
#[test]
fn squash_todo_across_a_boundary_defers_the_older_ref() {
let todo = squash_todo(&sample(), 2);
assert_eq!(
todo,
"pick c0 c0\npick c1 c1\nsquash c2 c2\nupdate-ref refs/heads/a\n"
);
}
#[test]
fn combine_bodies_picks_the_nonempty_side_or_joins() {
assert_eq!(combine_bodies("older", "newer"), "older\n\nnewer");
assert_eq!(combine_bodies("", "newer"), "newer");
assert_eq!(combine_bodies("older", ""), "older");
}
#[test]
fn parse_diff_numbers_selectable_lines() {
let diff = [
"diff --git a/f.txt b/f.txt",
"--- a/f.txt",
"+++ b/f.txt",
"@@ -1,2 +1,2 @@",
"-old",
"+new",
" ctx",
]
.join("\n")
+ "\n";
let files = parse_diff(&diff);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "f.txt");
let lines = &files[0].hunks[0].lines;
assert_eq!(lines[0].kind, LineKind::Removed);
assert_eq!(lines[0].change_index, Some(0));
assert_eq!(lines[1].kind, LineKind::Added);
assert_eq!(lines[1].change_index, Some(1));
assert_eq!(lines[2].kind, LineKind::Context);
assert_eq!(lines[2].change_index, None);
}
#[test]
fn reconstruct_middle_reverts_only_selected_changes() {
let file = FileDiff {
path: "f".into(),
binary: false,
hunks: vec![Hunk {
v_start: 0,
lines: vec![
HunkLine {
kind: LineKind::Added,
text: "A".into(),
change_index: Some(0),
},
HunkLine {
kind: LineKind::Added,
text: "B".into(),
change_index: Some(1),
},
],
}],
};
assert_eq!(
reconstruct_middle(&file, "A\nB\n", &HashSet::from([1])),
Some("A\n".into())
);
assert_eq!(
reconstruct_middle(&file, "A\nB\n", &HashSet::from([0])),
Some("B\n".into())
);
}
#[test]
fn reword_todo_marks_the_target_and_keeps_the_rest() {
let todo = reword_todo(&sample(), 0);
assert_eq!(
todo,
"reword c0 c0\npick c1 c1\nupdate-ref refs/heads/a\npick c2 c2\n"
);
}
#[test]
fn with_preserved_id_reattaches_the_original_trailer() {
assert_eq!(
with_preserved_id("hello world", Some("q-abc")),
"hello world\n\nStable-Commit-Id: q-abc\n"
);
assert_eq!(
with_preserved_id("subject\n\nStable-Commit-Id: q-typo", Some("q-abc")),
"subject\n\nStable-Commit-Id: q-abc\n"
);
assert_eq!(with_preserved_id("just text", None), "just text\n");
}
}