Skip to main content

devflow_core/
git.rs

1//! Git-flow operations implemented with plain `git` commands.
2
3use crate::config::GitFlowConfig;
4use crate::phase_id::PhaseId;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use tracing::{debug, info, warn};
8
9/// Errors produced by git-flow operations.
10#[derive(Debug, thiserror::Error)]
11pub enum GitError {
12    /// Spawning git failed.
13    #[error("failed to execute git: {0}")]
14    Io(#[from] std::io::Error),
15    /// Git returned a non-success status.
16    #[error("git command failed: {0}")]
17    Command(String),
18}
19
20/// Git's own list of repository-local environment variables, as reported by
21/// `git rev-parse --local-env-vars` (15 entries on git 2.55).
22///
23/// Kept as a constant rather than shelled out per call so building a command
24/// stays free of process spawns; `local_env_vars_match_git` asserts it still
25/// agrees with the installed git, so a version that adds one fails loudly
26/// instead of silently reopening the hole.
27pub const REPO_LOCAL_GIT_VARS: &[&str] = &[
28    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
29    "GIT_CONFIG",
30    "GIT_CONFIG_PARAMETERS",
31    "GIT_CONFIG_COUNT",
32    "GIT_OBJECT_DIRECTORY",
33    "GIT_DIR",
34    "GIT_WORK_TREE",
35    "GIT_IMPLICIT_WORK_TREE",
36    "GIT_GRAFT_FILE",
37    "GIT_INDEX_FILE",
38    "GIT_NO_REPLACE_OBJECTS",
39    "GIT_REPLACE_REF_BASE",
40    "GIT_PREFIX",
41    "GIT_SHALLOW_FILE",
42    "GIT_COMMON_DIR",
43];
44
45/// Variables that are not repository-local — and so absent from
46/// `--local-env-vars` — but still redirect where git reads or writes.
47///
48/// `GIT_CEILING_DIRECTORIES` is included for completeness rather than
49/// because a live path needs it (27-REVIEW WR-02): every production call
50/// site passes an explicit `current_dir` that is already a repository root,
51/// so git's upward discovery — the only thing this variable constrains —
52/// never runs. Scrubbing an unset variable costs nothing, and including it
53/// means no future call site that *does* rely on discovery has to
54/// rediscover the reasoning.
55pub const ALSO_REDIRECTING_GIT_VARS: &[&str] = &[
56    "GIT_NAMESPACE",
57    "GIT_DISCOVERY_ACROSS_FILESYSTEM",
58    "GIT_CEILING_DIRECTORIES",
59];
60
61/// A `git` command pinned to `repo` **and** stripped of every inherited
62/// variable that could redirect it somewhere else.
63///
64/// Use this for every production git invocation instead of building
65/// `Command::new("git")` directly. `GIT_EXEC_PATH` is deliberately left
66/// alone: it only locates git's own helper binaries and cannot change
67/// which repository git acts on.
68///
69/// Clearing `GIT_CONFIG_COUNT` is sufficient to neutralize any inherited
70/// `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` pair — git only reads those when
71/// the count is set — so they need no separate sweep.
72pub fn git_command(repo: &Path) -> Command {
73    hermetic_command("git", repo)
74}
75
76/// As [`git_command`], for a program that is not `git` itself but will
77/// shell out to it — `cargo`, whose build scripts invoke `git`, is the
78/// motivating case. The redirecting variables are inherited all the way
79/// down a process tree, so scrubbing only the direct `git` calls would
80/// leave that path open.
81///
82/// The scrub is unconditional: there is no bypass parameter, no
83/// environment variable, and no config lookup that can turn it back on.
84/// There is no legitimate reason a DevFlow-issued command should silently
85/// redirect via an inherited variable — an operator who wants DevFlow to
86/// act on a different repository passes it a different path (D-01).
87pub fn hermetic_command(program: &str, dir: &Path) -> Command {
88    let mut cmd = Command::new(program);
89    cmd.current_dir(dir);
90    for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
91        cmd.env_remove(var);
92    }
93    cmd
94}
95
96/// Repository helper bound to a project root.
97#[derive(Debug, Clone)]
98pub struct GitFlow {
99    root: PathBuf,
100    config: GitFlowConfig,
101}
102
103/// Summary of a feature branch for the `devflow list` command.
104#[derive(Debug, Clone)]
105pub struct BranchInfo {
106    /// Branch name (e.g. "feature/phase-05").
107    pub name: String,
108    /// Number of commits this branch has that develop doesn't.
109    pub ahead: usize,
110    /// Number of commits develop has that this branch doesn't.
111    pub behind: usize,
112    /// ISO-8601 date of the last commit on this branch.
113    pub last_commit: String,
114}
115
116impl GitFlow {
117    /// Create a git-flow helper for a project root, using the hardcoded
118    /// git-flow constants (`main`, `develop`, `feature/`).
119    pub fn new(root: impl AsRef<Path>) -> Self {
120        Self {
121            root: root.as_ref().to_path_buf(),
122            config: GitFlowConfig::default(),
123        }
124    }
125
126    /// Create a feature branch from the develop branch.
127    ///
128    /// Returns an error if the branch already exists (use
129    /// [`Self::feature_start_force`] to overwrite).
130    pub fn feature_start(&self, phase: PhaseId) -> Result<String, GitError> {
131        let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
132        info!("creating feature branch: {branch}");
133        self.git(["checkout", &self.config.develop])?;
134        self.git(["checkout", "-b", &branch])?;
135        Ok(branch)
136    }
137
138    /// Create or reset a feature branch, overwriting it if it already exists.
139    pub fn feature_start_force(&self, phase: PhaseId) -> Result<String, GitError> {
140        let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
141        warn!("force-creating feature branch: {branch}");
142        self.git(["checkout", &self.config.develop])?;
143        self.git(["checkout", "-B", &branch])?;
144        Ok(branch)
145    }
146
147    /// Merge a feature branch into develop and delete it.
148    pub fn feature_finish(&self, phase: PhaseId) -> Result<String, GitError> {
149        let branch = self.merge_feature_into_develop(phase)?;
150        self.git(["branch", "-d", &branch])?;
151        Ok(branch)
152    }
153
154    /// Merge a feature branch into develop without deleting it.
155    ///
156    /// Default DevFlow runs keep the feature branch checked out in a linked
157    /// worktree, so deletion belongs to the later best-effort cleanup hook.
158    pub fn merge_feature_into_develop(&self, phase: PhaseId) -> Result<String, GitError> {
159        let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
160        info!("merging feature branch: {branch}");
161        self.git(["checkout", &self.config.develop])?;
162        self.git(["merge", "--no-ff", &branch])?;
163        Ok(branch)
164    }
165
166    /// Whether a phase feature branch has nothing left to merge into develop.
167    ///
168    /// An absent branch is not proof of a merge. Callers must fail closed
169    /// rather than treating a deleted or never-created branch as shipped.
170    pub fn is_merged_into_develop(&self, phase: PhaseId) -> bool {
171        let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
172        if !self.branch_exists(&branch) {
173            return false;
174        }
175
176        git_command(&self.root)
177            .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
178            .output()
179            .map(|output| output.status.success())
180            .unwrap_or(false)
181    }
182
183    /// Create or reset a release branch from the current `HEAD`.
184    ///
185    /// The release branch is cut from wherever the caller currently is — the
186    /// branch being shipped — not from `develop`. `devflow ship` writes the
187    /// version bump into the working tree first, so branching from `HEAD`
188    /// keeps any commits unique to the shipped branch in the release.
189    pub fn release_start(&self, version: &str) -> Result<String, GitError> {
190        let branch = format!("release/{version}");
191        info!("creating release branch: {branch}");
192        self.git(["checkout", "-B", &branch])?;
193        Ok(branch)
194    }
195
196    /// Merge a release branch into main and develop, tag it, and delete it.
197    pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
198        let branch = format!("release/{version}");
199        info!("finishing release branch: {branch}");
200        self.git(["checkout", &self.config.main])?;
201        self.git(["merge", "--no-ff", &branch])?;
202        // `-c tag.gpgSign=false` scopes the override to this invocation only
203        // (never the user's global/repo config) — without it, a global
204        // `tag.gpgsign=true` forces this lightweight tag into an
205        // annotated+signed one requiring a message, which blocks on
206        // `$EDITOR` in what must be a headless, unattended flow (Phase 13
207        // dogfood finding).
208        self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
209        self.git(["checkout", &self.config.develop])?;
210        self.git(["merge", "--no-ff", &branch])?;
211        self.git(["branch", "-d", &branch])?;
212        Ok(branch)
213    }
214
215    /// Create an annotated-free lightweight tag at the current `HEAD`.
216    ///
217    /// Passes `-c tag.gpgSign=false` scoped to this invocation only — a
218    /// global `tag.gpgsign=true` (common for developers who sign their own
219    /// tags) otherwise forces this lightweight tag into an annotated+signed
220    /// one requiring a message, which blocks on `$EDITOR` in what must be a
221    /// headless, unattended flow (Phase 13 dogfood finding: VersionBump hung
222    /// on a live `devflow start --mode auto` run).
223    pub fn tag(&self, tag: &str) -> Result<(), GitError> {
224        info!("tagging {tag}");
225        self.git(["-c", "tag.gpgSign=false", "tag", tag])
226    }
227
228    /// Delete a single local branch.
229    ///
230    /// With `force`, uses `git branch -D` (deletes even if unmerged); otherwise
231    /// `git branch -d` (refuses to delete unmerged work). Protected branches
232    /// (`main`, `develop`) are never deleted.
233    pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
234        if branch == self.config.main || branch == self.config.develop {
235            return Err(GitError::Command(format!(
236                "refusing to delete protected branch `{branch}`"
237            )));
238        }
239        let flag = if force { "-D" } else { "-d" };
240        if force {
241            warn!("force-deleting branch: {branch}");
242        } else {
243            info!("deleting branch: {branch}");
244        }
245        self.git(["branch", flag, branch])
246    }
247
248    /// Whether a local branch exists.
249    pub fn branch_exists(&self, branch: &str) -> bool {
250        git_command(&self.root)
251            .args([
252                "rev-parse",
253                "--verify",
254                "--quiet",
255                &format!("refs/heads/{branch}"),
256            ])
257            .output()
258            .map(|o| o.status.success())
259            .unwrap_or(false)
260    }
261
262    /// The commit SHA at the tip of `branch`.
263    pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
264        Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
265    }
266
267    /// Create `branch` at `start_point` if it does not already exist, without
268    /// checking it out (leaves the current checkout untouched).
269    pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
270        if self.branch_exists(branch) {
271            return Ok(());
272        }
273        self.git(["branch", branch, start_point])
274    }
275
276    /// Check out an existing branch in the main worktree.
277    pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
278        debug!("checking out branch: {branch}");
279        self.git(["checkout", branch])
280    }
281
282    /// Delete `branch` on `origin` (best-effort; errors if no remote/branch).
283    pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
284        info!("deleting remote branch: {branch}");
285        self.git(["push", "origin", "--delete", branch])
286    }
287
288    /// Whether the repository has at least one configured remote.
289    pub fn has_remote(&self) -> bool {
290        self.git_output(["remote"])
291            .map(|s| !s.trim().is_empty())
292            .unwrap_or(false)
293    }
294
295    /// Push `branch` to `origin`, setting upstream.
296    pub fn push(&self, branch: &str) -> Result<(), GitError> {
297        info!("pushing branch: {branch}");
298        self.git(["push", "-u", "origin", branch])
299    }
300
301    /// Delete local branches already merged into `develop`.
302    ///
303    /// WR-04 (13-REVIEW.md): passes `develop` explicitly rather than relying
304    /// on `git branch --merged`'s default of "whatever HEAD currently is" —
305    /// if the main checkout is ever left on a branch other than `develop`
306    /// when this runs, an implicit baseline would silently prune branches
307    /// merged into that other branch instead.
308    ///
309    /// Deletion uses `-D`, not `-d`: `-d` verifies merged-into-HEAD, which
310    /// contradicts the `--merged develop` listing above in exactly the
311    /// checkout-not-on-develop scenario WR-04 targets (every genuinely
312    /// merged branch would be refused as "not fully merged"). The listing IS
313    /// the merge safety check. A branch git still refuses to delete (e.g.
314    /// checked out in a worktree) is logged and skipped so one failure
315    /// doesn't abort the rest of the sweep.
316    pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
317        let output = self.git_output(["branch", "--merged", &self.config.develop])?;
318        let protected = [self.config.main.as_str(), self.config.develop.as_str()];
319        let mut deleted = Vec::new();
320        for line in output.lines() {
321            // git's porcelain marker is an exact two-char prefix ("* " for
322            // the current branch, "+ " for a worktree checkout, "  "
323            // otherwise) — strip it positionally rather than trimming
324            // marker CHARACTERS, which would mangle a branch legitimately
325            // named e.g. "+foo" (WR-03, revised).
326            let branch = line
327                .strip_prefix("* ")
328                .or_else(|| line.strip_prefix("+ "))
329                .unwrap_or(line)
330                .trim();
331            // Skip blanks, protected trunks, and the detached-HEAD line
332            // ("(HEAD detached at ...)"), which is not a branch name.
333            if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
334                continue;
335            }
336            info!("cleaning up merged branch: {branch}");
337            match self.git(["branch", "-D", branch]) {
338                Ok(()) => deleted.push(branch.to_string()),
339                Err(err) => warn!("could not delete merged branch {branch}: {err}"),
340            }
341        }
342        Ok(deleted)
343    }
344
345    /// Stage all changes and commit with the given message.
346    /// Returns Ok(()) whether or not there were changes to commit.
347    pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
348        debug!("committing all changes: {message}");
349        self.git(["add", "."])?;
350        // --allow-empty so we don't fail when there are no changes
351        match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
352            Ok(()) => Ok(()),
353            // If the commit produced no changes and we used --allow-empty,
354            // this should still succeed. But just in case, ignore "nothing to commit".
355            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
356            Err(e) => Err(e),
357        }
358    }
359
360    /// Stage a single relative path and commit with the given message.
361    /// Mirrors `commit_all`, but scoped to one path, for hooks that must not
362    /// sweep in unrelated dirty state left by other hooks or the workflow.
363    /// Returns Ok(()) whether or not the path had changes to commit. Unlike
364    /// `commit_all`, a path with no changes produces **no commit** — it is a
365    /// genuine no-op, not a forced empty commit, so a caller such as
366    /// `hooks::version_bump` can never tag a release on a commit containing
367    /// nothing (19b/D-16).
368    pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
369        debug!("committing {relative_path}: {message}");
370        // `add` first so a brand-new file is known to git — a pathspec-only
371        // commit errors on a path git has never seen. The trailing pathspec is
372        // what actually scopes the commit: without it, `commit` writes whatever
373        // else is already in the index, which is exactly the sweep-in this
374        // function exists to prevent.
375        self.git(["add", relative_path])?;
376        match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
377            Ok(()) => Ok(()),
378            // No forcing flag above, so this arm is now the live no-op path:
379            // a path with nothing staged makes git exit non-zero with
380            // "nothing to commit", and we convert that back to Ok(()) rather
381            // than let it propagate as an error (19b/D-16, T-19-11).
382            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
383            Err(e) => Err(e),
384        }
385    }
386
387    /// Return divergence from develop: (ahead, behind) commit counts.
388    ///
389    /// If currently on the develop branch, returns (0, 0).
390    /// `ahead` = commits on current branch not yet on develop.
391    /// `behind` = commits on develop not yet on current branch.
392    pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
393        let current = self
394            .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
395            .trim()
396            .to_string();
397        if current == self.config.develop {
398            return Ok((0, 0));
399        }
400        let ahead = self
401            .rev_count(&format!("{}..{current}", self.config.develop))
402            .unwrap_or(0);
403        let behind = self
404            .rev_count(&format!("{current}..{}", self.config.develop))
405            .unwrap_or(0);
406        Ok((ahead, behind))
407    }
408
409    /// List all feature branches with divergence from develop.
410    ///
411    /// Returns branches matching `feature/phase-*` with ahead/behind counts
412    /// and last commit dates. Protected branches (main, develop) are excluded.
413    pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
414        let prefix = &self.config.feature_prefix;
415        let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
416        let mut result = Vec::new();
417        for name in branches.lines().map(|l| l.trim()) {
418            if name.is_empty()
419                || name == self.config.main
420                || name == self.config.develop
421                || !name.starts_with(prefix)
422            {
423                continue;
424            }
425            let ahead = self
426                .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
427                .unwrap_or(0);
428            let behind = self
429                .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
430                .unwrap_or(0);
431            let last_commit = self
432                .git_output(["log", "-1", "--format=%aI", name])
433                .map(|s| s.trim().to_string())
434                .unwrap_or_default();
435            result.push(BranchInfo {
436                name: name.to_string(),
437                ahead,
438                behind,
439                last_commit,
440            });
441        }
442        // Sort by phase number so phase-01 comes before phase-10.
443        result.sort_by(|a, b| a.name.cmp(&b.name));
444        Ok(result)
445    }
446
447    /// Count revisions in the given range. Returns None if the command fails.
448    fn rev_count(&self, range: &str) -> Option<usize> {
449        self.git_output(["rev-list", "--count", range])
450            .ok()
451            .and_then(|s| s.trim().parse().ok())
452    }
453
454    fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
455        debug!("git {}", args.join(" "));
456        // Pin the subprocess locale to C (Antigravity review, 19b): commit_path's
457        // "nothing to commit" match arm above compares against git's own
458        // English-locale output, which a non-English LC_ALL/LANG would
459        // localize, silently defeating the match and reopening 19b under a
460        // localized environment (T-19-14). Scoped to this one call path only.
461        let output = git_command(&self.root)
462            .args(args)
463            .env("LC_ALL", "C")
464            .env("LANG", "C")
465            .output()?;
466        if output.status.success() {
467            Ok(())
468        } else {
469            Err(GitError::Command(stderr_or_status(&output)))
470        }
471    }
472
473    /// Like [`git_raw`](Self::git_raw), but the error text combines stdout
474    /// with stderr instead of inspecting stderr alone.
475    ///
476    /// Discovered empirically while implementing 19b: `git commit`'s
477    /// "nothing to commit, working tree clean" message is written to
478    /// **stdout**, not stderr. `stderr_or_status` only ever inspects
479    /// `output.stderr`, so a plain `git_raw` error can never contain that
480    /// text — `commit_path`'s `nothing to commit` match arm (immediately
481    /// above its call site) would never fire, no matter how the arm itself
482    /// is written. This sibling exists solely so `commit_path` can see it;
483    /// `commit_all` keeps calling `git_raw` unchanged (D-17 out of scope),
484    /// and `git_raw`'s own error-mapping branch is untouched by this
485    /// addition.
486    fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
487        debug!("git {}", args.join(" "));
488        let output = git_command(&self.root)
489            .args(args)
490            .env("LC_ALL", "C")
491            .env("LANG", "C")
492            .output()?;
493        if output.status.success() {
494            Ok(())
495        } else {
496            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
497            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
498            let combined = match (stderr.is_empty(), stdout.is_empty()) {
499                (false, false) => format!("{stderr}\n{stdout}"),
500                (false, true) => stderr,
501                (true, false) => stdout,
502                (true, true) => format!("exited with {}", output.status),
503            };
504            Err(GitError::Command(combined))
505        }
506    }
507
508    fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
509        debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
510        let output = git_command(&self.root).args(args).output()?;
511        if output.status.success() {
512            Ok(())
513        } else {
514            Err(GitError::Command(stderr_or_status(&output)))
515        }
516    }
517
518    fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
519        let output = git_command(&self.root).args(args).output()?;
520        if output.status.success() {
521            Ok(String::from_utf8_lossy(&output.stdout).to_string())
522        } else {
523            Err(GitError::Command(stderr_or_status(&output)))
524        }
525    }
526}
527
528/// Result of checking whether `origin/main` is already an ancestor of
529/// `HEAD` — i.e. whether `scripts/sync-main-to-develop.sh` would be a no-op
530/// — WITHOUT issuing any `git fetch` (20d, review: Codex HIGH — a
531/// "read-only" preflight must not depend on the network).
532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
533pub enum AncestorStatus {
534    /// `origin/main` is an ancestor of `HEAD` — sync would be a no-op.
535    Ancestor,
536    /// `origin/main` resolves locally but is NOT an ancestor of `HEAD` —
537    /// develop has diverged and `scripts/sync-main-to-develop.sh` should be
538    /// run before cutting the next release.
539    Diverged,
540    /// `origin/main` does not resolve locally at all (never fetched, or no
541    /// remote configured). Distinct from [`Diverged`](Self::Diverged) so
542    /// the caller can degrade to an actionable "run `git fetch` first"
543    /// message instead of reporting a false divergence.
544    RefAbsent,
545}
546
547/// Check whether `origin/main` is an ancestor of `HEAD`, against
548/// ALREADY-FETCHED local refs — issues NO `git fetch`. Mirrors
549/// `scripts/sync-main-to-develop.sh`'s own `git merge-base --is-ancestor
550/// origin/main HEAD` invocation (`:41`), minus the preceding `git fetch`
551/// (`:38`), which mutates `.git/FETCH_HEAD`/tracking refs and would make a
552/// "read-only" preflight false (20d, review: Codex HIGH).
553pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
554    let ref_exists = git_command(project_root)
555        .args(["rev-parse", "--verify", "--quiet", "origin/main"])
556        .output()
557        .map(|out| out.status.success())
558        .unwrap_or(false);
559    if !ref_exists {
560        return AncestorStatus::RefAbsent;
561    }
562    let is_ancestor = git_command(project_root)
563        .args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
564        .output()
565        .map(|out| out.status.success())
566        .unwrap_or(false);
567    if is_ancestor {
568        AncestorStatus::Ancestor
569    } else {
570        AncestorStatus::Diverged
571    }
572}
573
574/// Whether the `ancestor` ref is an ancestor of the `descendant` ref, against
575/// ALREADY-FETCHED local refs — issues NO `git fetch`. Generalises
576/// [`origin_main_ancestor_status`] to an arbitrary ref pair (used by
577/// `release --verify`'s post-cut checks: is the release tag on `main`, and
578/// has the `main`→`develop` sync run). Distinguishes "not an ancestor"
579/// (exit 1) from "a ref is absent" (exit 128) so the caller can degrade to
580/// an actionable `git fetch` message instead of reporting a false
581/// divergence.
582pub fn ref_is_ancestor(project_root: &Path, ancestor: &str, descendant: &str) -> AncestorStatus {
583    let both_exist = [ancestor, descendant].iter().all(|r| {
584        git_command(project_root)
585            .args(["rev-parse", "--verify", "--quiet", r])
586            .output()
587            .map(|out| out.status.success())
588            .unwrap_or(false)
589    });
590    if !both_exist {
591        return AncestorStatus::RefAbsent;
592    }
593    let is_ancestor = git_command(project_root)
594        .args(["merge-base", "--is-ancestor", ancestor, descendant])
595        .output()
596        .map(|out| out.status.success())
597        .unwrap_or(false);
598    if is_ancestor {
599        AncestorStatus::Ancestor
600    } else {
601        AncestorStatus::Diverged
602    }
603}
604
605/// Derive the crates.io publish order for a workspace's local-path members
606/// (e.g. `devflow-core` before `devflow`) — sourced from the workspace's own
607/// `[workspace] members` list and each member's own `[dependencies]`
608/// section (which member depends on which), never a hardcoded prose string
609/// (20d). Read-only; returns an empty `Vec` (never panics) if the workspace
610/// Cargo.toml or a member manifest cannot be read.
611pub fn publish_order(project_root: &Path) -> Vec<String> {
612    let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
613        return Vec::new();
614    };
615    let member_paths = workspace_member_paths(&root_contents);
616
617    let mut members: Vec<(String, String)> = Vec::new();
618    for path in &member_paths {
619        let manifest = project_root.join(path).join("Cargo.toml");
620        let Ok(contents) = std::fs::read_to_string(&manifest) else {
621            continue;
622        };
623        let name = package_name(&contents).unwrap_or_else(|| path.clone());
624        members.push((name, contents));
625    }
626
627    let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
628    let mut edges: Vec<(String, String)> = Vec::new();
629    for (name, contents) in &members {
630        for other in &names {
631            if other != name && member_depends_on(contents, other) {
632                edges.push((name.clone(), other.clone()));
633            }
634        }
635    }
636    topo_sort(names, edges)
637}
638
639/// Extract the `[workspace] members = [...]` array's quoted path entries.
640/// Hand-rolled, single-array-only scan (this project deliberately avoids a
641/// TOML parser dependency for its version/workspace tooling — see
642/// `version.rs`).
643fn workspace_member_paths(contents: &str) -> Vec<String> {
644    let Some(start) = contents.find("members") else {
645        return Vec::new();
646    };
647    let rest = &contents[start..];
648    let Some(open) = rest.find('[') else {
649        return Vec::new();
650    };
651    let Some(close) = rest[open..].find(']') else {
652        return Vec::new();
653    };
654    let inner = &rest[open + 1..open + close];
655    inner
656        .split(',')
657        .filter_map(|fragment| {
658            let fragment = fragment.trim();
659            let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
660            (!fragment.is_empty()).then(|| fragment.to_string())
661        })
662        .collect()
663}
664
665/// Extract a member manifest's `[package] name`.
666fn package_name(contents: &str) -> Option<String> {
667    let mut current = String::new();
668    for line in contents.lines() {
669        let trimmed = line.trim();
670        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
671            current = inner.trim().to_string();
672            continue;
673        }
674        if current == "package"
675            && let Some((key, value)) = trimmed.split_once('=')
676            && key.trim() == "name"
677        {
678            return Some(value.trim().trim_matches('"').to_string());
679        }
680    }
681    None
682}
683
684/// Whether a member manifest's `[dependencies]` section references
685/// `dep_name` — either `dep_name.workspace = true` or `dep_name = { ... }`
686/// under an inline `[dependencies]` table, OR the equally-valid expanded
687/// long-form section `[dependencies.dep_name]` (WR-03, phase 20 review): a
688/// manifest may spell a dependency out as its own section (e.g.
689/// `[dependencies.devflow-core]\nworkspace = true`), which parses to a
690/// section header of `"dependencies.devflow-core"` — never equal to the
691/// plain `"dependencies"` the inline-table branch below checks against, so
692/// that edge was previously dropped from `publish_order`'s topo-sort
693/// entirely.
694fn member_depends_on(contents: &str, dep_name: &str) -> bool {
695    let mut current = String::new();
696    for line in contents.lines() {
697        let trimmed = line.trim();
698        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
699            current = inner.trim().to_string();
700            if let Some(name) = current.strip_prefix("dependencies.")
701                && name == dep_name
702            {
703                return true;
704            }
705            continue;
706        }
707        if current != "dependencies" {
708            continue;
709        }
710        let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
711        if key == dep_name {
712            return true;
713        }
714    }
715    false
716}
717
718/// Kahn's-algorithm topological sort: `edges` are `(dependent, dependency)`
719/// pairs, meaning `dependent` must be published AFTER `dependency`. Falls
720/// back to appending whatever remains (rather than looping forever) if a
721/// cycle is present — a genuine cyclic Cargo dependency would already fail
722/// `cargo build` long before this check runs.
723fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
724    let mut result = Vec::new();
725    let mut published: Vec<String> = Vec::new();
726    let mut remaining = names;
727    while !remaining.is_empty() {
728        let ready: Vec<String> = remaining
729            .iter()
730            .filter(|name| {
731                edges
732                    .iter()
733                    .filter(|(dependent, _)| dependent == *name)
734                    .all(|(_, dep)| published.contains(dep))
735            })
736            .cloned()
737            .collect();
738        if ready.is_empty() {
739            result.extend(remaining);
740            break;
741        }
742        for name in &ready {
743            published.push(name.clone());
744            result.push(name.clone());
745        }
746        remaining.retain(|name| !ready.contains(name));
747    }
748    result
749}
750
751// ---------------------------------------------------------------------------
752// tag-signing viability (20d, Pattern 4)
753// ---------------------------------------------------------------------------
754
755// The tag-signing viability probe (`check_signing_viability` and its SSH/GPG
756// helpers, plus the `SigningViability`/`SignProbeOutcome` enums) was REMOVED in
757// 999.104: it was capability-only — "can this key sign" — never identity, which
758// is what a wrong-key release tag actually needs. `scripts/cut-release.sh` now
759// fails loudly when `devflow.releaseSigningKey` is unset, and the identity check
760// that matters lives in `scripts/hooks/pre-push`. Do not reintroduce a
761// signing-viability probe here; it is dead weight by construction.
762
763fn stderr_or_status(output: &std::process::Output) -> String {
764    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
765    if stderr.is_empty() {
766        format!("exited with {}", output.status)
767    } else {
768        stderr
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use tempfile::TempDir;
776
777    /// Run a git command in `root`, asserting success.
778    fn git(root: &Path, args: &[&str]) {
779        let output = crate::test_support::git_command(root)
780            .args(args)
781            .output()
782            .expect("spawn git");
783        assert!(
784            output.status.success(),
785            "git {args:?} failed: {}",
786            String::from_utf8_lossy(&output.stderr)
787        );
788    }
789
790    fn current_branch(root: &Path) -> String {
791        let output = crate::test_support::git_command(root)
792            .args(["rev-parse", "--abbrev-ref", "HEAD"])
793            .output()
794            .expect("rev-parse");
795        String::from_utf8_lossy(&output.stdout).trim().to_string()
796    }
797
798    fn commit_file(root: &Path, name: &str) {
799        std::fs::write(root.join(name), name).unwrap();
800        git(root, &["add", "."]);
801        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
802    }
803
804    /// Initialize a repo with `main` and `develop` branches and one commit.
805    fn init_repo() -> TempDir {
806        let dir = tempfile::tempdir().unwrap();
807        let root = dir.path();
808        git(root, &["init", "-q"]);
809        git(root, &["config", "user.email", "test@example.com"]);
810        git(root, &["config", "user.name", "Test"]);
811        git(root, &["config", "commit.gpgsign", "false"]);
812        git(root, &["config", "tag.gpgsign", "false"]);
813        // Disable any globally-configured hooks (e.g. gitleaks) for isolation.
814        git(root, &["config", "core.hooksPath", "/dev/null"]);
815        commit_file(root, "README.md");
816        git(root, &["branch", "-M", "main"]);
817        git(root, &["checkout", "-q", "-b", "develop"]);
818        dir
819    }
820
821    fn flow(root: &Path) -> GitFlow {
822        GitFlow::new(root)
823    }
824
825    #[test]
826    fn feature_start_branches_from_develop() {
827        let repo = init_repo();
828        let root = repo.path();
829        let branch = flow(root)
830            .feature_start(PhaseId::new(3))
831            .expect("feature_start");
832        assert_eq!(branch, "feature/phase-03");
833        assert_eq!(current_branch(root), "feature/phase-03");
834    }
835
836    #[test]
837    fn list_feature_branches_reports_ahead_and_behind_semantics() {
838        let repo = init_repo();
839        let root = repo.path();
840        let gf = flow(root);
841
842        gf.feature_start(PhaseId::new(12)).expect("feature_start");
843        commit_file(root, "feature-one.txt");
844        commit_file(root, "feature-two.txt");
845        git(root, &["checkout", "-q", "develop"]);
846        commit_file(root, "develop-only.txt");
847
848        let branches = gf.list_feature_branches().unwrap();
849        let branch = branches
850            .iter()
851            .find(|branch| branch.name == "feature/phase-12")
852            .unwrap();
853
854        assert_eq!(branch.ahead, 2);
855        assert_eq!(branch.behind, 1);
856    }
857
858    #[test]
859    fn feature_finish_merges_into_develop_and_deletes() {
860        let repo = init_repo();
861        let root = repo.path();
862        let gf = flow(root);
863
864        gf.feature_start(PhaseId::new(1)).expect("start");
865        commit_file(root, "feature.txt");
866
867        let branch = gf.feature_finish(PhaseId::new(1)).expect("finish");
868        assert_eq!(branch, "feature/phase-01");
869        assert_eq!(current_branch(root), "develop");
870
871        // Branch is deleted and its work is now on develop.
872        let branches = crate::test_support::git_command(root)
873            .args(["branch"])
874            .output()
875            .unwrap();
876        let listing = String::from_utf8_lossy(&branches.stdout);
877        assert!(!listing.contains("feature/phase-01"));
878        assert!(root.join("feature.txt").exists());
879    }
880
881    #[test]
882    fn release_start_and_finish_tags_main_and_merges_both() {
883        let repo = init_repo();
884        let root = repo.path();
885        let gf = flow(root);
886
887        // Add work on develop so the release has content.
888        commit_file(root, "work.txt");
889        let branch = gf.release_start("1.2.0").expect("release_start");
890        assert_eq!(branch, "release/1.2.0");
891
892        gf.release_finish("1.2.0").expect("release_finish");
893        assert_eq!(current_branch(root), "develop");
894
895        // Tag exists.
896        let tags = crate::test_support::git_command(root)
897            .args(["tag"])
898            .output()
899            .unwrap();
900        assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
901
902        // Release branch deleted.
903        let branches = crate::test_support::git_command(root)
904            .args(["branch"])
905            .output()
906            .unwrap();
907        assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
908    }
909
910    /// A global/repo `tag.gpgsign=true` must not turn `tag()`'s lightweight
911    /// tag into an annotated+signed one — that would require a tag message
912    /// and block on `$EDITOR`, silently hanging a headless, unattended run
913    /// (Phase 13 dogfood finding: VersionBump hung on a live
914    /// `devflow start --mode auto` run because the operator's global
915    /// gitconfig sets `tag.gpgsign=true`).
916    #[test]
917    fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
918        let repo = init_repo();
919        let root = repo.path();
920        // Simulate an operator whose global config signs tags by default —
921        // override the test harness's own `tag.gpgsign false` to prove
922        // `tag()`'s per-invocation `-c` override wins regardless.
923        git(root, &["config", "tag.gpgsign", "true"]);
924
925        flow(root)
926            .tag("v9.9.9")
927            .expect("tag must not block on $EDITOR");
928
929        let tags = crate::test_support::git_command(root)
930            .args(["tag", "-l"])
931            .output()
932            .unwrap();
933        assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
934
935        // Confirm it's a lightweight tag (points directly at the commit),
936        // not an annotated tag object (which `cat-file -t` would report as
937        // "tag" rather than "commit").
938        let obj_type = crate::test_support::git_command(root)
939            .args(["cat-file", "-t", "v9.9.9"])
940            .output()
941            .unwrap();
942        assert_eq!(
943            String::from_utf8_lossy(&obj_type.stdout).trim(),
944            "commit",
945            "tag() must stay lightweight even when tag.gpgsign=true"
946        );
947    }
948
949    #[test]
950    fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
951        // The property that distinguishes commit_path from commit_all
952        // (17-12, Task 2b): a hook using commit_path must never sweep in
953        // unrelated dirty state.
954        let repo = init_repo();
955        let root = repo.path();
956        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
957        std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
958
959        // Stage the unrelated file BEFORE calling commit_path. An untracked
960        // file is excluded by any implementation and so proves nothing; an
961        // already-staged one is the real failure mode — a bare `git commit`
962        // writes the whole index and would sweep it in.
963        crate::test_support::git_command(root)
964            .args(["add", "unrelated.txt"])
965            .status()
966            .unwrap();
967
968        flow(root)
969            .commit_path("CHANGELOG.md", "docs: add changelog entry")
970            .expect("commit_path");
971
972        let committed = crate::test_support::git_command(root)
973            .args(["log", "-1", "--name-only", "--pretty=format:"])
974            .output()
975            .unwrap();
976        let committed_files = String::from_utf8_lossy(&committed.stdout);
977        assert!(committed_files.contains("CHANGELOG.md"));
978        assert!(!committed_files.contains("unrelated.txt"));
979
980        let status = crate::test_support::git_command(root)
981            .args(["status", "--porcelain"])
982            .output()
983            .unwrap();
984        let status = String::from_utf8_lossy(&status.stdout);
985        assert!(
986            status.contains("A  unrelated.txt"),
987            "unrelated.txt must remain staged-but-uncommitted, got: {status}"
988        );
989    }
990
991    /// `git rev-list --count HEAD`, parsed. Shared by the three tests below
992    /// so a failure reports both counts instead of a bare assertion.
993    fn rev_list_count(root: &Path) -> u32 {
994        let output = crate::test_support::git_command(root)
995            .args(["rev-list", "--count", "HEAD"])
996            .output()
997            .unwrap();
998        assert!(output.status.success(), "git rev-list --count HEAD failed");
999        String::from_utf8_lossy(&output.stdout)
1000            .trim()
1001            .parse::<u32>()
1002            .expect("rev-list --count HEAD must print an integer")
1003    }
1004
1005    /// 19b/D-16: `hooks::version_bump` (hooks.rs:242) calls `commit_path` and
1006    /// then tags whatever commit it last produced (hooks.rs:249). If a
1007    /// terminal-batch retry calls `commit_path` again with byte-identical
1008    /// content (the file untouched since the first call), a forced commit
1009    /// here means the release tag can end up naming a commit that contains
1010    /// nothing new. This pins the exact retry scenario: two calls, unchanged
1011    /// content, `git rev-list --count HEAD` must not move between them.
1012    #[test]
1013    fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1014        let repo = init_repo();
1015        let root = repo.path();
1016        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1017
1018        flow(root)
1019            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1020            .expect("first commit_path call");
1021        let n1 = rev_list_count(root);
1022
1023        // The file is not touched again -- this is the retry scenario, not
1024        // a second genuine change.
1025        flow(root)
1026            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1027            .expect("second commit_path call");
1028        let n2 = rev_list_count(root);
1029
1030        assert_eq!(
1031            n2, n1,
1032            "a repeat commit_path call on unchanged content must not add a \
1033             commit: n1={n1}, n2={n2}"
1034        );
1035    }
1036
1037    /// 19b/D-16, T-19-11: separates the "no commit" claim from the "no
1038    /// error" claim so a future change can't satisfy one by breaking the
1039    /// other. `hooks.rs` propagates `commit_path`'s `Result` with `?` at both
1040    /// call sites (changelog_append:225, version_bump:242) -- turning a
1041    /// genuine no-op into `Err` would stall the terminal hook batch (see
1042    /// T-19-11 in this plan's threat model), so both properties must hold
1043    /// simultaneously.
1044    #[test]
1045    fn commit_path_with_no_changes_returns_ok_without_committing() {
1046        let repo = init_repo();
1047        let root = repo.path();
1048        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1049        flow(root)
1050            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1051            .expect("initial commit_path");
1052        let n1 = rev_list_count(root);
1053
1054        // CHANGELOG.md is already committed and unmodified -- a single call
1055        // here has nothing to commit.
1056        let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1057        let n2 = rev_list_count(root);
1058
1059        assert!(
1060            result.is_ok(),
1061            "no-op call must return Ok(()), got: {result:?}"
1062        );
1063        assert_eq!(
1064            n2, n1,
1065            "no-op call must not create a commit: n1={n1}, n2={n2}"
1066        );
1067    }
1068
1069    /// Edge case the fix must NOT change: `commit_path` on a path that does
1070    /// not exist on disk still errors at the staging step (`git add` fails
1071    /// on an unknown pathspec). Asserted explicitly so the fix for the
1072    /// no-change case above cannot be over-applied into "commit_path never
1073    /// fails".
1074    #[test]
1075    fn commit_path_on_nonexistent_path_still_errors() {
1076        let repo = init_repo();
1077        let root = repo.path();
1078
1079        let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1080
1081        assert!(
1082            result.is_err(),
1083            "commit_path on an unknown pathspec must still error, got: {result:?}"
1084        );
1085    }
1086
1087    #[test]
1088    fn release_start_branches_from_current_head_not_develop() {
1089        let repo = init_repo();
1090        let root = repo.path();
1091        let gf = flow(root);
1092
1093        // Ship from a feature branch carrying a commit that is NOT on develop.
1094        gf.feature_start(PhaseId::new(5)).expect("feature_start");
1095        commit_file(root, "feature-only.txt");
1096        let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1097
1098        let branch = gf.release_start("2.0.0").expect("release_start");
1099        assert_eq!(branch, "release/2.0.0");
1100        assert_eq!(current_branch(root), "release/2.0.0");
1101
1102        // The release branch tip must descend from the feature commit — i.e.
1103        // the feature-only work is present, not dropped to develop's HEAD.
1104        let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1105        let is_ancestor = crate::test_support::git_command(root)
1106            .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1107            .output()
1108            .unwrap()
1109            .status
1110            .success();
1111        assert!(
1112            is_ancestor,
1113            "release branch must descend from the shipped feature commit"
1114        );
1115        assert!(root.join("feature-only.txt").exists());
1116    }
1117
1118    #[test]
1119    fn cleanup_merged_removes_merged_but_keeps_protected() {
1120        let repo = init_repo();
1121        let root = repo.path();
1122        let gf = flow(root);
1123
1124        // Create and merge a feature branch into develop.
1125        gf.feature_start(PhaseId::new(2)).expect("start");
1126        commit_file(root, "f.txt");
1127        gf.feature_finish(PhaseId::new(2)).expect("finish");
1128
1129        // Create an already-merged stray branch off develop.
1130        git(root, &["branch", "stale-merged"]);
1131
1132        let deleted = gf.cleanup_merged().expect("cleanup");
1133        assert!(deleted.contains(&"stale-merged".to_string()));
1134        // Protected branches survive.
1135        assert!(!deleted.contains(&"develop".to_string()));
1136        assert!(!deleted.contains(&"main".to_string()));
1137    }
1138
1139    /// WR-04 (13-REVIEW.md): `cleanup_merged` must compute "merged" relative
1140    /// to `develop` explicitly, not whatever the main checkout's current
1141    /// HEAD happens to be. If the main checkout is left on a divergent
1142    /// branch, an implicit-HEAD baseline would wrongly identify (and
1143    /// delete) a branch that's merged into that other branch but was never
1144    /// actually merged into `develop`.
1145    #[test]
1146    fn cleanup_merged_is_relative_to_develop_not_current_head() {
1147        let repo = init_repo();
1148        let root = repo.path();
1149        let gf = flow(root);
1150
1151        // `topic` diverges from develop with a unique commit develop never
1152        // sees, then `premature` branches off `topic`'s tip — so
1153        // `premature` is merged into `topic` but NOT into `develop`.
1154        git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1155        commit_file(root, "topic-only.txt");
1156        git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1157
1158        // Leave the main checkout on `topic` — NOT `develop` — before
1159        // calling cleanup_merged, mirroring an operator who forgot to
1160        // check out develop first. (`topic` itself is also technically
1161        // "merged into HEAD" under an implicit baseline since it IS HEAD,
1162        // which git's own `-d` correctly refuses as the checked-out branch
1163        // — so the call's overall Ok/Err is not itself decisive here; check
1164        // the actual side effect on `premature` instead.)
1165        git(root, &["checkout", "-q", "topic"]);
1166
1167        let _ = gf.cleanup_merged();
1168        assert!(
1169            gf.branch_exists("premature"),
1170            "premature is merged into topic (current HEAD) but not into \
1171             develop — it must survive cleanup_merged when the baseline is develop"
1172        );
1173    }
1174
1175    /// WR-03 (13-REVIEW.md), revised: `git branch --merged` prefixes a
1176    /// branch checked out in a linked worktree with `+ `. The prefix must be
1177    /// stripped positionally (not by trimming marker characters, which would
1178    /// mangle a branch legitimately named "+foo"), and a branch git refuses
1179    /// to delete — a worktree checkout can never be deleted, by design —
1180    /// must be skipped with a warning rather than aborting the sweep before
1181    /// the remaining merged branches.
1182    #[test]
1183    fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1184        let repo = init_repo();
1185        let root = repo.path();
1186        let gf = flow(root);
1187
1188        // Merge a branch into develop WITHOUT deleting it (feature_finish
1189        // deletes on merge, which would leave nothing to check out).
1190        git(
1191            root,
1192            &["checkout", "-q", "-b", "worktree-merged", "develop"],
1193        );
1194        commit_file(root, "g.txt");
1195        git(root, &["checkout", "-q", "develop"]);
1196        git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1197
1198        // Check the merged branch out in a linked worktree so
1199        // `git branch --merged` reports it with a `+ ` prefix.
1200        let wt_dir = tempfile::tempdir().unwrap();
1201        git(
1202            root,
1203            &[
1204                "worktree",
1205                "add",
1206                wt_dir.path().to_str().unwrap(),
1207                "worktree-merged",
1208            ],
1209        );
1210
1211        // A second merged branch that sorts after "worktree-merged" would be
1212        // reached only if the sweep survives the worktree refusal; "zz-" also
1213        // guards against luck in iteration order via the branch before it.
1214        git(root, &["branch", "aa-stale"]);
1215        git(root, &["branch", "zz-stale"]);
1216
1217        let deleted = gf
1218            .cleanup_merged()
1219            .expect("a skipped worktree branch must not abort the sweep");
1220        assert!(deleted.contains(&"aa-stale".to_string()));
1221        assert!(deleted.contains(&"zz-stale".to_string()));
1222        assert!(
1223            !deleted.contains(&"worktree-merged".to_string()),
1224            "worktree checkout cannot be deleted"
1225        );
1226        assert!(gf.branch_exists("worktree-merged"));
1227    }
1228
1229    /// The delete side must agree with the `--merged develop` listing: `-d`
1230    /// verifies merged-into-HEAD, so with the main checkout parked on a
1231    /// stale branch every genuinely-merged branch was refused as "not fully
1232    /// merged" — in exactly the scenario WR-04 exists for.
1233    #[test]
1234    fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1235        let repo = init_repo();
1236        let root = repo.path();
1237        let gf = flow(root);
1238
1239        // `old` is parked before the merge below, so nothing merged later is
1240        // reachable from HEAD while it's checked out.
1241        git(root, &["checkout", "-q", "-b", "old", "develop"]);
1242        git(root, &["checkout", "-q", "develop"]);
1243        git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1244        commit_file(root, "h.txt");
1245        git(root, &["checkout", "-q", "develop"]);
1246        git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1247        git(root, &["checkout", "-q", "old"]);
1248
1249        let deleted = gf.cleanup_merged().expect("cleanup");
1250        assert!(
1251            deleted.contains(&"merged-feature".to_string()),
1252            "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1253        );
1254        assert!(!gf.branch_exists("merged-feature"));
1255    }
1256
1257    #[test]
1258    fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1259        let repo = init_repo();
1260        let root = repo.path();
1261        let gf = flow(root);
1262
1263        // Create a feature branch with an unmerged commit.
1264        gf.feature_start(PhaseId::new(8)).expect("start");
1265        commit_file(root, "unmerged.txt");
1266        // Switch back to develop so the branch isn't checked out.
1267        git(root, &["checkout", "-q", "develop"]);
1268
1269        // -d would refuse (unmerged); force deletes it.
1270        assert!(gf.delete_branch("feature/phase-08", false).is_err());
1271        gf.delete_branch("feature/phase-08", true)
1272            .expect("force delete");
1273        let branches = crate::test_support::git_command(root)
1274            .args(["branch"])
1275            .output()
1276            .unwrap();
1277        assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1278
1279        // Protected branches are never deleted.
1280        assert!(gf.delete_branch("develop", true).is_err());
1281        assert!(gf.delete_branch("main", true).is_err());
1282    }
1283
1284    #[test]
1285    fn merge_of_missing_branch_is_an_error() {
1286        let repo = init_repo();
1287        let root = repo.path();
1288        // feature_finish for a phase that was never started: checkout develop
1289        // succeeds, but merging the nonexistent feature branch fails.
1290        let err = flow(root).feature_finish(PhaseId::new(99)).unwrap_err();
1291        assert!(matches!(err, GitError::Command(_)));
1292    }
1293
1294    // -----------------------------------------------------------------
1295    // 20d: publish-order helpers (pure, no I/O)
1296    // -----------------------------------------------------------------
1297
1298    #[test]
1299    fn workspace_member_paths_parses_multiline_array() {
1300        let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n";
1301        assert_eq!(
1302            workspace_member_paths(contents),
1303            vec![
1304                "crates/devflow-core".to_string(),
1305                "crates/devflow-cli".to_string()
1306            ]
1307        );
1308    }
1309
1310    #[test]
1311    fn package_name_reads_the_package_section() {
1312        let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1313        assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1314    }
1315
1316    #[test]
1317    fn member_depends_on_matches_dotted_workspace_shorthand() {
1318        let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1319        assert!(member_depends_on(contents, "devflow-core"));
1320        assert!(!member_depends_on(contents, "serde"));
1321    }
1322
1323    /// WR-03 (phase 20 review): the equally-valid expanded long-form TOML
1324    /// section syntax (`[dependencies.NAME]`) parses to a section header of
1325    /// `"dependencies.NAME"`, never equal to the plain `"dependencies"` the
1326    /// inline-table branch checks against — this must still be recognized
1327    /// as a dependency edge.
1328    #[test]
1329    fn member_depends_on_matches_long_form_dependency_section() {
1330        let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1331        assert!(member_depends_on(contents, "devflow-core"));
1332        assert!(member_depends_on(contents, "clap"));
1333        assert!(!member_depends_on(contents, "serde"));
1334    }
1335
1336    #[test]
1337    fn topo_sort_orders_dependency_before_dependent() {
1338        let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1339        let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1340        assert_eq!(
1341            topo_sort(names, edges),
1342            vec!["devflow-core".to_string(), "devflow".to_string()]
1343        );
1344    }
1345
1346    #[test]
1347    fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1348        // A genuine cyclic dependency would already fail `cargo build`
1349        // long before this check runs — this just proves no infinite loop.
1350        let names = vec!["a".to_string(), "b".to_string()];
1351        let edges = vec![
1352            ("a".to_string(), "b".to_string()),
1353            ("b".to_string(), "a".to_string()),
1354        ];
1355        let result = topo_sort(names, edges);
1356        assert_eq!(result.len(), 2);
1357    }
1358
1359    #[test]
1360    fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1361        let dir = tempfile::tempdir().unwrap();
1362        let root = dir.path();
1363        std::fs::write(
1364            root.join("Cargo.toml"),
1365            "[workspace]\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n",
1366        )
1367        .unwrap();
1368        std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1369        std::fs::write(
1370            root.join("crates/devflow-core/Cargo.toml"),
1371            "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1372        )
1373        .unwrap();
1374        std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1375        std::fs::write(
1376            root.join("crates/devflow-cli/Cargo.toml"),
1377            "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1378        )
1379        .unwrap();
1380
1381        assert_eq!(
1382            publish_order(root),
1383            vec!["devflow-core".to_string(), "devflow".to_string()]
1384        );
1385    }
1386
1387    /// WR-03 (phase 20 review): a workspace member manifest written with
1388    /// the long-form `[dependencies.devflow-core]` section (rather than the
1389    /// inline `[dependencies]\ndevflow-core.workspace = true` form) must
1390    /// still contribute its dependency edge to `publish_order`'s topo-sort
1391    /// — the release-safety-critical crates.io publish order this
1392    /// self-pin regression would otherwise silently get wrong.
1393    #[test]
1394    fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1395        let dir = tempfile::tempdir().unwrap();
1396        let root = dir.path();
1397        std::fs::write(
1398            root.join("Cargo.toml"),
1399            "[workspace]\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n",
1400        )
1401        .unwrap();
1402        std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1403        std::fs::write(
1404            root.join("crates/devflow-core/Cargo.toml"),
1405            "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1406        )
1407        .unwrap();
1408        std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1409        std::fs::write(
1410            root.join("crates/devflow-cli/Cargo.toml"),
1411            "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1412        )
1413        .unwrap();
1414
1415        assert_eq!(
1416            publish_order(root),
1417            vec!["devflow-core".to_string(), "devflow".to_string()],
1418            "the long-form dependency section must still order devflow-core before devflow"
1419        );
1420    }
1421
1422    // -----------------------------------------------------------------
1423    // 20d: origin/main ancestor check (no fetch)
1424    // -----------------------------------------------------------------
1425
1426    #[test]
1427    fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1428        let repo = init_repo();
1429        let root = repo.path();
1430        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1431    }
1432
1433    #[test]
1434    fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1435        let repo = init_repo();
1436        let root = repo.path();
1437        let head = crate::test_support::git_command(root)
1438            .args(["rev-parse", "HEAD"])
1439            .output()
1440            .unwrap();
1441        let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1442        git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1443        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1444    }
1445
1446    // -----------------------------------------------------------------
1447    // release --verify: ref_is_ancestor (arbitrary ref pair)
1448    // -----------------------------------------------------------------
1449
1450    #[test]
1451    fn ref_is_ancestor_is_ref_absent_without_remote_refs() {
1452        let repo = init_repo();
1453        let root = repo.path();
1454        assert_eq!(
1455            ref_is_ancestor(root, "origin/main", "origin/develop"),
1456            AncestorStatus::RefAbsent
1457        );
1458    }
1459
1460    #[test]
1461    fn ref_is_ancestor_is_ancestor_when_the_refs_are_in_order() {
1462        let repo = init_repo();
1463        let root = repo.path();
1464        let head = crate::test_support::git_command(root)
1465            .args(["rev-parse", "HEAD"])
1466            .output()
1467            .unwrap();
1468        let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1469        git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1470        git(
1471            root,
1472            &["update-ref", "refs/remotes/origin/develop", &head_sha],
1473        );
1474        assert_eq!(
1475            ref_is_ancestor(root, "origin/main", "origin/develop"),
1476            AncestorStatus::Ancestor
1477        );
1478    }
1479
1480    /// The case `release --verify` exists for: `main` and `develop` point at
1481    /// unrelated commits (main is a squash-merge lineage), so neither is an
1482    /// ancestor of the other — the "sync skipped" state that makes the next
1483    /// release PR conflict against a stale merge-base.
1484    #[test]
1485    fn ref_is_ancestor_is_diverged_for_unrelated_commits() {
1486        let repo = init_repo();
1487        let root = repo.path();
1488
1489        // develop is checked out at commit A. Build an unrelated orphan commit
1490        // for "main" (a squash-merge-shaped lineage that never descends from A).
1491        git(root, &["checkout", "--orphan", "orphan-main"]);
1492        commit_file(root, "orphan.txt");
1493        let orphan = crate::test_support::git_command(root)
1494            .args(["rev-parse", "HEAD"])
1495            .output()
1496            .unwrap();
1497        let orphan_sha = String::from_utf8_lossy(&orphan.stdout).trim().to_string();
1498
1499        git(root, &["checkout", "-q", "develop"]);
1500        let develop = crate::test_support::git_command(root)
1501            .args(["rev-parse", "HEAD"])
1502            .output()
1503            .unwrap();
1504        let develop_sha = String::from_utf8_lossy(&develop.stdout).trim().to_string();
1505
1506        git(
1507            root,
1508            &["update-ref", "refs/remotes/origin/main", &orphan_sha],
1509        );
1510        git(
1511            root,
1512            &["update-ref", "refs/remotes/origin/develop", &develop_sha],
1513        );
1514        assert_eq!(
1515            ref_is_ancestor(root, "origin/main", "origin/develop"),
1516            AncestorStatus::Diverged
1517        );
1518    }
1519
1520    // -----------------------------------------------------------------
1521    // 27-01 (D-03): the scrubbing constructor holds under a hostile GIT_DIR
1522    // -----------------------------------------------------------------
1523
1524    /// D-03: a real spawned `git` process built through the constructor
1525    /// resolves the caller-supplied root even when `GIT_DIR` points at an
1526    /// unrelated repository — proven by a subprocess test, not by
1527    /// inspecting the `Command` object alone.
1528    #[test]
1529    fn hermetic_command_resolves_caller_root_even_under_a_hostile_git_dir() {
1530        let real_repo = init_repo();
1531        let real_root = real_repo.path();
1532
1533        let foreign_repo = TempDir::new().unwrap();
1534        git(foreign_repo.path(), &["init", "-q"]);
1535
1536        let output = git_command(real_root)
1537            .args(["rev-parse", "--show-toplevel"])
1538            // Hostile injection chained AFTER the constructor — the
1539            // strongest form of the claim: `--show-toplevel` must still
1540            // resolve `real_root`, not `foreign_repo`.
1541            .env("GIT_DIR", foreign_repo.path().join(".git"))
1542            .output()
1543            .expect("spawn git");
1544        assert!(
1545            output.status.success(),
1546            "rev-parse --show-toplevel failed: {}",
1547            String::from_utf8_lossy(&output.stderr)
1548        );
1549
1550        let resolved = std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim())
1551            .expect("canonicalize resolved toplevel");
1552        let expected = std::fs::canonicalize(real_root).expect("canonicalize real_root");
1553        assert_eq!(
1554            resolved, expected,
1555            "hermetic_command must resolve real_root even with a foreign GIT_DIR set"
1556        );
1557    }
1558
1559    /// D-03: `origin_main_ancestor_status` produces the correct answer
1560    /// under a hostile `GIT_DIR` where it previously did not. Setting a
1561    /// process-global env var is forbidden (Rust 2024 `unsafe`, unsound
1562    /// under threaded tests — Phase 25 D-14), so this proves the property
1563    /// the way the constructor guarantees it, in two parts: (a) the
1564    /// `Command` this code path builds via `git_command` is
1565    /// unconditionally scrubbed — no bypass parameter, no env-var check,
1566    /// no config lookup (D-01), asserted directly on the built `Command`;
1567    /// (b) the actual mechanism `origin_main_ancestor_status` now depends
1568    /// on — scrubbed, with nothing in production code re-adding `GIT_DIR`
1569    /// afterward — reaches the correct answer for a real spawn. (A literal
1570    /// unscrubbed `Command::new("git")` reproduction chaining a hostile
1571    /// `.env("GIT_DIR", foreign)` on top was deliberately NOT added here:
1572    /// verified empirically against this machine's git 2.55.0 that doing
1573    /// so genuinely redirects `merge-base --is-ancestor`'s ref resolution
1574    /// to the foreign repo — unlike `--show-toplevel` above, which falls
1575    /// back to cwd when `GIT_WORK_TREE` is unset — so re-adding it here
1576    /// would both prove nothing new beyond (a) and inflate git.rs's
1577    /// unscrubbed-call-site count past the 7 sites this task deliberately
1578    /// leaves for 27-02.)
1579    #[test]
1580    fn origin_main_ancestor_status_holds_under_a_hostile_git_dir() {
1581        let repo = init_repo();
1582        let root = repo.path();
1583        let head = crate::test_support::git_command(root)
1584            .args(["rev-parse", "HEAD"])
1585            .output()
1586            .unwrap();
1587        let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1588        git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1589
1590        // (a) unconditionally scrubbed.
1591        let cmd = git_command(root);
1592        assert!(
1593            cmd.get_envs()
1594                .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
1595            "origin_main_ancestor_status's own Command must mark GIT_DIR for removal"
1596        );
1597
1598        // (b) the actual, scrubbed mechanism reaches the correct answer.
1599        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1600    }
1601
1602    // -----------------------------------------------------------------
1603    // 27-01: hermetic git command construction (moved from test_support,
1604    // now the canonical, always-compiled home — 999.37/999.39/27-01)
1605    // -----------------------------------------------------------------
1606
1607    /// The contract callers depend on, asserted on the built command rather
1608    /// than inferred: every redirecting variable is marked for removal.
1609    #[test]
1610    fn git_command_marks_every_redirecting_var_for_removal() {
1611        let cmd = git_command(Path::new("/tmp"));
1612        let removed: Vec<&str> = cmd
1613            .get_envs()
1614            .filter(|(_, value)| value.is_none())
1615            .filter_map(|(key, _)| key.to_str())
1616            .collect();
1617
1618        for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
1619            assert!(
1620                removed.contains(var),
1621                "{var} is not cleared by git_command — a fixture inheriting it \
1622                 would operate on that repository instead of its tempdir"
1623            );
1624        }
1625    }
1626
1627    /// GIT_EXEC_PATH must survive: clearing it can break git's own helper
1628    /// lookup on installations that rely on it, and it cannot redirect
1629    /// repository resolution.
1630    #[test]
1631    fn git_command_preserves_git_exec_path() {
1632        let cmd = git_command(Path::new("/tmp"));
1633        assert!(
1634            !cmd.get_envs()
1635                .any(|(key, value)| key == "GIT_EXEC_PATH" && value.is_none()),
1636            "GIT_EXEC_PATH must not be cleared"
1637        );
1638    }
1639
1640    /// Guards the hard-coded list against a git upgrade that adds a
1641    /// repository-local variable. If this fails, add the new name to
1642    /// `REPO_LOCAL_GIT_VARS` — do not delete the assertion.
1643    #[test]
1644    fn local_env_vars_match_git() {
1645        let output = git_command(Path::new("/tmp"))
1646            .args(["rev-parse", "--local-env-vars"])
1647            .output()
1648            .expect("run `git rev-parse --local-env-vars`");
1649        assert!(
1650            output.status.success(),
1651            "`git rev-parse --local-env-vars` failed"
1652        );
1653
1654        let mut from_git: Vec<String> = String::from_utf8_lossy(&output.stdout)
1655            .lines()
1656            .map(str::trim)
1657            .filter(|line| !line.is_empty())
1658            .map(str::to_string)
1659            .collect();
1660        let mut ours: Vec<String> = REPO_LOCAL_GIT_VARS
1661            .iter()
1662            .map(|v| (*v).to_string())
1663            .collect();
1664        from_git.sort();
1665        ours.sort();
1666
1667        assert_eq!(
1668            ours, from_git,
1669            "REPO_LOCAL_GIT_VARS has drifted from `git rev-parse --local-env-vars`"
1670        );
1671    }
1672}