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/// Repository helper bound to a project root.
21#[derive(Debug, Clone)]
22pub struct GitFlow {
23    root: PathBuf,
24    config: GitFlowConfig,
25}
26
27/// Summary of a feature branch for the `devflow list` command.
28#[derive(Debug, Clone)]
29pub struct BranchInfo {
30    /// Branch name (e.g. "feature/phase-05").
31    pub name: String,
32    /// Number of commits this branch has that develop doesn't.
33    pub ahead: usize,
34    /// Number of commits develop has that this branch doesn't.
35    pub behind: usize,
36    /// ISO-8601 date of the last commit on this branch.
37    pub last_commit: String,
38}
39
40impl GitFlow {
41    /// Create a git-flow helper for a project root, using the hardcoded
42    /// git-flow constants (`main`, `develop`, `feature/`).
43    pub fn new(root: impl AsRef<Path>) -> Self {
44        Self {
45            root: root.as_ref().to_path_buf(),
46            config: GitFlowConfig::default(),
47        }
48    }
49
50    /// Create a feature branch from the develop branch.
51    ///
52    /// Returns an error if the branch already exists (use
53    /// [`feature_start_force`] to overwrite).
54    pub fn feature_start(&self, phase: u32) -> Result<String, GitError> {
55        let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
56        info!("creating feature branch: {branch}");
57        self.git(["checkout", &self.config.develop])?;
58        self.git(["checkout", "-b", &branch])?;
59        Ok(branch)
60    }
61
62    /// Create or reset a feature branch, overwriting it if it already exists.
63    pub fn feature_start_force(&self, phase: u32) -> Result<String, GitError> {
64        let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
65        warn!("force-creating feature branch: {branch}");
66        self.git(["checkout", &self.config.develop])?;
67        self.git(["checkout", "-B", &branch])?;
68        Ok(branch)
69    }
70
71    /// Merge a feature branch into develop and delete it.
72    pub fn feature_finish(&self, phase: u32) -> Result<String, GitError> {
73        let branch = self.merge_feature_into_develop(phase)?;
74        self.git(["branch", "-d", &branch])?;
75        Ok(branch)
76    }
77
78    /// Merge a feature branch into develop without deleting it.
79    ///
80    /// Default DevFlow runs keep the feature branch checked out in a linked
81    /// worktree, so deletion belongs to the later best-effort cleanup hook.
82    pub fn merge_feature_into_develop(&self, phase: u32) -> Result<String, GitError> {
83        let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
84        info!("merging feature branch: {branch}");
85        self.git(["checkout", &self.config.develop])?;
86        self.git(["merge", "--no-ff", &branch])?;
87        Ok(branch)
88    }
89
90    /// Whether a phase feature branch has nothing left to merge into develop.
91    ///
92    /// An absent branch is not proof of a merge. Callers must fail closed
93    /// rather than treating a deleted or never-created branch as shipped.
94    pub fn is_merged_into_develop(&self, phase: u32) -> bool {
95        let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
96        if !self.branch_exists(&branch) {
97            return false;
98        }
99
100        Command::new("git")
101            .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
102            .current_dir(&self.root)
103            .output()
104            .map(|output| output.status.success())
105            .unwrap_or(false)
106    }
107
108    /// Create or reset a release branch from the current `HEAD`.
109    ///
110    /// The release branch is cut from wherever the caller currently is — the
111    /// branch being shipped — not from `develop`. `devflow ship` writes the
112    /// version bump into the working tree first, so branching from `HEAD`
113    /// keeps any commits unique to the shipped branch in the release.
114    pub fn release_start(&self, version: &str) -> Result<String, GitError> {
115        let branch = format!("release/{version}");
116        info!("creating release branch: {branch}");
117        self.git(["checkout", "-B", &branch])?;
118        Ok(branch)
119    }
120
121    /// Merge a release branch into main and develop, tag it, and delete it.
122    pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
123        let branch = format!("release/{version}");
124        info!("finishing release branch: {branch}");
125        self.git(["checkout", &self.config.main])?;
126        self.git(["merge", "--no-ff", &branch])?;
127        // `-c tag.gpgSign=false` scopes the override to this invocation only
128        // (never the user's global/repo config) — without it, a global
129        // `tag.gpgsign=true` forces this lightweight tag into an
130        // annotated+signed one requiring a message, which blocks on
131        // `$EDITOR` in what must be a headless, unattended flow (Phase 13
132        // dogfood finding).
133        self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
134        self.git(["checkout", &self.config.develop])?;
135        self.git(["merge", "--no-ff", &branch])?;
136        self.git(["branch", "-d", &branch])?;
137        Ok(branch)
138    }
139
140    /// Create an annotated-free lightweight tag at the current `HEAD`.
141    ///
142    /// Passes `-c tag.gpgSign=false` scoped to this invocation only — a
143    /// global `tag.gpgsign=true` (common for developers who sign their own
144    /// tags) otherwise forces this lightweight tag into an annotated+signed
145    /// one requiring a message, which blocks on `$EDITOR` in what must be a
146    /// headless, unattended flow (Phase 13 dogfood finding: VersionBump hung
147    /// on a live `devflow start --mode auto` run).
148    pub fn tag(&self, tag: &str) -> Result<(), GitError> {
149        info!("tagging {tag}");
150        self.git(["-c", "tag.gpgSign=false", "tag", tag])
151    }
152
153    /// Delete a single local branch.
154    ///
155    /// With `force`, uses `git branch -D` (deletes even if unmerged); otherwise
156    /// `git branch -d` (refuses to delete unmerged work). Protected branches
157    /// (`main`, `develop`) are never deleted.
158    pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
159        if branch == self.config.main || branch == self.config.develop {
160            return Err(GitError::Command(format!(
161                "refusing to delete protected branch `{branch}`"
162            )));
163        }
164        let flag = if force { "-D" } else { "-d" };
165        if force {
166            warn!("force-deleting branch: {branch}");
167        } else {
168            info!("deleting branch: {branch}");
169        }
170        self.git(["branch", flag, branch])
171    }
172
173    /// Whether a local branch exists.
174    pub fn branch_exists(&self, branch: &str) -> bool {
175        Command::new("git")
176            .args([
177                "rev-parse",
178                "--verify",
179                "--quiet",
180                &format!("refs/heads/{branch}"),
181            ])
182            .current_dir(&self.root)
183            .output()
184            .map(|o| o.status.success())
185            .unwrap_or(false)
186    }
187
188    /// The commit SHA at the tip of `branch`.
189    pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
190        Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
191    }
192
193    /// Create `branch` at `start_point` if it does not already exist, without
194    /// checking it out (leaves the current checkout untouched).
195    pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
196        if self.branch_exists(branch) {
197            return Ok(());
198        }
199        self.git(["branch", branch, start_point])
200    }
201
202    /// Check out an existing branch in the main worktree.
203    pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
204        debug!("checking out branch: {branch}");
205        self.git(["checkout", branch])
206    }
207
208    /// Delete `branch` on `origin` (best-effort; errors if no remote/branch).
209    pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
210        info!("deleting remote branch: {branch}");
211        self.git(["push", "origin", "--delete", branch])
212    }
213
214    /// Whether the repository has at least one configured remote.
215    pub fn has_remote(&self) -> bool {
216        self.git_output(["remote"])
217            .map(|s| !s.trim().is_empty())
218            .unwrap_or(false)
219    }
220
221    /// Push `branch` to `origin`, setting upstream.
222    pub fn push(&self, branch: &str) -> Result<(), GitError> {
223        info!("pushing branch: {branch}");
224        self.git(["push", "-u", "origin", branch])
225    }
226
227    /// Delete local branches already merged into `develop`.
228    ///
229    /// WR-04 (13-REVIEW.md): passes `develop` explicitly rather than relying
230    /// on `git branch --merged`'s default of "whatever HEAD currently is" —
231    /// if the main checkout is ever left on a branch other than `develop`
232    /// when this runs, an implicit baseline would silently prune branches
233    /// merged into that other branch instead.
234    ///
235    /// Deletion uses `-D`, not `-d`: `-d` verifies merged-into-HEAD, which
236    /// contradicts the `--merged develop` listing above in exactly the
237    /// checkout-not-on-develop scenario WR-04 targets (every genuinely
238    /// merged branch would be refused as "not fully merged"). The listing IS
239    /// the merge safety check. A branch git still refuses to delete (e.g.
240    /// checked out in a worktree) is logged and skipped so one failure
241    /// doesn't abort the rest of the sweep.
242    pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
243        let output = self.git_output(["branch", "--merged", &self.config.develop])?;
244        let protected = [self.config.main.as_str(), self.config.develop.as_str()];
245        let mut deleted = Vec::new();
246        for line in output.lines() {
247            // git's porcelain marker is an exact two-char prefix ("* " for
248            // the current branch, "+ " for a worktree checkout, "  "
249            // otherwise) — strip it positionally rather than trimming
250            // marker CHARACTERS, which would mangle a branch legitimately
251            // named e.g. "+foo" (WR-03, revised).
252            let branch = line
253                .strip_prefix("* ")
254                .or_else(|| line.strip_prefix("+ "))
255                .unwrap_or(line)
256                .trim();
257            // Skip blanks, protected trunks, and the detached-HEAD line
258            // ("(HEAD detached at ...)"), which is not a branch name.
259            if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
260                continue;
261            }
262            info!("cleaning up merged branch: {branch}");
263            match self.git(["branch", "-D", branch]) {
264                Ok(()) => deleted.push(branch.to_string()),
265                Err(err) => warn!("could not delete merged branch {branch}: {err}"),
266            }
267        }
268        Ok(deleted)
269    }
270
271    /// Stage all changes and commit with the given message.
272    /// Returns Ok(()) whether or not there were changes to commit.
273    pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
274        debug!("committing all changes: {message}");
275        self.git(["add", "."])?;
276        // --allow-empty so we don't fail when there are no changes
277        match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
278            Ok(()) => Ok(()),
279            // If the commit produced no changes and we used --allow-empty,
280            // this should still succeed. But just in case, ignore "nothing to commit".
281            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
282            Err(e) => Err(e),
283        }
284    }
285
286    /// Stage a single relative path and commit with the given message.
287    /// Mirrors `commit_all`, but scoped to one path, for hooks that must not
288    /// sweep in unrelated dirty state left by other hooks or the workflow.
289    /// Returns Ok(()) whether or not the path had changes to commit. Unlike
290    /// `commit_all`, a path with no changes produces **no commit** — it is a
291    /// genuine no-op, not a forced empty commit, so a caller such as
292    /// `hooks::version_bump` can never tag a release on a commit containing
293    /// nothing (19b/D-16).
294    pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
295        debug!("committing {relative_path}: {message}");
296        // `add` first so a brand-new file is known to git — a pathspec-only
297        // commit errors on a path git has never seen. The trailing pathspec is
298        // what actually scopes the commit: without it, `commit` writes whatever
299        // else is already in the index, which is exactly the sweep-in this
300        // function exists to prevent.
301        self.git(["add", relative_path])?;
302        match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
303            Ok(()) => Ok(()),
304            // No forcing flag above, so this arm is now the live no-op path:
305            // a path with nothing staged makes git exit non-zero with
306            // "nothing to commit", and we convert that back to Ok(()) rather
307            // than let it propagate as an error (19b/D-16, T-19-11).
308            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
309            Err(e) => Err(e),
310        }
311    }
312
313    /// Return divergence from develop: (ahead, behind) commit counts.
314    ///
315    /// If currently on the develop branch, returns (0, 0).
316    /// `ahead` = commits on current branch not yet on develop.
317    /// `behind` = commits on develop not yet on current branch.
318    pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
319        let current = self
320            .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
321            .trim()
322            .to_string();
323        if current == self.config.develop {
324            return Ok((0, 0));
325        }
326        let ahead = self
327            .rev_count(&format!("{}..{current}", self.config.develop))
328            .unwrap_or(0);
329        let behind = self
330            .rev_count(&format!("{current}..{}", self.config.develop))
331            .unwrap_or(0);
332        Ok((ahead, behind))
333    }
334
335    /// List all feature branches with divergence from develop.
336    ///
337    /// Returns branches matching `feature/phase-*` with ahead/behind counts
338    /// and last commit dates. Protected branches (main, develop) are excluded.
339    pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
340        let prefix = &self.config.feature_prefix;
341        let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
342        let mut result = Vec::new();
343        for name in branches.lines().map(|l| l.trim()) {
344            if name.is_empty()
345                || name == self.config.main
346                || name == self.config.develop
347                || !name.starts_with(prefix)
348            {
349                continue;
350            }
351            let ahead = self
352                .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
353                .unwrap_or(0);
354            let behind = self
355                .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
356                .unwrap_or(0);
357            let last_commit = self
358                .git_output(["log", "-1", "--format=%aI", name])
359                .map(|s| s.trim().to_string())
360                .unwrap_or_default();
361            result.push(BranchInfo {
362                name: name.to_string(),
363                ahead,
364                behind,
365                last_commit,
366            });
367        }
368        // Sort by phase number so phase-01 comes before phase-10.
369        result.sort_by(|a, b| a.name.cmp(&b.name));
370        Ok(result)
371    }
372
373    /// Count revisions in the given range. Returns None if the command fails.
374    fn rev_count(&self, range: &str) -> Option<usize> {
375        self.git_output(["rev-list", "--count", range])
376            .ok()
377            .and_then(|s| s.trim().parse().ok())
378    }
379
380    fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
381        debug!("git {}", args.join(" "));
382        // Pin the subprocess locale to C (Antigravity review, 19b): commit_path's
383        // "nothing to commit" match arm above compares against git's own
384        // English-locale output, which a non-English LC_ALL/LANG would
385        // localize, silently defeating the match and reopening 19b under a
386        // localized environment (T-19-14). Scoped to this one call path only.
387        let output = Command::new("git")
388            .args(args)
389            .env("LC_ALL", "C")
390            .env("LANG", "C")
391            .current_dir(&self.root)
392            .output()?;
393        if output.status.success() {
394            Ok(())
395        } else {
396            Err(GitError::Command(stderr_or_status(&output)))
397        }
398    }
399
400    /// Like [`git_raw`](Self::git_raw), but the error text combines stdout
401    /// with stderr instead of inspecting stderr alone.
402    ///
403    /// Discovered empirically while implementing 19b: `git commit`'s
404    /// "nothing to commit, working tree clean" message is written to
405    /// **stdout**, not stderr. `stderr_or_status` only ever inspects
406    /// `output.stderr`, so a plain `git_raw` error can never contain that
407    /// text — `commit_path`'s `nothing to commit` match arm (immediately
408    /// above its call site) would never fire, no matter how the arm itself
409    /// is written. This sibling exists solely so `commit_path` can see it;
410    /// `commit_all` keeps calling `git_raw` unchanged (D-17 out of scope),
411    /// and `git_raw`'s own error-mapping branch is untouched by this
412    /// addition.
413    fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
414        debug!("git {}", args.join(" "));
415        let output = Command::new("git")
416            .args(args)
417            .env("LC_ALL", "C")
418            .env("LANG", "C")
419            .current_dir(&self.root)
420            .output()?;
421        if output.status.success() {
422            Ok(())
423        } else {
424            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
425            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
426            let combined = match (stderr.is_empty(), stdout.is_empty()) {
427                (false, false) => format!("{stderr}\n{stdout}"),
428                (false, true) => stderr,
429                (true, false) => stdout,
430                (true, true) => format!("exited with {}", output.status),
431            };
432            Err(GitError::Command(combined))
433        }
434    }
435
436    fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
437        debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
438        let output = Command::new("git")
439            .args(args)
440            .current_dir(&self.root)
441            .output()?;
442        if output.status.success() {
443            Ok(())
444        } else {
445            Err(GitError::Command(stderr_or_status(&output)))
446        }
447    }
448
449    fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
450        let output = Command::new("git")
451            .args(args)
452            .current_dir(&self.root)
453            .output()?;
454        if output.status.success() {
455            Ok(String::from_utf8_lossy(&output.stdout).to_string())
456        } else {
457            Err(GitError::Command(stderr_or_status(&output)))
458        }
459    }
460}
461
462/// Result of checking whether `origin/main` is already an ancestor of
463/// `HEAD` — i.e. whether `scripts/sync-main-to-develop.sh` would be a no-op
464/// — WITHOUT issuing any `git fetch` (20d, review: Codex HIGH — a
465/// "read-only" preflight must not depend on the network).
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum AncestorStatus {
468    /// `origin/main` is an ancestor of `HEAD` — sync would be a no-op.
469    Ancestor,
470    /// `origin/main` resolves locally but is NOT an ancestor of `HEAD` —
471    /// develop has diverged and `scripts/sync-main-to-develop.sh` should be
472    /// run before cutting the next release.
473    Diverged,
474    /// `origin/main` does not resolve locally at all (never fetched, or no
475    /// remote configured). Distinct from [`Diverged`](Self::Diverged) so
476    /// the caller can degrade to an actionable "run `git fetch` first"
477    /// message instead of reporting a false divergence.
478    RefAbsent,
479}
480
481/// Check whether `origin/main` is an ancestor of `HEAD`, against
482/// ALREADY-FETCHED local refs — issues NO `git fetch`. Mirrors
483/// `scripts/sync-main-to-develop.sh`'s own `git merge-base --is-ancestor
484/// origin/main HEAD` invocation (`:41`), minus the preceding `git fetch`
485/// (`:38`), which mutates `.git/FETCH_HEAD`/tracking refs and would make a
486/// "read-only" preflight false (20d, review: Codex HIGH).
487pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
488    let ref_exists = Command::new("git")
489        .args(["rev-parse", "--verify", "--quiet", "origin/main"])
490        .current_dir(project_root)
491        .output()
492        .map(|out| out.status.success())
493        .unwrap_or(false);
494    if !ref_exists {
495        return AncestorStatus::RefAbsent;
496    }
497    let is_ancestor = Command::new("git")
498        .args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
499        .current_dir(project_root)
500        .output()
501        .map(|out| out.status.success())
502        .unwrap_or(false);
503    if is_ancestor {
504        AncestorStatus::Ancestor
505    } else {
506        AncestorStatus::Diverged
507    }
508}
509
510/// Derive the crates.io publish order for a workspace's local-path members
511/// (e.g. `devflow-core` before `devflow`) — sourced from the workspace's own
512/// `[workspace] members` list and each member's own `[dependencies]`
513/// section (which member depends on which), never a hardcoded prose string
514/// (20d). Read-only; returns an empty `Vec` (never panics) if the workspace
515/// Cargo.toml or a member manifest cannot be read.
516pub fn publish_order(project_root: &Path) -> Vec<String> {
517    let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
518        return Vec::new();
519    };
520    let member_paths = workspace_member_paths(&root_contents);
521
522    let mut members: Vec<(String, String)> = Vec::new();
523    for path in &member_paths {
524        let manifest = project_root.join(path).join("Cargo.toml");
525        let Ok(contents) = std::fs::read_to_string(&manifest) else {
526            continue;
527        };
528        let name = package_name(&contents).unwrap_or_else(|| path.clone());
529        members.push((name, contents));
530    }
531
532    let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
533    let mut edges: Vec<(String, String)> = Vec::new();
534    for (name, contents) in &members {
535        for other in &names {
536            if other != name && member_depends_on(contents, other) {
537                edges.push((name.clone(), other.clone()));
538            }
539        }
540    }
541    topo_sort(names, edges)
542}
543
544/// Extract the `[workspace] members = [...]` array's quoted path entries.
545/// Hand-rolled, single-array-only scan (this project deliberately avoids a
546/// TOML parser dependency for its version/workspace tooling — see
547/// `version.rs`).
548fn workspace_member_paths(contents: &str) -> Vec<String> {
549    let Some(start) = contents.find("members") else {
550        return Vec::new();
551    };
552    let rest = &contents[start..];
553    let Some(open) = rest.find('[') else {
554        return Vec::new();
555    };
556    let Some(close) = rest[open..].find(']') else {
557        return Vec::new();
558    };
559    let inner = &rest[open + 1..open + close];
560    inner
561        .split(',')
562        .filter_map(|fragment| {
563            let fragment = fragment.trim();
564            let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
565            (!fragment.is_empty()).then(|| fragment.to_string())
566        })
567        .collect()
568}
569
570/// Extract a member manifest's `[package] name`.
571fn package_name(contents: &str) -> Option<String> {
572    let mut current = String::new();
573    for line in contents.lines() {
574        let trimmed = line.trim();
575        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
576            current = inner.trim().to_string();
577            continue;
578        }
579        if current == "package"
580            && let Some((key, value)) = trimmed.split_once('=')
581            && key.trim() == "name"
582        {
583            return Some(value.trim().trim_matches('"').to_string());
584        }
585    }
586    None
587}
588
589/// Whether a member manifest's `[dependencies]` section references
590/// `dep_name` — either `dep_name.workspace = true` or `dep_name = { ... }`
591/// under an inline `[dependencies]` table, OR the equally-valid expanded
592/// long-form section `[dependencies.dep_name]` (WR-03, phase 20 review): a
593/// manifest may spell a dependency out as its own section (e.g.
594/// `[dependencies.devflow-core]\nworkspace = true`), which parses to a
595/// section header of `"dependencies.devflow-core"` — never equal to the
596/// plain `"dependencies"` the inline-table branch below checks against, so
597/// that edge was previously dropped from `publish_order`'s topo-sort
598/// entirely.
599fn member_depends_on(contents: &str, dep_name: &str) -> bool {
600    let mut current = String::new();
601    for line in contents.lines() {
602        let trimmed = line.trim();
603        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
604            current = inner.trim().to_string();
605            if let Some(name) = current.strip_prefix("dependencies.")
606                && name == dep_name
607            {
608                return true;
609            }
610            continue;
611        }
612        if current != "dependencies" {
613            continue;
614        }
615        let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
616        if key == dep_name {
617            return true;
618        }
619    }
620    false
621}
622
623/// Kahn's-algorithm topological sort: `edges` are `(dependent, dependency)`
624/// pairs, meaning `dependent` must be published AFTER `dependency`. Falls
625/// back to appending whatever remains (rather than looping forever) if a
626/// cycle is present — a genuine cyclic Cargo dependency would already fail
627/// `cargo build` long before this check runs.
628fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
629    let mut result = Vec::new();
630    let mut published: Vec<String> = Vec::new();
631    let mut remaining = names;
632    while !remaining.is_empty() {
633        let ready: Vec<String> = remaining
634            .iter()
635            .filter(|name| {
636                edges
637                    .iter()
638                    .filter(|(dependent, _)| dependent == *name)
639                    .all(|(_, dep)| published.contains(dep))
640            })
641            .cloned()
642            .collect();
643        if ready.is_empty() {
644            result.extend(remaining);
645            break;
646        }
647        for name in &ready {
648            published.push(name.clone());
649            result.push(name.clone());
650        }
651        remaining.retain(|name| !ready.contains(name));
652    }
653    result
654}
655
656// ---------------------------------------------------------------------------
657// tag-signing viability (20d, Pattern 4)
658// ---------------------------------------------------------------------------
659
660/// Pure classification of `ssh-add -l`'s exit code into an actionable
661/// signing-viability status. Isolated from any I/O so it can be
662/// unit-tested for all three documented exit codes without a live agent.
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub enum SigningStatus {
665    /// Exit 2 — no ssh-agent reachable (`SSH_AUTH_SOCK` unset or dead).
666    NoAgent,
667    /// Exit 1 — agent reachable but has no identities loaded.
668    AgentEmpty,
669    /// Exit 0 — agent has at least one key loaded (caller still must check
670    /// whether it's THIS key, via a fingerprint match).
671    KeysListed,
672    /// Any other exit code — genuinely unexpected; degrade rather than
673    /// crash or silently misclassify.
674    Unknown(i32),
675}
676
677/// Map `ssh-add -l`'s exit code to a [`SigningStatus`] (Pattern 4: exit
678/// 2 = no agent, 1 = agent-but-empty, 0 = keys listed).
679pub fn classify_ssh_add_status(exit_code: i32) -> SigningStatus {
680    match exit_code {
681        2 => SigningStatus::NoAgent,
682        1 => SigningStatus::AgentEmpty,
683        0 => SigningStatus::KeysListed,
684        other => SigningStatus::Unknown(other),
685    }
686}
687
688/// Outcome of the tag-signing viability check. Carries only a boolean-ish
689/// status plus an optional PUBLIC key fingerprint — never private key
690/// material or a full filesystem path (T-20-04, ASVS V6 / WR-02 — mirrors
691/// the existing "no path/username" discipline this project already applies
692/// elsewhere, e.g. `PhaseFinding`).
693#[derive(Debug, Clone, PartialEq, Eq)]
694pub enum SigningViability {
695    /// Signing is viable. `fingerprint` is the matched public key's
696    /// `SHA256:...` fingerprint, when one could be extracted.
697    Viable { fingerprint: Option<String> },
698    /// Not viable, with an actionable (never key-leaking) reason.
699    NotViable { reason: String },
700    /// Could not be determined — tool absent, format unset with no key,
701    /// etc. Fail-soft: never a crash.
702    Unknown { reason: String },
703}
704
705/// `git config --get <key>`, scoped to `project_root`. `None` if unset or
706/// the command fails (missing `git`, not a repo, etc.) — never panics.
707fn git_config(project_root: &Path, key: &str) -> Option<String> {
708    let output = Command::new("git")
709        .args(["config", "--get", key])
710        .current_dir(project_root)
711        .output()
712        .ok()?;
713    if !output.status.success() {
714        return None;
715    }
716    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
717    (!value.is_empty()).then_some(value)
718}
719
720/// `ssh-keygen -lf <pub_key_path>`'s fingerprint (`SHA256:...`) — reads only
721/// the PUBLIC key file, never a private key, and returns only the hash
722/// token, never a filesystem path.
723fn public_key_fingerprint(pub_key_path: &Path) -> Option<String> {
724    let path_str = pub_key_path.to_str()?;
725    let output = Command::new("ssh-keygen")
726        .args(["-lf", path_str])
727        .output()
728        .ok()?;
729    if !output.status.success() {
730        return None;
731    }
732    // Format: "<bits> SHA256:<hash> <comment> (<type>)"
733    String::from_utf8_lossy(&output.stdout)
734        .split_whitespace()
735        .nth(1)
736        .map(str::to_string)
737}
738
739/// Classifies a `user.signingkey` value the way `git` itself does (mirrors
740/// `man git-config`'s `user.signingKey` precedence, D-01): a `key::`-prefixed
741/// value is inline with the prefix stripped; otherwise a value starting with
742/// the deprecated raw `ssh-` compat form is inline as-is; otherwise the value
743/// is a filesystem path. Pure — no I/O, no `Path`, no `.exists()` — so the
744/// classification never depends on the host's filesystem.
745///
746/// The prefix decides unconditionally (D-02): a value that also happens to
747/// name an existing file (e.g. `ssh-key.pub`) is still classified inline,
748/// because git never stats the value. The raw allowlist is `ssh-` only
749/// (D-03) — `ecdsa-`/`sk-` bare forms are NOT added here; git treats those as
750/// paths, and they only reach the inline branch through the `key::` prefix.
751fn inline_signing_key_blob(signingkey: &str) -> Option<&str> {
752    let trimmed = signingkey.trim();
753    if let Some(remainder) = trimmed.strip_prefix("key::") {
754        Some(remainder)
755    } else if trimmed.starts_with("ssh-") {
756        Some(trimmed)
757    } else {
758        None
759    }
760}
761
762/// `ssh-keygen -lf -`'s fingerprint (`SHA256:...`) for an inline key blob
763/// piped over stdin — mirrors [`public_key_fingerprint`]'s `Option<String>`
764/// return, fail-soft `.ok()?` chain, and identical output parse (D-05: the
765/// output shape is the same whether the key arrived by path or by stdin).
766///
767/// The blob is written to the child's stdin ONLY — never as an argv element
768/// and never through a temp file (D-09): argv is world-readable via
769/// `/proc/<pid>/cmdline`. A later refactor that passes the blob as a
770/// `Command` argument is a security regression, not a cleanup.
771///
772/// Every failure mode — `ssh-keygen` absent, a non-zero exit, unparseable
773/// stdout, or the empty blob produced by a bare `key::` value — returns
774/// `None` here, which the caller routes to `SigningViability::Unknown`,
775/// never a hard-fail `NotViable` (D-06). That includes the empty-blob case:
776/// `ssh-keygen` exits non-zero on empty stdin, which this function surfaces
777/// as `None` with no special-case branch.
778fn inline_key_fingerprint(key_blob: &str) -> Option<String> {
779    let mut child = Command::new("ssh-keygen")
780        .args(["-lf", "-"])
781        .stdin(Stdio::piped())
782        .stdout(Stdio::piped())
783        .stderr(Stdio::piped())
784        .spawn()
785        .ok()?;
786
787    // `.take()` then `drop()` positively closes the stdin pipe before
788    // `wait_with_output()` — a borrow via `.as_mut()` happens to work on
789    // this host but is not a documented guarantee and could hang on a
790    // differently-shaped input.
791    let mut stdin = child.stdin.take()?;
792    stdin.write_all(key_blob.as_bytes()).ok()?;
793    drop(stdin);
794
795    let output = child.wait_with_output().ok()?;
796    if !output.status.success() {
797        return None;
798    }
799    String::from_utf8_lossy(&output.stdout)
800        .split_whitespace()
801        .nth(1)
802        .map(str::to_string)
803}
804
805/// `gpg.format == "ssh"` branch (Pattern 4): `user.signingkey` must be set.
806/// Its value is classified by git's own prefix rules (D-01) into either an
807/// inline key blob or a filesystem path; only a path value is required to
808/// exist. `ssh-add -l`'s exit code then determines viability. On a match,
809/// only the matched public key's `SHA256:` fingerprint is reported — never
810/// the configured value in any form (D-08's redaction contract, unchanged).
811fn check_ssh_signing_viability(project_root: &Path) -> SigningViability {
812    let Some(signingkey) = git_config(project_root, "user.signingkey") else {
813        return SigningViability::NotViable {
814            reason: "gpg.format=ssh but user.signingkey is not set".into(),
815        };
816    };
817
818    // Mirrors `man git-config`'s user.signingKey precedence (D-01): key::
819    // form, then deprecated raw ssh- form, else a path. Never stat a path
820    // for a prefix-matched value (D-02).
821    let inline_blob = inline_signing_key_blob(&signingkey);
822
823    // Path branch keeps today's early return, byte-for-byte (D-12): the
824    // `.exists()` check runs first and a missing file still returns the
825    // existing missing-key-file `NotViable` before `ssh-add` is ever
826    // spawned. No `.exists()` call executes for a prefix-matched value
827    // (RESEARCH Pitfall 3: an extra defensive stat here is exactly the
828    // divergence-from-git this phase exists to remove).
829    if inline_blob.is_none() {
830        let key_path = Path::new(&signingkey);
831        if !key_path.exists() {
832            return SigningViability::NotViable {
833                reason: "user.signingkey is set but the key file does not exist".into(),
834            };
835        }
836    }
837
838    let output = match Command::new("ssh-add").arg("-l").output() {
839        Ok(out) => out,
840        Err(_) => {
841            return SigningViability::Unknown {
842                reason: "cannot verify signing viability — ssh-add not found".into(),
843            };
844        }
845    };
846    let exit_code = output.status.code().unwrap_or(-1);
847    match classify_ssh_add_status(exit_code) {
848        SigningStatus::NoAgent => SigningViability::NotViable {
849            reason: "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)".into(),
850        },
851        SigningStatus::AgentEmpty => SigningViability::NotViable {
852            reason: "ssh-agent reachable but has no identities loaded".into(),
853        },
854        SigningStatus::KeysListed => {
855            let stdout = String::from_utf8_lossy(&output.stdout);
856            // Fingerprint acquisition stays lazy, inside this arm only
857            // (D-12): the path branch must still spawn exactly the same
858            // processes in the same order as today, so this selection
859            // cannot be hoisted above the `ssh-add -l` spawn.
860            let fingerprint = match inline_blob {
861                Some(blob) => inline_key_fingerprint(blob),
862                None => public_key_fingerprint(Path::new(&signingkey)),
863            };
864            match fingerprint {
865                Some(fingerprint) if stdout.contains(&fingerprint) => SigningViability::Viable {
866                    fingerprint: Some(fingerprint),
867                },
868                Some(_) => SigningViability::NotViable {
869                    reason: "ssh-agent has keys loaded, but not the configured signing key".into(),
870                },
871                None => SigningViability::Unknown {
872                    reason: "cannot verify signing viability — ssh-keygen not found or the key \
873                             is unreadable"
874                        .into(),
875                },
876            }
877        }
878        SigningStatus::Unknown(code) => SigningViability::Unknown {
879            reason: format!("ssh-add -l exited with an unexpected code {code}"),
880        },
881    }
882}
883
884/// `gpg.format` unset or `"openpgp"` branch (Pattern 4): verify a secret
885/// key exists for `user.signingkey` via `gpg --list-secret-keys`.
886fn check_gpg_signing_viability(project_root: &Path) -> SigningViability {
887    let Some(signingkey) = git_config(project_root, "user.signingkey") else {
888        return SigningViability::Unknown {
889            reason: "cannot verify signing viability — user.signingkey is not set".into(),
890        };
891    };
892    let output = match Command::new("gpg")
893        .args(["--list-secret-keys", &signingkey])
894        .output()
895    {
896        Ok(out) => out,
897        Err(_) => {
898            return SigningViability::Unknown {
899                reason: "cannot verify signing viability — gpg not found".into(),
900            };
901        }
902    };
903    if output.status.success() {
904        SigningViability::Viable {
905            fingerprint: Some(signingkey),
906        }
907    } else {
908        SigningViability::NotViable {
909            reason: "no secret key found for the configured user.signingkey".into(),
910        }
911    }
912}
913
914/// Tag-signing viability check (20d): branches on `git config gpg.format`
915/// since the check is a genuinely different code path per format — a
916/// GPG-only check would miss the `ssh_askpass` failure this project's own
917/// release actually hit (Pattern 4). Fail-soft throughout: an absent tool
918/// or unset config degrades to an actionable [`SigningViability::Unknown`],
919/// never a crash.
920pub fn check_signing_viability(project_root: &Path) -> SigningViability {
921    match git_config(project_root, "gpg.format").as_deref() {
922        Some("ssh") => check_ssh_signing_viability(project_root),
923        _ => check_gpg_signing_viability(project_root),
924    }
925}
926
927fn stderr_or_status(output: &std::process::Output) -> String {
928    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
929    if stderr.is_empty() {
930        format!("exited with {}", output.status)
931    } else {
932        stderr
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use tempfile::TempDir;
940
941    /// Run a git command in `root`, asserting success.
942    fn git(root: &Path, args: &[&str]) {
943        let output = crate::test_support::git_command(root)
944            .args(args)
945            .output()
946            .expect("spawn git");
947        assert!(
948            output.status.success(),
949            "git {args:?} failed: {}",
950            String::from_utf8_lossy(&output.stderr)
951        );
952    }
953
954    fn current_branch(root: &Path) -> String {
955        let output = crate::test_support::git_command(root)
956            .args(["rev-parse", "--abbrev-ref", "HEAD"])
957            .output()
958            .expect("rev-parse");
959        String::from_utf8_lossy(&output.stdout).trim().to_string()
960    }
961
962    fn commit_file(root: &Path, name: &str) {
963        std::fs::write(root.join(name), name).unwrap();
964        git(root, &["add", "."]);
965        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
966    }
967
968    /// Initialize a repo with `main` and `develop` branches and one commit.
969    fn init_repo() -> TempDir {
970        let dir = tempfile::tempdir().unwrap();
971        let root = dir.path();
972        git(root, &["init", "-q"]);
973        git(root, &["config", "user.email", "test@example.com"]);
974        git(root, &["config", "user.name", "Test"]);
975        git(root, &["config", "commit.gpgsign", "false"]);
976        git(root, &["config", "tag.gpgsign", "false"]);
977        // Disable any globally-configured hooks (e.g. gitleaks) for isolation.
978        git(root, &["config", "core.hooksPath", "/dev/null"]);
979        commit_file(root, "README.md");
980        git(root, &["branch", "-M", "main"]);
981        git(root, &["checkout", "-q", "-b", "develop"]);
982        dir
983    }
984
985    fn flow(root: &Path) -> GitFlow {
986        GitFlow::new(root)
987    }
988
989    #[test]
990    fn feature_start_branches_from_develop() {
991        let repo = init_repo();
992        let root = repo.path();
993        let branch = flow(root).feature_start(3).expect("feature_start");
994        assert_eq!(branch, "feature/phase-03");
995        assert_eq!(current_branch(root), "feature/phase-03");
996    }
997
998    #[test]
999    fn list_feature_branches_reports_ahead_and_behind_semantics() {
1000        let repo = init_repo();
1001        let root = repo.path();
1002        let gf = flow(root);
1003
1004        gf.feature_start(12).expect("feature_start");
1005        commit_file(root, "feature-one.txt");
1006        commit_file(root, "feature-two.txt");
1007        git(root, &["checkout", "-q", "develop"]);
1008        commit_file(root, "develop-only.txt");
1009
1010        let branches = gf.list_feature_branches().unwrap();
1011        let branch = branches
1012            .iter()
1013            .find(|branch| branch.name == "feature/phase-12")
1014            .unwrap();
1015
1016        assert_eq!(branch.ahead, 2);
1017        assert_eq!(branch.behind, 1);
1018    }
1019
1020    #[test]
1021    fn feature_finish_merges_into_develop_and_deletes() {
1022        let repo = init_repo();
1023        let root = repo.path();
1024        let gf = flow(root);
1025
1026        gf.feature_start(1).expect("start");
1027        commit_file(root, "feature.txt");
1028
1029        let branch = gf.feature_finish(1).expect("finish");
1030        assert_eq!(branch, "feature/phase-01");
1031        assert_eq!(current_branch(root), "develop");
1032
1033        // Branch is deleted and its work is now on develop.
1034        let branches = crate::test_support::git_command(root)
1035            .args(["branch"])
1036            .output()
1037            .unwrap();
1038        let listing = String::from_utf8_lossy(&branches.stdout);
1039        assert!(!listing.contains("feature/phase-01"));
1040        assert!(root.join("feature.txt").exists());
1041    }
1042
1043    #[test]
1044    fn release_start_and_finish_tags_main_and_merges_both() {
1045        let repo = init_repo();
1046        let root = repo.path();
1047        let gf = flow(root);
1048
1049        // Add work on develop so the release has content.
1050        commit_file(root, "work.txt");
1051        let branch = gf.release_start("1.2.0").expect("release_start");
1052        assert_eq!(branch, "release/1.2.0");
1053
1054        gf.release_finish("1.2.0").expect("release_finish");
1055        assert_eq!(current_branch(root), "develop");
1056
1057        // Tag exists.
1058        let tags = crate::test_support::git_command(root)
1059            .args(["tag"])
1060            .output()
1061            .unwrap();
1062        assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
1063
1064        // Release branch deleted.
1065        let branches = crate::test_support::git_command(root)
1066            .args(["branch"])
1067            .output()
1068            .unwrap();
1069        assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
1070    }
1071
1072    /// A global/repo `tag.gpgsign=true` must not turn `tag()`'s lightweight
1073    /// tag into an annotated+signed one — that would require a tag message
1074    /// and block on `$EDITOR`, silently hanging a headless, unattended run
1075    /// (Phase 13 dogfood finding: VersionBump hung on a live
1076    /// `devflow start --mode auto` run because the operator's global
1077    /// gitconfig sets `tag.gpgsign=true`).
1078    #[test]
1079    fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
1080        let repo = init_repo();
1081        let root = repo.path();
1082        // Simulate an operator whose global config signs tags by default —
1083        // override the test harness's own `tag.gpgsign false` to prove
1084        // `tag()`'s per-invocation `-c` override wins regardless.
1085        git(root, &["config", "tag.gpgsign", "true"]);
1086
1087        flow(root)
1088            .tag("v9.9.9")
1089            .expect("tag must not block on $EDITOR");
1090
1091        let tags = crate::test_support::git_command(root)
1092            .args(["tag", "-l"])
1093            .output()
1094            .unwrap();
1095        assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
1096
1097        // Confirm it's a lightweight tag (points directly at the commit),
1098        // not an annotated tag object (which `cat-file -t` would report as
1099        // "tag" rather than "commit").
1100        let obj_type = crate::test_support::git_command(root)
1101            .args(["cat-file", "-t", "v9.9.9"])
1102            .output()
1103            .unwrap();
1104        assert_eq!(
1105            String::from_utf8_lossy(&obj_type.stdout).trim(),
1106            "commit",
1107            "tag() must stay lightweight even when tag.gpgsign=true"
1108        );
1109    }
1110
1111    #[test]
1112    fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
1113        // The property that distinguishes commit_path from commit_all
1114        // (17-12, Task 2b): a hook using commit_path must never sweep in
1115        // unrelated dirty state.
1116        let repo = init_repo();
1117        let root = repo.path();
1118        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1119        std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
1120
1121        // Stage the unrelated file BEFORE calling commit_path. An untracked
1122        // file is excluded by any implementation and so proves nothing; an
1123        // already-staged one is the real failure mode — a bare `git commit`
1124        // writes the whole index and would sweep it in.
1125        crate::test_support::git_command(root)
1126            .args(["add", "unrelated.txt"])
1127            .status()
1128            .unwrap();
1129
1130        flow(root)
1131            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1132            .expect("commit_path");
1133
1134        let committed = crate::test_support::git_command(root)
1135            .args(["log", "-1", "--name-only", "--pretty=format:"])
1136            .output()
1137            .unwrap();
1138        let committed_files = String::from_utf8_lossy(&committed.stdout);
1139        assert!(committed_files.contains("CHANGELOG.md"));
1140        assert!(!committed_files.contains("unrelated.txt"));
1141
1142        let status = crate::test_support::git_command(root)
1143            .args(["status", "--porcelain"])
1144            .output()
1145            .unwrap();
1146        let status = String::from_utf8_lossy(&status.stdout);
1147        assert!(
1148            status.contains("A  unrelated.txt"),
1149            "unrelated.txt must remain staged-but-uncommitted, got: {status}"
1150        );
1151    }
1152
1153    /// `git rev-list --count HEAD`, parsed. Shared by the three tests below
1154    /// so a failure reports both counts instead of a bare assertion.
1155    fn rev_list_count(root: &Path) -> u32 {
1156        let output = crate::test_support::git_command(root)
1157            .args(["rev-list", "--count", "HEAD"])
1158            .output()
1159            .unwrap();
1160        assert!(output.status.success(), "git rev-list --count HEAD failed");
1161        String::from_utf8_lossy(&output.stdout)
1162            .trim()
1163            .parse::<u32>()
1164            .expect("rev-list --count HEAD must print an integer")
1165    }
1166
1167    /// 19b/D-16: `hooks::version_bump` (hooks.rs:242) calls `commit_path` and
1168    /// then tags whatever commit it last produced (hooks.rs:249). If a
1169    /// terminal-batch retry calls `commit_path` again with byte-identical
1170    /// content (the file untouched since the first call), a forced commit
1171    /// here means the release tag can end up naming a commit that contains
1172    /// nothing new. This pins the exact retry scenario: two calls, unchanged
1173    /// content, `git rev-list --count HEAD` must not move between them.
1174    #[test]
1175    fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1176        let repo = init_repo();
1177        let root = repo.path();
1178        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1179
1180        flow(root)
1181            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1182            .expect("first commit_path call");
1183        let n1 = rev_list_count(root);
1184
1185        // The file is not touched again -- this is the retry scenario, not
1186        // a second genuine change.
1187        flow(root)
1188            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1189            .expect("second commit_path call");
1190        let n2 = rev_list_count(root);
1191
1192        assert_eq!(
1193            n2, n1,
1194            "a repeat commit_path call on unchanged content must not add a \
1195             commit: n1={n1}, n2={n2}"
1196        );
1197    }
1198
1199    /// 19b/D-16, T-19-11: separates the "no commit" claim from the "no
1200    /// error" claim so a future change can't satisfy one by breaking the
1201    /// other. `hooks.rs` propagates `commit_path`'s `Result` with `?` at both
1202    /// call sites (changelog_append:225, version_bump:242) -- turning a
1203    /// genuine no-op into `Err` would stall the terminal hook batch (see
1204    /// T-19-11 in this plan's threat model), so both properties must hold
1205    /// simultaneously.
1206    #[test]
1207    fn commit_path_with_no_changes_returns_ok_without_committing() {
1208        let repo = init_repo();
1209        let root = repo.path();
1210        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1211        flow(root)
1212            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1213            .expect("initial commit_path");
1214        let n1 = rev_list_count(root);
1215
1216        // CHANGELOG.md is already committed and unmodified -- a single call
1217        // here has nothing to commit.
1218        let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1219        let n2 = rev_list_count(root);
1220
1221        assert!(
1222            result.is_ok(),
1223            "no-op call must return Ok(()), got: {result:?}"
1224        );
1225        assert_eq!(
1226            n2, n1,
1227            "no-op call must not create a commit: n1={n1}, n2={n2}"
1228        );
1229    }
1230
1231    /// Edge case the fix must NOT change: `commit_path` on a path that does
1232    /// not exist on disk still errors at the staging step (`git add` fails
1233    /// on an unknown pathspec). Asserted explicitly so the fix for the
1234    /// no-change case above cannot be over-applied into "commit_path never
1235    /// fails".
1236    #[test]
1237    fn commit_path_on_nonexistent_path_still_errors() {
1238        let repo = init_repo();
1239        let root = repo.path();
1240
1241        let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1242
1243        assert!(
1244            result.is_err(),
1245            "commit_path on an unknown pathspec must still error, got: {result:?}"
1246        );
1247    }
1248
1249    #[test]
1250    fn release_start_branches_from_current_head_not_develop() {
1251        let repo = init_repo();
1252        let root = repo.path();
1253        let gf = flow(root);
1254
1255        // Ship from a feature branch carrying a commit that is NOT on develop.
1256        gf.feature_start(5).expect("feature_start");
1257        commit_file(root, "feature-only.txt");
1258        let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1259
1260        let branch = gf.release_start("2.0.0").expect("release_start");
1261        assert_eq!(branch, "release/2.0.0");
1262        assert_eq!(current_branch(root), "release/2.0.0");
1263
1264        // The release branch tip must descend from the feature commit — i.e.
1265        // the feature-only work is present, not dropped to develop's HEAD.
1266        let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1267        let is_ancestor = crate::test_support::git_command(root)
1268            .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1269            .output()
1270            .unwrap()
1271            .status
1272            .success();
1273        assert!(
1274            is_ancestor,
1275            "release branch must descend from the shipped feature commit"
1276        );
1277        assert!(root.join("feature-only.txt").exists());
1278    }
1279
1280    #[test]
1281    fn cleanup_merged_removes_merged_but_keeps_protected() {
1282        let repo = init_repo();
1283        let root = repo.path();
1284        let gf = flow(root);
1285
1286        // Create and merge a feature branch into develop.
1287        gf.feature_start(2).expect("start");
1288        commit_file(root, "f.txt");
1289        gf.feature_finish(2).expect("finish");
1290
1291        // Create an already-merged stray branch off develop.
1292        git(root, &["branch", "stale-merged"]);
1293
1294        let deleted = gf.cleanup_merged().expect("cleanup");
1295        assert!(deleted.contains(&"stale-merged".to_string()));
1296        // Protected branches survive.
1297        assert!(!deleted.contains(&"develop".to_string()));
1298        assert!(!deleted.contains(&"main".to_string()));
1299    }
1300
1301    /// WR-04 (13-REVIEW.md): `cleanup_merged` must compute "merged" relative
1302    /// to `develop` explicitly, not whatever the main checkout's current
1303    /// HEAD happens to be. If the main checkout is left on a divergent
1304    /// branch, an implicit-HEAD baseline would wrongly identify (and
1305    /// delete) a branch that's merged into that other branch but was never
1306    /// actually merged into `develop`.
1307    #[test]
1308    fn cleanup_merged_is_relative_to_develop_not_current_head() {
1309        let repo = init_repo();
1310        let root = repo.path();
1311        let gf = flow(root);
1312
1313        // `topic` diverges from develop with a unique commit develop never
1314        // sees, then `premature` branches off `topic`'s tip — so
1315        // `premature` is merged into `topic` but NOT into `develop`.
1316        git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1317        commit_file(root, "topic-only.txt");
1318        git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1319
1320        // Leave the main checkout on `topic` — NOT `develop` — before
1321        // calling cleanup_merged, mirroring an operator who forgot to
1322        // check out develop first. (`topic` itself is also technically
1323        // "merged into HEAD" under an implicit baseline since it IS HEAD,
1324        // which git's own `-d` correctly refuses as the checked-out branch
1325        // — so the call's overall Ok/Err is not itself decisive here; check
1326        // the actual side effect on `premature` instead.)
1327        git(root, &["checkout", "-q", "topic"]);
1328
1329        let _ = gf.cleanup_merged();
1330        assert!(
1331            gf.branch_exists("premature"),
1332            "premature is merged into topic (current HEAD) but not into \
1333             develop — it must survive cleanup_merged when the baseline is develop"
1334        );
1335    }
1336
1337    /// WR-03 (13-REVIEW.md), revised: `git branch --merged` prefixes a
1338    /// branch checked out in a linked worktree with `+ `. The prefix must be
1339    /// stripped positionally (not by trimming marker characters, which would
1340    /// mangle a branch legitimately named "+foo"), and a branch git refuses
1341    /// to delete — a worktree checkout can never be deleted, by design —
1342    /// must be skipped with a warning rather than aborting the sweep before
1343    /// the remaining merged branches.
1344    #[test]
1345    fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1346        let repo = init_repo();
1347        let root = repo.path();
1348        let gf = flow(root);
1349
1350        // Merge a branch into develop WITHOUT deleting it (feature_finish
1351        // deletes on merge, which would leave nothing to check out).
1352        git(
1353            root,
1354            &["checkout", "-q", "-b", "worktree-merged", "develop"],
1355        );
1356        commit_file(root, "g.txt");
1357        git(root, &["checkout", "-q", "develop"]);
1358        git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1359
1360        // Check the merged branch out in a linked worktree so
1361        // `git branch --merged` reports it with a `+ ` prefix.
1362        let wt_dir = tempfile::tempdir().unwrap();
1363        git(
1364            root,
1365            &[
1366                "worktree",
1367                "add",
1368                wt_dir.path().to_str().unwrap(),
1369                "worktree-merged",
1370            ],
1371        );
1372
1373        // A second merged branch that sorts after "worktree-merged" would be
1374        // reached only if the sweep survives the worktree refusal; "zz-" also
1375        // guards against luck in iteration order via the branch before it.
1376        git(root, &["branch", "aa-stale"]);
1377        git(root, &["branch", "zz-stale"]);
1378
1379        let deleted = gf
1380            .cleanup_merged()
1381            .expect("a skipped worktree branch must not abort the sweep");
1382        assert!(deleted.contains(&"aa-stale".to_string()));
1383        assert!(deleted.contains(&"zz-stale".to_string()));
1384        assert!(
1385            !deleted.contains(&"worktree-merged".to_string()),
1386            "worktree checkout cannot be deleted"
1387        );
1388        assert!(gf.branch_exists("worktree-merged"));
1389    }
1390
1391    /// The delete side must agree with the `--merged develop` listing: `-d`
1392    /// verifies merged-into-HEAD, so with the main checkout parked on a
1393    /// stale branch every genuinely-merged branch was refused as "not fully
1394    /// merged" — in exactly the scenario WR-04 exists for.
1395    #[test]
1396    fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1397        let repo = init_repo();
1398        let root = repo.path();
1399        let gf = flow(root);
1400
1401        // `old` is parked before the merge below, so nothing merged later is
1402        // reachable from HEAD while it's checked out.
1403        git(root, &["checkout", "-q", "-b", "old", "develop"]);
1404        git(root, &["checkout", "-q", "develop"]);
1405        git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1406        commit_file(root, "h.txt");
1407        git(root, &["checkout", "-q", "develop"]);
1408        git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1409        git(root, &["checkout", "-q", "old"]);
1410
1411        let deleted = gf.cleanup_merged().expect("cleanup");
1412        assert!(
1413            deleted.contains(&"merged-feature".to_string()),
1414            "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1415        );
1416        assert!(!gf.branch_exists("merged-feature"));
1417    }
1418
1419    #[test]
1420    fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1421        let repo = init_repo();
1422        let root = repo.path();
1423        let gf = flow(root);
1424
1425        // Create a feature branch with an unmerged commit.
1426        gf.feature_start(8).expect("start");
1427        commit_file(root, "unmerged.txt");
1428        // Switch back to develop so the branch isn't checked out.
1429        git(root, &["checkout", "-q", "develop"]);
1430
1431        // -d would refuse (unmerged); force deletes it.
1432        assert!(gf.delete_branch("feature/phase-08", false).is_err());
1433        gf.delete_branch("feature/phase-08", true)
1434            .expect("force delete");
1435        let branches = crate::test_support::git_command(root)
1436            .args(["branch"])
1437            .output()
1438            .unwrap();
1439        assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1440
1441        // Protected branches are never deleted.
1442        assert!(gf.delete_branch("develop", true).is_err());
1443        assert!(gf.delete_branch("main", true).is_err());
1444    }
1445
1446    #[test]
1447    fn merge_of_missing_branch_is_an_error() {
1448        let repo = init_repo();
1449        let root = repo.path();
1450        // feature_finish for a phase that was never started: checkout develop
1451        // succeeds, but merging the nonexistent feature branch fails.
1452        let err = flow(root).feature_finish(99).unwrap_err();
1453        assert!(matches!(err, GitError::Command(_)));
1454    }
1455
1456    // -----------------------------------------------------------------
1457    // 20d: publish-order helpers (pure, no I/O)
1458    // -----------------------------------------------------------------
1459
1460    #[test]
1461    fn workspace_member_paths_parses_multiline_array() {
1462        let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n";
1463        assert_eq!(
1464            workspace_member_paths(contents),
1465            vec![
1466                "crates/devflow-core".to_string(),
1467                "crates/devflow-cli".to_string()
1468            ]
1469        );
1470    }
1471
1472    #[test]
1473    fn package_name_reads_the_package_section() {
1474        let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1475        assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1476    }
1477
1478    #[test]
1479    fn member_depends_on_matches_dotted_workspace_shorthand() {
1480        let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1481        assert!(member_depends_on(contents, "devflow-core"));
1482        assert!(!member_depends_on(contents, "serde"));
1483    }
1484
1485    /// WR-03 (phase 20 review): the equally-valid expanded long-form TOML
1486    /// section syntax (`[dependencies.NAME]`) parses to a section header of
1487    /// `"dependencies.NAME"`, never equal to the plain `"dependencies"` the
1488    /// inline-table branch checks against — this must still be recognized
1489    /// as a dependency edge.
1490    #[test]
1491    fn member_depends_on_matches_long_form_dependency_section() {
1492        let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1493        assert!(member_depends_on(contents, "devflow-core"));
1494        assert!(member_depends_on(contents, "clap"));
1495        assert!(!member_depends_on(contents, "serde"));
1496    }
1497
1498    #[test]
1499    fn topo_sort_orders_dependency_before_dependent() {
1500        let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1501        let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1502        assert_eq!(
1503            topo_sort(names, edges),
1504            vec!["devflow-core".to_string(), "devflow".to_string()]
1505        );
1506    }
1507
1508    #[test]
1509    fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1510        // A genuine cyclic dependency would already fail `cargo build`
1511        // long before this check runs — this just proves no infinite loop.
1512        let names = vec!["a".to_string(), "b".to_string()];
1513        let edges = vec![
1514            ("a".to_string(), "b".to_string()),
1515            ("b".to_string(), "a".to_string()),
1516        ];
1517        let result = topo_sort(names, edges);
1518        assert_eq!(result.len(), 2);
1519    }
1520
1521    #[test]
1522    fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1523        let dir = tempfile::tempdir().unwrap();
1524        let root = dir.path();
1525        std::fs::write(
1526            root.join("Cargo.toml"),
1527            "[workspace]\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n",
1528        )
1529        .unwrap();
1530        std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1531        std::fs::write(
1532            root.join("crates/devflow-core/Cargo.toml"),
1533            "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1534        )
1535        .unwrap();
1536        std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1537        std::fs::write(
1538            root.join("crates/devflow-cli/Cargo.toml"),
1539            "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1540        )
1541        .unwrap();
1542
1543        assert_eq!(
1544            publish_order(root),
1545            vec!["devflow-core".to_string(), "devflow".to_string()]
1546        );
1547    }
1548
1549    /// WR-03 (phase 20 review): a workspace member manifest written with
1550    /// the long-form `[dependencies.devflow-core]` section (rather than the
1551    /// inline `[dependencies]\ndevflow-core.workspace = true` form) must
1552    /// still contribute its dependency edge to `publish_order`'s topo-sort
1553    /// — the release-safety-critical crates.io publish order this
1554    /// self-pin regression would otherwise silently get wrong.
1555    #[test]
1556    fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1557        let dir = tempfile::tempdir().unwrap();
1558        let root = dir.path();
1559        std::fs::write(
1560            root.join("Cargo.toml"),
1561            "[workspace]\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n",
1562        )
1563        .unwrap();
1564        std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1565        std::fs::write(
1566            root.join("crates/devflow-core/Cargo.toml"),
1567            "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1568        )
1569        .unwrap();
1570        std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1571        std::fs::write(
1572            root.join("crates/devflow-cli/Cargo.toml"),
1573            "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1574        )
1575        .unwrap();
1576
1577        assert_eq!(
1578            publish_order(root),
1579            vec!["devflow-core".to_string(), "devflow".to_string()],
1580            "the long-form dependency section must still order devflow-core before devflow"
1581        );
1582    }
1583
1584    // -----------------------------------------------------------------
1585    // 20d: origin/main ancestor check (no fetch)
1586    // -----------------------------------------------------------------
1587
1588    #[test]
1589    fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1590        let repo = init_repo();
1591        let root = repo.path();
1592        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1593    }
1594
1595    #[test]
1596    fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1597        let repo = init_repo();
1598        let root = repo.path();
1599        let head = crate::test_support::git_command(root)
1600            .args(["rev-parse", "HEAD"])
1601            .output()
1602            .unwrap();
1603        let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1604        git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1605        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1606    }
1607
1608    // -----------------------------------------------------------------
1609    // 20d: signing-viability helpers
1610    // -----------------------------------------------------------------
1611
1612    #[test]
1613    fn classify_ssh_add_status_maps_all_three_documented_exit_codes() {
1614        assert_eq!(classify_ssh_add_status(2), SigningStatus::NoAgent);
1615        assert_eq!(classify_ssh_add_status(1), SigningStatus::AgentEmpty);
1616        assert_eq!(classify_ssh_add_status(0), SigningStatus::KeysListed);
1617        assert_eq!(classify_ssh_add_status(7), SigningStatus::Unknown(7));
1618    }
1619
1620    /// Guards tests that temporarily override the process-global `HOME`
1621    /// env var (same idiom as `config.rs`'s test-local `ENV_MUTEX`) — this
1622    /// project's own dev machine sets `gpg.format=ssh` / `user.signingkey`
1623    /// GLOBALLY (the exact Pattern 4 research finding), so a hermetic test
1624    /// of the "unset" branch must isolate `$HOME/.gitconfig`, not just the
1625    /// repo-local config.
1626    static HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1627
1628    #[test]
1629    fn check_signing_viability_degrades_when_gpg_format_unset_and_no_signingkey() {
1630        // 20d/empty: no gpg.format, no user.signingkey — must degrade to an
1631        // actionable message, never panic.
1632        let _lock = HOME_ENV_MUTEX.lock().unwrap();
1633        let repo = init_repo();
1634        let root = repo.path();
1635        let fake_home = tempfile::tempdir().unwrap();
1636        let original_home = std::env::var_os("HOME");
1637        // SAFETY: serialized under HOME_ENV_MUTEX; restored below before
1638        // the guard drops.
1639        unsafe { std::env::set_var("HOME", fake_home.path()) };
1640
1641        let result = check_signing_viability(root);
1642
1643        // SAFETY: still serialized under HOME_ENV_MUTEX.
1644        match original_home {
1645            Some(home) => unsafe { std::env::set_var("HOME", home) },
1646            None => unsafe { std::env::remove_var("HOME") },
1647        }
1648
1649        match result {
1650            SigningViability::Unknown { reason } => {
1651                assert!(
1652                    reason.contains("user.signingkey"),
1653                    "unexpected reason: {reason}"
1654                );
1655            }
1656            other => panic!("expected Unknown (fail-soft), got: {other:?}"),
1657        }
1658    }
1659
1660    /// D-01/D-02/D-10: an inline `user.signingkey` value — either the
1661    /// `key::`-prefixed form or the raw deprecated `ssh-` compat form — must
1662    /// never be classified as a missing filesystem path. Git never stats an
1663    /// inline value, so this must never return the missing-key-file
1664    /// `NotViable`. This test intentionally does NOT assert which of
1665    /// `Viable`/agent-`NotViable`/`Unknown` is returned, since that depends
1666    /// on the host's ssh-agent state (D-10).
1667    #[test]
1668    fn check_signing_viability_never_reports_key_file_missing_for_inline_key() {
1669        const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
1670        let inline_values = [
1671            "key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
1672            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
1673        ];
1674        for value in inline_values {
1675            let repo = init_repo();
1676            let root = repo.path();
1677            git(root, &["config", "gpg.format", "ssh"]);
1678            git(root, &["config", "user.signingkey", value]);
1679
1680            let result = check_signing_viability(root);
1681
1682            if let SigningViability::NotViable { reason } = &result {
1683                assert_ne!(
1684                    reason, MISSING_FILE_REASON,
1685                    "inline signingkey value {value:?} incorrectly classified as a \
1686                     missing file: {result:?}"
1687                );
1688            }
1689        }
1690    }
1691
1692    /// D-01/D-02/D-03: a flat table over the pure classifier proving git's
1693    /// own prefix precedence — `key::` strip first, then the raw `ssh-`
1694    /// compat form, else a path. Non-`ssh-` algorithms (`ecdsa-`, `sk-`)
1695    /// reach the inline branch ONLY through `key::` (D-03) — a bare form of
1696    /// either is a path, matching git.
1697    #[test]
1698    fn inline_signing_key_blob_follows_git_prefix_precedence() {
1699        assert_eq!(
1700            inline_signing_key_blob("key::ssh-rsa AAAAB3 id"),
1701            Some("ssh-rsa AAAAB3 id")
1702        );
1703        assert_eq!(
1704            inline_signing_key_blob("key::ssh-ed25519 AAAAC3 id"),
1705            Some("ssh-ed25519 AAAAC3 id")
1706        );
1707        assert_eq!(
1708            inline_signing_key_blob("key::ecdsa-sha2-nistp256 AAAAE2 id"),
1709            Some("ecdsa-sha2-nistp256 AAAAE2 id")
1710        );
1711        assert_eq!(inline_signing_key_blob("key::"), Some(""));
1712        assert_eq!(
1713            inline_signing_key_blob("ssh-ed25519 AAAAC3 id"),
1714            Some("ssh-ed25519 AAAAC3 id")
1715        );
1716        assert_eq!(
1717            inline_signing_key_blob("  key::ssh-ed25519 AAAAC3 id  "),
1718            Some("ssh-ed25519 AAAAC3 id")
1719        );
1720        // D-02: a value that plausibly names an existing file is STILL
1721        // inline, because the classifier never stats it.
1722        assert_eq!(inline_signing_key_blob("ssh-key.pub"), Some("ssh-key.pub"));
1723        assert_eq!(
1724            inline_signing_key_blob("/home/operator/.ssh/id_ed25519.pub"),
1725            None
1726        );
1727        // D-03: bare, no `key::` prefix, so git treats these as paths and so
1728        // must DevFlow.
1729        assert_eq!(
1730            inline_signing_key_blob("ecdsa-sha2-nistp256 AAAAE2 id"),
1731            None
1732        );
1733        assert_eq!(
1734            inline_signing_key_blob("sk-ssh-ed25519@openssh.com AAAAG id"),
1735            None
1736        );
1737        assert_eq!(inline_signing_key_blob("ABCD1234"), None);
1738    }
1739
1740    /// D-03/D-12: values that neither start with `key::` nor `ssh-` still
1741    /// take the path branch and keep today's byte-for-byte behavior — the
1742    /// early `.exists()` return, before `ssh-add` is ever spawned. This is
1743    /// the D-03 falsifier: bare `ecdsa-`/`sk-` forms must NOT be treated as
1744    /// inline.
1745    #[test]
1746    fn check_signing_viability_still_reports_missing_file_for_a_path_value() {
1747        const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
1748        let path_values = [
1749            "/nonexistent/path/to/a/signing/key/that/does/not/exist",
1750            "ecdsa-sha2-nistp256 AAAAE2 devflow-fixture",
1751            "sk-ssh-ed25519@openssh.com AAAAG devflow-fixture",
1752        ];
1753        for value in path_values {
1754            let repo = init_repo();
1755            let root = repo.path();
1756            git(root, &["config", "gpg.format", "ssh"]);
1757            git(root, &["config", "user.signingkey", value]);
1758
1759            let result = check_signing_viability(root);
1760
1761            assert_eq!(
1762                result,
1763                SigningViability::NotViable {
1764                    reason: MISSING_FILE_REASON.to_string(),
1765                },
1766                "value {value:?} did not take the path branch: {result:?}"
1767            );
1768        }
1769    }
1770
1771    /// D-04/D-05/D-09: `inline_key_fingerprint` (stdin) must produce the
1772    /// EXACT SAME `SHA256:` fingerprint as `public_key_fingerprint` (path)
1773    /// for the same real key — proving the D-01 -> D-05 chain and that the
1774    /// blob genuinely reached `ssh-keygen` via stdin. `ssh-keygen -lf`
1775    /// interprets a `-f` argument as a filename, so a blob passed on argv
1776    /// (or never written to the pipe) could not produce a correct
1777    /// fingerprint; a green assertion here is only reachable if the blob
1778    /// went to stdin.
1779    #[test]
1780    fn inline_key_fingerprint_matches_the_path_branch_for_the_same_key() {
1781        let dir = tempfile::tempdir().unwrap();
1782        let key_path = dir.path().join("devflow-fixture-key");
1783        let keygen = Command::new("ssh-keygen")
1784            .args([
1785                "-t",
1786                "ed25519",
1787                "-f",
1788                key_path.to_str().unwrap(),
1789                "-N",
1790                "",
1791                "-q",
1792            ])
1793            .output()
1794            .expect("spawn ssh-keygen");
1795        assert!(
1796            keygen.status.success(),
1797            "ssh-keygen fixture setup failed: {}",
1798            String::from_utf8_lossy(&keygen.stderr)
1799        );
1800        let pub_key_path = dir.path().join("devflow-fixture-key.pub");
1801        let blob = std::fs::read_to_string(&pub_key_path)
1802            .unwrap()
1803            .trim()
1804            .to_string();
1805
1806        // Assert the inline result FIRST and independently, before any
1807        // comparison — a both-`None` result must never pass tautologically.
1808        let inline_fp = inline_key_fingerprint(&blob);
1809        assert!(
1810            inline_fp.is_some(),
1811            "inline_key_fingerprint returned None for a real key"
1812        );
1813        let inline_fp = inline_fp.unwrap();
1814        assert!(
1815            inline_fp.starts_with("SHA256:"),
1816            "unexpected fingerprint shape: {inline_fp}"
1817        );
1818
1819        let path_fp = public_key_fingerprint(&pub_key_path);
1820        assert!(
1821            path_fp.is_some(),
1822            "public_key_fingerprint returned None for a real key"
1823        );
1824        let path_fp = path_fp.unwrap();
1825
1826        assert_eq!(inline_fp, path_fp);
1827
1828        // Closing the D-01 -> D-05 chain: feeding the key:: prefixed form
1829        // through the classifier and then the fingerprint helper yields the
1830        // same fingerprint.
1831        let prefixed = format!("key::{blob}");
1832        let classified_blob = inline_signing_key_blob(&prefixed).unwrap();
1833        let chained_fp = inline_key_fingerprint(classified_blob).unwrap();
1834        assert_eq!(chained_fp, path_fp);
1835    }
1836
1837    /// D-06: every inline-branch failure mode must degrade to `Unknown`
1838    /// (or, at the `pub` boundary, one of the two shared agent-state
1839    /// `NotViable` reasons that both branches can legitimately reach) —
1840    /// never a NEW hard fail introduced by this phase. Agent-independent:
1841    /// these values are unparseable/empty regardless of host ssh-agent
1842    /// state.
1843    #[test]
1844    fn check_signing_viability_never_hard_fails_on_an_unparseable_inline_key() {
1845        const NO_AGENT_REASON: &str = "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)";
1846        const AGENT_EMPTY_REASON: &str = "ssh-agent reachable but has no identities loaded";
1847        let unparseable_values = ["key::", "key::this is not a key at all"];
1848        for value in unparseable_values {
1849            let repo = init_repo();
1850            let root = repo.path();
1851            git(root, &["config", "gpg.format", "ssh"]);
1852            git(root, &["config", "user.signingkey", value]);
1853
1854            let result = check_signing_viability(root);
1855
1856            if let SigningViability::NotViable { reason } = &result {
1857                assert!(
1858                    reason == NO_AGENT_REASON || reason == AGENT_EMPTY_REASON,
1859                    "value {value:?} produced an unexpected hard fail: {result:?}"
1860                );
1861            }
1862        }
1863
1864        assert_eq!(inline_key_fingerprint(""), None);
1865        assert_eq!(inline_key_fingerprint("not a key\n"), None);
1866    }
1867}