Skip to main content

devflow_core/
git.rs

1//! Git-flow operations implemented with plain `git` commands.
2
3use crate::config::GitFlowConfig;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use tracing::{debug, info, warn};
7
8/// Errors produced by git-flow operations.
9#[derive(Debug, thiserror::Error)]
10pub enum GitError {
11    /// Spawning git failed.
12    #[error("failed to execute git: {0}")]
13    Io(#[from] std::io::Error),
14    /// Git returned a non-success status.
15    #[error("git command failed: {0}")]
16    Command(String),
17}
18
19/// Repository helper bound to a project root.
20#[derive(Debug, Clone)]
21pub struct GitFlow {
22    root: PathBuf,
23    config: GitFlowConfig,
24}
25
26/// Summary of a feature branch for the `devflow list` command.
27#[derive(Debug, Clone)]
28pub struct BranchInfo {
29    /// Branch name (e.g. "feature/phase-05").
30    pub name: String,
31    /// Number of commits this branch has that develop doesn't.
32    pub ahead: usize,
33    /// Number of commits develop has that this branch doesn't.
34    pub behind: usize,
35    /// ISO-8601 date of the last commit on this branch.
36    pub last_commit: String,
37}
38
39impl GitFlow {
40    /// Create a git-flow helper for a project root, using the hardcoded
41    /// git-flow constants (`main`, `develop`, `feature/`).
42    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    /// Create a feature branch from the develop branch.
50    ///
51    /// Returns an error if the branch already exists (use
52    /// [`feature_start_force`] to overwrite).
53    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    /// Create or reset a feature branch, overwriting it if it already exists.
62    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    /// Merge a feature branch into develop and delete it.
71    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    /// Create or reset a release branch from the current `HEAD`.
81    ///
82    /// The release branch is cut from wherever the caller currently is — the
83    /// branch being shipped — not from `develop`. `devflow ship` writes the
84    /// version bump into the working tree first, so branching from `HEAD`
85    /// keeps any commits unique to the shipped branch in the release.
86    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    /// Merge a release branch into main and develop, tag it, and delete it.
94    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        // `-c tag.gpgSign=false` scopes the override to this invocation only
100        // (never the user's global/repo config) — without it, a global
101        // `tag.gpgsign=true` forces this lightweight tag into an
102        // annotated+signed one requiring a message, which blocks on
103        // `$EDITOR` in what must be a headless, unattended flow (Phase 13
104        // dogfood finding).
105        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    /// Create an annotated-free lightweight tag at the current `HEAD`.
113    ///
114    /// Passes `-c tag.gpgSign=false` scoped to this invocation only — a
115    /// global `tag.gpgsign=true` (common for developers who sign their own
116    /// tags) otherwise forces this lightweight tag into an annotated+signed
117    /// one requiring a message, which blocks on `$EDITOR` in what must be a
118    /// headless, unattended flow (Phase 13 dogfood finding: VersionBump hung
119    /// on a live `devflow start --mode auto` run).
120    pub fn tag(&self, tag: &str) -> Result<(), GitError> {
121        info!("tagging {tag}");
122        self.git(["-c", "tag.gpgSign=false", "tag", tag])
123    }
124
125    /// Delete a single local branch.
126    ///
127    /// With `force`, uses `git branch -D` (deletes even if unmerged); otherwise
128    /// `git branch -d` (refuses to delete unmerged work). Protected branches
129    /// (`main`, `develop`) are never deleted.
130    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    /// Whether a local branch exists.
146    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    /// The commit SHA at the tip of `branch`.
161    pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
162        Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
163    }
164
165    /// Create `branch` at `start_point` if it does not already exist, without
166    /// checking it out (leaves the current checkout untouched).
167    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    /// Fast-forward `target`'s ref to `source` (must be a descendant).
175    ///
176    /// `target` must not be checked out in any worktree. Errors if the move
177    /// would not be a fast-forward.
178    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    /// Rebase the branch checked out at `dir` onto `onto`.
194    ///
195    /// Runs `git rebase` inside the given worktree directory. On conflict the
196    /// rebase is aborted and an error is returned so the caller can surface it.
197    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                // Leave the worktree clean for the user to retry.
203                warn!("rebase conflict in {}; aborting", dir.display());
204                let _ = git_in(dir, &["rebase", "--abort"]);
205                Err(err)
206            }
207        }
208    }
209
210    /// Check out an existing branch in the main worktree.
211    pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
212        debug!("checking out branch: {branch}");
213        self.git(["checkout", branch])
214    }
215
216    /// Delete `branch` on `origin` (best-effort; errors if no remote/branch).
217    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    /// Whether the repository has at least one configured remote.
223    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    /// Push `branch` to `origin`, setting upstream.
230    pub fn push(&self, branch: &str) -> Result<(), GitError> {
231        info!("pushing branch: {branch}");
232        self.git(["push", "-u", "origin", branch])
233    }
234
235    /// Delete local branches already merged into `develop`.
236    ///
237    /// WR-04 (13-REVIEW.md): passes `develop` explicitly rather than relying
238    /// on `git branch --merged`'s default of "whatever HEAD currently is" —
239    /// if the main checkout is ever left on a branch other than `develop`
240    /// when this runs, an implicit baseline would silently prune branches
241    /// merged into that other branch instead.
242    ///
243    /// Deletion uses `-D`, not `-d`: `-d` verifies merged-into-HEAD, which
244    /// contradicts the `--merged develop` listing above in exactly the
245    /// checkout-not-on-develop scenario WR-04 targets (every genuinely
246    /// merged branch would be refused as "not fully merged"). The listing IS
247    /// the merge safety check. A branch git still refuses to delete (e.g.
248    /// checked out in a worktree) is logged and skipped so one failure
249    /// doesn't abort the rest of the sweep.
250    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            // git's porcelain marker is an exact two-char prefix ("* " for
256            // the current branch, "+ " for a worktree checkout, "  "
257            // otherwise) — strip it positionally rather than trimming
258            // marker CHARACTERS, which would mangle a branch legitimately
259            // named e.g. "+foo" (WR-03, revised).
260            let branch = line
261                .strip_prefix("* ")
262                .or_else(|| line.strip_prefix("+ "))
263                .unwrap_or(line)
264                .trim();
265            // Skip blanks, protected trunks, and the detached-HEAD line
266            // ("(HEAD detached at ...)"), which is not a branch name.
267            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    /// Stage all changes and commit with the given message.
280    /// Returns Ok(()) whether or not there were changes to commit.
281    pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
282        debug!("committing all changes: {message}");
283        self.git(["add", "."])?;
284        // --allow-empty so we don't fail when there are no changes
285        match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
286            Ok(()) => Ok(()),
287            // If the commit produced no changes and we used --allow-empty,
288            // this should still succeed. But just in case, ignore "nothing to commit".
289            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
290            Err(e) => Err(e),
291        }
292    }
293
294    /// Return divergence from develop: (ahead, behind) commit counts.
295    ///
296    /// If currently on the develop branch, returns (0, 0).
297    /// `ahead` = commits on current branch not yet on develop.
298    /// `behind` = commits on develop not yet on current branch.
299    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    /// List all feature branches with divergence from develop.
317    ///
318    /// Returns branches matching `feature/phase-*` with ahead/behind counts
319    /// and last commit dates. Protected branches (main, develop) are excluded.
320    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        // Sort by phase number so phase-01 comes before phase-10.
350        result.sort_by(|a, b| a.name.cmp(&b.name));
351        Ok(result)
352    }
353
354    /// Count revisions in the given range. Returns None if the command fails.
355    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
400/// Run a git command in an arbitrary directory (e.g. a worktree).
401fn 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    /// Run a git command in `root`, asserting success.
427    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    /// Initialize a repo with `main` and `develop` branches and one commit.
456    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        // Disable any globally-configured hooks (e.g. gitleaks) for isolation.
465        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        // Branch is deleted and its work is now on develop.
521        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        // Add work on develop so the release has content.
538        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        // Tag exists.
546        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        // Release branch deleted.
554        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    /// A global/repo `tag.gpgsign=true` must not turn `tag()`'s lightweight
563    /// tag into an annotated+signed one — that would require a tag message
564    /// and block on `$EDITOR`, silently hanging a headless, unattended run
565    /// (Phase 13 dogfood finding: VersionBump hung on a live
566    /// `devflow start --mode auto` run because the operator's global
567    /// gitconfig sets `tag.gpgsign=true`).
568    #[test]
569    fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
570        let repo = init_repo();
571        let root = repo.path();
572        // Simulate an operator whose global config signs tags by default —
573        // override the test harness's own `tag.gpgsign false` to prove
574        // `tag()`'s per-invocation `-c` override wins regardless.
575        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        // Confirm it's a lightweight tag (points directly at the commit),
589        // not an annotated tag object (which `cat-file -t` would report as
590        // "tag" rather than "commit").
591        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        // Ship from a feature branch carrying a commit that is NOT on develop.
610        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        // The release branch tip must descend from the feature commit — i.e.
619        // the feature-only work is present, not dropped to develop's HEAD.
620        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        // Create and merge a feature branch into develop.
642        gf.feature_start(2).expect("start");
643        commit_file(root, "f.txt");
644        gf.feature_finish(2).expect("finish");
645
646        // Create an already-merged stray branch off develop.
647        git(root, &["branch", "stale-merged"]);
648
649        let deleted = gf.cleanup_merged().expect("cleanup");
650        assert!(deleted.contains(&"stale-merged".to_string()));
651        // Protected branches survive.
652        assert!(!deleted.contains(&"develop".to_string()));
653        assert!(!deleted.contains(&"main".to_string()));
654    }
655
656    /// WR-04 (13-REVIEW.md): `cleanup_merged` must compute "merged" relative
657    /// to `develop` explicitly, not whatever the main checkout's current
658    /// HEAD happens to be. If the main checkout is left on a divergent
659    /// branch, an implicit-HEAD baseline would wrongly identify (and
660    /// delete) a branch that's merged into that other branch but was never
661    /// actually merged into `develop`.
662    #[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        // `topic` diverges from develop with a unique commit develop never
669        // sees, then `premature` branches off `topic`'s tip — so
670        // `premature` is merged into `topic` but NOT into `develop`.
671        git(root, &["checkout", "-q", "-b", "topic", "develop"]);
672        commit_file(root, "topic-only.txt");
673        git(root, &["checkout", "-q", "-b", "premature", "topic"]);
674
675        // Leave the main checkout on `topic` — NOT `develop` — before
676        // calling cleanup_merged, mirroring an operator who forgot to
677        // check out develop first. (`topic` itself is also technically
678        // "merged into HEAD" under an implicit baseline since it IS HEAD,
679        // which git's own `-d` correctly refuses as the checked-out branch
680        // — so the call's overall Ok/Err is not itself decisive here; check
681        // the actual side effect on `premature` instead.)
682        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    /// WR-03 (13-REVIEW.md), revised: `git branch --merged` prefixes a
693    /// branch checked out in a linked worktree with `+ `. The prefix must be
694    /// stripped positionally (not by trimming marker characters, which would
695    /// mangle a branch legitimately named "+foo"), and a branch git refuses
696    /// to delete — a worktree checkout can never be deleted, by design —
697    /// must be skipped with a warning rather than aborting the sweep before
698    /// the remaining merged branches.
699    #[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        // Merge a branch into develop WITHOUT deleting it (feature_finish
706        // deletes on merge, which would leave nothing to check out).
707        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        // Check the merged branch out in a linked worktree so
716        // `git branch --merged` reports it with a `+ ` prefix.
717        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        // A second merged branch that sorts after "worktree-merged" would be
729        // reached only if the sweep survives the worktree refusal; "zz-" also
730        // guards against luck in iteration order via the branch before it.
731        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    /// The delete side must agree with the `--merged develop` listing: `-d`
747    /// verifies merged-into-HEAD, so with the main checkout parked on a
748    /// stale branch every genuinely-merged branch was refused as "not fully
749    /// merged" — in exactly the scenario WR-04 exists for.
750    #[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        // `old` is parked before the merge below, so nothing merged later is
757        // reachable from HEAD while it's checked out.
758        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        // Create a feature branch with an unmerged commit.
781        gf.feature_start(8).expect("start");
782        commit_file(root, "unmerged.txt");
783        // Switch back to develop so the branch isn't checked out.
784        git(root, &["checkout", "-q", "develop"]);
785
786        // -d would refuse (unmerged); force deletes it.
787        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        // Protected branches are never deleted.
798        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        // Base branch off develop, not checked out anywhere.
809        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        // ensure_branch is idempotent.
814        gf.ensure_branch("feature/phase-07", "develop")
815            .expect("ensure again");
816
817        // Two agent worktrees off the same base tip.
818        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        // Agent A commits a new file, then we integrate A into the base (ff).
824        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        // Agent B (no overlapping changes) rebases onto the updated base cleanly.
835        gf.rebase_in(&wt_b, "feature/phase-07")
836            .expect("clean rebase");
837        // B now contains A's file.
838        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        // Worktree B is created off the ORIGINAL base, then edits a.txt.
851        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        // Meanwhile the base advances with a conflicting a.txt (via worktree A).
858        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        // Rebasing B onto the updated base conflicts on a.txt → error + abort.
867        let err = gf.rebase_in(&wt_b, "feature/phase-07").unwrap_err();
868        assert!(matches!(err, GitError::Command(_)));
869        // The abort left no rebase-in-progress state behind.
870        assert!(!root.join(".git/worktrees/b/rebase-merge").exists());
871        // B is still usable: its own commit is intact.
872        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        // feature_finish for a phase that was never started: checkout develop
883        // succeeds, but merging the nonexistent feature branch fails.
884        let err = flow(root).feature_finish(99).unwrap_err();
885        assert!(matches!(err, GitError::Command(_)));
886    }
887}