1use crate::git;
18use crate::ident;
19use crate::meta;
20use crate::queue::Queue;
21use anyhow::{bail, Result};
22use std::collections::HashSet;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Commit {
27 pub sha: String,
30 pub id: Option<String>,
35 pub subject: String,
37 pub empty: bool,
40}
41
42impl Commit {
43 pub fn described(&self) -> bool {
46 !self.subject.trim().is_empty()
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Boundary {
58 pub name: String,
60 pub end: usize,
63}
64
65#[derive(Debug, Clone)]
67pub struct EditableLine {
68 pub base: String,
71 pub commits: Vec<Commit>,
73 pub boundaries: Vec<Boundary>,
76}
77
78impl EditableLine {
79 #[allow(clippy::unwrap_used)] pub(crate) fn tip_branch(&self) -> &str {
83 &self.boundaries.last().unwrap().name
84 }
85
86 pub fn commits_of(&self, index: usize) -> &[Commit] {
88 let start = if index == 0 {
89 0
90 } else {
91 self.boundaries[index - 1].end
92 };
93 &self.commits[start..self.boundaries[index].end]
94 }
95
96 pub fn boundary_of(&self, commit_index: usize) -> usize {
98 self.boundaries
99 .iter()
100 .position(|b| commit_index < b.end)
101 .unwrap_or_else(|| self.boundaries.len().saturating_sub(1))
102 }
103
104 pub fn rows(&self) -> Vec<Row> {
108 let mut rows = Vec::new();
109 for (b, boundary) in self.boundaries.iter().enumerate() {
110 rows.push(Row::Branch { boundary: b });
111 let start = if b == 0 {
112 0
113 } else {
114 self.boundaries[b - 1].end
115 };
116 for index in start..boundary.end {
117 rows.push(Row::Commit { index });
118 }
119 }
120 rows
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum Row {
127 Branch { boundary: usize },
129 Commit { index: usize },
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum SplitKind {
136 Meta,
138 Context,
140 Added,
142 Removed,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct SplitLine {
150 pub kind: SplitKind,
151 pub text: String,
152 pub change_index: Option<usize>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
158enum LineKind {
159 Context,
160 Added,
161 Removed,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165struct HunkLine {
166 kind: LineKind,
167 text: String,
168 change_index: Option<usize>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173struct Hunk {
174 v_start: usize,
176 lines: Vec<HunkLine>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180struct FileDiff {
181 path: String,
182 hunks: Vec<Hunk>,
183 binary: bool,
185}
186
187fn parse_diff(diff: &str) -> Vec<FileDiff> {
190 let mut files: Vec<FileDiff> = Vec::new();
191 let mut change_counter = 0usize;
192 for raw in diff.lines() {
193 if let Some(rest) = raw.strip_prefix("diff --git ") {
194 let path = rest
196 .split_once(" b/")
197 .map_or_else(|| rest.to_string(), |(_, b)| b.to_string());
198 files.push(FileDiff {
199 path,
200 hunks: Vec::new(),
201 binary: false,
202 });
203 } else if raw.starts_with("Binary files") {
204 if let Some(f) = files.last_mut() {
205 f.binary = true;
206 }
207 } else if let Some(rest) = raw.strip_prefix("@@") {
208 let v_start = rest
210 .split_once('+')
211 .and_then(|(_, r)| r.split([',', ' ']).next())
212 .and_then(|n| n.parse::<usize>().ok())
213 .map_or(0, |n| n.saturating_sub(1));
214 if let Some(f) = files.last_mut() {
215 f.hunks.push(Hunk {
216 v_start,
217 lines: Vec::new(),
218 });
219 }
220 } else if raw.starts_with("+++") || raw.starts_with("---") {
221 } else if let Some(f) = files.last_mut() {
223 let Some(hunk) = f.hunks.last_mut() else {
224 continue;
225 };
226 let (kind, text) = if let Some(t) = raw.strip_prefix('+') {
227 (LineKind::Added, t)
228 } else if let Some(t) = raw.strip_prefix('-') {
229 (LineKind::Removed, t)
230 } else if let Some(t) = raw.strip_prefix(' ') {
231 (LineKind::Context, t)
232 } else {
233 continue; };
235 let change_index = if kind == LineKind::Context {
236 None
237 } else {
238 let i = change_counter;
239 change_counter += 1;
240 Some(i)
241 };
242 hunk.lines.push(HunkLine {
243 kind,
244 text: text.to_string(),
245 change_index,
246 });
247 }
248 }
249 files
250}
251
252fn reconstruct_middle(
257 file: &FileDiff,
258 v_content: &str,
259 selected: &HashSet<usize>,
260) -> Option<String> {
261 if file.binary {
262 return None; }
264 let mut v_lines: Vec<String> = v_content.lines().map(str::to_string).collect();
265 for hunk in file.hunks.iter().rev() {
267 let v_len = hunk
269 .lines
270 .iter()
271 .filter(|l| matches!(l.kind, LineKind::Context | LineKind::Added))
272 .count();
273 let mut region: Vec<String> = Vec::new();
274 for l in &hunk.lines {
275 let selected_line = l.change_index.is_some_and(|i| selected.contains(&i));
276 let keep = match l.kind {
277 LineKind::Context => true,
278 LineKind::Removed => selected_line,
281 LineKind::Added => !selected_line,
283 };
284 if keep {
285 region.push(l.text.clone());
286 }
287 }
288 let end = (hunk.v_start + v_len).min(v_lines.len());
289 let start = hunk.v_start.min(v_lines.len());
290 v_lines.splice(start..end, region);
291 }
292 let mut result = v_lines.join("\n");
293 if v_content.ends_with('\n') && !result.is_empty() {
295 result.push('\n');
296 }
297 Some(result)
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
310#[non_exhaustive]
311pub enum Operation {
312 Reorder { from: usize, to: usize },
315 Squash {
320 index: usize,
321 message: Option<String>,
322 },
323 Split {
328 index: usize,
329 selected: HashSet<usize>,
330 message: String,
331 },
332 Reword { index: usize, message: String },
335 Delete { index: usize },
337 AddBoundary { index: usize, name: String },
341 RemoveBoundary { boundary: usize },
344 MoveBoundary { boundary: usize, delta: isize },
347 RenameBranch { boundary: usize, name: String },
349 Undo,
351 Redo,
353}
354
355#[derive(Debug)]
357pub struct Engine {
358 line: EditableLine,
359 qname: String,
362 namespaced: bool,
364 current: String,
367 launched_from: String,
369 original: Vec<(String, Option<u64>)>,
372 undo: Vec<Snapshot>,
374 redo: Vec<Snapshot>,
376 touched: bool,
380 pending_conflict: Option<Snapshot>,
384 pr_cache: Vec<Option<u64>>,
388}
389
390#[derive(Debug, PartialEq, Eq)]
392pub enum Applied {
393 Done,
395 Conflict,
400}
401
402#[derive(Debug, Clone)]
407struct BranchSnap {
408 name: String,
409 sha: String,
410 parent: String,
411 parent_sha: Option<String>,
412 queue: Option<String>,
413 pr: Option<u64>,
414 description: Option<String>,
415}
416
417#[derive(Debug, Clone)]
419struct Snapshot {
420 branches: Vec<BranchSnap>,
421 head: String,
422}
423
424impl Engine {
425 pub fn load() -> Result<Self> {
434 git::ensure_repo()?;
435 if git::rebase_in_progress() {
436 bail!(
437 "a rebase is in progress — finish resolving it (`git status`, then \
438 `git rebase --continue` or `--abort`), then re-run `git queue tui`"
439 );
440 }
441 if !git::worktree_clean() {
442 bail!(
443 "working tree has uncommitted changes; commit or stash them before \
444 opening the queue in the TUI"
445 );
446 }
447
448 let queue = Queue::load()?;
449 let branch = git::current_branch()?;
450 let (branches, base) = Self::scope(&queue, &branch)?;
451 let line = Self::build_line(base, &branches)?;
452 if line.commits.is_empty() {
453 bail!("the queue has no commits to edit");
454 }
455
456 let qname = branch_queue_name(&branches).unwrap_or_else(|| branch.replace('/', "-"));
457 let namespaced = branches.iter().any(|b| b.starts_with("queue/"));
458 let original: Vec<(String, Option<u64>)> =
459 branches.iter().map(|b| (b.clone(), meta::pr(b))).collect();
460 let pr_cache = line.boundaries.iter().map(|b| meta::pr(&b.name)).collect();
461
462 Ok(Self {
463 line,
464 qname,
465 namespaced,
466 current: branch.clone(),
467 launched_from: branch,
468 original,
469 undo: Vec::new(),
470 redo: Vec::new(),
471 touched: false,
472 pending_conflict: None,
473 pr_cache,
474 })
475 }
476
477 fn scope(queue: &Queue, branch: &str) -> Result<(Vec<String>, String)> {
480 if queue.is_tracked(branch) {
481 let line = queue.line_through(branch)?;
482 if line.branches.iter().any(|b| queue.children(b).len() > 1) {
486 bail!(
487 "`{branch}` is on a forked queue line; the TUI cannot rewrite history \
488 shared with a sibling line. Use `git queue edit` for ref-only \
489 boundary changes, or checkout a single unforked line first"
490 );
491 }
492 Ok((line.branches, line.base))
493 } else {
494 Ok((vec![branch.to_string()], queue.trunk.clone()))
495 }
496 }
497
498 fn build_line(base: String, branches: &[String]) -> Result<EditableLine> {
501 let mut commits = Vec::new();
502 let mut boundaries = Vec::new();
503 let mut parent = base.clone();
504 for b in branches {
505 for (sha, id, subject) in git::commits_between_with_ids(&parent, b)? {
506 let empty = git::commit_is_empty(&sha);
507 commits.push(Commit {
508 sha,
509 id,
510 subject,
511 empty,
512 });
513 }
514 boundaries.push(Boundary {
515 name: b.clone(),
516 end: commits.len(),
517 });
518 parent = b.clone();
519 }
520 Ok(EditableLine {
521 base,
522 commits,
523 boundaries,
524 })
525 }
526
527 pub const fn line(&self) -> &EditableLine {
529 &self.line
530 }
531
532 pub fn launched_from(&self) -> &str {
534 &self.launched_from
535 }
536
537 pub fn landing_branch(&self) -> &str {
539 &self.current
540 }
541
542 pub fn queue_name(&self) -> &str {
544 &self.qname
545 }
546
547 pub const fn has_pending(&self) -> bool {
549 !self.undo.is_empty()
550 }
551
552 pub const fn changed(&self) -> bool {
554 self.touched
555 }
556
557 pub fn layout(&self) -> Vec<(String, String)> {
559 let mut out = Vec::new();
560 let mut parent = self.line.base.clone();
561 for b in &self.line.boundaries {
562 out.push((parent.clone(), b.name.clone()));
563 parent = b.name.clone();
564 }
565 out
566 }
567
568 pub fn dissolved_with_prs(&self) -> Vec<(String, u64)> {
571 let present: HashSet<&str> = self
572 .line
573 .boundaries
574 .iter()
575 .map(|b| b.name.as_str())
576 .collect();
577 self.original
578 .iter()
579 .filter(|(name, _)| !present.contains(name.as_str()))
580 .filter_map(|(name, pr)| pr.map(|n| (name.clone(), n)))
581 .collect()
582 }
583
584 pub fn apply(&mut self, op: Operation) -> Result<Applied> {
591 if self.pending_conflict.is_some() {
592 bail!("a conflict is pending; resolve it in your shell or undo it first");
593 }
594 match op {
595 Operation::RenameBranch { boundary, name } => {
596 self.rename_branch(boundary, &name)?;
597 Ok(Applied::Done)
598 }
599 Operation::AddBoundary { index, name } => {
600 self.add_boundary(index, &name)?;
601 Ok(Applied::Done)
602 }
603 Operation::RemoveBoundary { boundary } => {
604 self.remove_boundary(boundary)?;
605 Ok(Applied::Done)
606 }
607 Operation::MoveBoundary { boundary, delta } => {
608 self.move_boundary(boundary, delta)?;
609 Ok(Applied::Done)
610 }
611 Operation::Reorder { from, to } => self.reorder(from, to),
612 Operation::Reword { index, message } => {
613 self.reword(index, &message)?;
614 Ok(Applied::Done)
615 }
616 Operation::Delete { index } => self.delete(index),
617 Operation::Squash { index, message } => self.squash(index, message.as_deref()),
618 Operation::Split {
619 index,
620 selected,
621 message,
622 } => self.split(index, &selected, &message),
623 Operation::Undo => {
624 self.undo()?;
625 Ok(Applied::Done)
626 }
627 Operation::Redo => {
628 self.redo()?;
629 Ok(Applied::Done)
630 }
631 }
632 }
633
634 pub const fn conflicted(&self) -> bool {
636 self.pending_conflict.is_some()
637 }
638
639 pub fn undo_conflict(&mut self) -> Result<()> {
642 let Some(snap) = self.pending_conflict.take() else {
643 bail!("no conflict to undo");
644 };
645 if git::rebase_in_progress() {
646 git::rebase_abort()?;
647 }
648 self.restore(&snap)?;
649 Ok(())
650 }
651
652 fn reorder(&mut self, from: usize, to: usize) -> Result<Applied> {
659 let n = self.line.commits.len();
660 if from >= n || to >= n {
661 bail!("reorder index out of range");
662 }
663 if from == to {
664 return Ok(Applied::Done);
665 }
666
667 let todo = reorder_todo(&self.line, from, to);
668 let snap = self.snapshot()?;
669 let base = self.line.base.clone();
670 let top = self.line.tip_branch().to_string();
671 let land = self.current.clone();
672 match git::rebase_with_todo_stop(&base, &top, &todo)? {
673 git::Rewrite::Clean => {
674 self.finish_rewrite(&land)?;
675 self.undo.push(snap);
676 self.redo.clear();
677 self.touched = true;
678 self.auto_drop_empties(&land)?;
681 Ok(Applied::Done)
682 }
683 git::Rewrite::Conflict => {
684 self.pending_conflict = Some(snap);
685 self.touched = true;
686 Ok(Applied::Conflict)
687 }
688 }
689 }
690
691 fn delete(&mut self, index: usize) -> Result<Applied> {
698 let n = self.line.commits.len();
699 if index >= n {
700 bail!("delete index out of range");
701 }
702 if n == 1 {
703 bail!("cannot delete the queue's only commit");
704 }
705 let mut drop = HashSet::new();
706 drop.insert(index);
707 let todo = drop_todo(&self.line, &drop);
708 let snap = self.snapshot()?;
709 let base = self.line.base.clone();
710 let top = self.line.tip_branch().to_string();
711 let land = self.current.clone();
712 match git::rebase_with_todo_stop(&base, &top, &todo)? {
713 git::Rewrite::Clean => {
714 self.finish_rewrite(&land)?;
715 self.undo.push(snap);
718 self.redo.clear();
719 self.touched = true;
720 self.auto_drop_empties(&land)?;
721 Ok(Applied::Done)
722 }
723 git::Rewrite::Conflict => {
724 self.pending_conflict = Some(snap);
725 self.touched = true;
726 Ok(Applied::Conflict)
727 }
728 }
729 }
730
731 fn auto_drop_empties(&mut self, land: &str) -> Result<()> {
735 for _ in 0..64 {
736 let drop: HashSet<usize> = self
737 .line
738 .commits
739 .iter()
740 .enumerate()
741 .filter(|(_, c)| c.empty && !c.described())
742 .map(|(i, _)| i)
743 .collect();
744 if drop.is_empty() {
745 return Ok(());
746 }
747 let todo = drop_todo(&self.line, &drop);
748 let base = self.line.base.clone();
749 let top = self.line.tip_branch().to_string();
750 match git::rebase_with_todo_stop(&base, &top, &todo)? {
751 git::Rewrite::Clean => self.finish_rewrite(land)?,
752 git::Rewrite::Conflict => {
753 git::rebase_abort()?;
756 bail!("auto-dropping empty commits unexpectedly conflicted");
757 }
758 }
759 }
760 Ok(())
761 }
762
763 pub fn empty_branches(&self) -> Vec<usize> {
766 (0..self.line.boundaries.len())
767 .filter(|&i| self.line.commits_of(i).is_empty())
768 .collect()
769 }
770
771 pub fn pr_of(&self, boundary: usize) -> Option<u64> {
774 self.pr_cache.get(boundary).copied().flatten()
775 }
776
777 pub fn branch_name(&self, boundary: usize) -> &str {
779 &self.line.boundaries[boundary].name
780 }
781
782 pub fn split_lines(&self, index: usize) -> Result<Vec<SplitLine>> {
787 if index >= self.line.commits.len() {
788 bail!("split index out of range");
789 }
790 let diff = git::commit_diff(&self.line.commits[index].sha)?;
791 let mut out = Vec::new();
792 for f in parse_diff(&diff) {
793 out.push(SplitLine {
794 kind: SplitKind::Meta,
795 text: format!("── {} ──", f.path),
796 change_index: None,
797 });
798 if f.binary {
799 out.push(SplitLine {
800 kind: SplitKind::Meta,
801 text: "(binary — peeled into the new commit)".into(),
802 change_index: None,
803 });
804 continue;
805 }
806 for h in &f.hunks {
807 out.push(SplitLine {
808 kind: SplitKind::Meta,
809 text: "@@".into(),
810 change_index: None,
811 });
812 for l in &h.lines {
813 let (kind, prefix) = match l.kind {
814 LineKind::Added => (SplitKind::Added, '+'),
815 LineKind::Removed => (SplitKind::Removed, '-'),
816 LineKind::Context => (SplitKind::Context, ' '),
817 };
818 out.push(SplitLine {
819 kind,
820 text: format!("{prefix}{}", l.text),
821 change_index: l.change_index,
822 });
823 }
824 }
825 }
826 Ok(out)
827 }
828
829 pub fn split_change_count(&self, index: usize) -> Result<usize> {
831 let diff = git::commit_diff(&self.line.commits[index].sha)?;
832 Ok(parse_diff(&diff)
833 .iter()
834 .flat_map(|f| &f.hunks)
835 .flat_map(|h| &h.lines)
836 .filter(|l| l.change_index.is_some())
837 .count())
838 }
839
840 fn split(&mut self, index: usize, selected: &HashSet<usize>, message: &str) -> Result<Applied> {
845 if index >= self.line.commits.len() {
846 bail!("split index out of range");
847 }
848 let total = self.split_change_count(index)?;
849 if selected.is_empty() || selected.len() >= total {
850 bail!("select some — but not all — lines to peel into the new commit");
851 }
852
853 let c_sha = self.line.commits[index].sha.clone();
854 let parent_sha = if index == 0 {
855 git::rev_parse(&self.line.base)?
856 } else {
857 self.line.commits[index - 1].sha.clone()
858 };
859 let diff = git::commit_diff(&c_sha)?;
860 let files = parse_diff(&diff);
861
862 let parent_tree = git::tree_of(&parent_sha)?;
864 let mut changes: Vec<(String, Option<String>)> = Vec::new();
865 for f in &files {
866 let v = git::file_at(&c_sha, &f.path);
867 let Some(m) = reconstruct_middle(f, &v, selected) else {
868 continue; };
870 let parent_has = !git::file_at(&parent_sha, &f.path).is_empty();
871 if m.is_empty() {
872 if parent_has {
873 changes.push((f.path.clone(), None)); }
875 } else {
877 changes.push((f.path.clone(), Some(m)));
878 }
879 }
880 let m_tree = git::build_tree(&parent_tree, &changes)?;
881
882 let older = git::commit_tree(&m_tree, &parent_sha, &git::commit_message(&c_sha)?)?;
884 let newer_msg = with_preserved_id(message, Some(&ident::new_id()));
885 let newer = git::commit_tree(&git::tree_of(&c_sha)?, &older, &newer_msg)?;
886
887 let snap = self.snapshot()?;
888 let mut mapping: std::collections::HashMap<String, String> =
890 std::collections::HashMap::new();
891 let mut prev = newer.clone();
892 for c in &self.line.commits[index + 1..] {
893 prev = git::cherry_pick_onto(&prev, &c.sha)?;
894 mapping.insert(c.sha.clone(), prev.clone());
895 }
896
897 let boundaries = self.line.boundaries.clone();
899 let mut parent = self.line.base.clone();
900 for b in &boundaries {
901 let tip_old = &self.line.commits[b.end - 1].sha;
902 let new_tip = if *tip_old == c_sha {
903 newer.clone()
904 } else if let Some(n) = mapping.get(tip_old) {
905 n.clone()
906 } else {
907 tip_old.clone()
908 };
909 git::force_ref(&b.name, &new_tip)?;
910 meta::set_parent(&b.name, &parent)?;
911 meta::set_parent_sha(&b.name, &git::rev_parse(&parent)?)?;
912 parent = b.name.clone();
913 }
914
915 let land = self.current.clone();
916 git::checkout_quiet(&land)?;
917 git::reset_hard_head()?;
918 self.reload()?;
919 self.undo.push(snap);
920 self.redo.clear();
921 self.touched = true;
922 self.auto_drop_empties(&land)?;
923 Ok(Applied::Done)
924 }
925
926 pub fn squash_needs_message(&self, index: usize) -> Result<bool> {
932 if index == 0 || index >= self.line.commits.len() {
933 bail!("no older commit to squash into");
934 }
935 let older = message_body(&self.line.commits[index - 1].sha)?;
936 let newer = message_body(&self.line.commits[index].sha)?;
937 Ok(!older.trim().is_empty() && !newer.trim().is_empty())
938 }
939
940 pub fn squash_default_message(&self, index: usize) -> Result<String> {
943 if index == 0 || index >= self.line.commits.len() {
944 bail!("no older commit to squash into");
945 }
946 Ok(combine_bodies(
947 &message_body(&self.line.commits[index - 1].sha)?,
948 &message_body(&self.line.commits[index].sha)?,
949 ))
950 }
951
952 fn squash(&mut self, index: usize, message: Option<&str>) -> Result<Applied> {
957 if index == 0 {
958 bail!("the front commit has no older neighbour to squash into");
959 }
960 if index >= self.line.commits.len() {
961 bail!("squash index out of range");
962 }
963 let body = match message {
966 Some(m) => m.to_string(),
967 None => combine_bodies(
968 &message_body(&self.line.commits[index - 1].sha)?,
969 &message_body(&self.line.commits[index].sha)?,
970 ),
971 };
972 let final_message = with_preserved_id(&body, self.line.commits[index - 1].id.as_deref());
973
974 let todo = squash_todo(&self.line, index);
975 let snap = self.snapshot()?;
976 let base = self.line.base.clone();
977 let top = self.line.tip_branch().to_string();
978 let land = self.current.clone();
979 match git::rebase_squash_stop(&base, &top, &todo, &final_message)? {
980 git::Rewrite::Clean => {
981 self.finish_rewrite(&land)?;
982 self.undo.push(snap);
983 self.redo.clear();
984 self.touched = true;
985 self.auto_drop_empties(&land)?;
986 Ok(Applied::Done)
987 }
988 git::Rewrite::Conflict => {
989 self.pending_conflict = Some(snap);
990 self.touched = true;
991 Ok(Applied::Conflict)
992 }
993 }
994 }
995
996 fn reword(&mut self, index: usize, new_text: &str) -> Result<()> {
1002 if index >= self.line.commits.len() {
1003 bail!("reword index out of range");
1004 }
1005 let message = with_preserved_id(new_text, self.line.commits[index].id.as_deref());
1006 let todo = reword_todo(&self.line, index);
1007 let snap = self.snapshot()?;
1008 let base = self.line.base.clone();
1009 let top = self.line.tip_branch().to_string();
1010 let land = self.current.clone();
1011 git::rebase_with_todo_message(&base, &top, &todo, &message)?;
1012 self.finish_rewrite(&land)?;
1013 self.undo.push(snap);
1014 self.redo.clear();
1015 self.touched = true;
1016 Ok(())
1017 }
1018
1019 fn finish_rewrite(&mut self, land: &str) -> Result<()> {
1022 let names: Vec<String> = self
1023 .line
1024 .boundaries
1025 .iter()
1026 .map(|b| b.name.clone())
1027 .collect();
1028 let mut parent = self.line.base.clone();
1029 for b in &names {
1030 meta::set_parent_sha(b, &git::rev_parse(&parent)?)?;
1031 parent = b.clone();
1032 }
1033 git::checkout_quiet(land)?;
1034 git::reset_hard_head()?;
1035 self.reload()?;
1036 Ok(())
1037 }
1038
1039 fn rename_branch(&mut self, boundary: usize, new_name: &str) -> Result<()> {
1043 let resolved = self.resolve_name(new_name);
1044 let old = self.line.boundaries[boundary].name.clone();
1045 if resolved == old {
1046 return Ok(());
1047 }
1048 if git::branch_exists(&resolved) {
1049 bail!("branch `{resolved}` already exists; pick a different name");
1050 }
1051 let mut segs = self.line.boundaries.clone();
1052 segs[boundary].name = resolved.clone();
1053 let land = if self.current == old {
1054 resolved
1055 } else {
1056 self.current.clone()
1057 };
1058 self.commit_op(segs, land)
1059 }
1060
1061 fn add_boundary(&mut self, index: usize, name: &str) -> Result<()> {
1067 let resolved = self.resolve_name(name);
1068 if git::branch_exists(&resolved) {
1069 bail!("branch `{resolved}` already exists; pick a different name");
1070 }
1071 let b = self.line.boundary_of(index);
1072 let start = if b == 0 {
1073 0
1074 } else {
1075 self.line.boundaries[b - 1].end
1076 };
1077 if index == start {
1078 bail!(
1079 "`{}` already starts at that commit; highlight a later commit to \
1080 split off a new branch",
1081 self.line.boundaries[b].name
1082 );
1083 }
1084 let mut segs = self.line.boundaries.clone();
1085 let end = segs[b].end;
1086 segs[b].end = index;
1089 segs.insert(
1090 b + 1,
1091 Boundary {
1092 name: resolved,
1093 end,
1094 },
1095 );
1096 let land = self.current.clone();
1097 self.commit_op(segs, land)
1098 }
1099
1100 fn remove_boundary(&mut self, boundary: usize) -> Result<()> {
1103 if self.line.boundaries.len() < 2 {
1104 bail!("cannot dissolve the only branch of the line");
1105 }
1106 let removed = self.line.boundaries[boundary].name.clone();
1107 let is_tip = boundary == self.line.boundaries.len() - 1;
1108 let survivor = if is_tip {
1109 self.line.boundaries[boundary - 1].name.clone()
1110 } else {
1111 self.line.boundaries[boundary + 1].name.clone()
1112 };
1113 let mut segs = self.line.boundaries.clone();
1114 segs.remove(boundary);
1115 if let Some(last) = segs.last_mut() {
1118 last.end = self.line.commits.len();
1119 }
1120 let land = if self.current == removed {
1121 survivor
1122 } else {
1123 self.current.clone()
1124 };
1125 self.commit_op(segs, land)
1126 }
1127
1128 fn move_boundary(&mut self, boundary: usize, delta: isize) -> Result<()> {
1131 if boundary + 1 >= self.line.boundaries.len() {
1132 bail!("the last branch's boundary is fixed at the tip");
1133 }
1134 let lower = if boundary == 0 {
1135 0
1136 } else {
1137 self.line.boundaries[boundary - 1].end as isize
1138 };
1139 let upper = self.line.boundaries[boundary + 1].end as isize;
1140 let new_end = self.line.boundaries[boundary].end as isize + delta;
1141 if new_end <= lower || new_end >= upper {
1142 bail!("that shift would empty a branch");
1143 }
1144 let mut segs = self.line.boundaries.clone();
1145 segs[boundary].end = new_end as usize;
1146 let land = self.current.clone();
1147 self.commit_op(segs, land)
1148 }
1149
1150 fn undo(&mut self) -> Result<()> {
1153 let Some(snap) = self.undo.pop() else {
1154 bail!("nothing to undo");
1155 };
1156 let redo = self.snapshot()?;
1157 self.restore(&snap)?;
1158 self.redo.push(redo);
1159 Ok(())
1160 }
1161
1162 fn redo(&mut self) -> Result<()> {
1163 let Some(snap) = self.redo.pop() else {
1164 bail!("nothing to redo");
1165 };
1166 let undo = self.snapshot()?;
1167 self.restore(&snap)?;
1168 self.undo.push(undo);
1169 Ok(())
1170 }
1171
1172 fn resolve_name(&self, n: &str) -> String {
1178 if n.contains('/') || self.line.boundaries.iter().any(|b| b.name == n) || !self.namespaced {
1179 n.to_string()
1180 } else {
1181 format!("queue/{}/{n}", self.qname)
1182 }
1183 }
1184
1185 fn commit_op(&mut self, segments: Vec<Boundary>, land: String) -> Result<()> {
1188 let snap = self.snapshot()?;
1189 self.materialise(&segments)?;
1190 git::checkout_quiet(&land)?;
1191 self.reload()?;
1192 self.undo.push(snap);
1193 self.redo.clear();
1194 self.touched = true;
1195 Ok(())
1196 }
1197
1198 fn materialise(&self, segments: &[Boundary]) -> Result<()> {
1202 git::detach_head()?;
1203 let target: HashSet<&str> = segments.iter().map(|s| s.name.as_str()).collect();
1204 let mut parent = self.line.base.clone();
1205 for s in segments {
1206 let tip_sha = self.line.commits[s.end - 1].sha.clone();
1207 if git::branch_exists(&s.name) {
1208 git::force_ref(&s.name, &tip_sha)?;
1209 } else {
1210 git::create_branch(&s.name, &tip_sha)?;
1211 }
1212 meta::set_parent(&s.name, &parent)?;
1213 meta::set_parent_sha(&s.name, &git::rev_parse(&parent)?)?;
1214 meta::set_branch_queue(&s.name, &self.qname)?;
1215 parent = s.name.clone();
1216 }
1217 for old in self.line.boundaries.iter().map(|b| b.name.clone()) {
1218 if !target.contains(old.as_str()) {
1219 meta::untrack(&old);
1220 git::run(&["branch", "-q", "-D", &old])?;
1221 }
1222 }
1223 meta::touch_queue(&self.qname);
1224 Ok(())
1225 }
1226
1227 fn snapshot(&self) -> Result<Snapshot> {
1229 let mut branches = Vec::new();
1230 for b in self.line.boundaries.iter().map(|x| &x.name) {
1231 branches.push(BranchSnap {
1232 name: b.clone(),
1233 sha: git::rev_parse(b)?,
1234 parent: meta::parent(b).unwrap_or_else(|| self.line.base.clone()),
1235 parent_sha: meta::parent_sha(b),
1236 queue: meta::branch_queue(b),
1237 pr: meta::pr(b),
1238 description: meta::description(b),
1239 });
1240 }
1241 Ok(Snapshot {
1242 branches,
1243 head: self.current.clone(),
1244 })
1245 }
1246
1247 fn restore(&mut self, snap: &Snapshot) -> Result<()> {
1250 git::detach_head()?;
1251 let keep: HashSet<&str> = snap.branches.iter().map(|b| b.name.as_str()).collect();
1252 for b in self.line.boundaries.iter().map(|x| x.name.clone()) {
1253 if !keep.contains(b.as_str()) {
1254 meta::untrack(&b);
1255 git::run(&["branch", "-q", "-D", &b])?;
1256 }
1257 }
1258 for bs in &snap.branches {
1259 if git::branch_exists(&bs.name) {
1260 git::force_ref(&bs.name, &bs.sha)?;
1261 } else {
1262 git::create_branch(&bs.name, &bs.sha)?;
1263 }
1264 meta::set_parent(&bs.name, &bs.parent)?;
1265 if let Some(s) = &bs.parent_sha {
1266 meta::set_parent_sha(&bs.name, s)?;
1267 }
1268 if let Some(q) = &bs.queue {
1269 meta::set_branch_queue(&bs.name, q)?;
1270 }
1271 if let Some(pr) = bs.pr {
1272 meta::set_pr(&bs.name, pr)?;
1273 }
1274 if let Some(d) = &bs.description {
1275 meta::set_description(&bs.name, d)?;
1276 }
1277 }
1278 git::checkout_quiet(&snap.head)?;
1279 self.reload()?;
1280 Ok(())
1281 }
1282
1283 fn reload(&mut self) -> Result<()> {
1286 let queue = Queue::load()?;
1287 let branch = git::current_branch()?;
1288 let (branches, base) = Self::scope(&queue, &branch)?;
1289 self.line = Self::build_line(base, &branches)?;
1290 self.pr_cache = self
1291 .line
1292 .boundaries
1293 .iter()
1294 .map(|b| meta::pr(&b.name))
1295 .collect();
1296 self.current = branch;
1297 Ok(())
1298 }
1299}
1300
1301fn reorder_todo(line: &EditableLine, from: usize, to: usize) -> String {
1312 let commits = &line.commits;
1313 let pick = |i: usize| format!("pick {} {}", commits[i].sha, commits[i].subject);
1314
1315 let last = line.boundaries.len() - 1;
1317 let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
1318 for (bi, b) in line.boundaries.iter().enumerate() {
1319 if bi != last {
1320 ref_after.insert(b.end - 1, b.name.as_str());
1321 }
1322 }
1323
1324 let mut lines: Vec<String> = Vec::new();
1326 for i in 0..commits.len() {
1327 lines.push(pick(i));
1328 if let Some(name) = ref_after.get(&i) {
1329 lines.push(format!("update-ref refs/heads/{name}"));
1330 }
1331 }
1332
1333 let moved_line = pick(from);
1335 lines.retain(|l| l != &moved_line);
1336 let mut order: Vec<usize> = (0..commits.len()).collect();
1337 let m = order.remove(from);
1338 order.insert(to, m);
1339 let idx = if to == 0 {
1340 0
1341 } else {
1342 let anchor = pick(order[to - 1]);
1343 lines.iter().position(|l| *l == anchor).map_or(0, |i| i + 1)
1344 };
1345 lines.insert(idx, moved_line);
1346 lines.join("\n") + "\n"
1347}
1348
1349fn reword_todo(line: &EditableLine, index: usize) -> String {
1353 let commits = &line.commits;
1354 let last = line.boundaries.len() - 1;
1355 let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
1356 for (bi, b) in line.boundaries.iter().enumerate() {
1357 if bi != last {
1358 ref_after.insert(b.end - 1, b.name.as_str());
1359 }
1360 }
1361 let mut lines: Vec<String> = Vec::new();
1362 for (i, c) in commits.iter().enumerate() {
1363 let verb = if i == index { "reword" } else { "pick" };
1364 lines.push(format!("{verb} {} {}", c.sha, c.subject));
1365 if let Some(name) = ref_after.get(&i) {
1366 lines.push(format!("update-ref refs/heads/{name}"));
1367 }
1368 }
1369 lines.join("\n") + "\n"
1370}
1371
1372fn drop_todo(line: &EditableLine, drop: &HashSet<usize>) -> String {
1377 let commits = &line.commits;
1378 let last = line.boundaries.len() - 1;
1379 let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
1380 for (bi, b) in line.boundaries.iter().enumerate() {
1381 if bi != last {
1382 ref_after.insert(b.end - 1, b.name.as_str());
1383 }
1384 }
1385 let mut lines: Vec<String> = Vec::new();
1386 for (i, c) in commits.iter().enumerate() {
1387 if !drop.contains(&i) {
1388 lines.push(format!("pick {} {}", c.sha, c.subject));
1389 }
1390 if let Some(name) = ref_after.get(&i) {
1391 lines.push(format!("update-ref refs/heads/{name}"));
1392 }
1393 }
1394 lines.join("\n") + "\n"
1395}
1396
1397fn squash_todo(line: &EditableLine, index: usize) -> String {
1403 let commits = &line.commits;
1404 let last = line.boundaries.len() - 1;
1405 let mut ref_after: std::collections::HashMap<usize, &str> = std::collections::HashMap::new();
1406 for (bi, b) in line.boundaries.iter().enumerate() {
1407 if bi != last {
1408 ref_after.insert(b.end - 1, b.name.as_str());
1409 }
1410 }
1411 let cross = line.boundary_of(index - 1) != line.boundary_of(index);
1412
1413 let mut lines: Vec<String> = Vec::new();
1414 for (i, c) in commits.iter().enumerate() {
1415 let verb = if i == index { "squash" } else { "pick" };
1416 lines.push(format!("{verb} {} {}", c.sha, c.subject));
1417 if let Some(name) = ref_after.get(&i) {
1418 if !(cross && i == index - 1) {
1420 lines.push(format!("update-ref refs/heads/{name}"));
1421 }
1422 }
1423 if cross && i == index {
1424 if let Some(name) = ref_after.get(&(index - 1)) {
1425 lines.push(format!("update-ref refs/heads/{name}"));
1426 }
1427 }
1428 }
1429 lines.join("\n") + "\n"
1430}
1431
1432fn message_body(sha: &str) -> Result<String> {
1435 let prefix = format!("{}:", ident::TRAILER);
1436 let msg = git::commit_message(sha)?;
1437 Ok(msg
1438 .lines()
1439 .filter(|l| !l.trim_start().starts_with(&prefix))
1440 .collect::<Vec<_>>()
1441 .join("\n")
1442 .trim()
1443 .to_string())
1444}
1445
1446fn combine_bodies(older: &str, newer: &str) -> String {
1449 match (older.trim(), newer.trim()) {
1450 ("", n) => n.to_string(),
1451 (o, "") => o.to_string(),
1452 (o, n) => format!("{o}\n\n{n}"),
1453 }
1454}
1455
1456fn with_preserved_id(new_text: &str, id: Option<&str>) -> String {
1461 let prefix = format!("{}:", ident::TRAILER);
1462 let body = new_text
1463 .lines()
1464 .filter(|l| !l.trim_start().starts_with(&prefix))
1465 .collect::<Vec<_>>()
1466 .join("\n");
1467 let body = body.trim_end();
1468 match id {
1469 Some(id) => format!("{body}\n\n{}: {id}\n", ident::TRAILER),
1470 None => format!("{body}\n"),
1471 }
1472}
1473
1474fn branch_queue_name(branches: &[String]) -> Option<String> {
1478 for b in branches {
1479 if let Some(n) = meta::branch_queue(b) {
1480 return Some(n);
1481 }
1482 }
1483 for b in branches {
1484 if let Some(rest) = b.strip_prefix("queue/") {
1485 if let Some((n, _)) = rest.split_once('/') {
1486 return Some(n.to_string());
1487 }
1488 }
1489 }
1490 None
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 #![allow(clippy::unwrap_used, clippy::expect_used)]
1496 use super::*;
1497
1498 fn commit(subject: &str) -> Commit {
1499 Commit {
1500 sha: subject.into(),
1501 id: None,
1502 subject: subject.into(),
1503 empty: false,
1504 }
1505 }
1506
1507 fn sample() -> EditableLine {
1509 EditableLine {
1510 base: "main".into(),
1511 commits: vec![commit("c0"), commit("c1"), commit("c2")],
1512 boundaries: vec![
1513 Boundary {
1514 name: "a".into(),
1515 end: 2,
1516 },
1517 Boundary {
1518 name: "b".into(),
1519 end: 3,
1520 },
1521 ],
1522 }
1523 }
1524
1525 #[test]
1526 fn rows_interleave_branch_headers_front_to_tip() {
1527 let line = sample();
1528 assert_eq!(
1529 line.rows(),
1530 vec![
1531 Row::Branch { boundary: 0 },
1532 Row::Commit { index: 0 },
1533 Row::Commit { index: 1 },
1534 Row::Branch { boundary: 1 },
1535 Row::Commit { index: 2 },
1536 ]
1537 );
1538 }
1539
1540 #[test]
1541 fn boundary_of_maps_commits_to_their_branch() {
1542 let line = sample();
1543 assert_eq!(line.boundary_of(0), 0);
1544 assert_eq!(line.boundary_of(1), 0);
1545 assert_eq!(line.boundary_of(2), 1);
1546 }
1547
1548 #[test]
1549 fn reorder_todo_moves_a_pick_across_a_boundary_update_ref() {
1550 let todo = reorder_todo(&sample(), 1, 2);
1554 assert_eq!(
1555 todo,
1556 "pick c0 c0\nupdate-ref refs/heads/a\npick c2 c2\npick c1 c1\n"
1557 );
1558 }
1559
1560 #[test]
1561 fn reorder_todo_to_the_front_puts_the_pick_first() {
1562 let todo = reorder_todo(&sample(), 2, 0);
1564 assert_eq!(
1565 todo,
1566 "pick c2 c2\npick c0 c0\npick c1 c1\nupdate-ref refs/heads/a\n"
1567 );
1568 }
1569
1570 #[test]
1571 fn drop_todo_omits_the_dropped_pick_and_keeps_update_refs() {
1572 let mut drop = HashSet::new();
1574 drop.insert(1usize);
1575 let todo = drop_todo(&sample(), &drop);
1576 assert_eq!(todo, "pick c0 c0\nupdate-ref refs/heads/a\npick c2 c2\n");
1577 }
1578
1579 #[test]
1580 fn squash_todo_same_branch_folds_into_the_previous_pick() {
1581 let todo = squash_todo(&sample(), 1);
1583 assert_eq!(
1584 todo,
1585 "pick c0 c0\nsquash c1 c1\nupdate-ref refs/heads/a\npick c2 c2\n"
1586 );
1587 }
1588
1589 #[test]
1590 fn squash_todo_across_a_boundary_defers_the_older_ref() {
1591 let todo = squash_todo(&sample(), 2);
1594 assert_eq!(
1595 todo,
1596 "pick c0 c0\npick c1 c1\nsquash c2 c2\nupdate-ref refs/heads/a\n"
1597 );
1598 }
1599
1600 #[test]
1601 fn combine_bodies_picks_the_nonempty_side_or_joins() {
1602 assert_eq!(combine_bodies("older", "newer"), "older\n\nnewer");
1603 assert_eq!(combine_bodies("", "newer"), "newer");
1604 assert_eq!(combine_bodies("older", ""), "older");
1605 }
1606
1607 #[test]
1608 fn parse_diff_numbers_selectable_lines() {
1609 let diff = [
1610 "diff --git a/f.txt b/f.txt",
1611 "--- a/f.txt",
1612 "+++ b/f.txt",
1613 "@@ -1,2 +1,2 @@",
1614 "-old",
1615 "+new",
1616 " ctx",
1617 ]
1618 .join("\n")
1619 + "\n";
1620 let files = parse_diff(&diff);
1621 assert_eq!(files.len(), 1);
1622 assert_eq!(files[0].path, "f.txt");
1623 let lines = &files[0].hunks[0].lines;
1624 assert_eq!(lines[0].kind, LineKind::Removed);
1625 assert_eq!(lines[0].change_index, Some(0));
1626 assert_eq!(lines[1].kind, LineKind::Added);
1627 assert_eq!(lines[1].change_index, Some(1));
1628 assert_eq!(lines[2].kind, LineKind::Context);
1629 assert_eq!(lines[2].change_index, None);
1630 }
1631
1632 #[test]
1633 fn reconstruct_middle_reverts_only_selected_changes() {
1634 let file = FileDiff {
1635 path: "f".into(),
1636 binary: false,
1637 hunks: vec![Hunk {
1638 v_start: 0,
1639 lines: vec![
1640 HunkLine {
1641 kind: LineKind::Added,
1642 text: "A".into(),
1643 change_index: Some(0),
1644 },
1645 HunkLine {
1646 kind: LineKind::Added,
1647 text: "B".into(),
1648 change_index: Some(1),
1649 },
1650 ],
1651 }],
1652 };
1653 assert_eq!(
1655 reconstruct_middle(&file, "A\nB\n", &HashSet::from([1])),
1656 Some("A\n".into())
1657 );
1658 assert_eq!(
1660 reconstruct_middle(&file, "A\nB\n", &HashSet::from([0])),
1661 Some("B\n".into())
1662 );
1663 }
1664
1665 #[test]
1666 fn reword_todo_marks_the_target_and_keeps_the_rest() {
1667 let todo = reword_todo(&sample(), 0);
1668 assert_eq!(
1669 todo,
1670 "reword c0 c0\npick c1 c1\nupdate-ref refs/heads/a\npick c2 c2\n"
1671 );
1672 }
1673
1674 #[test]
1675 fn with_preserved_id_reattaches_the_original_trailer() {
1676 assert_eq!(
1678 with_preserved_id("hello world", Some("q-abc")),
1679 "hello world\n\nStable-Commit-Id: q-abc\n"
1680 );
1681 assert_eq!(
1683 with_preserved_id("subject\n\nStable-Commit-Id: q-typo", Some("q-abc")),
1684 "subject\n\nStable-Commit-Id: q-abc\n"
1685 );
1686 assert_eq!(with_preserved_id("just text", None), "just text\n");
1688 }
1689}