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)]
55pub struct Boundary {
56 pub name: String,
58 pub end: usize,
61}
62
63#[derive(Debug, Clone)]
65pub struct EditableLine {
66 pub base: String,
69 pub commits: Vec<Commit>,
71 pub boundaries: Vec<Boundary>,
74}
75
76impl EditableLine {
77 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Row {
118 Branch { boundary: usize },
120 Commit { index: usize },
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum SplitKind {
127 Meta,
129 Context,
131 Added,
133 Removed,
135}
136
137#[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#[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 change_index: Option<usize>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164struct Hunk {
165 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 binary: bool,
176}
177
178fn 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 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 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 } 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; };
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
245fn reconstruct_middle(file: &FileDiff, v_content: &str, selected: &HashSet<usize>) -> Option<String> {
250 if file.binary {
251 return None; }
253 let mut v_lines: Vec<String> = v_content.lines().map(str::to_string).collect();
254 for hunk in file.hunks.iter().rev() {
256 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 LineKind::Removed => selected_line,
270 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 if v_content.ends_with('\n') && !result.is_empty() {
284 result.push('\n');
285 }
286 Some(result)
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
297#[non_exhaustive]
298pub enum Operation {
299 Reorder { from: usize, to: usize },
302 Squash {
307 index: usize,
308 message: Option<String>,
309 },
310 Split {
315 index: usize,
316 selected: HashSet<usize>,
317 message: String,
318 },
319 Reword { index: usize, message: String },
322 Delete { index: usize },
324 AddBoundary { index: usize, name: String },
328 RemoveBoundary { boundary: usize },
331 MoveBoundary { boundary: usize, delta: isize },
334 RenameBranch { boundary: usize, name: String },
336 Undo,
338 Redo,
340}
341
342#[derive(Debug)]
344pub struct Engine {
345 line: EditableLine,
346 qname: String,
349 namespaced: bool,
351 current: String,
354 launched_from: String,
356 original: Vec<(String, Option<u64>)>,
359 undo: Vec<Snapshot>,
361 redo: Vec<Snapshot>,
363 touched: bool,
367 pending_conflict: Option<Snapshot>,
371 pr_cache: Vec<Option<u64>>,
375}
376
377#[derive(Debug, PartialEq, Eq)]
379pub enum Applied {
380 Done,
382 Conflict,
387}
388
389#[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#[derive(Debug, Clone)]
406struct Snapshot {
407 branches: Vec<BranchSnap>,
408 head: String,
409}
410
411impl Engine {
412 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 fn scope(queue: &Queue, branch: &str) -> Result<(Vec<String>, String)> {
467 if queue.is_tracked(branch) {
468 let line = queue.line_through(branch)?;
469 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 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 pub fn line(&self) -> &EditableLine {
516 &self.line
517 }
518
519 pub fn launched_from(&self) -> &str {
521 &self.launched_from
522 }
523
524 pub fn landing_branch(&self) -> &str {
526 &self.current
527 }
528
529 pub fn queue_name(&self) -> &str {
531 &self.qname
532 }
533
534 pub fn has_pending(&self) -> bool {
536 !self.undo.is_empty()
537 }
538
539 pub fn changed(&self) -> bool {
541 self.touched
542 }
543
544 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 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 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 pub fn conflicted(&self) -> bool {
618 self.pending_conflict.is_some()
619 }
620
621 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 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 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 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 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 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 git::rebase_abort()?;
738 bail!("auto-dropping empty commits unexpectedly conflicted");
739 }
740 }
741 }
742 Ok(())
743 }
744
745 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 pub fn pr_of(&self, boundary: usize) -> Option<u64> {
756 self.pr_cache.get(boundary).copied().flatten()
757 }
758
759 pub fn branch_name(&self, boundary: usize) -> &str {
761 &self.line.boundaries[boundary].name
762 }
763
764 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 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 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 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; };
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)); }
857 } else {
859 changes.push((f.path.clone(), Some(m)));
860 }
861 }
862 let m_tree = git::build_tree(&parent_tree, &changes)?;
863
864 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1277fn 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 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 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 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
1329fn 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
1352fn 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
1377fn 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 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
1412fn 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
1426fn 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
1436fn 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
1454fn 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 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 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 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 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 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 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 assert_eq!(
1637 reconstruct_middle(&file, "A\nB\n", &HashSet::from([1])),
1638 Some("A\n".into())
1639 );
1640 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 assert_eq!(
1660 with_preserved_id("hello world", Some("q-abc")),
1661 "hello world\n\nStable-Commit-Id: q-abc\n"
1662 );
1663 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 assert_eq!(with_preserved_id("just text", None), "just text\n");
1670 }
1671}