Skip to main content

devflow_core/
git.rs

1//! Git-flow operations implemented with plain `git` commands.
2
3use crate::config::GitFlowConfig;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
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    /// [`feature_start_force`] to overwrite).
130    pub fn feature_start(&self, phase: u32) -> Result<String, GitError> {
131        let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
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: u32) -> Result<String, GitError> {
140        let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
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: u32) -> 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: u32) -> Result<String, GitError> {
159        let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
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: u32) -> bool {
171        let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
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/// Derive the crates.io publish order for a workspace's local-path members
575/// (e.g. `devflow-core` before `devflow`) — sourced from the workspace's own
576/// `[workspace] members` list and each member's own `[dependencies]`
577/// section (which member depends on which), never a hardcoded prose string
578/// (20d). Read-only; returns an empty `Vec` (never panics) if the workspace
579/// Cargo.toml or a member manifest cannot be read.
580pub fn publish_order(project_root: &Path) -> Vec<String> {
581    let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
582        return Vec::new();
583    };
584    let member_paths = workspace_member_paths(&root_contents);
585
586    let mut members: Vec<(String, String)> = Vec::new();
587    for path in &member_paths {
588        let manifest = project_root.join(path).join("Cargo.toml");
589        let Ok(contents) = std::fs::read_to_string(&manifest) else {
590            continue;
591        };
592        let name = package_name(&contents).unwrap_or_else(|| path.clone());
593        members.push((name, contents));
594    }
595
596    let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
597    let mut edges: Vec<(String, String)> = Vec::new();
598    for (name, contents) in &members {
599        for other in &names {
600            if other != name && member_depends_on(contents, other) {
601                edges.push((name.clone(), other.clone()));
602            }
603        }
604    }
605    topo_sort(names, edges)
606}
607
608/// Extract the `[workspace] members = [...]` array's quoted path entries.
609/// Hand-rolled, single-array-only scan (this project deliberately avoids a
610/// TOML parser dependency for its version/workspace tooling — see
611/// `version.rs`).
612fn workspace_member_paths(contents: &str) -> Vec<String> {
613    let Some(start) = contents.find("members") else {
614        return Vec::new();
615    };
616    let rest = &contents[start..];
617    let Some(open) = rest.find('[') else {
618        return Vec::new();
619    };
620    let Some(close) = rest[open..].find(']') else {
621        return Vec::new();
622    };
623    let inner = &rest[open + 1..open + close];
624    inner
625        .split(',')
626        .filter_map(|fragment| {
627            let fragment = fragment.trim();
628            let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
629            (!fragment.is_empty()).then(|| fragment.to_string())
630        })
631        .collect()
632}
633
634/// Extract a member manifest's `[package] name`.
635fn package_name(contents: &str) -> Option<String> {
636    let mut current = String::new();
637    for line in contents.lines() {
638        let trimmed = line.trim();
639        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
640            current = inner.trim().to_string();
641            continue;
642        }
643        if current == "package"
644            && let Some((key, value)) = trimmed.split_once('=')
645            && key.trim() == "name"
646        {
647            return Some(value.trim().trim_matches('"').to_string());
648        }
649    }
650    None
651}
652
653/// Whether a member manifest's `[dependencies]` section references
654/// `dep_name` — either `dep_name.workspace = true` or `dep_name = { ... }`
655/// under an inline `[dependencies]` table, OR the equally-valid expanded
656/// long-form section `[dependencies.dep_name]` (WR-03, phase 20 review): a
657/// manifest may spell a dependency out as its own section (e.g.
658/// `[dependencies.devflow-core]\nworkspace = true`), which parses to a
659/// section header of `"dependencies.devflow-core"` — never equal to the
660/// plain `"dependencies"` the inline-table branch below checks against, so
661/// that edge was previously dropped from `publish_order`'s topo-sort
662/// entirely.
663fn member_depends_on(contents: &str, dep_name: &str) -> bool {
664    let mut current = String::new();
665    for line in contents.lines() {
666        let trimmed = line.trim();
667        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
668            current = inner.trim().to_string();
669            if let Some(name) = current.strip_prefix("dependencies.")
670                && name == dep_name
671            {
672                return true;
673            }
674            continue;
675        }
676        if current != "dependencies" {
677            continue;
678        }
679        let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
680        if key == dep_name {
681            return true;
682        }
683    }
684    false
685}
686
687/// Kahn's-algorithm topological sort: `edges` are `(dependent, dependency)`
688/// pairs, meaning `dependent` must be published AFTER `dependency`. Falls
689/// back to appending whatever remains (rather than looping forever) if a
690/// cycle is present — a genuine cyclic Cargo dependency would already fail
691/// `cargo build` long before this check runs.
692fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
693    let mut result = Vec::new();
694    let mut published: Vec<String> = Vec::new();
695    let mut remaining = names;
696    while !remaining.is_empty() {
697        let ready: Vec<String> = remaining
698            .iter()
699            .filter(|name| {
700                edges
701                    .iter()
702                    .filter(|(dependent, _)| dependent == *name)
703                    .all(|(_, dep)| published.contains(dep))
704            })
705            .cloned()
706            .collect();
707        if ready.is_empty() {
708            result.extend(remaining);
709            break;
710        }
711        for name in &ready {
712            published.push(name.clone());
713            result.push(name.clone());
714        }
715        remaining.retain(|name| !ready.contains(name));
716    }
717    result
718}
719
720// ---------------------------------------------------------------------------
721// tag-signing viability (20d, Pattern 4)
722// ---------------------------------------------------------------------------
723
724/// Pure classification of `ssh-add -l`'s exit code into an actionable
725/// signing-viability status. Isolated from any I/O so it can be
726/// unit-tested for all three documented exit codes without a live agent.
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
728pub enum SigningStatus {
729    /// Exit 2 — no ssh-agent reachable (`SSH_AUTH_SOCK` unset or dead).
730    NoAgent,
731    /// Exit 1 — agent reachable but has no identities loaded.
732    AgentEmpty,
733    /// Exit 0 — agent has at least one key loaded (caller still must check
734    /// whether it's THIS key, via a fingerprint match).
735    KeysListed,
736    /// Any other exit code — genuinely unexpected; degrade rather than
737    /// crash or silently misclassify.
738    Unknown(i32),
739}
740
741/// Map `ssh-add -l`'s exit code to a [`SigningStatus`] (Pattern 4: exit
742/// 2 = no agent, 1 = agent-but-empty, 0 = keys listed).
743pub fn classify_ssh_add_status(exit_code: i32) -> SigningStatus {
744    match exit_code {
745        2 => SigningStatus::NoAgent,
746        1 => SigningStatus::AgentEmpty,
747        0 => SigningStatus::KeysListed,
748        other => SigningStatus::Unknown(other),
749    }
750}
751
752/// Outcome of the tag-signing viability check. Carries only a boolean-ish
753/// status plus an optional PUBLIC key fingerprint — never private key
754/// material or a full filesystem path (T-20-04, ASVS V6 / WR-02 — mirrors
755/// the existing "no path/username" discipline this project already applies
756/// elsewhere, e.g. `PhaseFinding`).
757#[derive(Debug, Clone, PartialEq, Eq)]
758pub enum SigningViability {
759    /// Signing is viable. `fingerprint` is the matched public key's
760    /// `SHA256:...` fingerprint, when one could be extracted.
761    Viable { fingerprint: Option<String> },
762    /// Not viable, with an actionable (never key-leaking) reason.
763    NotViable { reason: String },
764    /// Could not be determined — tool absent, format unset with no key,
765    /// etc. Fail-soft: never a crash.
766    Unknown { reason: String },
767}
768
769/// `git config --get <key>`, scoped to `project_root`. `None` if unset or
770/// the command fails (missing `git`, not a repo, etc.) — never panics.
771fn git_config(project_root: &Path, key: &str) -> Option<String> {
772    let output = git_command(project_root)
773        .args(["config", "--get", key])
774        .output()
775        .ok()?;
776    if !output.status.success() {
777        return None;
778    }
779    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
780    (!value.is_empty()).then_some(value)
781}
782
783/// `ssh-keygen -lf <pub_key_path>`'s fingerprint (`SHA256:...`) — reads only
784/// the PUBLIC key file, never a private key, and returns only the hash
785/// token, never a filesystem path.
786fn public_key_fingerprint(pub_key_path: &Path) -> Option<String> {
787    let path_str = pub_key_path.to_str()?;
788    let output = Command::new("ssh-keygen")
789        .args(["-lf", path_str])
790        .output()
791        .ok()?;
792    if !output.status.success() {
793        return None;
794    }
795    // Format: "<bits> SHA256:<hash> <comment> (<type>)"
796    String::from_utf8_lossy(&output.stdout)
797        .split_whitespace()
798        .nth(1)
799        .map(str::to_string)
800}
801
802/// Classifies a `user.signingkey` value the way `git` itself does (mirrors
803/// `man git-config`'s `user.signingKey` precedence, D-01): a `key::`-prefixed
804/// value is inline with the prefix stripped; otherwise a value starting with
805/// the deprecated raw `ssh-` compat form is inline as-is; otherwise the value
806/// is a filesystem path. Pure — no I/O, no `Path`, no `.exists()` — so the
807/// classification never depends on the host's filesystem.
808///
809/// The prefix decides unconditionally (D-02): a value that also happens to
810/// name an existing file (e.g. `ssh-key.pub`) is still classified inline,
811/// because git never stats the value. The raw allowlist is `ssh-` only
812/// (D-03) — `ecdsa-`/`sk-` bare forms are NOT added here; git treats those as
813/// paths, and they only reach the inline branch through the `key::` prefix.
814fn inline_signing_key_blob(signingkey: &str) -> Option<&str> {
815    let trimmed = signingkey.trim();
816    if let Some(remainder) = trimmed.strip_prefix("key::") {
817        Some(remainder)
818    } else if trimmed.starts_with("ssh-") {
819        Some(trimmed)
820    } else {
821        None
822    }
823}
824
825/// `ssh-keygen -lf -`'s fingerprint (`SHA256:...`) for an inline key blob
826/// piped over stdin — mirrors [`public_key_fingerprint`]'s `Option<String>`
827/// return, fail-soft `.ok()?` chain, and identical output parse (D-05: the
828/// output shape is the same whether the key arrived by path or by stdin).
829///
830/// The blob is written to the child's stdin ONLY — never as an argv element
831/// and never through a temp file (D-09): argv is world-readable via
832/// `/proc/<pid>/cmdline`. A later refactor that passes the blob as a
833/// `Command` argument is a security regression, not a cleanup.
834///
835/// Every failure mode — `ssh-keygen` absent, a non-zero exit, unparseable
836/// stdout, or the empty blob produced by a bare `key::` value — returns
837/// `None` here, which the caller routes to `SigningViability::Unknown`,
838/// never a hard-fail `NotViable` (D-06). That includes the empty-blob case:
839/// `ssh-keygen` exits non-zero on empty stdin, which this function surfaces
840/// as `None` with no special-case branch.
841fn inline_key_fingerprint(key_blob: &str) -> Option<String> {
842    let mut child = Command::new("ssh-keygen")
843        .args(["-lf", "-"])
844        .stdin(Stdio::piped())
845        .stdout(Stdio::piped())
846        .stderr(Stdio::piped())
847        .spawn()
848        .ok()?;
849
850    // `.take()` then `drop()` positively closes the stdin pipe before
851    // `wait_with_output()` — a borrow via `.as_mut()` happens to work on
852    // this host but is not a documented guarantee and could hang on a
853    // differently-shaped input.
854    let mut stdin = child.stdin.take()?;
855    stdin.write_all(key_blob.as_bytes()).ok()?;
856    drop(stdin);
857
858    let output = child.wait_with_output().ok()?;
859    if !output.status.success() {
860        return None;
861    }
862    String::from_utf8_lossy(&output.stdout)
863        .split_whitespace()
864        .nth(1)
865        .map(str::to_string)
866}
867
868/// `gpg.format == "ssh"` branch (Pattern 4): `user.signingkey` must be set.
869/// Its value is classified by git's own prefix rules (D-01) into either an
870/// inline key blob or a filesystem path; only a path value is required to
871/// exist. `ssh-add -l`'s exit code then determines viability. On a match,
872/// only the matched public key's `SHA256:` fingerprint is reported — never
873/// the configured value in any form (D-08's redaction contract, unchanged).
874fn check_ssh_signing_viability(project_root: &Path) -> SigningViability {
875    let Some(signingkey) = git_config(project_root, "user.signingkey") else {
876        return SigningViability::NotViable {
877            reason: "gpg.format=ssh but user.signingkey is not set".into(),
878        };
879    };
880
881    // Mirrors `man git-config`'s user.signingKey precedence (D-01): key::
882    // form, then deprecated raw ssh- form, else a path. Never stat a path
883    // for a prefix-matched value (D-02).
884    let inline_blob = inline_signing_key_blob(&signingkey);
885
886    // Path branch keeps today's early return, byte-for-byte (D-12): the
887    // `.exists()` check runs first and a missing file still returns the
888    // existing missing-key-file `NotViable` before `ssh-add` is ever
889    // spawned. No `.exists()` call executes for a prefix-matched value
890    // (RESEARCH Pitfall 3: an extra defensive stat here is exactly the
891    // divergence-from-git this phase exists to remove).
892    if inline_blob.is_none() {
893        let key_path = Path::new(&signingkey);
894        if !key_path.exists() {
895            return SigningViability::NotViable {
896                reason: "user.signingkey is set but the key file does not exist".into(),
897            };
898        }
899    }
900
901    let output = match Command::new("ssh-add").arg("-l").output() {
902        Ok(out) => out,
903        Err(_) => {
904            return SigningViability::Unknown {
905                reason: "cannot verify signing viability — ssh-add not found".into(),
906            };
907        }
908    };
909    let exit_code = output.status.code().unwrap_or(-1);
910    match classify_ssh_add_status(exit_code) {
911        SigningStatus::NoAgent => SigningViability::NotViable {
912            reason: "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)".into(),
913        },
914        SigningStatus::AgentEmpty => SigningViability::NotViable {
915            reason: "ssh-agent reachable but has no identities loaded".into(),
916        },
917        SigningStatus::KeysListed => {
918            let stdout = String::from_utf8_lossy(&output.stdout);
919            // Fingerprint acquisition stays lazy, inside this arm only
920            // (D-12): the path branch must still spawn exactly the same
921            // processes in the same order as today, so this selection
922            // cannot be hoisted above the `ssh-add -l` spawn.
923            let fingerprint = match inline_blob {
924                Some(blob) => inline_key_fingerprint(blob),
925                None => public_key_fingerprint(Path::new(&signingkey)),
926            };
927            match fingerprint {
928                Some(fingerprint) if stdout.contains(&fingerprint) => SigningViability::Viable {
929                    fingerprint: Some(fingerprint),
930                },
931                Some(_) => SigningViability::NotViable {
932                    reason: "ssh-agent has keys loaded, but not the configured signing key".into(),
933                },
934                None => SigningViability::Unknown {
935                    reason: "cannot verify signing viability — ssh-keygen not found or the key \
936                             is unreadable"
937                        .into(),
938                },
939            }
940        }
941        SigningStatus::Unknown(code) => SigningViability::Unknown {
942            reason: format!("ssh-add -l exited with an unexpected code {code}"),
943        },
944    }
945}
946
947/// `gpg.format` unset or `"openpgp"` branch (Pattern 4): verify a secret
948/// key exists for `user.signingkey` via `gpg --list-secret-keys`.
949fn check_gpg_signing_viability(project_root: &Path) -> SigningViability {
950    let Some(signingkey) = git_config(project_root, "user.signingkey") else {
951        return SigningViability::Unknown {
952            reason: "cannot verify signing viability — user.signingkey is not set".into(),
953        };
954    };
955    let output = match Command::new("gpg")
956        .args(["--list-secret-keys", &signingkey])
957        .output()
958    {
959        Ok(out) => out,
960        Err(_) => {
961            return SigningViability::Unknown {
962                reason: "cannot verify signing viability — gpg not found".into(),
963            };
964        }
965    };
966    if output.status.success() {
967        SigningViability::Viable {
968            fingerprint: Some(signingkey),
969        }
970    } else {
971        SigningViability::NotViable {
972            reason: "no secret key found for the configured user.signingkey".into(),
973        }
974    }
975}
976
977/// Tag-signing viability check (20d): branches on `git config gpg.format`
978/// since the check is a genuinely different code path per format — a
979/// GPG-only check would miss the `ssh_askpass` failure this project's own
980/// release actually hit (Pattern 4). Fail-soft throughout: an absent tool
981/// or unset config degrades to an actionable [`SigningViability::Unknown`],
982/// never a crash.
983pub fn check_signing_viability(project_root: &Path) -> SigningViability {
984    match git_config(project_root, "gpg.format").as_deref() {
985        Some("ssh") => check_ssh_signing_viability(project_root),
986        _ => check_gpg_signing_viability(project_root),
987    }
988}
989
990fn stderr_or_status(output: &std::process::Output) -> String {
991    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
992    if stderr.is_empty() {
993        format!("exited with {}", output.status)
994    } else {
995        stderr
996    }
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use tempfile::TempDir;
1003
1004    /// Run a git command in `root`, asserting success.
1005    fn git(root: &Path, args: &[&str]) {
1006        let output = crate::test_support::git_command(root)
1007            .args(args)
1008            .output()
1009            .expect("spawn git");
1010        assert!(
1011            output.status.success(),
1012            "git {args:?} failed: {}",
1013            String::from_utf8_lossy(&output.stderr)
1014        );
1015    }
1016
1017    fn current_branch(root: &Path) -> String {
1018        let output = crate::test_support::git_command(root)
1019            .args(["rev-parse", "--abbrev-ref", "HEAD"])
1020            .output()
1021            .expect("rev-parse");
1022        String::from_utf8_lossy(&output.stdout).trim().to_string()
1023    }
1024
1025    fn commit_file(root: &Path, name: &str) {
1026        std::fs::write(root.join(name), name).unwrap();
1027        git(root, &["add", "."]);
1028        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
1029    }
1030
1031    /// Initialize a repo with `main` and `develop` branches and one commit.
1032    fn init_repo() -> TempDir {
1033        let dir = tempfile::tempdir().unwrap();
1034        let root = dir.path();
1035        git(root, &["init", "-q"]);
1036        git(root, &["config", "user.email", "test@example.com"]);
1037        git(root, &["config", "user.name", "Test"]);
1038        git(root, &["config", "commit.gpgsign", "false"]);
1039        git(root, &["config", "tag.gpgsign", "false"]);
1040        // Disable any globally-configured hooks (e.g. gitleaks) for isolation.
1041        git(root, &["config", "core.hooksPath", "/dev/null"]);
1042        commit_file(root, "README.md");
1043        git(root, &["branch", "-M", "main"]);
1044        git(root, &["checkout", "-q", "-b", "develop"]);
1045        dir
1046    }
1047
1048    fn flow(root: &Path) -> GitFlow {
1049        GitFlow::new(root)
1050    }
1051
1052    #[test]
1053    fn feature_start_branches_from_develop() {
1054        let repo = init_repo();
1055        let root = repo.path();
1056        let branch = flow(root).feature_start(3).expect("feature_start");
1057        assert_eq!(branch, "feature/phase-03");
1058        assert_eq!(current_branch(root), "feature/phase-03");
1059    }
1060
1061    #[test]
1062    fn list_feature_branches_reports_ahead_and_behind_semantics() {
1063        let repo = init_repo();
1064        let root = repo.path();
1065        let gf = flow(root);
1066
1067        gf.feature_start(12).expect("feature_start");
1068        commit_file(root, "feature-one.txt");
1069        commit_file(root, "feature-two.txt");
1070        git(root, &["checkout", "-q", "develop"]);
1071        commit_file(root, "develop-only.txt");
1072
1073        let branches = gf.list_feature_branches().unwrap();
1074        let branch = branches
1075            .iter()
1076            .find(|branch| branch.name == "feature/phase-12")
1077            .unwrap();
1078
1079        assert_eq!(branch.ahead, 2);
1080        assert_eq!(branch.behind, 1);
1081    }
1082
1083    #[test]
1084    fn feature_finish_merges_into_develop_and_deletes() {
1085        let repo = init_repo();
1086        let root = repo.path();
1087        let gf = flow(root);
1088
1089        gf.feature_start(1).expect("start");
1090        commit_file(root, "feature.txt");
1091
1092        let branch = gf.feature_finish(1).expect("finish");
1093        assert_eq!(branch, "feature/phase-01");
1094        assert_eq!(current_branch(root), "develop");
1095
1096        // Branch is deleted and its work is now on develop.
1097        let branches = crate::test_support::git_command(root)
1098            .args(["branch"])
1099            .output()
1100            .unwrap();
1101        let listing = String::from_utf8_lossy(&branches.stdout);
1102        assert!(!listing.contains("feature/phase-01"));
1103        assert!(root.join("feature.txt").exists());
1104    }
1105
1106    #[test]
1107    fn release_start_and_finish_tags_main_and_merges_both() {
1108        let repo = init_repo();
1109        let root = repo.path();
1110        let gf = flow(root);
1111
1112        // Add work on develop so the release has content.
1113        commit_file(root, "work.txt");
1114        let branch = gf.release_start("1.2.0").expect("release_start");
1115        assert_eq!(branch, "release/1.2.0");
1116
1117        gf.release_finish("1.2.0").expect("release_finish");
1118        assert_eq!(current_branch(root), "develop");
1119
1120        // Tag exists.
1121        let tags = crate::test_support::git_command(root)
1122            .args(["tag"])
1123            .output()
1124            .unwrap();
1125        assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
1126
1127        // Release branch deleted.
1128        let branches = crate::test_support::git_command(root)
1129            .args(["branch"])
1130            .output()
1131            .unwrap();
1132        assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
1133    }
1134
1135    /// A global/repo `tag.gpgsign=true` must not turn `tag()`'s lightweight
1136    /// tag into an annotated+signed one — that would require a tag message
1137    /// and block on `$EDITOR`, silently hanging a headless, unattended run
1138    /// (Phase 13 dogfood finding: VersionBump hung on a live
1139    /// `devflow start --mode auto` run because the operator's global
1140    /// gitconfig sets `tag.gpgsign=true`).
1141    #[test]
1142    fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
1143        let repo = init_repo();
1144        let root = repo.path();
1145        // Simulate an operator whose global config signs tags by default —
1146        // override the test harness's own `tag.gpgsign false` to prove
1147        // `tag()`'s per-invocation `-c` override wins regardless.
1148        git(root, &["config", "tag.gpgsign", "true"]);
1149
1150        flow(root)
1151            .tag("v9.9.9")
1152            .expect("tag must not block on $EDITOR");
1153
1154        let tags = crate::test_support::git_command(root)
1155            .args(["tag", "-l"])
1156            .output()
1157            .unwrap();
1158        assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
1159
1160        // Confirm it's a lightweight tag (points directly at the commit),
1161        // not an annotated tag object (which `cat-file -t` would report as
1162        // "tag" rather than "commit").
1163        let obj_type = crate::test_support::git_command(root)
1164            .args(["cat-file", "-t", "v9.9.9"])
1165            .output()
1166            .unwrap();
1167        assert_eq!(
1168            String::from_utf8_lossy(&obj_type.stdout).trim(),
1169            "commit",
1170            "tag() must stay lightweight even when tag.gpgsign=true"
1171        );
1172    }
1173
1174    #[test]
1175    fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
1176        // The property that distinguishes commit_path from commit_all
1177        // (17-12, Task 2b): a hook using commit_path must never sweep in
1178        // unrelated dirty state.
1179        let repo = init_repo();
1180        let root = repo.path();
1181        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1182        std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
1183
1184        // Stage the unrelated file BEFORE calling commit_path. An untracked
1185        // file is excluded by any implementation and so proves nothing; an
1186        // already-staged one is the real failure mode — a bare `git commit`
1187        // writes the whole index and would sweep it in.
1188        crate::test_support::git_command(root)
1189            .args(["add", "unrelated.txt"])
1190            .status()
1191            .unwrap();
1192
1193        flow(root)
1194            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1195            .expect("commit_path");
1196
1197        let committed = crate::test_support::git_command(root)
1198            .args(["log", "-1", "--name-only", "--pretty=format:"])
1199            .output()
1200            .unwrap();
1201        let committed_files = String::from_utf8_lossy(&committed.stdout);
1202        assert!(committed_files.contains("CHANGELOG.md"));
1203        assert!(!committed_files.contains("unrelated.txt"));
1204
1205        let status = crate::test_support::git_command(root)
1206            .args(["status", "--porcelain"])
1207            .output()
1208            .unwrap();
1209        let status = String::from_utf8_lossy(&status.stdout);
1210        assert!(
1211            status.contains("A  unrelated.txt"),
1212            "unrelated.txt must remain staged-but-uncommitted, got: {status}"
1213        );
1214    }
1215
1216    /// `git rev-list --count HEAD`, parsed. Shared by the three tests below
1217    /// so a failure reports both counts instead of a bare assertion.
1218    fn rev_list_count(root: &Path) -> u32 {
1219        let output = crate::test_support::git_command(root)
1220            .args(["rev-list", "--count", "HEAD"])
1221            .output()
1222            .unwrap();
1223        assert!(output.status.success(), "git rev-list --count HEAD failed");
1224        String::from_utf8_lossy(&output.stdout)
1225            .trim()
1226            .parse::<u32>()
1227            .expect("rev-list --count HEAD must print an integer")
1228    }
1229
1230    /// 19b/D-16: `hooks::version_bump` (hooks.rs:242) calls `commit_path` and
1231    /// then tags whatever commit it last produced (hooks.rs:249). If a
1232    /// terminal-batch retry calls `commit_path` again with byte-identical
1233    /// content (the file untouched since the first call), a forced commit
1234    /// here means the release tag can end up naming a commit that contains
1235    /// nothing new. This pins the exact retry scenario: two calls, unchanged
1236    /// content, `git rev-list --count HEAD` must not move between them.
1237    #[test]
1238    fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1239        let repo = init_repo();
1240        let root = repo.path();
1241        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1242
1243        flow(root)
1244            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1245            .expect("first commit_path call");
1246        let n1 = rev_list_count(root);
1247
1248        // The file is not touched again -- this is the retry scenario, not
1249        // a second genuine change.
1250        flow(root)
1251            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1252            .expect("second commit_path call");
1253        let n2 = rev_list_count(root);
1254
1255        assert_eq!(
1256            n2, n1,
1257            "a repeat commit_path call on unchanged content must not add a \
1258             commit: n1={n1}, n2={n2}"
1259        );
1260    }
1261
1262    /// 19b/D-16, T-19-11: separates the "no commit" claim from the "no
1263    /// error" claim so a future change can't satisfy one by breaking the
1264    /// other. `hooks.rs` propagates `commit_path`'s `Result` with `?` at both
1265    /// call sites (changelog_append:225, version_bump:242) -- turning a
1266    /// genuine no-op into `Err` would stall the terminal hook batch (see
1267    /// T-19-11 in this plan's threat model), so both properties must hold
1268    /// simultaneously.
1269    #[test]
1270    fn commit_path_with_no_changes_returns_ok_without_committing() {
1271        let repo = init_repo();
1272        let root = repo.path();
1273        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1274        flow(root)
1275            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1276            .expect("initial commit_path");
1277        let n1 = rev_list_count(root);
1278
1279        // CHANGELOG.md is already committed and unmodified -- a single call
1280        // here has nothing to commit.
1281        let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1282        let n2 = rev_list_count(root);
1283
1284        assert!(
1285            result.is_ok(),
1286            "no-op call must return Ok(()), got: {result:?}"
1287        );
1288        assert_eq!(
1289            n2, n1,
1290            "no-op call must not create a commit: n1={n1}, n2={n2}"
1291        );
1292    }
1293
1294    /// Edge case the fix must NOT change: `commit_path` on a path that does
1295    /// not exist on disk still errors at the staging step (`git add` fails
1296    /// on an unknown pathspec). Asserted explicitly so the fix for the
1297    /// no-change case above cannot be over-applied into "commit_path never
1298    /// fails".
1299    #[test]
1300    fn commit_path_on_nonexistent_path_still_errors() {
1301        let repo = init_repo();
1302        let root = repo.path();
1303
1304        let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1305
1306        assert!(
1307            result.is_err(),
1308            "commit_path on an unknown pathspec must still error, got: {result:?}"
1309        );
1310    }
1311
1312    #[test]
1313    fn release_start_branches_from_current_head_not_develop() {
1314        let repo = init_repo();
1315        let root = repo.path();
1316        let gf = flow(root);
1317
1318        // Ship from a feature branch carrying a commit that is NOT on develop.
1319        gf.feature_start(5).expect("feature_start");
1320        commit_file(root, "feature-only.txt");
1321        let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1322
1323        let branch = gf.release_start("2.0.0").expect("release_start");
1324        assert_eq!(branch, "release/2.0.0");
1325        assert_eq!(current_branch(root), "release/2.0.0");
1326
1327        // The release branch tip must descend from the feature commit — i.e.
1328        // the feature-only work is present, not dropped to develop's HEAD.
1329        let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1330        let is_ancestor = crate::test_support::git_command(root)
1331            .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1332            .output()
1333            .unwrap()
1334            .status
1335            .success();
1336        assert!(
1337            is_ancestor,
1338            "release branch must descend from the shipped feature commit"
1339        );
1340        assert!(root.join("feature-only.txt").exists());
1341    }
1342
1343    #[test]
1344    fn cleanup_merged_removes_merged_but_keeps_protected() {
1345        let repo = init_repo();
1346        let root = repo.path();
1347        let gf = flow(root);
1348
1349        // Create and merge a feature branch into develop.
1350        gf.feature_start(2).expect("start");
1351        commit_file(root, "f.txt");
1352        gf.feature_finish(2).expect("finish");
1353
1354        // Create an already-merged stray branch off develop.
1355        git(root, &["branch", "stale-merged"]);
1356
1357        let deleted = gf.cleanup_merged().expect("cleanup");
1358        assert!(deleted.contains(&"stale-merged".to_string()));
1359        // Protected branches survive.
1360        assert!(!deleted.contains(&"develop".to_string()));
1361        assert!(!deleted.contains(&"main".to_string()));
1362    }
1363
1364    /// WR-04 (13-REVIEW.md): `cleanup_merged` must compute "merged" relative
1365    /// to `develop` explicitly, not whatever the main checkout's current
1366    /// HEAD happens to be. If the main checkout is left on a divergent
1367    /// branch, an implicit-HEAD baseline would wrongly identify (and
1368    /// delete) a branch that's merged into that other branch but was never
1369    /// actually merged into `develop`.
1370    #[test]
1371    fn cleanup_merged_is_relative_to_develop_not_current_head() {
1372        let repo = init_repo();
1373        let root = repo.path();
1374        let gf = flow(root);
1375
1376        // `topic` diverges from develop with a unique commit develop never
1377        // sees, then `premature` branches off `topic`'s tip — so
1378        // `premature` is merged into `topic` but NOT into `develop`.
1379        git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1380        commit_file(root, "topic-only.txt");
1381        git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1382
1383        // Leave the main checkout on `topic` — NOT `develop` — before
1384        // calling cleanup_merged, mirroring an operator who forgot to
1385        // check out develop first. (`topic` itself is also technically
1386        // "merged into HEAD" under an implicit baseline since it IS HEAD,
1387        // which git's own `-d` correctly refuses as the checked-out branch
1388        // — so the call's overall Ok/Err is not itself decisive here; check
1389        // the actual side effect on `premature` instead.)
1390        git(root, &["checkout", "-q", "topic"]);
1391
1392        let _ = gf.cleanup_merged();
1393        assert!(
1394            gf.branch_exists("premature"),
1395            "premature is merged into topic (current HEAD) but not into \
1396             develop — it must survive cleanup_merged when the baseline is develop"
1397        );
1398    }
1399
1400    /// WR-03 (13-REVIEW.md), revised: `git branch --merged` prefixes a
1401    /// branch checked out in a linked worktree with `+ `. The prefix must be
1402    /// stripped positionally (not by trimming marker characters, which would
1403    /// mangle a branch legitimately named "+foo"), and a branch git refuses
1404    /// to delete — a worktree checkout can never be deleted, by design —
1405    /// must be skipped with a warning rather than aborting the sweep before
1406    /// the remaining merged branches.
1407    #[test]
1408    fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1409        let repo = init_repo();
1410        let root = repo.path();
1411        let gf = flow(root);
1412
1413        // Merge a branch into develop WITHOUT deleting it (feature_finish
1414        // deletes on merge, which would leave nothing to check out).
1415        git(
1416            root,
1417            &["checkout", "-q", "-b", "worktree-merged", "develop"],
1418        );
1419        commit_file(root, "g.txt");
1420        git(root, &["checkout", "-q", "develop"]);
1421        git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1422
1423        // Check the merged branch out in a linked worktree so
1424        // `git branch --merged` reports it with a `+ ` prefix.
1425        let wt_dir = tempfile::tempdir().unwrap();
1426        git(
1427            root,
1428            &[
1429                "worktree",
1430                "add",
1431                wt_dir.path().to_str().unwrap(),
1432                "worktree-merged",
1433            ],
1434        );
1435
1436        // A second merged branch that sorts after "worktree-merged" would be
1437        // reached only if the sweep survives the worktree refusal; "zz-" also
1438        // guards against luck in iteration order via the branch before it.
1439        git(root, &["branch", "aa-stale"]);
1440        git(root, &["branch", "zz-stale"]);
1441
1442        let deleted = gf
1443            .cleanup_merged()
1444            .expect("a skipped worktree branch must not abort the sweep");
1445        assert!(deleted.contains(&"aa-stale".to_string()));
1446        assert!(deleted.contains(&"zz-stale".to_string()));
1447        assert!(
1448            !deleted.contains(&"worktree-merged".to_string()),
1449            "worktree checkout cannot be deleted"
1450        );
1451        assert!(gf.branch_exists("worktree-merged"));
1452    }
1453
1454    /// The delete side must agree with the `--merged develop` listing: `-d`
1455    /// verifies merged-into-HEAD, so with the main checkout parked on a
1456    /// stale branch every genuinely-merged branch was refused as "not fully
1457    /// merged" — in exactly the scenario WR-04 exists for.
1458    #[test]
1459    fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1460        let repo = init_repo();
1461        let root = repo.path();
1462        let gf = flow(root);
1463
1464        // `old` is parked before the merge below, so nothing merged later is
1465        // reachable from HEAD while it's checked out.
1466        git(root, &["checkout", "-q", "-b", "old", "develop"]);
1467        git(root, &["checkout", "-q", "develop"]);
1468        git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1469        commit_file(root, "h.txt");
1470        git(root, &["checkout", "-q", "develop"]);
1471        git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1472        git(root, &["checkout", "-q", "old"]);
1473
1474        let deleted = gf.cleanup_merged().expect("cleanup");
1475        assert!(
1476            deleted.contains(&"merged-feature".to_string()),
1477            "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1478        );
1479        assert!(!gf.branch_exists("merged-feature"));
1480    }
1481
1482    #[test]
1483    fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1484        let repo = init_repo();
1485        let root = repo.path();
1486        let gf = flow(root);
1487
1488        // Create a feature branch with an unmerged commit.
1489        gf.feature_start(8).expect("start");
1490        commit_file(root, "unmerged.txt");
1491        // Switch back to develop so the branch isn't checked out.
1492        git(root, &["checkout", "-q", "develop"]);
1493
1494        // -d would refuse (unmerged); force deletes it.
1495        assert!(gf.delete_branch("feature/phase-08", false).is_err());
1496        gf.delete_branch("feature/phase-08", true)
1497            .expect("force delete");
1498        let branches = crate::test_support::git_command(root)
1499            .args(["branch"])
1500            .output()
1501            .unwrap();
1502        assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1503
1504        // Protected branches are never deleted.
1505        assert!(gf.delete_branch("develop", true).is_err());
1506        assert!(gf.delete_branch("main", true).is_err());
1507    }
1508
1509    #[test]
1510    fn merge_of_missing_branch_is_an_error() {
1511        let repo = init_repo();
1512        let root = repo.path();
1513        // feature_finish for a phase that was never started: checkout develop
1514        // succeeds, but merging the nonexistent feature branch fails.
1515        let err = flow(root).feature_finish(99).unwrap_err();
1516        assert!(matches!(err, GitError::Command(_)));
1517    }
1518
1519    // -----------------------------------------------------------------
1520    // 20d: publish-order helpers (pure, no I/O)
1521    // -----------------------------------------------------------------
1522
1523    #[test]
1524    fn workspace_member_paths_parses_multiline_array() {
1525        let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n";
1526        assert_eq!(
1527            workspace_member_paths(contents),
1528            vec![
1529                "crates/devflow-core".to_string(),
1530                "crates/devflow-cli".to_string()
1531            ]
1532        );
1533    }
1534
1535    #[test]
1536    fn package_name_reads_the_package_section() {
1537        let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1538        assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1539    }
1540
1541    #[test]
1542    fn member_depends_on_matches_dotted_workspace_shorthand() {
1543        let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1544        assert!(member_depends_on(contents, "devflow-core"));
1545        assert!(!member_depends_on(contents, "serde"));
1546    }
1547
1548    /// WR-03 (phase 20 review): the equally-valid expanded long-form TOML
1549    /// section syntax (`[dependencies.NAME]`) parses to a section header of
1550    /// `"dependencies.NAME"`, never equal to the plain `"dependencies"` the
1551    /// inline-table branch checks against — this must still be recognized
1552    /// as a dependency edge.
1553    #[test]
1554    fn member_depends_on_matches_long_form_dependency_section() {
1555        let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1556        assert!(member_depends_on(contents, "devflow-core"));
1557        assert!(member_depends_on(contents, "clap"));
1558        assert!(!member_depends_on(contents, "serde"));
1559    }
1560
1561    #[test]
1562    fn topo_sort_orders_dependency_before_dependent() {
1563        let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1564        let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1565        assert_eq!(
1566            topo_sort(names, edges),
1567            vec!["devflow-core".to_string(), "devflow".to_string()]
1568        );
1569    }
1570
1571    #[test]
1572    fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1573        // A genuine cyclic dependency would already fail `cargo build`
1574        // long before this check runs — this just proves no infinite loop.
1575        let names = vec!["a".to_string(), "b".to_string()];
1576        let edges = vec![
1577            ("a".to_string(), "b".to_string()),
1578            ("b".to_string(), "a".to_string()),
1579        ];
1580        let result = topo_sort(names, edges);
1581        assert_eq!(result.len(), 2);
1582    }
1583
1584    #[test]
1585    fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1586        let dir = tempfile::tempdir().unwrap();
1587        let root = dir.path();
1588        std::fs::write(
1589            root.join("Cargo.toml"),
1590            "[workspace]\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n",
1591        )
1592        .unwrap();
1593        std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1594        std::fs::write(
1595            root.join("crates/devflow-core/Cargo.toml"),
1596            "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1597        )
1598        .unwrap();
1599        std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1600        std::fs::write(
1601            root.join("crates/devflow-cli/Cargo.toml"),
1602            "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1603        )
1604        .unwrap();
1605
1606        assert_eq!(
1607            publish_order(root),
1608            vec!["devflow-core".to_string(), "devflow".to_string()]
1609        );
1610    }
1611
1612    /// WR-03 (phase 20 review): a workspace member manifest written with
1613    /// the long-form `[dependencies.devflow-core]` section (rather than the
1614    /// inline `[dependencies]\ndevflow-core.workspace = true` form) must
1615    /// still contribute its dependency edge to `publish_order`'s topo-sort
1616    /// — the release-safety-critical crates.io publish order this
1617    /// self-pin regression would otherwise silently get wrong.
1618    #[test]
1619    fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1620        let dir = tempfile::tempdir().unwrap();
1621        let root = dir.path();
1622        std::fs::write(
1623            root.join("Cargo.toml"),
1624            "[workspace]\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n",
1625        )
1626        .unwrap();
1627        std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1628        std::fs::write(
1629            root.join("crates/devflow-core/Cargo.toml"),
1630            "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1631        )
1632        .unwrap();
1633        std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1634        std::fs::write(
1635            root.join("crates/devflow-cli/Cargo.toml"),
1636            "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1637        )
1638        .unwrap();
1639
1640        assert_eq!(
1641            publish_order(root),
1642            vec!["devflow-core".to_string(), "devflow".to_string()],
1643            "the long-form dependency section must still order devflow-core before devflow"
1644        );
1645    }
1646
1647    // -----------------------------------------------------------------
1648    // 20d: origin/main ancestor check (no fetch)
1649    // -----------------------------------------------------------------
1650
1651    #[test]
1652    fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1653        let repo = init_repo();
1654        let root = repo.path();
1655        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1656    }
1657
1658    #[test]
1659    fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1660        let repo = init_repo();
1661        let root = repo.path();
1662        let head = crate::test_support::git_command(root)
1663            .args(["rev-parse", "HEAD"])
1664            .output()
1665            .unwrap();
1666        let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1667        git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1668        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1669    }
1670
1671    // -----------------------------------------------------------------
1672    // 27-01 (D-03): the scrubbing constructor holds under a hostile GIT_DIR
1673    // -----------------------------------------------------------------
1674
1675    /// D-03: a real spawned `git` process built through the constructor
1676    /// resolves the caller-supplied root even when `GIT_DIR` points at an
1677    /// unrelated repository — proven by a subprocess test, not by
1678    /// inspecting the `Command` object alone.
1679    #[test]
1680    fn hermetic_command_resolves_caller_root_even_under_a_hostile_git_dir() {
1681        let real_repo = init_repo();
1682        let real_root = real_repo.path();
1683
1684        let foreign_repo = TempDir::new().unwrap();
1685        git(foreign_repo.path(), &["init", "-q"]);
1686
1687        let output = git_command(real_root)
1688            .args(["rev-parse", "--show-toplevel"])
1689            // Hostile injection chained AFTER the constructor — the
1690            // strongest form of the claim: `--show-toplevel` must still
1691            // resolve `real_root`, not `foreign_repo`.
1692            .env("GIT_DIR", foreign_repo.path().join(".git"))
1693            .output()
1694            .expect("spawn git");
1695        assert!(
1696            output.status.success(),
1697            "rev-parse --show-toplevel failed: {}",
1698            String::from_utf8_lossy(&output.stderr)
1699        );
1700
1701        let resolved = std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim())
1702            .expect("canonicalize resolved toplevel");
1703        let expected = std::fs::canonicalize(real_root).expect("canonicalize real_root");
1704        assert_eq!(
1705            resolved, expected,
1706            "hermetic_command must resolve real_root even with a foreign GIT_DIR set"
1707        );
1708    }
1709
1710    /// D-03: `origin_main_ancestor_status` produces the correct answer
1711    /// under a hostile `GIT_DIR` where it previously did not. Setting a
1712    /// process-global env var is forbidden (Rust 2024 `unsafe`, unsound
1713    /// under threaded tests — Phase 25 D-14), so this proves the property
1714    /// the way the constructor guarantees it, in two parts: (a) the
1715    /// `Command` this code path builds via `git_command` is
1716    /// unconditionally scrubbed — no bypass parameter, no env-var check,
1717    /// no config lookup (D-01), asserted directly on the built `Command`;
1718    /// (b) the actual mechanism `origin_main_ancestor_status` now depends
1719    /// on — scrubbed, with nothing in production code re-adding `GIT_DIR`
1720    /// afterward — reaches the correct answer for a real spawn. (A literal
1721    /// unscrubbed `Command::new("git")` reproduction chaining a hostile
1722    /// `.env("GIT_DIR", foreign)` on top was deliberately NOT added here:
1723    /// verified empirically against this machine's git 2.55.0 that doing
1724    /// so genuinely redirects `merge-base --is-ancestor`'s ref resolution
1725    /// to the foreign repo — unlike `--show-toplevel` above, which falls
1726    /// back to cwd when `GIT_WORK_TREE` is unset — so re-adding it here
1727    /// would both prove nothing new beyond (a) and inflate git.rs's
1728    /// unscrubbed-call-site count past the 7 sites this task deliberately
1729    /// leaves for 27-02.)
1730    #[test]
1731    fn origin_main_ancestor_status_holds_under_a_hostile_git_dir() {
1732        let repo = init_repo();
1733        let root = repo.path();
1734        let head = crate::test_support::git_command(root)
1735            .args(["rev-parse", "HEAD"])
1736            .output()
1737            .unwrap();
1738        let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1739        git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1740
1741        // (a) unconditionally scrubbed.
1742        let cmd = git_command(root);
1743        assert!(
1744            cmd.get_envs()
1745                .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
1746            "origin_main_ancestor_status's own Command must mark GIT_DIR for removal"
1747        );
1748
1749        // (b) the actual, scrubbed mechanism reaches the correct answer.
1750        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1751    }
1752
1753    // -----------------------------------------------------------------
1754    // 27-01: hermetic git command construction (moved from test_support,
1755    // now the canonical, always-compiled home — 999.37/999.39/27-01)
1756    // -----------------------------------------------------------------
1757
1758    /// The contract callers depend on, asserted on the built command rather
1759    /// than inferred: every redirecting variable is marked for removal.
1760    #[test]
1761    fn git_command_marks_every_redirecting_var_for_removal() {
1762        let cmd = git_command(Path::new("/tmp"));
1763        let removed: Vec<&str> = cmd
1764            .get_envs()
1765            .filter(|(_, value)| value.is_none())
1766            .filter_map(|(key, _)| key.to_str())
1767            .collect();
1768
1769        for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
1770            assert!(
1771                removed.contains(var),
1772                "{var} is not cleared by git_command — a fixture inheriting it \
1773                 would operate on that repository instead of its tempdir"
1774            );
1775        }
1776    }
1777
1778    /// GIT_EXEC_PATH must survive: clearing it can break git's own helper
1779    /// lookup on installations that rely on it, and it cannot redirect
1780    /// repository resolution.
1781    #[test]
1782    fn git_command_preserves_git_exec_path() {
1783        let cmd = git_command(Path::new("/tmp"));
1784        assert!(
1785            !cmd.get_envs()
1786                .any(|(key, value)| key == "GIT_EXEC_PATH" && value.is_none()),
1787            "GIT_EXEC_PATH must not be cleared"
1788        );
1789    }
1790
1791    /// Guards the hard-coded list against a git upgrade that adds a
1792    /// repository-local variable. If this fails, add the new name to
1793    /// `REPO_LOCAL_GIT_VARS` — do not delete the assertion.
1794    #[test]
1795    fn local_env_vars_match_git() {
1796        let output = git_command(Path::new("/tmp"))
1797            .args(["rev-parse", "--local-env-vars"])
1798            .output()
1799            .expect("run `git rev-parse --local-env-vars`");
1800        assert!(
1801            output.status.success(),
1802            "`git rev-parse --local-env-vars` failed"
1803        );
1804
1805        let mut from_git: Vec<String> = String::from_utf8_lossy(&output.stdout)
1806            .lines()
1807            .map(str::trim)
1808            .filter(|line| !line.is_empty())
1809            .map(str::to_string)
1810            .collect();
1811        let mut ours: Vec<String> = REPO_LOCAL_GIT_VARS
1812            .iter()
1813            .map(|v| (*v).to_string())
1814            .collect();
1815        from_git.sort();
1816        ours.sort();
1817
1818        assert_eq!(
1819            ours, from_git,
1820            "REPO_LOCAL_GIT_VARS has drifted from `git rev-parse --local-env-vars`"
1821        );
1822    }
1823
1824    // -----------------------------------------------------------------
1825    // 20d: signing-viability helpers
1826    // -----------------------------------------------------------------
1827
1828    #[test]
1829    fn classify_ssh_add_status_maps_all_three_documented_exit_codes() {
1830        assert_eq!(classify_ssh_add_status(2), SigningStatus::NoAgent);
1831        assert_eq!(classify_ssh_add_status(1), SigningStatus::AgentEmpty);
1832        assert_eq!(classify_ssh_add_status(0), SigningStatus::KeysListed);
1833        assert_eq!(classify_ssh_add_status(7), SigningStatus::Unknown(7));
1834    }
1835
1836    /// Guards tests that temporarily override the process-global `HOME`
1837    /// env var (same idiom as `config.rs`'s test-local `ENV_MUTEX`) — this
1838    /// project's own dev machine sets `gpg.format=ssh` / `user.signingkey`
1839    /// GLOBALLY (the exact Pattern 4 research finding), so a hermetic test
1840    /// of the "unset" branch must isolate `$HOME/.gitconfig`, not just the
1841    /// repo-local config.
1842    static HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1843
1844    #[test]
1845    fn check_signing_viability_degrades_when_gpg_format_unset_and_no_signingkey() {
1846        // 20d/empty: no gpg.format, no user.signingkey — must degrade to an
1847        // actionable message, never panic.
1848        let _lock = HOME_ENV_MUTEX.lock().unwrap();
1849        let repo = init_repo();
1850        let root = repo.path();
1851        let fake_home = tempfile::tempdir().unwrap();
1852        let original_home = std::env::var_os("HOME");
1853        // SAFETY: serialized under HOME_ENV_MUTEX; restored below before
1854        // the guard drops.
1855        unsafe { std::env::set_var("HOME", fake_home.path()) };
1856
1857        let result = check_signing_viability(root);
1858
1859        // SAFETY: still serialized under HOME_ENV_MUTEX.
1860        match original_home {
1861            Some(home) => unsafe { std::env::set_var("HOME", home) },
1862            None => unsafe { std::env::remove_var("HOME") },
1863        }
1864
1865        match result {
1866            SigningViability::Unknown { reason } => {
1867                assert!(
1868                    reason.contains("user.signingkey"),
1869                    "unexpected reason: {reason}"
1870                );
1871            }
1872            other => panic!("expected Unknown (fail-soft), got: {other:?}"),
1873        }
1874    }
1875
1876    /// D-01/D-02/D-10: an inline `user.signingkey` value — either the
1877    /// `key::`-prefixed form or the raw deprecated `ssh-` compat form — must
1878    /// never be classified as a missing filesystem path. Git never stats an
1879    /// inline value, so this must never return the missing-key-file
1880    /// `NotViable`. This test intentionally does NOT assert which of
1881    /// `Viable`/agent-`NotViable`/`Unknown` is returned, since that depends
1882    /// on the host's ssh-agent state (D-10).
1883    #[test]
1884    fn check_signing_viability_never_reports_key_file_missing_for_inline_key() {
1885        const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
1886        let inline_values = [
1887            "key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
1888            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
1889        ];
1890        for value in inline_values {
1891            let repo = init_repo();
1892            let root = repo.path();
1893            git(root, &["config", "gpg.format", "ssh"]);
1894            git(root, &["config", "user.signingkey", value]);
1895
1896            let result = check_signing_viability(root);
1897
1898            if let SigningViability::NotViable { reason } = &result {
1899                assert_ne!(
1900                    reason, MISSING_FILE_REASON,
1901                    "inline signingkey value {value:?} incorrectly classified as a \
1902                     missing file: {result:?}"
1903                );
1904            }
1905        }
1906    }
1907
1908    /// D-01/D-02/D-03: a flat table over the pure classifier proving git's
1909    /// own prefix precedence — `key::` strip first, then the raw `ssh-`
1910    /// compat form, else a path. Non-`ssh-` algorithms (`ecdsa-`, `sk-`)
1911    /// reach the inline branch ONLY through `key::` (D-03) — a bare form of
1912    /// either is a path, matching git.
1913    #[test]
1914    fn inline_signing_key_blob_follows_git_prefix_precedence() {
1915        assert_eq!(
1916            inline_signing_key_blob("key::ssh-rsa AAAAB3 id"),
1917            Some("ssh-rsa AAAAB3 id")
1918        );
1919        assert_eq!(
1920            inline_signing_key_blob("key::ssh-ed25519 AAAAC3 id"),
1921            Some("ssh-ed25519 AAAAC3 id")
1922        );
1923        assert_eq!(
1924            inline_signing_key_blob("key::ecdsa-sha2-nistp256 AAAAE2 id"),
1925            Some("ecdsa-sha2-nistp256 AAAAE2 id")
1926        );
1927        assert_eq!(inline_signing_key_blob("key::"), Some(""));
1928        assert_eq!(
1929            inline_signing_key_blob("ssh-ed25519 AAAAC3 id"),
1930            Some("ssh-ed25519 AAAAC3 id")
1931        );
1932        assert_eq!(
1933            inline_signing_key_blob("  key::ssh-ed25519 AAAAC3 id  "),
1934            Some("ssh-ed25519 AAAAC3 id")
1935        );
1936        // D-02: a value that plausibly names an existing file is STILL
1937        // inline, because the classifier never stats it.
1938        assert_eq!(inline_signing_key_blob("ssh-key.pub"), Some("ssh-key.pub"));
1939        assert_eq!(
1940            inline_signing_key_blob("/home/operator/.ssh/id_ed25519.pub"),
1941            None
1942        );
1943        // D-03: bare, no `key::` prefix, so git treats these as paths and so
1944        // must DevFlow.
1945        assert_eq!(
1946            inline_signing_key_blob("ecdsa-sha2-nistp256 AAAAE2 id"),
1947            None
1948        );
1949        assert_eq!(
1950            inline_signing_key_blob("sk-ssh-ed25519@openssh.com AAAAG id"),
1951            None
1952        );
1953        assert_eq!(inline_signing_key_blob("ABCD1234"), None);
1954    }
1955
1956    /// D-03/D-12: values that neither start with `key::` nor `ssh-` still
1957    /// take the path branch and keep today's byte-for-byte behavior — the
1958    /// early `.exists()` return, before `ssh-add` is ever spawned. This is
1959    /// the D-03 falsifier: bare `ecdsa-`/`sk-` forms must NOT be treated as
1960    /// inline.
1961    #[test]
1962    fn check_signing_viability_still_reports_missing_file_for_a_path_value() {
1963        const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
1964        let path_values = [
1965            "/nonexistent/path/to/a/signing/key/that/does/not/exist",
1966            "ecdsa-sha2-nistp256 AAAAE2 devflow-fixture",
1967            "sk-ssh-ed25519@openssh.com AAAAG devflow-fixture",
1968        ];
1969        for value in path_values {
1970            let repo = init_repo();
1971            let root = repo.path();
1972            git(root, &["config", "gpg.format", "ssh"]);
1973            git(root, &["config", "user.signingkey", value]);
1974
1975            let result = check_signing_viability(root);
1976
1977            assert_eq!(
1978                result,
1979                SigningViability::NotViable {
1980                    reason: MISSING_FILE_REASON.to_string(),
1981                },
1982                "value {value:?} did not take the path branch: {result:?}"
1983            );
1984        }
1985    }
1986
1987    /// D-04/D-05/D-09: `inline_key_fingerprint` (stdin) must produce the
1988    /// EXACT SAME `SHA256:` fingerprint as `public_key_fingerprint` (path)
1989    /// for the same real key — proving the D-01 -> D-05 chain and that the
1990    /// blob genuinely reached `ssh-keygen` via stdin. `ssh-keygen -lf`
1991    /// interprets a `-f` argument as a filename, so a blob passed on argv
1992    /// (or never written to the pipe) could not produce a correct
1993    /// fingerprint; a green assertion here is only reachable if the blob
1994    /// went to stdin.
1995    #[test]
1996    fn inline_key_fingerprint_matches_the_path_branch_for_the_same_key() {
1997        let dir = tempfile::tempdir().unwrap();
1998        let key_path = dir.path().join("devflow-fixture-key");
1999        let keygen = Command::new("ssh-keygen")
2000            .args([
2001                "-t",
2002                "ed25519",
2003                "-f",
2004                key_path.to_str().unwrap(),
2005                "-N",
2006                "",
2007                "-q",
2008            ])
2009            .output()
2010            .expect("spawn ssh-keygen");
2011        assert!(
2012            keygen.status.success(),
2013            "ssh-keygen fixture setup failed: {}",
2014            String::from_utf8_lossy(&keygen.stderr)
2015        );
2016        let pub_key_path = dir.path().join("devflow-fixture-key.pub");
2017        let blob = std::fs::read_to_string(&pub_key_path)
2018            .unwrap()
2019            .trim()
2020            .to_string();
2021
2022        // Assert the inline result FIRST and independently, before any
2023        // comparison — a both-`None` result must never pass tautologically.
2024        let inline_fp = inline_key_fingerprint(&blob);
2025        assert!(
2026            inline_fp.is_some(),
2027            "inline_key_fingerprint returned None for a real key"
2028        );
2029        let inline_fp = inline_fp.unwrap();
2030        assert!(
2031            inline_fp.starts_with("SHA256:"),
2032            "unexpected fingerprint shape: {inline_fp}"
2033        );
2034
2035        let path_fp = public_key_fingerprint(&pub_key_path);
2036        assert!(
2037            path_fp.is_some(),
2038            "public_key_fingerprint returned None for a real key"
2039        );
2040        let path_fp = path_fp.unwrap();
2041
2042        assert_eq!(inline_fp, path_fp);
2043
2044        // Closing the D-01 -> D-05 chain: feeding the key:: prefixed form
2045        // through the classifier and then the fingerprint helper yields the
2046        // same fingerprint.
2047        let prefixed = format!("key::{blob}");
2048        let classified_blob = inline_signing_key_blob(&prefixed).unwrap();
2049        let chained_fp = inline_key_fingerprint(classified_blob).unwrap();
2050        assert_eq!(chained_fp, path_fp);
2051    }
2052
2053    /// D-06: every inline-branch failure mode must degrade to `Unknown`
2054    /// (or, at the `pub` boundary, one of the two shared agent-state
2055    /// `NotViable` reasons that both branches can legitimately reach) —
2056    /// never a NEW hard fail introduced by this phase. Agent-independent:
2057    /// these values are unparseable/empty regardless of host ssh-agent
2058    /// state.
2059    #[test]
2060    fn check_signing_viability_never_hard_fails_on_an_unparseable_inline_key() {
2061        const NO_AGENT_REASON: &str = "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)";
2062        const AGENT_EMPTY_REASON: &str = "ssh-agent reachable but has no identities loaded";
2063        let unparseable_values = ["key::", "key::this is not a key at all"];
2064        for value in unparseable_values {
2065            let repo = init_repo();
2066            let root = repo.path();
2067            git(root, &["config", "gpg.format", "ssh"]);
2068            git(root, &["config", "user.signingkey", value]);
2069
2070            let result = check_signing_viability(root);
2071
2072            if let SigningViability::NotViable { reason } = &result {
2073                assert!(
2074                    reason == NO_AGENT_REASON || reason == AGENT_EMPTY_REASON,
2075                    "value {value:?} produced an unexpected hard fail: {result:?}"
2076                );
2077            }
2078        }
2079
2080        assert_eq!(inline_key_fingerprint(""), None);
2081        assert_eq!(inline_key_fingerprint("not a key\n"), None);
2082    }
2083}