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 = format!("{}phase-{:02}", self.config.feature_prefix, phase);
73 info!("finishing feature branch: {branch}");
74 self.git(["checkout", &self.config.develop])?;
75 self.git(["merge", "--no-ff", &branch])?;
76 self.git(["branch", "-d", &branch])?;
77 Ok(branch)
78 }
79
80 pub fn release_start(&self, version: &str) -> Result<String, GitError> {
87 let branch = format!("release/{version}");
88 info!("creating release branch: {branch}");
89 self.git(["checkout", "-B", &branch])?;
90 Ok(branch)
91 }
92
93 pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
95 let branch = format!("release/{version}");
96 info!("finishing release branch: {branch}");
97 self.git(["checkout", &self.config.main])?;
98 self.git(["merge", "--no-ff", &branch])?;
99 self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
106 self.git(["checkout", &self.config.develop])?;
107 self.git(["merge", "--no-ff", &branch])?;
108 self.git(["branch", "-d", &branch])?;
109 Ok(branch)
110 }
111
112 pub fn tag(&self, tag: &str) -> Result<(), GitError> {
121 info!("tagging {tag}");
122 self.git(["-c", "tag.gpgSign=false", "tag", tag])
123 }
124
125 pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
131 if branch == self.config.main || branch == self.config.develop {
132 return Err(GitError::Command(format!(
133 "refusing to delete protected branch `{branch}`"
134 )));
135 }
136 let flag = if force { "-D" } else { "-d" };
137 if force {
138 warn!("force-deleting branch: {branch}");
139 } else {
140 info!("deleting branch: {branch}");
141 }
142 self.git(["branch", flag, branch])
143 }
144
145 pub fn branch_exists(&self, branch: &str) -> bool {
147 Command::new("git")
148 .args([
149 "rev-parse",
150 "--verify",
151 "--quiet",
152 &format!("refs/heads/{branch}"),
153 ])
154 .current_dir(&self.root)
155 .output()
156 .map(|o| o.status.success())
157 .unwrap_or(false)
158 }
159
160 pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
162 Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
163 }
164
165 pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
168 if self.branch_exists(branch) {
169 return Ok(());
170 }
171 self.git(["branch", branch, start_point])
172 }
173
174 pub fn fast_forward_branch(&self, target: &str, source: &str) -> Result<(), GitError> {
179 let is_ancestor = Command::new("git")
180 .args(["merge-base", "--is-ancestor", target, source])
181 .current_dir(&self.root)
182 .output()?
183 .status
184 .success();
185 if !is_ancestor {
186 return Err(GitError::Command(format!(
187 "{target} is not an ancestor of {source}; refusing non-fast-forward update"
188 )));
189 }
190 self.git(["branch", "-f", target, source])
191 }
192
193 pub fn rebase_in(&self, dir: &Path, onto: &str) -> Result<(), GitError> {
198 debug!("rebasing worktree at {} onto {onto}", dir.display());
199 match git_in(dir, &["rebase", onto]) {
200 Ok(()) => Ok(()),
201 Err(err) => {
202 warn!("rebase conflict in {}; aborting", dir.display());
204 let _ = git_in(dir, &["rebase", "--abort"]);
205 Err(err)
206 }
207 }
208 }
209
210 pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
212 debug!("checking out branch: {branch}");
213 self.git(["checkout", branch])
214 }
215
216 pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
218 info!("deleting remote branch: {branch}");
219 self.git(["push", "origin", "--delete", branch])
220 }
221
222 pub fn has_remote(&self) -> bool {
224 self.git_output(["remote"])
225 .map(|s| !s.trim().is_empty())
226 .unwrap_or(false)
227 }
228
229 pub fn push(&self, branch: &str) -> Result<(), GitError> {
231 info!("pushing branch: {branch}");
232 self.git(["push", "-u", "origin", branch])
233 }
234
235 pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
251 let output = self.git_output(["branch", "--merged", &self.config.develop])?;
252 let protected = [self.config.main.as_str(), self.config.develop.as_str()];
253 let mut deleted = Vec::new();
254 for line in output.lines() {
255 let branch = line
261 .strip_prefix("* ")
262 .or_else(|| line.strip_prefix("+ "))
263 .unwrap_or(line)
264 .trim();
265 if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
268 continue;
269 }
270 info!("cleaning up merged branch: {branch}");
271 match self.git(["branch", "-D", branch]) {
272 Ok(()) => deleted.push(branch.to_string()),
273 Err(err) => warn!("could not delete merged branch {branch}: {err}"),
274 }
275 }
276 Ok(deleted)
277 }
278
279 pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
282 debug!("committing all changes: {message}");
283 self.git(["add", "."])?;
284 match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
286 Ok(()) => Ok(()),
287 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
290 Err(e) => Err(e),
291 }
292 }
293
294 pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
300 let current = self
301 .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
302 .trim()
303 .to_string();
304 if current == self.config.develop {
305 return Ok((0, 0));
306 }
307 let ahead = self
308 .rev_count(&format!("{}..{current}", self.config.develop))
309 .unwrap_or(0);
310 let behind = self
311 .rev_count(&format!("{current}..{}", self.config.develop))
312 .unwrap_or(0);
313 Ok((ahead, behind))
314 }
315
316 pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
321 let prefix = &self.config.feature_prefix;
322 let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
323 let mut result = Vec::new();
324 for name in branches.lines().map(|l| l.trim()) {
325 if name.is_empty()
326 || name == self.config.main
327 || name == self.config.develop
328 || !name.starts_with(prefix)
329 {
330 continue;
331 }
332 let ahead = self
333 .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
334 .unwrap_or(0);
335 let behind = self
336 .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
337 .unwrap_or(0);
338 let last_commit = self
339 .git_output(["log", "-1", "--format=%aI", name])
340 .map(|s| s.trim().to_string())
341 .unwrap_or_default();
342 result.push(BranchInfo {
343 name: name.to_string(),
344 ahead,
345 behind,
346 last_commit,
347 });
348 }
349 result.sort_by(|a, b| a.name.cmp(&b.name));
351 Ok(result)
352 }
353
354 fn rev_count(&self, range: &str) -> Option<usize> {
356 self.git_output(["rev-list", "--count", range])
357 .ok()
358 .and_then(|s| s.trim().parse().ok())
359 }
360
361 fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
362 debug!("git {}", args.join(" "));
363 let output = Command::new("git")
364 .args(args)
365 .current_dir(&self.root)
366 .output()?;
367 if output.status.success() {
368 Ok(())
369 } else {
370 Err(GitError::Command(stderr_or_status(&output)))
371 }
372 }
373
374 fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
375 debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
376 let output = Command::new("git")
377 .args(args)
378 .current_dir(&self.root)
379 .output()?;
380 if output.status.success() {
381 Ok(())
382 } else {
383 Err(GitError::Command(stderr_or_status(&output)))
384 }
385 }
386
387 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
388 let output = Command::new("git")
389 .args(args)
390 .current_dir(&self.root)
391 .output()?;
392 if output.status.success() {
393 Ok(String::from_utf8_lossy(&output.stdout).to_string())
394 } else {
395 Err(GitError::Command(stderr_or_status(&output)))
396 }
397 }
398}
399
400fn git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
402 debug!("git (in {}) {}", dir.display(), args.join(" "));
403 let output = Command::new("git").args(args).current_dir(dir).output()?;
404 if output.status.success() {
405 Ok(())
406 } else {
407 Err(GitError::Command(stderr_or_status(&output)))
408 }
409}
410
411fn stderr_or_status(output: &std::process::Output) -> String {
412 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
413 if stderr.is_empty() {
414 format!("exited with {}", output.status)
415 } else {
416 stderr
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use std::process::Command;
424 use tempfile::TempDir;
425
426 fn git(root: &Path, args: &[&str]) {
428 let output = Command::new("git")
429 .args(args)
430 .current_dir(root)
431 .output()
432 .expect("spawn git");
433 assert!(
434 output.status.success(),
435 "git {args:?} failed: {}",
436 String::from_utf8_lossy(&output.stderr)
437 );
438 }
439
440 fn current_branch(root: &Path) -> String {
441 let output = Command::new("git")
442 .args(["rev-parse", "--abbrev-ref", "HEAD"])
443 .current_dir(root)
444 .output()
445 .expect("rev-parse");
446 String::from_utf8_lossy(&output.stdout).trim().to_string()
447 }
448
449 fn commit_file(root: &Path, name: &str) {
450 std::fs::write(root.join(name), name).unwrap();
451 git(root, &["add", "."]);
452 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
453 }
454
455 fn init_repo() -> TempDir {
457 let dir = tempfile::tempdir().unwrap();
458 let root = dir.path();
459 git(root, &["init", "-q"]);
460 git(root, &["config", "user.email", "test@example.com"]);
461 git(root, &["config", "user.name", "Test"]);
462 git(root, &["config", "commit.gpgsign", "false"]);
463 git(root, &["config", "tag.gpgsign", "false"]);
464 git(root, &["config", "core.hooksPath", "/dev/null"]);
466 commit_file(root, "README.md");
467 git(root, &["branch", "-M", "main"]);
468 git(root, &["checkout", "-q", "-b", "develop"]);
469 dir
470 }
471
472 fn flow(root: &Path) -> GitFlow {
473 GitFlow::new(root)
474 }
475
476 #[test]
477 fn feature_start_branches_from_develop() {
478 let repo = init_repo();
479 let root = repo.path();
480 let branch = flow(root).feature_start(3).expect("feature_start");
481 assert_eq!(branch, "feature/phase-03");
482 assert_eq!(current_branch(root), "feature/phase-03");
483 }
484
485 #[test]
486 fn list_feature_branches_reports_ahead_and_behind_semantics() {
487 let repo = init_repo();
488 let root = repo.path();
489 let gf = flow(root);
490
491 gf.feature_start(12).expect("feature_start");
492 commit_file(root, "feature-one.txt");
493 commit_file(root, "feature-two.txt");
494 git(root, &["checkout", "-q", "develop"]);
495 commit_file(root, "develop-only.txt");
496
497 let branches = gf.list_feature_branches().unwrap();
498 let branch = branches
499 .iter()
500 .find(|branch| branch.name == "feature/phase-12")
501 .unwrap();
502
503 assert_eq!(branch.ahead, 2);
504 assert_eq!(branch.behind, 1);
505 }
506
507 #[test]
508 fn feature_finish_merges_into_develop_and_deletes() {
509 let repo = init_repo();
510 let root = repo.path();
511 let gf = flow(root);
512
513 gf.feature_start(1).expect("start");
514 commit_file(root, "feature.txt");
515
516 let branch = gf.feature_finish(1).expect("finish");
517 assert_eq!(branch, "feature/phase-01");
518 assert_eq!(current_branch(root), "develop");
519
520 let branches = Command::new("git")
522 .args(["branch"])
523 .current_dir(root)
524 .output()
525 .unwrap();
526 let listing = String::from_utf8_lossy(&branches.stdout);
527 assert!(!listing.contains("feature/phase-01"));
528 assert!(root.join("feature.txt").exists());
529 }
530
531 #[test]
532 fn release_start_and_finish_tags_main_and_merges_both() {
533 let repo = init_repo();
534 let root = repo.path();
535 let gf = flow(root);
536
537 commit_file(root, "work.txt");
539 let branch = gf.release_start("1.2.0").expect("release_start");
540 assert_eq!(branch, "release/1.2.0");
541
542 gf.release_finish("1.2.0").expect("release_finish");
543 assert_eq!(current_branch(root), "develop");
544
545 let tags = Command::new("git")
547 .args(["tag"])
548 .current_dir(root)
549 .output()
550 .unwrap();
551 assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
552
553 let branches = Command::new("git")
555 .args(["branch"])
556 .current_dir(root)
557 .output()
558 .unwrap();
559 assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
560 }
561
562 #[test]
569 fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
570 let repo = init_repo();
571 let root = repo.path();
572 git(root, &["config", "tag.gpgsign", "true"]);
576
577 flow(root)
578 .tag("v9.9.9")
579 .expect("tag must not block on $EDITOR");
580
581 let tags = Command::new("git")
582 .args(["tag", "-l"])
583 .current_dir(root)
584 .output()
585 .unwrap();
586 assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
587
588 let obj_type = Command::new("git")
592 .args(["cat-file", "-t", "v9.9.9"])
593 .current_dir(root)
594 .output()
595 .unwrap();
596 assert_eq!(
597 String::from_utf8_lossy(&obj_type.stdout).trim(),
598 "commit",
599 "tag() must stay lightweight even when tag.gpgsign=true"
600 );
601 }
602
603 #[test]
604 fn release_start_branches_from_current_head_not_develop() {
605 let repo = init_repo();
606 let root = repo.path();
607 let gf = flow(root);
608
609 gf.feature_start(5).expect("feature_start");
611 commit_file(root, "feature-only.txt");
612 let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
613
614 let branch = gf.release_start("2.0.0").expect("release_start");
615 assert_eq!(branch, "release/2.0.0");
616 assert_eq!(current_branch(root), "release/2.0.0");
617
618 let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
621 let is_ancestor = Command::new("git")
622 .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
623 .current_dir(root)
624 .output()
625 .unwrap()
626 .status
627 .success();
628 assert!(
629 is_ancestor,
630 "release branch must descend from the shipped feature commit"
631 );
632 assert!(root.join("feature-only.txt").exists());
633 }
634
635 #[test]
636 fn cleanup_merged_removes_merged_but_keeps_protected() {
637 let repo = init_repo();
638 let root = repo.path();
639 let gf = flow(root);
640
641 gf.feature_start(2).expect("start");
643 commit_file(root, "f.txt");
644 gf.feature_finish(2).expect("finish");
645
646 git(root, &["branch", "stale-merged"]);
648
649 let deleted = gf.cleanup_merged().expect("cleanup");
650 assert!(deleted.contains(&"stale-merged".to_string()));
651 assert!(!deleted.contains(&"develop".to_string()));
653 assert!(!deleted.contains(&"main".to_string()));
654 }
655
656 #[test]
663 fn cleanup_merged_is_relative_to_develop_not_current_head() {
664 let repo = init_repo();
665 let root = repo.path();
666 let gf = flow(root);
667
668 git(root, &["checkout", "-q", "-b", "topic", "develop"]);
672 commit_file(root, "topic-only.txt");
673 git(root, &["checkout", "-q", "-b", "premature", "topic"]);
674
675 git(root, &["checkout", "-q", "topic"]);
683
684 let _ = gf.cleanup_merged();
685 assert!(
686 gf.branch_exists("premature"),
687 "premature is merged into topic (current HEAD) but not into \
688 develop — it must survive cleanup_merged when the baseline is develop"
689 );
690 }
691
692 #[test]
700 fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
701 let repo = init_repo();
702 let root = repo.path();
703 let gf = flow(root);
704
705 git(
708 root,
709 &["checkout", "-q", "-b", "worktree-merged", "develop"],
710 );
711 commit_file(root, "g.txt");
712 git(root, &["checkout", "-q", "develop"]);
713 git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
714
715 let wt_dir = tempfile::tempdir().unwrap();
718 git(
719 root,
720 &[
721 "worktree",
722 "add",
723 wt_dir.path().to_str().unwrap(),
724 "worktree-merged",
725 ],
726 );
727
728 git(root, &["branch", "aa-stale"]);
732 git(root, &["branch", "zz-stale"]);
733
734 let deleted = gf
735 .cleanup_merged()
736 .expect("a skipped worktree branch must not abort the sweep");
737 assert!(deleted.contains(&"aa-stale".to_string()));
738 assert!(deleted.contains(&"zz-stale".to_string()));
739 assert!(
740 !deleted.contains(&"worktree-merged".to_string()),
741 "worktree checkout cannot be deleted"
742 );
743 assert!(gf.branch_exists("worktree-merged"));
744 }
745
746 #[test]
751 fn cleanup_merged_deletes_when_head_is_not_on_develop() {
752 let repo = init_repo();
753 let root = repo.path();
754 let gf = flow(root);
755
756 git(root, &["checkout", "-q", "-b", "old", "develop"]);
759 git(root, &["checkout", "-q", "develop"]);
760 git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
761 commit_file(root, "h.txt");
762 git(root, &["checkout", "-q", "develop"]);
763 git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
764 git(root, &["checkout", "-q", "old"]);
765
766 let deleted = gf.cleanup_merged().expect("cleanup");
767 assert!(
768 deleted.contains(&"merged-feature".to_string()),
769 "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
770 );
771 assert!(!gf.branch_exists("merged-feature"));
772 }
773
774 #[test]
775 fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
776 let repo = init_repo();
777 let root = repo.path();
778 let gf = flow(root);
779
780 gf.feature_start(8).expect("start");
782 commit_file(root, "unmerged.txt");
783 git(root, &["checkout", "-q", "develop"]);
785
786 assert!(gf.delete_branch("feature/phase-08", false).is_err());
788 gf.delete_branch("feature/phase-08", true)
789 .expect("force delete");
790 let branches = Command::new("git")
791 .args(["branch"])
792 .current_dir(root)
793 .output()
794 .unwrap();
795 assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
796
797 assert!(gf.delete_branch("develop", true).is_err());
799 assert!(gf.delete_branch("main", true).is_err());
800 }
801
802 #[test]
803 fn sequentagent_helpers_integrate_and_rebase_cleanly() {
804 let repo = init_repo();
805 let root = repo.path();
806 let gf = flow(root);
807
808 gf.ensure_branch("feature/phase-07", "develop")
810 .expect("ensure base");
811 assert!(gf.branch_exists("feature/phase-07"));
812 assert!(!gf.branch_tip("feature/phase-07").unwrap().is_empty());
813 gf.ensure_branch("feature/phase-07", "develop")
815 .expect("ensure again");
816
817 let wt_a = root.join(".worktrees/a");
819 let wt_b = root.join(".worktrees/b");
820 crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
821 crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
822
823 std::fs::write(wt_a.join("a.txt"), "from-a\n").unwrap();
825 git(&wt_a, &["add", "."]);
826 git(&wt_a, &["commit", "-q", "-m", "a work"]);
827 gf.fast_forward_branch("feature/phase-07", "feat-a")
828 .expect("ff base to A");
829 assert_eq!(
830 gf.branch_tip("feature/phase-07").unwrap(),
831 gf.branch_tip("feat-a").unwrap()
832 );
833
834 gf.rebase_in(&wt_b, "feature/phase-07")
836 .expect("clean rebase");
837 assert!(wt_b.join("a.txt").exists());
839 }
840
841 #[test]
842 fn rebase_in_aborts_and_errors_on_conflict() {
843 let repo = init_repo();
844 let root = repo.path();
845 let gf = flow(root);
846
847 gf.ensure_branch("feature/phase-07", "develop")
848 .expect("ensure base");
849
850 let wt_b = root.join(".worktrees/b");
852 crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
853 std::fs::write(wt_b.join("a.txt"), "from-b\n").unwrap();
854 git(&wt_b, &["add", "."]);
855 git(&wt_b, &["commit", "-q", "-m", "b edits a"]);
856
857 let wt_a = root.join(".worktrees/a");
859 crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
860 std::fs::write(wt_a.join("a.txt"), "from-base\n").unwrap();
861 git(&wt_a, &["add", "."]);
862 git(&wt_a, &["commit", "-q", "-m", "base edits a"]);
863 gf.fast_forward_branch("feature/phase-07", "feat-a")
864 .expect("ff base to A");
865
866 let err = gf.rebase_in(&wt_b, "feature/phase-07").unwrap_err();
868 assert!(matches!(err, GitError::Command(_)));
869 assert!(!root.join(".git/worktrees/b/rebase-merge").exists());
871 assert_eq!(
873 std::fs::read_to_string(wt_b.join("a.txt")).unwrap(),
874 "from-b\n"
875 );
876 }
877
878 #[test]
879 fn merge_of_missing_branch_is_an_error() {
880 let repo = init_repo();
881 let root = repo.path();
882 let err = flow(root).feature_finish(99).unwrap_err();
885 assert!(matches!(err, GitError::Command(_)));
886 }
887}