1use crate::config::GitFlowConfig;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use tracing::{debug, info, warn};
7
8#[derive(Debug, thiserror::Error)]
10pub enum GitError {
11 #[error("failed to execute git: {0}")]
13 Io(#[from] std::io::Error),
14 #[error("git command failed: {0}")]
16 Command(String),
17}
18
19#[derive(Debug, Clone)]
21pub struct GitFlow {
22 root: PathBuf,
23 config: GitFlowConfig,
24}
25
26#[derive(Debug, Clone)]
28pub struct BranchInfo {
29 pub name: String,
31 pub ahead: usize,
33 pub behind: usize,
35 pub last_commit: String,
37}
38
39impl GitFlow {
40 pub fn new(root: impl AsRef<Path>) -> Self {
43 Self {
44 root: root.as_ref().to_path_buf(),
45 config: GitFlowConfig::default(),
46 }
47 }
48
49 pub fn feature_start(&self, phase: u32) -> Result<String, GitError> {
54 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
55 info!("creating feature branch: {branch}");
56 self.git(["checkout", &self.config.develop])?;
57 self.git(["checkout", "-b", &branch])?;
58 Ok(branch)
59 }
60
61 pub fn feature_start_force(&self, phase: u32) -> Result<String, GitError> {
63 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
64 warn!("force-creating feature branch: {branch}");
65 self.git(["checkout", &self.config.develop])?;
66 self.git(["checkout", "-B", &branch])?;
67 Ok(branch)
68 }
69
70 pub fn feature_finish(&self, phase: u32) -> Result<String, GitError> {
72 let branch = self.merge_feature_into_develop(phase)?;
73 self.git(["branch", "-d", &branch])?;
74 Ok(branch)
75 }
76
77 pub fn merge_feature_into_develop(&self, phase: u32) -> Result<String, GitError> {
82 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
83 info!("merging feature branch: {branch}");
84 self.git(["checkout", &self.config.develop])?;
85 self.git(["merge", "--no-ff", &branch])?;
86 Ok(branch)
87 }
88
89 pub fn is_merged_into_develop(&self, phase: u32) -> bool {
94 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
95 if !self.branch_exists(&branch) {
96 return false;
97 }
98
99 Command::new("git")
100 .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
101 .current_dir(&self.root)
102 .output()
103 .map(|output| output.status.success())
104 .unwrap_or(false)
105 }
106
107 pub fn release_start(&self, version: &str) -> Result<String, GitError> {
114 let branch = format!("release/{version}");
115 info!("creating release branch: {branch}");
116 self.git(["checkout", "-B", &branch])?;
117 Ok(branch)
118 }
119
120 pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
122 let branch = format!("release/{version}");
123 info!("finishing release branch: {branch}");
124 self.git(["checkout", &self.config.main])?;
125 self.git(["merge", "--no-ff", &branch])?;
126 self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
133 self.git(["checkout", &self.config.develop])?;
134 self.git(["merge", "--no-ff", &branch])?;
135 self.git(["branch", "-d", &branch])?;
136 Ok(branch)
137 }
138
139 pub fn tag(&self, tag: &str) -> Result<(), GitError> {
148 info!("tagging {tag}");
149 self.git(["-c", "tag.gpgSign=false", "tag", tag])
150 }
151
152 pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
158 if branch == self.config.main || branch == self.config.develop {
159 return Err(GitError::Command(format!(
160 "refusing to delete protected branch `{branch}`"
161 )));
162 }
163 let flag = if force { "-D" } else { "-d" };
164 if force {
165 warn!("force-deleting branch: {branch}");
166 } else {
167 info!("deleting branch: {branch}");
168 }
169 self.git(["branch", flag, branch])
170 }
171
172 pub fn branch_exists(&self, branch: &str) -> bool {
174 Command::new("git")
175 .args([
176 "rev-parse",
177 "--verify",
178 "--quiet",
179 &format!("refs/heads/{branch}"),
180 ])
181 .current_dir(&self.root)
182 .output()
183 .map(|o| o.status.success())
184 .unwrap_or(false)
185 }
186
187 pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
189 Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
190 }
191
192 pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
195 if self.branch_exists(branch) {
196 return Ok(());
197 }
198 self.git(["branch", branch, start_point])
199 }
200
201 pub fn fast_forward_branch(&self, target: &str, source: &str) -> Result<(), GitError> {
206 let is_ancestor = Command::new("git")
207 .args(["merge-base", "--is-ancestor", target, source])
208 .current_dir(&self.root)
209 .output()?
210 .status
211 .success();
212 if !is_ancestor {
213 return Err(GitError::Command(format!(
214 "{target} is not an ancestor of {source}; refusing non-fast-forward update"
215 )));
216 }
217 self.git(["branch", "-f", target, source])
218 }
219
220 pub fn rebase_in(&self, dir: &Path, onto: &str) -> Result<(), GitError> {
225 debug!("rebasing worktree at {} onto {onto}", dir.display());
226 match git_in(dir, &["rebase", onto]) {
227 Ok(()) => Ok(()),
228 Err(err) => {
229 warn!("rebase conflict in {}; aborting", dir.display());
231 let _ = git_in(dir, &["rebase", "--abort"]);
232 Err(err)
233 }
234 }
235 }
236
237 pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
239 debug!("checking out branch: {branch}");
240 self.git(["checkout", branch])
241 }
242
243 pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
245 info!("deleting remote branch: {branch}");
246 self.git(["push", "origin", "--delete", branch])
247 }
248
249 pub fn has_remote(&self) -> bool {
251 self.git_output(["remote"])
252 .map(|s| !s.trim().is_empty())
253 .unwrap_or(false)
254 }
255
256 pub fn push(&self, branch: &str) -> Result<(), GitError> {
258 info!("pushing branch: {branch}");
259 self.git(["push", "-u", "origin", branch])
260 }
261
262 pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
278 let output = self.git_output(["branch", "--merged", &self.config.develop])?;
279 let protected = [self.config.main.as_str(), self.config.develop.as_str()];
280 let mut deleted = Vec::new();
281 for line in output.lines() {
282 let branch = line
288 .strip_prefix("* ")
289 .or_else(|| line.strip_prefix("+ "))
290 .unwrap_or(line)
291 .trim();
292 if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
295 continue;
296 }
297 info!("cleaning up merged branch: {branch}");
298 match self.git(["branch", "-D", branch]) {
299 Ok(()) => deleted.push(branch.to_string()),
300 Err(err) => warn!("could not delete merged branch {branch}: {err}"),
301 }
302 }
303 Ok(deleted)
304 }
305
306 pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
309 debug!("committing all changes: {message}");
310 self.git(["add", "."])?;
311 match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
313 Ok(()) => Ok(()),
314 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
317 Err(e) => Err(e),
318 }
319 }
320
321 pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
327 let current = self
328 .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
329 .trim()
330 .to_string();
331 if current == self.config.develop {
332 return Ok((0, 0));
333 }
334 let ahead = self
335 .rev_count(&format!("{}..{current}", self.config.develop))
336 .unwrap_or(0);
337 let behind = self
338 .rev_count(&format!("{current}..{}", self.config.develop))
339 .unwrap_or(0);
340 Ok((ahead, behind))
341 }
342
343 pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
348 let prefix = &self.config.feature_prefix;
349 let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
350 let mut result = Vec::new();
351 for name in branches.lines().map(|l| l.trim()) {
352 if name.is_empty()
353 || name == self.config.main
354 || name == self.config.develop
355 || !name.starts_with(prefix)
356 {
357 continue;
358 }
359 let ahead = self
360 .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
361 .unwrap_or(0);
362 let behind = self
363 .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
364 .unwrap_or(0);
365 let last_commit = self
366 .git_output(["log", "-1", "--format=%aI", name])
367 .map(|s| s.trim().to_string())
368 .unwrap_or_default();
369 result.push(BranchInfo {
370 name: name.to_string(),
371 ahead,
372 behind,
373 last_commit,
374 });
375 }
376 result.sort_by(|a, b| a.name.cmp(&b.name));
378 Ok(result)
379 }
380
381 fn rev_count(&self, range: &str) -> Option<usize> {
383 self.git_output(["rev-list", "--count", range])
384 .ok()
385 .and_then(|s| s.trim().parse().ok())
386 }
387
388 fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
389 debug!("git {}", args.join(" "));
390 let output = Command::new("git")
391 .args(args)
392 .current_dir(&self.root)
393 .output()?;
394 if output.status.success() {
395 Ok(())
396 } else {
397 Err(GitError::Command(stderr_or_status(&output)))
398 }
399 }
400
401 fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
402 debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
403 let output = Command::new("git")
404 .args(args)
405 .current_dir(&self.root)
406 .output()?;
407 if output.status.success() {
408 Ok(())
409 } else {
410 Err(GitError::Command(stderr_or_status(&output)))
411 }
412 }
413
414 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
415 let output = Command::new("git")
416 .args(args)
417 .current_dir(&self.root)
418 .output()?;
419 if output.status.success() {
420 Ok(String::from_utf8_lossy(&output.stdout).to_string())
421 } else {
422 Err(GitError::Command(stderr_or_status(&output)))
423 }
424 }
425}
426
427fn git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
429 debug!("git (in {}) {}", dir.display(), args.join(" "));
430 let output = Command::new("git").args(args).current_dir(dir).output()?;
431 if output.status.success() {
432 Ok(())
433 } else {
434 Err(GitError::Command(stderr_or_status(&output)))
435 }
436}
437
438fn stderr_or_status(output: &std::process::Output) -> String {
439 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
440 if stderr.is_empty() {
441 format!("exited with {}", output.status)
442 } else {
443 stderr
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use std::process::Command;
451 use tempfile::TempDir;
452
453 fn git(root: &Path, args: &[&str]) {
455 let output = Command::new("git")
456 .args(args)
457 .current_dir(root)
458 .output()
459 .expect("spawn git");
460 assert!(
461 output.status.success(),
462 "git {args:?} failed: {}",
463 String::from_utf8_lossy(&output.stderr)
464 );
465 }
466
467 fn current_branch(root: &Path) -> String {
468 let output = Command::new("git")
469 .args(["rev-parse", "--abbrev-ref", "HEAD"])
470 .current_dir(root)
471 .output()
472 .expect("rev-parse");
473 String::from_utf8_lossy(&output.stdout).trim().to_string()
474 }
475
476 fn commit_file(root: &Path, name: &str) {
477 std::fs::write(root.join(name), name).unwrap();
478 git(root, &["add", "."]);
479 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
480 }
481
482 fn init_repo() -> TempDir {
484 let dir = tempfile::tempdir().unwrap();
485 let root = dir.path();
486 git(root, &["init", "-q"]);
487 git(root, &["config", "user.email", "test@example.com"]);
488 git(root, &["config", "user.name", "Test"]);
489 git(root, &["config", "commit.gpgsign", "false"]);
490 git(root, &["config", "tag.gpgsign", "false"]);
491 git(root, &["config", "core.hooksPath", "/dev/null"]);
493 commit_file(root, "README.md");
494 git(root, &["branch", "-M", "main"]);
495 git(root, &["checkout", "-q", "-b", "develop"]);
496 dir
497 }
498
499 fn flow(root: &Path) -> GitFlow {
500 GitFlow::new(root)
501 }
502
503 #[test]
504 fn feature_start_branches_from_develop() {
505 let repo = init_repo();
506 let root = repo.path();
507 let branch = flow(root).feature_start(3).expect("feature_start");
508 assert_eq!(branch, "feature/phase-03");
509 assert_eq!(current_branch(root), "feature/phase-03");
510 }
511
512 #[test]
513 fn list_feature_branches_reports_ahead_and_behind_semantics() {
514 let repo = init_repo();
515 let root = repo.path();
516 let gf = flow(root);
517
518 gf.feature_start(12).expect("feature_start");
519 commit_file(root, "feature-one.txt");
520 commit_file(root, "feature-two.txt");
521 git(root, &["checkout", "-q", "develop"]);
522 commit_file(root, "develop-only.txt");
523
524 let branches = gf.list_feature_branches().unwrap();
525 let branch = branches
526 .iter()
527 .find(|branch| branch.name == "feature/phase-12")
528 .unwrap();
529
530 assert_eq!(branch.ahead, 2);
531 assert_eq!(branch.behind, 1);
532 }
533
534 #[test]
535 fn feature_finish_merges_into_develop_and_deletes() {
536 let repo = init_repo();
537 let root = repo.path();
538 let gf = flow(root);
539
540 gf.feature_start(1).expect("start");
541 commit_file(root, "feature.txt");
542
543 let branch = gf.feature_finish(1).expect("finish");
544 assert_eq!(branch, "feature/phase-01");
545 assert_eq!(current_branch(root), "develop");
546
547 let branches = Command::new("git")
549 .args(["branch"])
550 .current_dir(root)
551 .output()
552 .unwrap();
553 let listing = String::from_utf8_lossy(&branches.stdout);
554 assert!(!listing.contains("feature/phase-01"));
555 assert!(root.join("feature.txt").exists());
556 }
557
558 #[test]
559 fn release_start_and_finish_tags_main_and_merges_both() {
560 let repo = init_repo();
561 let root = repo.path();
562 let gf = flow(root);
563
564 commit_file(root, "work.txt");
566 let branch = gf.release_start("1.2.0").expect("release_start");
567 assert_eq!(branch, "release/1.2.0");
568
569 gf.release_finish("1.2.0").expect("release_finish");
570 assert_eq!(current_branch(root), "develop");
571
572 let tags = Command::new("git")
574 .args(["tag"])
575 .current_dir(root)
576 .output()
577 .unwrap();
578 assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
579
580 let branches = Command::new("git")
582 .args(["branch"])
583 .current_dir(root)
584 .output()
585 .unwrap();
586 assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
587 }
588
589 #[test]
596 fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
597 let repo = init_repo();
598 let root = repo.path();
599 git(root, &["config", "tag.gpgsign", "true"]);
603
604 flow(root)
605 .tag("v9.9.9")
606 .expect("tag must not block on $EDITOR");
607
608 let tags = Command::new("git")
609 .args(["tag", "-l"])
610 .current_dir(root)
611 .output()
612 .unwrap();
613 assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
614
615 let obj_type = Command::new("git")
619 .args(["cat-file", "-t", "v9.9.9"])
620 .current_dir(root)
621 .output()
622 .unwrap();
623 assert_eq!(
624 String::from_utf8_lossy(&obj_type.stdout).trim(),
625 "commit",
626 "tag() must stay lightweight even when tag.gpgsign=true"
627 );
628 }
629
630 #[test]
631 fn release_start_branches_from_current_head_not_develop() {
632 let repo = init_repo();
633 let root = repo.path();
634 let gf = flow(root);
635
636 gf.feature_start(5).expect("feature_start");
638 commit_file(root, "feature-only.txt");
639 let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
640
641 let branch = gf.release_start("2.0.0").expect("release_start");
642 assert_eq!(branch, "release/2.0.0");
643 assert_eq!(current_branch(root), "release/2.0.0");
644
645 let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
648 let is_ancestor = Command::new("git")
649 .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
650 .current_dir(root)
651 .output()
652 .unwrap()
653 .status
654 .success();
655 assert!(
656 is_ancestor,
657 "release branch must descend from the shipped feature commit"
658 );
659 assert!(root.join("feature-only.txt").exists());
660 }
661
662 #[test]
663 fn cleanup_merged_removes_merged_but_keeps_protected() {
664 let repo = init_repo();
665 let root = repo.path();
666 let gf = flow(root);
667
668 gf.feature_start(2).expect("start");
670 commit_file(root, "f.txt");
671 gf.feature_finish(2).expect("finish");
672
673 git(root, &["branch", "stale-merged"]);
675
676 let deleted = gf.cleanup_merged().expect("cleanup");
677 assert!(deleted.contains(&"stale-merged".to_string()));
678 assert!(!deleted.contains(&"develop".to_string()));
680 assert!(!deleted.contains(&"main".to_string()));
681 }
682
683 #[test]
690 fn cleanup_merged_is_relative_to_develop_not_current_head() {
691 let repo = init_repo();
692 let root = repo.path();
693 let gf = flow(root);
694
695 git(root, &["checkout", "-q", "-b", "topic", "develop"]);
699 commit_file(root, "topic-only.txt");
700 git(root, &["checkout", "-q", "-b", "premature", "topic"]);
701
702 git(root, &["checkout", "-q", "topic"]);
710
711 let _ = gf.cleanup_merged();
712 assert!(
713 gf.branch_exists("premature"),
714 "premature is merged into topic (current HEAD) but not into \
715 develop — it must survive cleanup_merged when the baseline is develop"
716 );
717 }
718
719 #[test]
727 fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
728 let repo = init_repo();
729 let root = repo.path();
730 let gf = flow(root);
731
732 git(
735 root,
736 &["checkout", "-q", "-b", "worktree-merged", "develop"],
737 );
738 commit_file(root, "g.txt");
739 git(root, &["checkout", "-q", "develop"]);
740 git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
741
742 let wt_dir = tempfile::tempdir().unwrap();
745 git(
746 root,
747 &[
748 "worktree",
749 "add",
750 wt_dir.path().to_str().unwrap(),
751 "worktree-merged",
752 ],
753 );
754
755 git(root, &["branch", "aa-stale"]);
759 git(root, &["branch", "zz-stale"]);
760
761 let deleted = gf
762 .cleanup_merged()
763 .expect("a skipped worktree branch must not abort the sweep");
764 assert!(deleted.contains(&"aa-stale".to_string()));
765 assert!(deleted.contains(&"zz-stale".to_string()));
766 assert!(
767 !deleted.contains(&"worktree-merged".to_string()),
768 "worktree checkout cannot be deleted"
769 );
770 assert!(gf.branch_exists("worktree-merged"));
771 }
772
773 #[test]
778 fn cleanup_merged_deletes_when_head_is_not_on_develop() {
779 let repo = init_repo();
780 let root = repo.path();
781 let gf = flow(root);
782
783 git(root, &["checkout", "-q", "-b", "old", "develop"]);
786 git(root, &["checkout", "-q", "develop"]);
787 git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
788 commit_file(root, "h.txt");
789 git(root, &["checkout", "-q", "develop"]);
790 git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
791 git(root, &["checkout", "-q", "old"]);
792
793 let deleted = gf.cleanup_merged().expect("cleanup");
794 assert!(
795 deleted.contains(&"merged-feature".to_string()),
796 "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
797 );
798 assert!(!gf.branch_exists("merged-feature"));
799 }
800
801 #[test]
802 fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
803 let repo = init_repo();
804 let root = repo.path();
805 let gf = flow(root);
806
807 gf.feature_start(8).expect("start");
809 commit_file(root, "unmerged.txt");
810 git(root, &["checkout", "-q", "develop"]);
812
813 assert!(gf.delete_branch("feature/phase-08", false).is_err());
815 gf.delete_branch("feature/phase-08", true)
816 .expect("force delete");
817 let branches = Command::new("git")
818 .args(["branch"])
819 .current_dir(root)
820 .output()
821 .unwrap();
822 assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
823
824 assert!(gf.delete_branch("develop", true).is_err());
826 assert!(gf.delete_branch("main", true).is_err());
827 }
828
829 #[test]
830 fn sequentagent_helpers_integrate_and_rebase_cleanly() {
831 let repo = init_repo();
832 let root = repo.path();
833 let gf = flow(root);
834
835 gf.ensure_branch("feature/phase-07", "develop")
837 .expect("ensure base");
838 assert!(gf.branch_exists("feature/phase-07"));
839 assert!(!gf.branch_tip("feature/phase-07").unwrap().is_empty());
840 gf.ensure_branch("feature/phase-07", "develop")
842 .expect("ensure again");
843
844 let wt_a = root.join(".worktrees/a");
846 let wt_b = root.join(".worktrees/b");
847 crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
848 crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
849
850 std::fs::write(wt_a.join("a.txt"), "from-a\n").unwrap();
852 git(&wt_a, &["add", "."]);
853 git(&wt_a, &["commit", "-q", "-m", "a work"]);
854 gf.fast_forward_branch("feature/phase-07", "feat-a")
855 .expect("ff base to A");
856 assert_eq!(
857 gf.branch_tip("feature/phase-07").unwrap(),
858 gf.branch_tip("feat-a").unwrap()
859 );
860
861 gf.rebase_in(&wt_b, "feature/phase-07")
863 .expect("clean rebase");
864 assert!(wt_b.join("a.txt").exists());
866 }
867
868 #[test]
869 fn rebase_in_aborts_and_errors_on_conflict() {
870 let repo = init_repo();
871 let root = repo.path();
872 let gf = flow(root);
873
874 gf.ensure_branch("feature/phase-07", "develop")
875 .expect("ensure base");
876
877 let wt_b = root.join(".worktrees/b");
879 crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
880 std::fs::write(wt_b.join("a.txt"), "from-b\n").unwrap();
881 git(&wt_b, &["add", "."]);
882 git(&wt_b, &["commit", "-q", "-m", "b edits a"]);
883
884 let wt_a = root.join(".worktrees/a");
886 crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
887 std::fs::write(wt_a.join("a.txt"), "from-base\n").unwrap();
888 git(&wt_a, &["add", "."]);
889 git(&wt_a, &["commit", "-q", "-m", "base edits a"]);
890 gf.fast_forward_branch("feature/phase-07", "feat-a")
891 .expect("ff base to A");
892
893 let err = gf.rebase_in(&wt_b, "feature/phase-07").unwrap_err();
895 assert!(matches!(err, GitError::Command(_)));
896 assert!(!root.join(".git/worktrees/b/rebase-merge").exists());
898 assert_eq!(
900 std::fs::read_to_string(wt_b.join("a.txt")).unwrap(),
901 "from-b\n"
902 );
903 }
904
905 #[test]
906 fn merge_of_missing_branch_is_an_error() {
907 let repo = init_repo();
908 let root = repo.path();
909 let err = flow(root).feature_finish(99).unwrap_err();
912 assert!(matches!(err, GitError::Command(_)));
913 }
914}