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 commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
330 debug!("committing {relative_path}: {message}");
331 self.git(["add", relative_path])?;
337 match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
338 Ok(()) => Ok(()),
339 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
344 Err(e) => Err(e),
345 }
346 }
347
348 pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
354 let current = self
355 .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
356 .trim()
357 .to_string();
358 if current == self.config.develop {
359 return Ok((0, 0));
360 }
361 let ahead = self
362 .rev_count(&format!("{}..{current}", self.config.develop))
363 .unwrap_or(0);
364 let behind = self
365 .rev_count(&format!("{current}..{}", self.config.develop))
366 .unwrap_or(0);
367 Ok((ahead, behind))
368 }
369
370 pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
375 let prefix = &self.config.feature_prefix;
376 let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
377 let mut result = Vec::new();
378 for name in branches.lines().map(|l| l.trim()) {
379 if name.is_empty()
380 || name == self.config.main
381 || name == self.config.develop
382 || !name.starts_with(prefix)
383 {
384 continue;
385 }
386 let ahead = self
387 .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
388 .unwrap_or(0);
389 let behind = self
390 .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
391 .unwrap_or(0);
392 let last_commit = self
393 .git_output(["log", "-1", "--format=%aI", name])
394 .map(|s| s.trim().to_string())
395 .unwrap_or_default();
396 result.push(BranchInfo {
397 name: name.to_string(),
398 ahead,
399 behind,
400 last_commit,
401 });
402 }
403 result.sort_by(|a, b| a.name.cmp(&b.name));
405 Ok(result)
406 }
407
408 fn rev_count(&self, range: &str) -> Option<usize> {
410 self.git_output(["rev-list", "--count", range])
411 .ok()
412 .and_then(|s| s.trim().parse().ok())
413 }
414
415 fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
416 debug!("git {}", args.join(" "));
417 let output = Command::new("git")
423 .args(args)
424 .env("LC_ALL", "C")
425 .env("LANG", "C")
426 .current_dir(&self.root)
427 .output()?;
428 if output.status.success() {
429 Ok(())
430 } else {
431 Err(GitError::Command(stderr_or_status(&output)))
432 }
433 }
434
435 fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
449 debug!("git {}", args.join(" "));
450 let output = Command::new("git")
451 .args(args)
452 .env("LC_ALL", "C")
453 .env("LANG", "C")
454 .current_dir(&self.root)
455 .output()?;
456 if output.status.success() {
457 Ok(())
458 } else {
459 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
460 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
461 let combined = match (stderr.is_empty(), stdout.is_empty()) {
462 (false, false) => format!("{stderr}\n{stdout}"),
463 (false, true) => stderr,
464 (true, false) => stdout,
465 (true, true) => format!("exited with {}", output.status),
466 };
467 Err(GitError::Command(combined))
468 }
469 }
470
471 fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
472 debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
473 let output = Command::new("git")
474 .args(args)
475 .current_dir(&self.root)
476 .output()?;
477 if output.status.success() {
478 Ok(())
479 } else {
480 Err(GitError::Command(stderr_or_status(&output)))
481 }
482 }
483
484 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
485 let output = Command::new("git")
486 .args(args)
487 .current_dir(&self.root)
488 .output()?;
489 if output.status.success() {
490 Ok(String::from_utf8_lossy(&output.stdout).to_string())
491 } else {
492 Err(GitError::Command(stderr_or_status(&output)))
493 }
494 }
495}
496
497fn git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
499 debug!("git (in {}) {}", dir.display(), args.join(" "));
500 let output = Command::new("git").args(args).current_dir(dir).output()?;
501 if output.status.success() {
502 Ok(())
503 } else {
504 Err(GitError::Command(stderr_or_status(&output)))
505 }
506}
507
508fn stderr_or_status(output: &std::process::Output) -> String {
509 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
510 if stderr.is_empty() {
511 format!("exited with {}", output.status)
512 } else {
513 stderr
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use std::process::Command;
521 use tempfile::TempDir;
522
523 fn git(root: &Path, args: &[&str]) {
525 let output = Command::new("git")
526 .args(args)
527 .current_dir(root)
528 .output()
529 .expect("spawn git");
530 assert!(
531 output.status.success(),
532 "git {args:?} failed: {}",
533 String::from_utf8_lossy(&output.stderr)
534 );
535 }
536
537 fn current_branch(root: &Path) -> String {
538 let output = Command::new("git")
539 .args(["rev-parse", "--abbrev-ref", "HEAD"])
540 .current_dir(root)
541 .output()
542 .expect("rev-parse");
543 String::from_utf8_lossy(&output.stdout).trim().to_string()
544 }
545
546 fn commit_file(root: &Path, name: &str) {
547 std::fs::write(root.join(name), name).unwrap();
548 git(root, &["add", "."]);
549 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
550 }
551
552 fn init_repo() -> TempDir {
554 let dir = tempfile::tempdir().unwrap();
555 let root = dir.path();
556 git(root, &["init", "-q"]);
557 git(root, &["config", "user.email", "test@example.com"]);
558 git(root, &["config", "user.name", "Test"]);
559 git(root, &["config", "commit.gpgsign", "false"]);
560 git(root, &["config", "tag.gpgsign", "false"]);
561 git(root, &["config", "core.hooksPath", "/dev/null"]);
563 commit_file(root, "README.md");
564 git(root, &["branch", "-M", "main"]);
565 git(root, &["checkout", "-q", "-b", "develop"]);
566 dir
567 }
568
569 fn flow(root: &Path) -> GitFlow {
570 GitFlow::new(root)
571 }
572
573 #[test]
574 fn feature_start_branches_from_develop() {
575 let repo = init_repo();
576 let root = repo.path();
577 let branch = flow(root).feature_start(3).expect("feature_start");
578 assert_eq!(branch, "feature/phase-03");
579 assert_eq!(current_branch(root), "feature/phase-03");
580 }
581
582 #[test]
583 fn list_feature_branches_reports_ahead_and_behind_semantics() {
584 let repo = init_repo();
585 let root = repo.path();
586 let gf = flow(root);
587
588 gf.feature_start(12).expect("feature_start");
589 commit_file(root, "feature-one.txt");
590 commit_file(root, "feature-two.txt");
591 git(root, &["checkout", "-q", "develop"]);
592 commit_file(root, "develop-only.txt");
593
594 let branches = gf.list_feature_branches().unwrap();
595 let branch = branches
596 .iter()
597 .find(|branch| branch.name == "feature/phase-12")
598 .unwrap();
599
600 assert_eq!(branch.ahead, 2);
601 assert_eq!(branch.behind, 1);
602 }
603
604 #[test]
605 fn feature_finish_merges_into_develop_and_deletes() {
606 let repo = init_repo();
607 let root = repo.path();
608 let gf = flow(root);
609
610 gf.feature_start(1).expect("start");
611 commit_file(root, "feature.txt");
612
613 let branch = gf.feature_finish(1).expect("finish");
614 assert_eq!(branch, "feature/phase-01");
615 assert_eq!(current_branch(root), "develop");
616
617 let branches = Command::new("git")
619 .args(["branch"])
620 .current_dir(root)
621 .output()
622 .unwrap();
623 let listing = String::from_utf8_lossy(&branches.stdout);
624 assert!(!listing.contains("feature/phase-01"));
625 assert!(root.join("feature.txt").exists());
626 }
627
628 #[test]
629 fn release_start_and_finish_tags_main_and_merges_both() {
630 let repo = init_repo();
631 let root = repo.path();
632 let gf = flow(root);
633
634 commit_file(root, "work.txt");
636 let branch = gf.release_start("1.2.0").expect("release_start");
637 assert_eq!(branch, "release/1.2.0");
638
639 gf.release_finish("1.2.0").expect("release_finish");
640 assert_eq!(current_branch(root), "develop");
641
642 let tags = Command::new("git")
644 .args(["tag"])
645 .current_dir(root)
646 .output()
647 .unwrap();
648 assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
649
650 let branches = Command::new("git")
652 .args(["branch"])
653 .current_dir(root)
654 .output()
655 .unwrap();
656 assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
657 }
658
659 #[test]
666 fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
667 let repo = init_repo();
668 let root = repo.path();
669 git(root, &["config", "tag.gpgsign", "true"]);
673
674 flow(root)
675 .tag("v9.9.9")
676 .expect("tag must not block on $EDITOR");
677
678 let tags = Command::new("git")
679 .args(["tag", "-l"])
680 .current_dir(root)
681 .output()
682 .unwrap();
683 assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
684
685 let obj_type = Command::new("git")
689 .args(["cat-file", "-t", "v9.9.9"])
690 .current_dir(root)
691 .output()
692 .unwrap();
693 assert_eq!(
694 String::from_utf8_lossy(&obj_type.stdout).trim(),
695 "commit",
696 "tag() must stay lightweight even when tag.gpgsign=true"
697 );
698 }
699
700 #[test]
701 fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
702 let repo = init_repo();
706 let root = repo.path();
707 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
708 std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
709
710 Command::new("git")
715 .args(["add", "unrelated.txt"])
716 .current_dir(root)
717 .status()
718 .unwrap();
719
720 flow(root)
721 .commit_path("CHANGELOG.md", "docs: add changelog entry")
722 .expect("commit_path");
723
724 let committed = Command::new("git")
725 .args(["log", "-1", "--name-only", "--pretty=format:"])
726 .current_dir(root)
727 .output()
728 .unwrap();
729 let committed_files = String::from_utf8_lossy(&committed.stdout);
730 assert!(committed_files.contains("CHANGELOG.md"));
731 assert!(!committed_files.contains("unrelated.txt"));
732
733 let status = Command::new("git")
734 .args(["status", "--porcelain"])
735 .current_dir(root)
736 .output()
737 .unwrap();
738 let status = String::from_utf8_lossy(&status.stdout);
739 assert!(
740 status.contains("A unrelated.txt"),
741 "unrelated.txt must remain staged-but-uncommitted, got: {status}"
742 );
743 }
744
745 fn rev_list_count(root: &Path) -> u32 {
748 let output = Command::new("git")
749 .args(["rev-list", "--count", "HEAD"])
750 .current_dir(root)
751 .output()
752 .unwrap();
753 assert!(output.status.success(), "git rev-list --count HEAD failed");
754 String::from_utf8_lossy(&output.stdout)
755 .trim()
756 .parse::<u32>()
757 .expect("rev-list --count HEAD must print an integer")
758 }
759
760 #[test]
768 fn commit_path_twice_with_identical_content_creates_only_one_commit() {
769 let repo = init_repo();
770 let root = repo.path();
771 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
772
773 flow(root)
774 .commit_path("CHANGELOG.md", "docs: add changelog entry")
775 .expect("first commit_path call");
776 let n1 = rev_list_count(root);
777
778 flow(root)
781 .commit_path("CHANGELOG.md", "docs: add changelog entry")
782 .expect("second commit_path call");
783 let n2 = rev_list_count(root);
784
785 assert_eq!(
786 n2, n1,
787 "a repeat commit_path call on unchanged content must not add a \
788 commit: n1={n1}, n2={n2}"
789 );
790 }
791
792 #[test]
800 fn commit_path_with_no_changes_returns_ok_without_committing() {
801 let repo = init_repo();
802 let root = repo.path();
803 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
804 flow(root)
805 .commit_path("CHANGELOG.md", "docs: add changelog entry")
806 .expect("initial commit_path");
807 let n1 = rev_list_count(root);
808
809 let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
812 let n2 = rev_list_count(root);
813
814 assert!(
815 result.is_ok(),
816 "no-op call must return Ok(()), got: {result:?}"
817 );
818 assert_eq!(
819 n2, n1,
820 "no-op call must not create a commit: n1={n1}, n2={n2}"
821 );
822 }
823
824 #[test]
830 fn commit_path_on_nonexistent_path_still_errors() {
831 let repo = init_repo();
832 let root = repo.path();
833
834 let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
835
836 assert!(
837 result.is_err(),
838 "commit_path on an unknown pathspec must still error, got: {result:?}"
839 );
840 }
841
842 #[test]
843 fn release_start_branches_from_current_head_not_develop() {
844 let repo = init_repo();
845 let root = repo.path();
846 let gf = flow(root);
847
848 gf.feature_start(5).expect("feature_start");
850 commit_file(root, "feature-only.txt");
851 let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
852
853 let branch = gf.release_start("2.0.0").expect("release_start");
854 assert_eq!(branch, "release/2.0.0");
855 assert_eq!(current_branch(root), "release/2.0.0");
856
857 let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
860 let is_ancestor = Command::new("git")
861 .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
862 .current_dir(root)
863 .output()
864 .unwrap()
865 .status
866 .success();
867 assert!(
868 is_ancestor,
869 "release branch must descend from the shipped feature commit"
870 );
871 assert!(root.join("feature-only.txt").exists());
872 }
873
874 #[test]
875 fn cleanup_merged_removes_merged_but_keeps_protected() {
876 let repo = init_repo();
877 let root = repo.path();
878 let gf = flow(root);
879
880 gf.feature_start(2).expect("start");
882 commit_file(root, "f.txt");
883 gf.feature_finish(2).expect("finish");
884
885 git(root, &["branch", "stale-merged"]);
887
888 let deleted = gf.cleanup_merged().expect("cleanup");
889 assert!(deleted.contains(&"stale-merged".to_string()));
890 assert!(!deleted.contains(&"develop".to_string()));
892 assert!(!deleted.contains(&"main".to_string()));
893 }
894
895 #[test]
902 fn cleanup_merged_is_relative_to_develop_not_current_head() {
903 let repo = init_repo();
904 let root = repo.path();
905 let gf = flow(root);
906
907 git(root, &["checkout", "-q", "-b", "topic", "develop"]);
911 commit_file(root, "topic-only.txt");
912 git(root, &["checkout", "-q", "-b", "premature", "topic"]);
913
914 git(root, &["checkout", "-q", "topic"]);
922
923 let _ = gf.cleanup_merged();
924 assert!(
925 gf.branch_exists("premature"),
926 "premature is merged into topic (current HEAD) but not into \
927 develop — it must survive cleanup_merged when the baseline is develop"
928 );
929 }
930
931 #[test]
939 fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
940 let repo = init_repo();
941 let root = repo.path();
942 let gf = flow(root);
943
944 git(
947 root,
948 &["checkout", "-q", "-b", "worktree-merged", "develop"],
949 );
950 commit_file(root, "g.txt");
951 git(root, &["checkout", "-q", "develop"]);
952 git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
953
954 let wt_dir = tempfile::tempdir().unwrap();
957 git(
958 root,
959 &[
960 "worktree",
961 "add",
962 wt_dir.path().to_str().unwrap(),
963 "worktree-merged",
964 ],
965 );
966
967 git(root, &["branch", "aa-stale"]);
971 git(root, &["branch", "zz-stale"]);
972
973 let deleted = gf
974 .cleanup_merged()
975 .expect("a skipped worktree branch must not abort the sweep");
976 assert!(deleted.contains(&"aa-stale".to_string()));
977 assert!(deleted.contains(&"zz-stale".to_string()));
978 assert!(
979 !deleted.contains(&"worktree-merged".to_string()),
980 "worktree checkout cannot be deleted"
981 );
982 assert!(gf.branch_exists("worktree-merged"));
983 }
984
985 #[test]
990 fn cleanup_merged_deletes_when_head_is_not_on_develop() {
991 let repo = init_repo();
992 let root = repo.path();
993 let gf = flow(root);
994
995 git(root, &["checkout", "-q", "-b", "old", "develop"]);
998 git(root, &["checkout", "-q", "develop"]);
999 git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1000 commit_file(root, "h.txt");
1001 git(root, &["checkout", "-q", "develop"]);
1002 git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1003 git(root, &["checkout", "-q", "old"]);
1004
1005 let deleted = gf.cleanup_merged().expect("cleanup");
1006 assert!(
1007 deleted.contains(&"merged-feature".to_string()),
1008 "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1009 );
1010 assert!(!gf.branch_exists("merged-feature"));
1011 }
1012
1013 #[test]
1014 fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1015 let repo = init_repo();
1016 let root = repo.path();
1017 let gf = flow(root);
1018
1019 gf.feature_start(8).expect("start");
1021 commit_file(root, "unmerged.txt");
1022 git(root, &["checkout", "-q", "develop"]);
1024
1025 assert!(gf.delete_branch("feature/phase-08", false).is_err());
1027 gf.delete_branch("feature/phase-08", true)
1028 .expect("force delete");
1029 let branches = Command::new("git")
1030 .args(["branch"])
1031 .current_dir(root)
1032 .output()
1033 .unwrap();
1034 assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1035
1036 assert!(gf.delete_branch("develop", true).is_err());
1038 assert!(gf.delete_branch("main", true).is_err());
1039 }
1040
1041 #[test]
1042 fn sequentagent_helpers_integrate_and_rebase_cleanly() {
1043 let repo = init_repo();
1044 let root = repo.path();
1045 let gf = flow(root);
1046
1047 gf.ensure_branch("feature/phase-07", "develop")
1049 .expect("ensure base");
1050 assert!(gf.branch_exists("feature/phase-07"));
1051 assert!(!gf.branch_tip("feature/phase-07").unwrap().is_empty());
1052 gf.ensure_branch("feature/phase-07", "develop")
1054 .expect("ensure again");
1055
1056 let wt_a = root.join(".worktrees/a");
1058 let wt_b = root.join(".worktrees/b");
1059 crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
1060 crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
1061
1062 std::fs::write(wt_a.join("a.txt"), "from-a\n").unwrap();
1064 git(&wt_a, &["add", "."]);
1065 git(&wt_a, &["commit", "-q", "-m", "a work"]);
1066 gf.fast_forward_branch("feature/phase-07", "feat-a")
1067 .expect("ff base to A");
1068 assert_eq!(
1069 gf.branch_tip("feature/phase-07").unwrap(),
1070 gf.branch_tip("feat-a").unwrap()
1071 );
1072
1073 gf.rebase_in(&wt_b, "feature/phase-07")
1075 .expect("clean rebase");
1076 assert!(wt_b.join("a.txt").exists());
1078 }
1079
1080 #[test]
1081 fn rebase_in_aborts_and_errors_on_conflict() {
1082 let repo = init_repo();
1083 let root = repo.path();
1084 let gf = flow(root);
1085
1086 gf.ensure_branch("feature/phase-07", "develop")
1087 .expect("ensure base");
1088
1089 let wt_b = root.join(".worktrees/b");
1091 crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
1092 std::fs::write(wt_b.join("a.txt"), "from-b\n").unwrap();
1093 git(&wt_b, &["add", "."]);
1094 git(&wt_b, &["commit", "-q", "-m", "b edits a"]);
1095
1096 let wt_a = root.join(".worktrees/a");
1098 crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
1099 std::fs::write(wt_a.join("a.txt"), "from-base\n").unwrap();
1100 git(&wt_a, &["add", "."]);
1101 git(&wt_a, &["commit", "-q", "-m", "base edits a"]);
1102 gf.fast_forward_branch("feature/phase-07", "feat-a")
1103 .expect("ff base to A");
1104
1105 let err = gf.rebase_in(&wt_b, "feature/phase-07").unwrap_err();
1107 assert!(matches!(err, GitError::Command(_)));
1108 assert!(!root.join(".git/worktrees/b/rebase-merge").exists());
1110 assert_eq!(
1112 std::fs::read_to_string(wt_b.join("a.txt")).unwrap(),
1113 "from-b\n"
1114 );
1115 }
1116
1117 #[test]
1118 fn merge_of_missing_branch_is_an_error() {
1119 let repo = init_repo();
1120 let root = repo.path();
1121 let err = flow(root).feature_finish(99).unwrap_err();
1124 assert!(matches!(err, GitError::Command(_)));
1125 }
1126}