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 = self.merge_feature_into_develop(phase)?;
73        self.git(["branch", "-d", &branch])?;
74        Ok(branch)
75    }
76
77    /// Merge a feature branch into develop without deleting it.
78    ///
79    /// Default DevFlow runs keep the feature branch checked out in a linked
80    /// worktree, so deletion belongs to the later best-effort cleanup hook.
81    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    /// Whether a phase feature branch has nothing left to merge into develop.
90    ///
91    /// An absent branch is not proof of a merge. Callers must fail closed
92    /// rather than treating a deleted or never-created branch as shipped.
93    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    /// Create or reset a release branch from the current `HEAD`.
108    ///
109    /// The release branch is cut from wherever the caller currently is — the
110    /// branch being shipped — not from `develop`. `devflow ship` writes the
111    /// version bump into the working tree first, so branching from `HEAD`
112    /// keeps any commits unique to the shipped branch in the release.
113    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    /// Merge a release branch into main and develop, tag it, and delete it.
121    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        // `-c tag.gpgSign=false` scopes the override to this invocation only
127        // (never the user's global/repo config) — without it, a global
128        // `tag.gpgsign=true` forces this lightweight tag into an
129        // annotated+signed one requiring a message, which blocks on
130        // `$EDITOR` in what must be a headless, unattended flow (Phase 13
131        // dogfood finding).
132        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    /// Create an annotated-free lightweight tag at the current `HEAD`.
140    ///
141    /// Passes `-c tag.gpgSign=false` scoped to this invocation only — a
142    /// global `tag.gpgsign=true` (common for developers who sign their own
143    /// tags) otherwise forces this lightweight tag into an annotated+signed
144    /// one requiring a message, which blocks on `$EDITOR` in what must be a
145    /// headless, unattended flow (Phase 13 dogfood finding: VersionBump hung
146    /// on a live `devflow start --mode auto` run).
147    pub fn tag(&self, tag: &str) -> Result<(), GitError> {
148        info!("tagging {tag}");
149        self.git(["-c", "tag.gpgSign=false", "tag", tag])
150    }
151
152    /// Delete a single local branch.
153    ///
154    /// With `force`, uses `git branch -D` (deletes even if unmerged); otherwise
155    /// `git branch -d` (refuses to delete unmerged work). Protected branches
156    /// (`main`, `develop`) are never deleted.
157    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    /// Whether a local branch exists.
173    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    /// The commit SHA at the tip of `branch`.
188    pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
189        Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
190    }
191
192    /// Create `branch` at `start_point` if it does not already exist, without
193    /// checking it out (leaves the current checkout untouched).
194    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    /// Fast-forward `target`'s ref to `source` (must be a descendant).
202    ///
203    /// `target` must not be checked out in any worktree. Errors if the move
204    /// would not be a fast-forward.
205    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    /// Rebase the branch checked out at `dir` onto `onto`.
221    ///
222    /// Runs `git rebase` inside the given worktree directory. On conflict the
223    /// rebase is aborted and an error is returned so the caller can surface it.
224    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                // Leave the worktree clean for the user to retry.
230                warn!("rebase conflict in {}; aborting", dir.display());
231                let _ = git_in(dir, &["rebase", "--abort"]);
232                Err(err)
233            }
234        }
235    }
236
237    /// Check out an existing branch in the main worktree.
238    pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
239        debug!("checking out branch: {branch}");
240        self.git(["checkout", branch])
241    }
242
243    /// Delete `branch` on `origin` (best-effort; errors if no remote/branch).
244    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    /// Whether the repository has at least one configured remote.
250    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    /// Push `branch` to `origin`, setting upstream.
257    pub fn push(&self, branch: &str) -> Result<(), GitError> {
258        info!("pushing branch: {branch}");
259        self.git(["push", "-u", "origin", branch])
260    }
261
262    /// Delete local branches already merged into `develop`.
263    ///
264    /// WR-04 (13-REVIEW.md): passes `develop` explicitly rather than relying
265    /// on `git branch --merged`'s default of "whatever HEAD currently is" —
266    /// if the main checkout is ever left on a branch other than `develop`
267    /// when this runs, an implicit baseline would silently prune branches
268    /// merged into that other branch instead.
269    ///
270    /// Deletion uses `-D`, not `-d`: `-d` verifies merged-into-HEAD, which
271    /// contradicts the `--merged develop` listing above in exactly the
272    /// checkout-not-on-develop scenario WR-04 targets (every genuinely
273    /// merged branch would be refused as "not fully merged"). The listing IS
274    /// the merge safety check. A branch git still refuses to delete (e.g.
275    /// checked out in a worktree) is logged and skipped so one failure
276    /// doesn't abort the rest of the sweep.
277    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            // git's porcelain marker is an exact two-char prefix ("* " for
283            // the current branch, "+ " for a worktree checkout, "  "
284            // otherwise) — strip it positionally rather than trimming
285            // marker CHARACTERS, which would mangle a branch legitimately
286            // named e.g. "+foo" (WR-03, revised).
287            let branch = line
288                .strip_prefix("* ")
289                .or_else(|| line.strip_prefix("+ "))
290                .unwrap_or(line)
291                .trim();
292            // Skip blanks, protected trunks, and the detached-HEAD line
293            // ("(HEAD detached at ...)"), which is not a branch name.
294            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    /// Stage all changes and commit with the given message.
307    /// Returns Ok(()) whether or not there were changes to commit.
308    pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
309        debug!("committing all changes: {message}");
310        self.git(["add", "."])?;
311        // --allow-empty so we don't fail when there are no changes
312        match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
313            Ok(()) => Ok(()),
314            // If the commit produced no changes and we used --allow-empty,
315            // this should still succeed. But just in case, ignore "nothing to commit".
316            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
317            Err(e) => Err(e),
318        }
319    }
320
321    /// Stage a single relative path and commit with the given message.
322    /// Mirrors `commit_all`, but scoped to one path, for hooks that must not
323    /// sweep in unrelated dirty state left by other hooks or the workflow.
324    /// Returns Ok(()) whether or not the path had changes to commit.
325    pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
326        debug!("committing {relative_path}: {message}");
327        // `add` first so a brand-new file is known to git — a pathspec-only
328        // commit errors on a path git has never seen. The trailing pathspec is
329        // what actually scopes the commit: without it, `commit` writes whatever
330        // else is already in the index, which is exactly the sweep-in this
331        // function exists to prevent.
332        self.git(["add", relative_path])?;
333        // --allow-empty so we don't fail when the path had no changes.
334        match self.git_raw(&[
335            "commit",
336            "--allow-empty",
337            "-m",
338            message,
339            "--",
340            relative_path,
341        ]) {
342            Ok(()) => Ok(()),
343            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
344            Err(e) => Err(e),
345        }
346    }
347
348    /// Return divergence from develop: (ahead, behind) commit counts.
349    ///
350    /// If currently on the develop branch, returns (0, 0).
351    /// `ahead` = commits on current branch not yet on develop.
352    /// `behind` = commits on develop not yet on current branch.
353    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    /// List all feature branches with divergence from develop.
371    ///
372    /// Returns branches matching `feature/phase-*` with ahead/behind counts
373    /// and last commit dates. Protected branches (main, develop) are excluded.
374    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        // Sort by phase number so phase-01 comes before phase-10.
404        result.sort_by(|a, b| a.name.cmp(&b.name));
405        Ok(result)
406    }
407
408    /// Count revisions in the given range. Returns None if the command fails.
409    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")
418            .args(args)
419            .current_dir(&self.root)
420            .output()?;
421        if output.status.success() {
422            Ok(())
423        } else {
424            Err(GitError::Command(stderr_or_status(&output)))
425        }
426    }
427
428    fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
429        debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
430        let output = Command::new("git")
431            .args(args)
432            .current_dir(&self.root)
433            .output()?;
434        if output.status.success() {
435            Ok(())
436        } else {
437            Err(GitError::Command(stderr_or_status(&output)))
438        }
439    }
440
441    fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
442        let output = Command::new("git")
443            .args(args)
444            .current_dir(&self.root)
445            .output()?;
446        if output.status.success() {
447            Ok(String::from_utf8_lossy(&output.stdout).to_string())
448        } else {
449            Err(GitError::Command(stderr_or_status(&output)))
450        }
451    }
452}
453
454/// Run a git command in an arbitrary directory (e.g. a worktree).
455fn git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
456    debug!("git (in {}) {}", dir.display(), args.join(" "));
457    let output = Command::new("git").args(args).current_dir(dir).output()?;
458    if output.status.success() {
459        Ok(())
460    } else {
461        Err(GitError::Command(stderr_or_status(&output)))
462    }
463}
464
465fn stderr_or_status(output: &std::process::Output) -> String {
466    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
467    if stderr.is_empty() {
468        format!("exited with {}", output.status)
469    } else {
470        stderr
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use std::process::Command;
478    use tempfile::TempDir;
479
480    /// Run a git command in `root`, asserting success.
481    fn git(root: &Path, args: &[&str]) {
482        let output = Command::new("git")
483            .args(args)
484            .current_dir(root)
485            .output()
486            .expect("spawn git");
487        assert!(
488            output.status.success(),
489            "git {args:?} failed: {}",
490            String::from_utf8_lossy(&output.stderr)
491        );
492    }
493
494    fn current_branch(root: &Path) -> String {
495        let output = Command::new("git")
496            .args(["rev-parse", "--abbrev-ref", "HEAD"])
497            .current_dir(root)
498            .output()
499            .expect("rev-parse");
500        String::from_utf8_lossy(&output.stdout).trim().to_string()
501    }
502
503    fn commit_file(root: &Path, name: &str) {
504        std::fs::write(root.join(name), name).unwrap();
505        git(root, &["add", "."]);
506        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
507    }
508
509    /// Initialize a repo with `main` and `develop` branches and one commit.
510    fn init_repo() -> TempDir {
511        let dir = tempfile::tempdir().unwrap();
512        let root = dir.path();
513        git(root, &["init", "-q"]);
514        git(root, &["config", "user.email", "test@example.com"]);
515        git(root, &["config", "user.name", "Test"]);
516        git(root, &["config", "commit.gpgsign", "false"]);
517        git(root, &["config", "tag.gpgsign", "false"]);
518        // Disable any globally-configured hooks (e.g. gitleaks) for isolation.
519        git(root, &["config", "core.hooksPath", "/dev/null"]);
520        commit_file(root, "README.md");
521        git(root, &["branch", "-M", "main"]);
522        git(root, &["checkout", "-q", "-b", "develop"]);
523        dir
524    }
525
526    fn flow(root: &Path) -> GitFlow {
527        GitFlow::new(root)
528    }
529
530    #[test]
531    fn feature_start_branches_from_develop() {
532        let repo = init_repo();
533        let root = repo.path();
534        let branch = flow(root).feature_start(3).expect("feature_start");
535        assert_eq!(branch, "feature/phase-03");
536        assert_eq!(current_branch(root), "feature/phase-03");
537    }
538
539    #[test]
540    fn list_feature_branches_reports_ahead_and_behind_semantics() {
541        let repo = init_repo();
542        let root = repo.path();
543        let gf = flow(root);
544
545        gf.feature_start(12).expect("feature_start");
546        commit_file(root, "feature-one.txt");
547        commit_file(root, "feature-two.txt");
548        git(root, &["checkout", "-q", "develop"]);
549        commit_file(root, "develop-only.txt");
550
551        let branches = gf.list_feature_branches().unwrap();
552        let branch = branches
553            .iter()
554            .find(|branch| branch.name == "feature/phase-12")
555            .unwrap();
556
557        assert_eq!(branch.ahead, 2);
558        assert_eq!(branch.behind, 1);
559    }
560
561    #[test]
562    fn feature_finish_merges_into_develop_and_deletes() {
563        let repo = init_repo();
564        let root = repo.path();
565        let gf = flow(root);
566
567        gf.feature_start(1).expect("start");
568        commit_file(root, "feature.txt");
569
570        let branch = gf.feature_finish(1).expect("finish");
571        assert_eq!(branch, "feature/phase-01");
572        assert_eq!(current_branch(root), "develop");
573
574        // Branch is deleted and its work is now on develop.
575        let branches = Command::new("git")
576            .args(["branch"])
577            .current_dir(root)
578            .output()
579            .unwrap();
580        let listing = String::from_utf8_lossy(&branches.stdout);
581        assert!(!listing.contains("feature/phase-01"));
582        assert!(root.join("feature.txt").exists());
583    }
584
585    #[test]
586    fn release_start_and_finish_tags_main_and_merges_both() {
587        let repo = init_repo();
588        let root = repo.path();
589        let gf = flow(root);
590
591        // Add work on develop so the release has content.
592        commit_file(root, "work.txt");
593        let branch = gf.release_start("1.2.0").expect("release_start");
594        assert_eq!(branch, "release/1.2.0");
595
596        gf.release_finish("1.2.0").expect("release_finish");
597        assert_eq!(current_branch(root), "develop");
598
599        // Tag exists.
600        let tags = Command::new("git")
601            .args(["tag"])
602            .current_dir(root)
603            .output()
604            .unwrap();
605        assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
606
607        // Release branch deleted.
608        let branches = Command::new("git")
609            .args(["branch"])
610            .current_dir(root)
611            .output()
612            .unwrap();
613        assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
614    }
615
616    /// A global/repo `tag.gpgsign=true` must not turn `tag()`'s lightweight
617    /// tag into an annotated+signed one — that would require a tag message
618    /// and block on `$EDITOR`, silently hanging a headless, unattended run
619    /// (Phase 13 dogfood finding: VersionBump hung on a live
620    /// `devflow start --mode auto` run because the operator's global
621    /// gitconfig sets `tag.gpgsign=true`).
622    #[test]
623    fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
624        let repo = init_repo();
625        let root = repo.path();
626        // Simulate an operator whose global config signs tags by default —
627        // override the test harness's own `tag.gpgsign false` to prove
628        // `tag()`'s per-invocation `-c` override wins regardless.
629        git(root, &["config", "tag.gpgsign", "true"]);
630
631        flow(root)
632            .tag("v9.9.9")
633            .expect("tag must not block on $EDITOR");
634
635        let tags = Command::new("git")
636            .args(["tag", "-l"])
637            .current_dir(root)
638            .output()
639            .unwrap();
640        assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
641
642        // Confirm it's a lightweight tag (points directly at the commit),
643        // not an annotated tag object (which `cat-file -t` would report as
644        // "tag" rather than "commit").
645        let obj_type = Command::new("git")
646            .args(["cat-file", "-t", "v9.9.9"])
647            .current_dir(root)
648            .output()
649            .unwrap();
650        assert_eq!(
651            String::from_utf8_lossy(&obj_type.stdout).trim(),
652            "commit",
653            "tag() must stay lightweight even when tag.gpgsign=true"
654        );
655    }
656
657    #[test]
658    fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
659        // The property that distinguishes commit_path from commit_all
660        // (17-12, Task 2b): a hook using commit_path must never sweep in
661        // unrelated dirty state.
662        let repo = init_repo();
663        let root = repo.path();
664        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
665        std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
666
667        // Stage the unrelated file BEFORE calling commit_path. An untracked
668        // file is excluded by any implementation and so proves nothing; an
669        // already-staged one is the real failure mode — a bare `git commit`
670        // writes the whole index and would sweep it in.
671        Command::new("git")
672            .args(["add", "unrelated.txt"])
673            .current_dir(root)
674            .status()
675            .unwrap();
676
677        flow(root)
678            .commit_path("CHANGELOG.md", "docs: add changelog entry")
679            .expect("commit_path");
680
681        let committed = Command::new("git")
682            .args(["log", "-1", "--name-only", "--pretty=format:"])
683            .current_dir(root)
684            .output()
685            .unwrap();
686        let committed_files = String::from_utf8_lossy(&committed.stdout);
687        assert!(committed_files.contains("CHANGELOG.md"));
688        assert!(!committed_files.contains("unrelated.txt"));
689
690        let status = Command::new("git")
691            .args(["status", "--porcelain"])
692            .current_dir(root)
693            .output()
694            .unwrap();
695        let status = String::from_utf8_lossy(&status.stdout);
696        assert!(
697            status.contains("A  unrelated.txt"),
698            "unrelated.txt must remain staged-but-uncommitted, got: {status}"
699        );
700    }
701
702    #[test]
703    fn release_start_branches_from_current_head_not_develop() {
704        let repo = init_repo();
705        let root = repo.path();
706        let gf = flow(root);
707
708        // Ship from a feature branch carrying a commit that is NOT on develop.
709        gf.feature_start(5).expect("feature_start");
710        commit_file(root, "feature-only.txt");
711        let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
712
713        let branch = gf.release_start("2.0.0").expect("release_start");
714        assert_eq!(branch, "release/2.0.0");
715        assert_eq!(current_branch(root), "release/2.0.0");
716
717        // The release branch tip must descend from the feature commit — i.e.
718        // the feature-only work is present, not dropped to develop's HEAD.
719        let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
720        let is_ancestor = Command::new("git")
721            .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
722            .current_dir(root)
723            .output()
724            .unwrap()
725            .status
726            .success();
727        assert!(
728            is_ancestor,
729            "release branch must descend from the shipped feature commit"
730        );
731        assert!(root.join("feature-only.txt").exists());
732    }
733
734    #[test]
735    fn cleanup_merged_removes_merged_but_keeps_protected() {
736        let repo = init_repo();
737        let root = repo.path();
738        let gf = flow(root);
739
740        // Create and merge a feature branch into develop.
741        gf.feature_start(2).expect("start");
742        commit_file(root, "f.txt");
743        gf.feature_finish(2).expect("finish");
744
745        // Create an already-merged stray branch off develop.
746        git(root, &["branch", "stale-merged"]);
747
748        let deleted = gf.cleanup_merged().expect("cleanup");
749        assert!(deleted.contains(&"stale-merged".to_string()));
750        // Protected branches survive.
751        assert!(!deleted.contains(&"develop".to_string()));
752        assert!(!deleted.contains(&"main".to_string()));
753    }
754
755    /// WR-04 (13-REVIEW.md): `cleanup_merged` must compute "merged" relative
756    /// to `develop` explicitly, not whatever the main checkout's current
757    /// HEAD happens to be. If the main checkout is left on a divergent
758    /// branch, an implicit-HEAD baseline would wrongly identify (and
759    /// delete) a branch that's merged into that other branch but was never
760    /// actually merged into `develop`.
761    #[test]
762    fn cleanup_merged_is_relative_to_develop_not_current_head() {
763        let repo = init_repo();
764        let root = repo.path();
765        let gf = flow(root);
766
767        // `topic` diverges from develop with a unique commit develop never
768        // sees, then `premature` branches off `topic`'s tip — so
769        // `premature` is merged into `topic` but NOT into `develop`.
770        git(root, &["checkout", "-q", "-b", "topic", "develop"]);
771        commit_file(root, "topic-only.txt");
772        git(root, &["checkout", "-q", "-b", "premature", "topic"]);
773
774        // Leave the main checkout on `topic` — NOT `develop` — before
775        // calling cleanup_merged, mirroring an operator who forgot to
776        // check out develop first. (`topic` itself is also technically
777        // "merged into HEAD" under an implicit baseline since it IS HEAD,
778        // which git's own `-d` correctly refuses as the checked-out branch
779        // — so the call's overall Ok/Err is not itself decisive here; check
780        // the actual side effect on `premature` instead.)
781        git(root, &["checkout", "-q", "topic"]);
782
783        let _ = gf.cleanup_merged();
784        assert!(
785            gf.branch_exists("premature"),
786            "premature is merged into topic (current HEAD) but not into \
787             develop — it must survive cleanup_merged when the baseline is develop"
788        );
789    }
790
791    /// WR-03 (13-REVIEW.md), revised: `git branch --merged` prefixes a
792    /// branch checked out in a linked worktree with `+ `. The prefix must be
793    /// stripped positionally (not by trimming marker characters, which would
794    /// mangle a branch legitimately named "+foo"), and a branch git refuses
795    /// to delete — a worktree checkout can never be deleted, by design —
796    /// must be skipped with a warning rather than aborting the sweep before
797    /// the remaining merged branches.
798    #[test]
799    fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
800        let repo = init_repo();
801        let root = repo.path();
802        let gf = flow(root);
803
804        // Merge a branch into develop WITHOUT deleting it (feature_finish
805        // deletes on merge, which would leave nothing to check out).
806        git(
807            root,
808            &["checkout", "-q", "-b", "worktree-merged", "develop"],
809        );
810        commit_file(root, "g.txt");
811        git(root, &["checkout", "-q", "develop"]);
812        git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
813
814        // Check the merged branch out in a linked worktree so
815        // `git branch --merged` reports it with a `+ ` prefix.
816        let wt_dir = tempfile::tempdir().unwrap();
817        git(
818            root,
819            &[
820                "worktree",
821                "add",
822                wt_dir.path().to_str().unwrap(),
823                "worktree-merged",
824            ],
825        );
826
827        // A second merged branch that sorts after "worktree-merged" would be
828        // reached only if the sweep survives the worktree refusal; "zz-" also
829        // guards against luck in iteration order via the branch before it.
830        git(root, &["branch", "aa-stale"]);
831        git(root, &["branch", "zz-stale"]);
832
833        let deleted = gf
834            .cleanup_merged()
835            .expect("a skipped worktree branch must not abort the sweep");
836        assert!(deleted.contains(&"aa-stale".to_string()));
837        assert!(deleted.contains(&"zz-stale".to_string()));
838        assert!(
839            !deleted.contains(&"worktree-merged".to_string()),
840            "worktree checkout cannot be deleted"
841        );
842        assert!(gf.branch_exists("worktree-merged"));
843    }
844
845    /// The delete side must agree with the `--merged develop` listing: `-d`
846    /// verifies merged-into-HEAD, so with the main checkout parked on a
847    /// stale branch every genuinely-merged branch was refused as "not fully
848    /// merged" — in exactly the scenario WR-04 exists for.
849    #[test]
850    fn cleanup_merged_deletes_when_head_is_not_on_develop() {
851        let repo = init_repo();
852        let root = repo.path();
853        let gf = flow(root);
854
855        // `old` is parked before the merge below, so nothing merged later is
856        // reachable from HEAD while it's checked out.
857        git(root, &["checkout", "-q", "-b", "old", "develop"]);
858        git(root, &["checkout", "-q", "develop"]);
859        git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
860        commit_file(root, "h.txt");
861        git(root, &["checkout", "-q", "develop"]);
862        git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
863        git(root, &["checkout", "-q", "old"]);
864
865        let deleted = gf.cleanup_merged().expect("cleanup");
866        assert!(
867            deleted.contains(&"merged-feature".to_string()),
868            "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
869        );
870        assert!(!gf.branch_exists("merged-feature"));
871    }
872
873    #[test]
874    fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
875        let repo = init_repo();
876        let root = repo.path();
877        let gf = flow(root);
878
879        // Create a feature branch with an unmerged commit.
880        gf.feature_start(8).expect("start");
881        commit_file(root, "unmerged.txt");
882        // Switch back to develop so the branch isn't checked out.
883        git(root, &["checkout", "-q", "develop"]);
884
885        // -d would refuse (unmerged); force deletes it.
886        assert!(gf.delete_branch("feature/phase-08", false).is_err());
887        gf.delete_branch("feature/phase-08", true)
888            .expect("force delete");
889        let branches = Command::new("git")
890            .args(["branch"])
891            .current_dir(root)
892            .output()
893            .unwrap();
894        assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
895
896        // Protected branches are never deleted.
897        assert!(gf.delete_branch("develop", true).is_err());
898        assert!(gf.delete_branch("main", true).is_err());
899    }
900
901    #[test]
902    fn sequentagent_helpers_integrate_and_rebase_cleanly() {
903        let repo = init_repo();
904        let root = repo.path();
905        let gf = flow(root);
906
907        // Base branch off develop, not checked out anywhere.
908        gf.ensure_branch("feature/phase-07", "develop")
909            .expect("ensure base");
910        assert!(gf.branch_exists("feature/phase-07"));
911        assert!(!gf.branch_tip("feature/phase-07").unwrap().is_empty());
912        // ensure_branch is idempotent.
913        gf.ensure_branch("feature/phase-07", "develop")
914            .expect("ensure again");
915
916        // Two agent worktrees off the same base tip.
917        let wt_a = root.join(".worktrees/a");
918        let wt_b = root.join(".worktrees/b");
919        crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
920        crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
921
922        // Agent A commits a new file, then we integrate A into the base (ff).
923        std::fs::write(wt_a.join("a.txt"), "from-a\n").unwrap();
924        git(&wt_a, &["add", "."]);
925        git(&wt_a, &["commit", "-q", "-m", "a work"]);
926        gf.fast_forward_branch("feature/phase-07", "feat-a")
927            .expect("ff base to A");
928        assert_eq!(
929            gf.branch_tip("feature/phase-07").unwrap(),
930            gf.branch_tip("feat-a").unwrap()
931        );
932
933        // Agent B (no overlapping changes) rebases onto the updated base cleanly.
934        gf.rebase_in(&wt_b, "feature/phase-07")
935            .expect("clean rebase");
936        // B now contains A's file.
937        assert!(wt_b.join("a.txt").exists());
938    }
939
940    #[test]
941    fn rebase_in_aborts_and_errors_on_conflict() {
942        let repo = init_repo();
943        let root = repo.path();
944        let gf = flow(root);
945
946        gf.ensure_branch("feature/phase-07", "develop")
947            .expect("ensure base");
948
949        // Worktree B is created off the ORIGINAL base, then edits a.txt.
950        let wt_b = root.join(".worktrees/b");
951        crate::worktree::add(root, &wt_b, "feat-b", "feature/phase-07", true).expect("add B");
952        std::fs::write(wt_b.join("a.txt"), "from-b\n").unwrap();
953        git(&wt_b, &["add", "."]);
954        git(&wt_b, &["commit", "-q", "-m", "b edits a"]);
955
956        // Meanwhile the base advances with a conflicting a.txt (via worktree A).
957        let wt_a = root.join(".worktrees/a");
958        crate::worktree::add(root, &wt_a, "feat-a", "feature/phase-07", true).expect("add A");
959        std::fs::write(wt_a.join("a.txt"), "from-base\n").unwrap();
960        git(&wt_a, &["add", "."]);
961        git(&wt_a, &["commit", "-q", "-m", "base edits a"]);
962        gf.fast_forward_branch("feature/phase-07", "feat-a")
963            .expect("ff base to A");
964
965        // Rebasing B onto the updated base conflicts on a.txt → error + abort.
966        let err = gf.rebase_in(&wt_b, "feature/phase-07").unwrap_err();
967        assert!(matches!(err, GitError::Command(_)));
968        // The abort left no rebase-in-progress state behind.
969        assert!(!root.join(".git/worktrees/b/rebase-merge").exists());
970        // B is still usable: its own commit is intact.
971        assert_eq!(
972            std::fs::read_to_string(wt_b.join("a.txt")).unwrap(),
973            "from-b\n"
974        );
975    }
976
977    #[test]
978    fn merge_of_missing_branch_is_an_error() {
979        let repo = init_repo();
980        let root = repo.path();
981        // feature_finish for a phase that was never started: checkout develop
982        // succeeds, but merging the nonexistent feature branch fails.
983        let err = flow(root).feature_finish(99).unwrap_err();
984        assert!(matches!(err, GitError::Command(_)));
985    }
986}