Skip to main content

devflow_core/
git.rs

1//! Git-flow operations implemented with plain `git` commands.
2
3use crate::config::GitFlowConfig;
4use crate::phase_id::PhaseId;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7use std::time::{Duration, Instant};
8use tracing::{debug, info, warn};
9
10/// Errors produced by git-flow operations.
11#[derive(Debug, thiserror::Error)]
12pub enum GitError {
13    /// Spawning git failed.
14    #[error("failed to execute git: {0}")]
15    Io(#[from] std::io::Error),
16    /// Git returned a non-success status.
17    #[error("git command failed: {0}")]
18    Command(String),
19}
20
21/// Git's own list of repository-local environment variables, as reported by
22/// `git rev-parse --local-env-vars` (15 entries on git 2.55).
23///
24/// Kept as a constant rather than shelled out per call so building a command
25/// stays free of process spawns; `local_env_vars_match_git` asserts it still
26/// agrees with the installed git, so a version that adds one fails loudly
27/// instead of silently reopening the hole.
28pub const REPO_LOCAL_GIT_VARS: &[&str] = &[
29    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
30    "GIT_CONFIG",
31    "GIT_CONFIG_PARAMETERS",
32    "GIT_CONFIG_COUNT",
33    "GIT_OBJECT_DIRECTORY",
34    "GIT_DIR",
35    "GIT_WORK_TREE",
36    "GIT_IMPLICIT_WORK_TREE",
37    "GIT_GRAFT_FILE",
38    "GIT_INDEX_FILE",
39    "GIT_NO_REPLACE_OBJECTS",
40    "GIT_REPLACE_REF_BASE",
41    "GIT_PREFIX",
42    "GIT_SHALLOW_FILE",
43    "GIT_COMMON_DIR",
44];
45
46/// Variables that are not repository-local — and so absent from
47/// `--local-env-vars` — but still redirect where git reads or writes.
48///
49/// `GIT_CEILING_DIRECTORIES` is included for completeness rather than
50/// because a live path needs it (27-REVIEW WR-02): every production call
51/// site passes an explicit `current_dir` that is already a repository root,
52/// so git's upward discovery — the only thing this variable constrains —
53/// never runs. Scrubbing an unset variable costs nothing, and including it
54/// means no future call site that *does* rely on discovery has to
55/// rediscover the reasoning.
56pub const ALSO_REDIRECTING_GIT_VARS: &[&str] = &[
57    "GIT_NAMESPACE",
58    "GIT_DISCOVERY_ACROSS_FILESYSTEM",
59    "GIT_CEILING_DIRECTORIES",
60];
61
62/// A `git` command pinned to `repo` **and** stripped of every inherited
63/// variable that could redirect it somewhere else.
64///
65/// Use this for every production git invocation instead of building
66/// `Command::new("git")` directly. `GIT_EXEC_PATH` is deliberately left
67/// alone: it only locates git's own helper binaries and cannot change
68/// which repository git acts on.
69///
70/// Clearing `GIT_CONFIG_COUNT` is sufficient to neutralize any inherited
71/// `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` pair — git only reads those when
72/// the count is set — so they need no separate sweep.
73pub fn git_command(repo: &Path) -> Command {
74    hermetic_command("git", repo)
75}
76
77/// As [`git_command`], for a program that is not `git` itself but will
78/// shell out to it — `cargo`, whose build scripts invoke `git`, is the
79/// motivating case. The redirecting variables are inherited all the way
80/// down a process tree, so scrubbing only the direct `git` calls would
81/// leave that path open.
82///
83/// The scrub is unconditional: there is no bypass parameter, no
84/// environment variable, and no config lookup that can turn it back on.
85/// There is no legitimate reason a DevFlow-issued command should silently
86/// redirect via an inherited variable — an operator who wants DevFlow to
87/// act on a different repository passes it a different path (D-01).
88pub fn hermetic_command(program: &str, dir: &Path) -> Command {
89    let mut cmd = Command::new(program);
90    cmd.current_dir(dir);
91    for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
92        cmd.env_remove(var);
93    }
94    cmd
95}
96
97/// Repository helper bound to a project root.
98#[derive(Debug, Clone)]
99pub struct GitFlow {
100    root: PathBuf,
101    config: GitFlowConfig,
102}
103
104/// Summary of a feature branch for the `devflow list` command.
105#[derive(Debug, Clone)]
106pub struct BranchInfo {
107    /// Branch name (e.g. "feature/phase-05").
108    pub name: String,
109    /// Number of commits this branch has that develop doesn't.
110    pub ahead: usize,
111    /// Number of commits develop has that this branch doesn't.
112    pub behind: usize,
113    /// ISO-8601 date of the last commit on this branch.
114    pub last_commit: String,
115}
116
117impl GitFlow {
118    /// Create a git-flow helper for a project root, using the hardcoded
119    /// git-flow constants (`main`, `develop`, `feature/`).
120    pub fn new(root: impl AsRef<Path>) -> Self {
121        Self {
122            root: root.as_ref().to_path_buf(),
123            config: GitFlowConfig::default(),
124        }
125    }
126
127    /// Create a feature branch from the develop branch.
128    ///
129    /// Returns an error if the branch already exists (use
130    /// [`Self::feature_start_force`] to overwrite).
131    pub fn feature_start(&self, phase: PhaseId) -> Result<String, GitError> {
132        let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
133        info!("creating feature branch: {branch}");
134        self.git(["checkout", &self.config.develop])?;
135        self.git(["checkout", "-b", &branch])?;
136        Ok(branch)
137    }
138
139    /// Create or reset a feature branch, overwriting it if it already exists.
140    pub fn feature_start_force(&self, phase: PhaseId) -> Result<String, GitError> {
141        let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
142        warn!("force-creating feature branch: {branch}");
143        self.git(["checkout", &self.config.develop])?;
144        self.git(["checkout", "-B", &branch])?;
145        Ok(branch)
146    }
147
148    /// Merge a feature branch into develop and delete it.
149    pub fn feature_finish(&self, phase: PhaseId) -> Result<String, GitError> {
150        let branch = self.merge_feature_into_develop(phase)?;
151        self.git(["branch", "-d", &branch])?;
152        Ok(branch)
153    }
154
155    /// Merge a feature branch into develop without deleting it.
156    ///
157    /// Default DevFlow runs keep the feature branch checked out in a linked
158    /// worktree, so deletion belongs to the later best-effort cleanup hook.
159    pub fn merge_feature_into_develop(&self, phase: PhaseId) -> Result<String, GitError> {
160        let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
161        info!("merging feature branch: {branch}");
162        self.git(["checkout", &self.config.develop])?;
163        self.git(["merge", "--no-ff", &branch])?;
164        Ok(branch)
165    }
166
167    /// Whether a phase feature branch has nothing left to merge into develop.
168    ///
169    /// An absent branch is not proof of a merge. Callers must fail closed
170    /// rather than treating a deleted or never-created branch as shipped.
171    pub fn is_merged_into_develop(&self, phase: PhaseId) -> bool {
172        let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
173        if !self.branch_exists(&branch) {
174            return false;
175        }
176
177        git_command(&self.root)
178            .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
179            .output()
180            .map(|output| output.status.success())
181            .unwrap_or(false)
182    }
183
184    /// Create or reset a release branch from the current `HEAD`.
185    ///
186    /// The release branch is cut from wherever the caller currently is — the
187    /// branch being shipped — not from `develop`. `devflow ship` writes the
188    /// version bump into the working tree first, so branching from `HEAD`
189    /// keeps any commits unique to the shipped branch in the release.
190    pub fn release_start(&self, version: &str) -> Result<String, GitError> {
191        let branch = format!("release/{version}");
192        info!("creating release branch: {branch}");
193        self.git(["checkout", "-B", &branch])?;
194        Ok(branch)
195    }
196
197    /// Merge a release branch into main and develop, tag it, and delete it.
198    pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
199        let branch = format!("release/{version}");
200        info!("finishing release branch: {branch}");
201        self.git(["checkout", &self.config.main])?;
202        self.git(["merge", "--no-ff", &branch])?;
203        // `-c tag.gpgSign=false` scopes the override to this invocation only
204        // (never the user's global/repo config) — without it, a global
205        // `tag.gpgsign=true` forces this lightweight tag into an
206        // annotated+signed one requiring a message, which blocks on
207        // `$EDITOR` in what must be a headless, unattended flow (Phase 13
208        // dogfood finding).
209        self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
210        self.git(["checkout", &self.config.develop])?;
211        self.git(["merge", "--no-ff", &branch])?;
212        self.git(["branch", "-d", &branch])?;
213        Ok(branch)
214    }
215
216    /// Create an annotated-free lightweight tag at the current `HEAD`.
217    ///
218    /// Passes `-c tag.gpgSign=false` scoped to this invocation only — a
219    /// global `tag.gpgsign=true` (common for developers who sign their own
220    /// tags) otherwise forces this lightweight tag into an annotated+signed
221    /// one requiring a message, which blocks on `$EDITOR` in what must be a
222    /// headless, unattended flow (Phase 13 dogfood finding: VersionBump hung
223    /// on a live `devflow start --mode auto` run).
224    pub fn tag(&self, tag: &str) -> Result<(), GitError> {
225        info!("tagging {tag}");
226        self.git(["-c", "tag.gpgSign=false", "tag", tag])
227    }
228
229    /// Delete a single local branch.
230    ///
231    /// With `force`, uses `git branch -D` (deletes even if unmerged); otherwise
232    /// `git branch -d` (refuses to delete unmerged work). Protected branches
233    /// (`main`, `develop`) are never deleted.
234    pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
235        if branch == self.config.main || branch == self.config.develop {
236            return Err(GitError::Command(format!(
237                "refusing to delete protected branch `{branch}`"
238            )));
239        }
240        let flag = if force { "-D" } else { "-d" };
241        if force {
242            warn!("force-deleting branch: {branch}");
243        } else {
244            info!("deleting branch: {branch}");
245        }
246        self.git(["branch", flag, branch])
247    }
248
249    /// Whether a local branch exists.
250    pub fn branch_exists(&self, branch: &str) -> bool {
251        git_command(&self.root)
252            .args([
253                "rev-parse",
254                "--verify",
255                "--quiet",
256                &format!("refs/heads/{branch}"),
257            ])
258            .output()
259            .map(|o| o.status.success())
260            .unwrap_or(false)
261    }
262
263    /// The commit SHA at the tip of `branch`.
264    pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
265        Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
266    }
267
268    /// Create `branch` at `start_point` if it does not already exist, without
269    /// checking it out (leaves the current checkout untouched).
270    pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
271        if self.branch_exists(branch) {
272            return Ok(());
273        }
274        self.git(["branch", branch, start_point])
275    }
276
277    /// Check out an existing branch in the main worktree.
278    pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
279        debug!("checking out branch: {branch}");
280        self.git(["checkout", branch])
281    }
282
283    /// Delete `branch` on `origin` (best-effort; errors if no remote/branch).
284    pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
285        info!("deleting remote branch: {branch}");
286        self.git(["push", "origin", "--delete", branch])
287    }
288
289    /// Whether the repository has at least one configured remote.
290    pub fn has_remote(&self) -> bool {
291        self.git_output(["remote"])
292            .map(|s| !s.trim().is_empty())
293            .unwrap_or(false)
294    }
295
296    /// Push `branch` to `origin`, setting upstream.
297    pub fn push(&self, branch: &str) -> Result<(), GitError> {
298        info!("pushing branch: {branch}");
299        self.git(["push", "-u", "origin", branch])
300    }
301
302    /// Delete local branches already merged into `develop`.
303    ///
304    /// WR-04 (13-REVIEW.md): passes `develop` explicitly rather than relying
305    /// on `git branch --merged`'s default of "whatever HEAD currently is" —
306    /// if the main checkout is ever left on a branch other than `develop`
307    /// when this runs, an implicit baseline would silently prune branches
308    /// merged into that other branch instead.
309    ///
310    /// Deletion uses `-D`, not `-d`: `-d` verifies merged-into-HEAD, which
311    /// contradicts the `--merged develop` listing above in exactly the
312    /// checkout-not-on-develop scenario WR-04 targets (every genuinely
313    /// merged branch would be refused as "not fully merged"). The listing IS
314    /// the merge safety check. A branch git still refuses to delete (e.g.
315    /// checked out in a worktree) is logged and skipped so one failure
316    /// doesn't abort the rest of the sweep.
317    pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
318        let output = self.git_output(["branch", "--merged", &self.config.develop])?;
319        let protected = [self.config.main.as_str(), self.config.develop.as_str()];
320        let mut deleted = Vec::new();
321        for line in output.lines() {
322            // git's porcelain marker is an exact two-char prefix ("* " for
323            // the current branch, "+ " for a worktree checkout, "  "
324            // otherwise) — strip it positionally rather than trimming
325            // marker CHARACTERS, which would mangle a branch legitimately
326            // named e.g. "+foo" (WR-03, revised).
327            let branch = line
328                .strip_prefix("* ")
329                .or_else(|| line.strip_prefix("+ "))
330                .unwrap_or(line)
331                .trim();
332            // Skip blanks, protected trunks, and the detached-HEAD line
333            // ("(HEAD detached at ...)"), which is not a branch name.
334            if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
335                continue;
336            }
337            info!("cleaning up merged branch: {branch}");
338            match self.git(["branch", "-D", branch]) {
339                Ok(()) => deleted.push(branch.to_string()),
340                Err(err) => warn!("could not delete merged branch {branch}: {err}"),
341            }
342        }
343        Ok(deleted)
344    }
345
346    /// Stage all changes and commit with the given message.
347    /// Returns Ok(()) whether or not there were changes to commit.
348    pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
349        debug!("committing all changes: {message}");
350        self.git(["add", "."])?;
351        // --allow-empty so we don't fail when there are no changes
352        match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
353            Ok(()) => Ok(()),
354            // If the commit produced no changes and we used --allow-empty,
355            // this should still succeed. But just in case, ignore "nothing to commit".
356            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
357            Err(e) => Err(e),
358        }
359    }
360
361    /// Stage a single relative path and commit with the given message.
362    /// Mirrors `commit_all`, but scoped to one path, for hooks that must not
363    /// sweep in unrelated dirty state left by other hooks or the workflow.
364    /// Returns Ok(()) whether or not the path had changes to commit. Unlike
365    /// `commit_all`, a path with no changes produces **no commit** — it is a
366    /// genuine no-op, not a forced empty commit, so a caller such as
367    /// `hooks::version_bump` can never tag a release on a commit containing
368    /// nothing (19b/D-16).
369    pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
370        debug!("committing {relative_path}: {message}");
371        // `add` first so a brand-new file is known to git — a pathspec-only
372        // commit errors on a path git has never seen. The trailing pathspec is
373        // what actually scopes the commit: without it, `commit` writes whatever
374        // else is already in the index, which is exactly the sweep-in this
375        // function exists to prevent.
376        self.git(["add", relative_path])?;
377        match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
378            Ok(()) => Ok(()),
379            // No forcing flag above, so this arm is now the live no-op path:
380            // a path with nothing staged makes git exit non-zero with
381            // "nothing to commit", and we convert that back to Ok(()) rather
382            // than let it propagate as an error (19b/D-16, T-19-11).
383            Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
384            Err(e) => Err(e),
385        }
386    }
387
388    /// Return divergence from develop: (ahead, behind) commit counts.
389    ///
390    /// If currently on the develop branch, returns (0, 0).
391    /// `ahead` = commits on current branch not yet on develop.
392    /// `behind` = commits on develop not yet on current branch.
393    pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
394        let current = self
395            .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
396            .trim()
397            .to_string();
398        if current == self.config.develop {
399            return Ok((0, 0));
400        }
401        let ahead = self
402            .rev_count(&format!("{}..{current}", self.config.develop))
403            .unwrap_or(0);
404        let behind = self
405            .rev_count(&format!("{current}..{}", self.config.develop))
406            .unwrap_or(0);
407        Ok((ahead, behind))
408    }
409
410    /// List all feature branches with divergence from develop.
411    ///
412    /// Returns branches matching `feature/phase-*` with ahead/behind counts
413    /// and last commit dates. Protected branches (main, develop) are excluded.
414    pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
415        let prefix = &self.config.feature_prefix;
416        let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
417        let mut result = Vec::new();
418        for name in branches.lines().map(|l| l.trim()) {
419            if name.is_empty()
420                || name == self.config.main
421                || name == self.config.develop
422                || !name.starts_with(prefix)
423            {
424                continue;
425            }
426            let ahead = self
427                .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
428                .unwrap_or(0);
429            let behind = self
430                .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
431                .unwrap_or(0);
432            let last_commit = self
433                .git_output(["log", "-1", "--format=%aI", name])
434                .map(|s| s.trim().to_string())
435                .unwrap_or_default();
436            result.push(BranchInfo {
437                name: name.to_string(),
438                ahead,
439                behind,
440                last_commit,
441            });
442        }
443        // Sort by phase number so phase-01 comes before phase-10.
444        result.sort_by(|a, b| a.name.cmp(&b.name));
445        Ok(result)
446    }
447
448    /// Count revisions in the given range. Returns None if the command fails.
449    fn rev_count(&self, range: &str) -> Option<usize> {
450        self.git_output(["rev-list", "--count", range])
451            .ok()
452            .and_then(|s| s.trim().parse().ok())
453    }
454
455    fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
456        debug!("git {}", args.join(" "));
457        // Pin the subprocess locale to C (Antigravity review, 19b): commit_path's
458        // "nothing to commit" match arm above compares against git's own
459        // English-locale output, which a non-English LC_ALL/LANG would
460        // localize, silently defeating the match and reopening 19b under a
461        // localized environment (T-19-14). Scoped to this one call path only.
462        let output = git_command(&self.root)
463            .args(args)
464            .env("LC_ALL", "C")
465            .env("LANG", "C")
466            .output()?;
467        if output.status.success() {
468            Ok(())
469        } else {
470            Err(GitError::Command(stderr_or_status(&output)))
471        }
472    }
473
474    /// Like [`git_raw`](Self::git_raw), but the error text combines stdout
475    /// with stderr instead of inspecting stderr alone.
476    ///
477    /// Discovered empirically while implementing 19b: `git commit`'s
478    /// "nothing to commit, working tree clean" message is written to
479    /// **stdout**, not stderr. `stderr_or_status` only ever inspects
480    /// `output.stderr`, so a plain `git_raw` error can never contain that
481    /// text — `commit_path`'s `nothing to commit` match arm (immediately
482    /// above its call site) would never fire, no matter how the arm itself
483    /// is written. This sibling exists solely so `commit_path` can see it;
484    /// `commit_all` keeps calling `git_raw` unchanged (D-17 out of scope),
485    /// and `git_raw`'s own error-mapping branch is untouched by this
486    /// addition.
487    fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
488        debug!("git {}", args.join(" "));
489        let output = git_command(&self.root)
490            .args(args)
491            .env("LC_ALL", "C")
492            .env("LANG", "C")
493            .output()?;
494        if output.status.success() {
495            Ok(())
496        } else {
497            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
498            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
499            let combined = match (stderr.is_empty(), stdout.is_empty()) {
500                (false, false) => format!("{stderr}\n{stdout}"),
501                (false, true) => stderr,
502                (true, false) => stdout,
503                (true, true) => format!("exited with {}", output.status),
504            };
505            Err(GitError::Command(combined))
506        }
507    }
508
509    fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
510        debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
511        let output = git_command(&self.root).args(args).output()?;
512        if output.status.success() {
513            Ok(())
514        } else {
515            Err(GitError::Command(stderr_or_status(&output)))
516        }
517    }
518
519    fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
520        let output = git_command(&self.root).args(args).output()?;
521        if output.status.success() {
522            Ok(String::from_utf8_lossy(&output.stdout).to_string())
523        } else {
524            Err(GitError::Command(stderr_or_status(&output)))
525        }
526    }
527}
528
529/// Result of checking whether `origin/main` is already an ancestor of
530/// `HEAD` — i.e. whether `scripts/sync-main-to-develop.sh` would be a no-op
531/// — WITHOUT issuing any `git fetch` (20d, review: Codex HIGH — a
532/// "read-only" preflight must not depend on the network).
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum AncestorStatus {
535    /// `origin/main` is an ancestor of `HEAD` — sync would be a no-op.
536    Ancestor,
537    /// `origin/main` resolves locally but is NOT an ancestor of `HEAD` —
538    /// develop has diverged and `scripts/sync-main-to-develop.sh` should be
539    /// run before cutting the next release.
540    Diverged,
541    /// `origin/main` does not resolve locally at all (never fetched, or no
542    /// remote configured). Distinct from [`Diverged`](Self::Diverged) so
543    /// the caller can degrade to an actionable "run `git fetch` first"
544    /// message instead of reporting a false divergence.
545    RefAbsent,
546}
547
548/// Check whether `origin/main` is an ancestor of `HEAD`, against
549/// ALREADY-FETCHED local refs — issues NO `git fetch`. Mirrors
550/// `scripts/sync-main-to-develop.sh`'s own `git merge-base --is-ancestor
551/// origin/main HEAD` invocation (`:41`), minus the preceding `git fetch`
552/// (`:38`), which mutates `.git/FETCH_HEAD`/tracking refs and would make a
553/// "read-only" preflight false (20d, review: Codex HIGH).
554pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
555    let ref_exists = git_command(project_root)
556        .args(["rev-parse", "--verify", "--quiet", "origin/main"])
557        .output()
558        .map(|out| out.status.success())
559        .unwrap_or(false);
560    if !ref_exists {
561        return AncestorStatus::RefAbsent;
562    }
563    let is_ancestor = git_command(project_root)
564        .args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
565        .output()
566        .map(|out| out.status.success())
567        .unwrap_or(false);
568    if is_ancestor {
569        AncestorStatus::Ancestor
570    } else {
571        AncestorStatus::Diverged
572    }
573}
574
575/// Derive the crates.io publish order for a workspace's local-path members
576/// (e.g. `devflow-core` before `devflow`) — sourced from the workspace's own
577/// `[workspace] members` list and each member's own `[dependencies]`
578/// section (which member depends on which), never a hardcoded prose string
579/// (20d). Read-only; returns an empty `Vec` (never panics) if the workspace
580/// Cargo.toml or a member manifest cannot be read.
581pub fn publish_order(project_root: &Path) -> Vec<String> {
582    let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
583        return Vec::new();
584    };
585    let member_paths = workspace_member_paths(&root_contents);
586
587    let mut members: Vec<(String, String)> = Vec::new();
588    for path in &member_paths {
589        let manifest = project_root.join(path).join("Cargo.toml");
590        let Ok(contents) = std::fs::read_to_string(&manifest) else {
591            continue;
592        };
593        let name = package_name(&contents).unwrap_or_else(|| path.clone());
594        members.push((name, contents));
595    }
596
597    let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
598    let mut edges: Vec<(String, String)> = Vec::new();
599    for (name, contents) in &members {
600        for other in &names {
601            if other != name && member_depends_on(contents, other) {
602                edges.push((name.clone(), other.clone()));
603            }
604        }
605    }
606    topo_sort(names, edges)
607}
608
609/// Extract the `[workspace] members = [...]` array's quoted path entries.
610/// Hand-rolled, single-array-only scan (this project deliberately avoids a
611/// TOML parser dependency for its version/workspace tooling — see
612/// `version.rs`).
613fn workspace_member_paths(contents: &str) -> Vec<String> {
614    let Some(start) = contents.find("members") else {
615        return Vec::new();
616    };
617    let rest = &contents[start..];
618    let Some(open) = rest.find('[') else {
619        return Vec::new();
620    };
621    let Some(close) = rest[open..].find(']') else {
622        return Vec::new();
623    };
624    let inner = &rest[open + 1..open + close];
625    inner
626        .split(',')
627        .filter_map(|fragment| {
628            let fragment = fragment.trim();
629            let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
630            (!fragment.is_empty()).then(|| fragment.to_string())
631        })
632        .collect()
633}
634
635/// Extract a member manifest's `[package] name`.
636fn package_name(contents: &str) -> Option<String> {
637    let mut current = String::new();
638    for line in contents.lines() {
639        let trimmed = line.trim();
640        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
641            current = inner.trim().to_string();
642            continue;
643        }
644        if current == "package"
645            && let Some((key, value)) = trimmed.split_once('=')
646            && key.trim() == "name"
647        {
648            return Some(value.trim().trim_matches('"').to_string());
649        }
650    }
651    None
652}
653
654/// Whether a member manifest's `[dependencies]` section references
655/// `dep_name` — either `dep_name.workspace = true` or `dep_name = { ... }`
656/// under an inline `[dependencies]` table, OR the equally-valid expanded
657/// long-form section `[dependencies.dep_name]` (WR-03, phase 20 review): a
658/// manifest may spell a dependency out as its own section (e.g.
659/// `[dependencies.devflow-core]\nworkspace = true`), which parses to a
660/// section header of `"dependencies.devflow-core"` — never equal to the
661/// plain `"dependencies"` the inline-table branch below checks against, so
662/// that edge was previously dropped from `publish_order`'s topo-sort
663/// entirely.
664fn member_depends_on(contents: &str, dep_name: &str) -> bool {
665    let mut current = String::new();
666    for line in contents.lines() {
667        let trimmed = line.trim();
668        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
669            current = inner.trim().to_string();
670            if let Some(name) = current.strip_prefix("dependencies.")
671                && name == dep_name
672            {
673                return true;
674            }
675            continue;
676        }
677        if current != "dependencies" {
678            continue;
679        }
680        let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
681        if key == dep_name {
682            return true;
683        }
684    }
685    false
686}
687
688/// Kahn's-algorithm topological sort: `edges` are `(dependent, dependency)`
689/// pairs, meaning `dependent` must be published AFTER `dependency`. Falls
690/// back to appending whatever remains (rather than looping forever) if a
691/// cycle is present — a genuine cyclic Cargo dependency would already fail
692/// `cargo build` long before this check runs.
693fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
694    let mut result = Vec::new();
695    let mut published: Vec<String> = Vec::new();
696    let mut remaining = names;
697    while !remaining.is_empty() {
698        let ready: Vec<String> = remaining
699            .iter()
700            .filter(|name| {
701                edges
702                    .iter()
703                    .filter(|(dependent, _)| dependent == *name)
704                    .all(|(_, dep)| published.contains(dep))
705            })
706            .cloned()
707            .collect();
708        if ready.is_empty() {
709            result.extend(remaining);
710            break;
711        }
712        for name in &ready {
713            published.push(name.clone());
714            result.push(name.clone());
715        }
716        remaining.retain(|name| !ready.contains(name));
717    }
718    result
719}
720
721// ---------------------------------------------------------------------------
722// tag-signing viability (20d, Pattern 4)
723// ---------------------------------------------------------------------------
724
725// REMOVED in v2.5.0 (999.86, D-04/D-08) — `pub enum SigningStatus` and
726// `pub fn classify_ssh_add_status` used to live here, immediately below this
727// banner. Both were `pub` items of this crate, so their removal is a breaking
728// change; it is enumerated in `CHANGELOG.md` under 2.5.0. The private
729// `inline_key_fingerprint` helper went with them, orphaned by D-03.
730//
731// Why they are gone: they PREDICTED tag-signing viability by classifying
732// `ssh-add -l`'s exit code and comparing fingerprints — that is, they inferred
733// it from the agent's identity list. An agent listing cannot see private key
734// material sitting unencrypted on disk, so the predictor tested a condition the
735// real signing operation does not require, and reported `NotViable` for a
736// perfectly signable key that no agent happened to hold. That is not a
737// hypothetical: it false-negatived on two separate release cuts with the
738// correct key present.
739//
740// What replaced them: `check_signing_viability` below, which establishes
741// viability by PERFORMING the operation — a bounded, non-interactive
742// `ssh-keygen -Y sign` over throwaway bytes in a private per-call workspace,
743// whose exit code is the whole verdict. A probe has no independent behaviour to
744// drift out of sync with what `git tag -s` actually does, which is the
745// structural property the predictor lacked rather than a bug it happened to
746// have.
747//
748// This note exists because a bare absence invites the mistake in reverse. Dead
749// public API that still reads like the sanctioned way to judge signing
750// viability is how the predictor survived review twice; do not reintroduce an
751// agent-membership check here under a new name.
752
753/// Outcome of the tag-signing viability check. Carries only a boolean-ish
754/// status plus an optional PUBLIC key fingerprint — never private key
755/// material or a full filesystem path (T-20-04, ASVS V6 / WR-02 — mirrors
756/// the existing "no path/username" discipline this project already applies
757/// elsewhere, e.g. `PhaseFinding`).
758#[derive(Debug, Clone, PartialEq, Eq)]
759pub enum SigningViability {
760    /// Signing is viable. `fingerprint` is the matched public key's
761    /// `SHA256:...` fingerprint, when one could be extracted.
762    Viable { fingerprint: Option<String> },
763    /// Not viable, with an actionable (never key-leaking) reason.
764    NotViable { reason: String },
765    /// Could not be determined — tool absent, format unset with no key,
766    /// etc. Fail-soft: never a crash.
767    Unknown { reason: String },
768}
769
770/// `git config --get <key>`, scoped to `project_root`. `None` if unset or
771/// the command fails (missing `git`, not a repo, etc.) — never panics.
772fn git_config(project_root: &Path, key: &str) -> Option<String> {
773    let output = git_command(project_root)
774        .args(["config", "--get", key])
775        .output()
776        .ok()?;
777    if !output.status.success() {
778        return None;
779    }
780    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
781    (!value.is_empty()).then_some(value)
782}
783
784/// `ssh-keygen -lf <pub_key_path>`'s fingerprint (`SHA256:...`) — reads only
785/// the PUBLIC key file, never a private key, and returns only the hash
786/// token, never a filesystem path.
787fn public_key_fingerprint(pub_key_path: &Path) -> Option<String> {
788    let path_str = pub_key_path.to_str()?;
789    let output = Command::new("ssh-keygen")
790        .args(["-lf", path_str])
791        .output()
792        .ok()?;
793    if !output.status.success() {
794        return None;
795    }
796    // Format: "<bits> SHA256:<hash> <comment> (<type>)"
797    String::from_utf8_lossy(&output.stdout)
798        .split_whitespace()
799        .nth(1)
800        .map(str::to_string)
801}
802
803/// Classifies a `user.signingkey` value the way `git` itself does (mirrors
804/// `man git-config`'s `user.signingKey` precedence, D-01): a `key::`-prefixed
805/// value is inline with the prefix stripped; otherwise a value starting with
806/// the deprecated raw `ssh-` compat form is inline as-is; otherwise the value
807/// is a filesystem path. Pure — no I/O, no `Path`, no `.exists()` — so the
808/// classification never depends on the host's filesystem.
809///
810/// The prefix decides unconditionally (D-02): a value that also happens to
811/// name an existing file (e.g. `ssh-key.pub`) is still classified inline,
812/// because git never stats the value. The raw allowlist is `ssh-` only
813/// (D-03) — `ecdsa-`/`sk-` bare forms are NOT added here; git treats those as
814/// paths, and they only reach the inline branch through the `key::` prefix.
815fn inline_signing_key_blob(signingkey: &str) -> Option<&str> {
816    let trimmed = signingkey.trim();
817    if let Some(remainder) = trimmed.strip_prefix("key::") {
818        Some(remainder)
819    } else if trimmed.starts_with("ssh-") {
820        Some(trimmed)
821    } else {
822        None
823    }
824}
825
826/// The SSHSIG namespace `git` itself writes into a tag signature.
827///
828/// Decoded byte-for-byte out of a real git-produced SSHSIG blob — this
829/// repository's own `v2.4.0` signed tag. After the `SSHSIG` magic and the
830/// uint32 version come the length-prefixed public key and then the
831/// namespace field, which reads `\0\0\0\x03git`; the following `sha512`
832/// hash-algorithm field lands exactly where that length says it should,
833/// which is what makes the offset reading self-checking rather than a
834/// guess.
835///
836/// Do NOT re-derive this value from documentation or from memory. The
837/// probe's entire worth is that it performs the operation git performs
838/// rather than approximating it, and a namespace that differs from git's
839/// would silently make the probe measure something git never does.
840const SSH_SIGN_NAMESPACE: &str = "git";
841
842/// Wall-clock ceiling for the signing probe (D-01).
843///
844/// `SSH_ASKPASS_REQUIRE=never` closes the passphrase-prompt route; this
845/// closes the rest. Both are required: the env var alone leaves non-askpass
846/// blocking routes open (a wedged `ssh-agent`, a stalled PKCS11 provider —
847/// reasoned, not measured), and a timeout alone would turn a working
848/// graphical askpass into a false `NotViable`.
849const SSH_SIGN_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
850
851/// Poll interval while waiting for the probe child to exit.
852const SSH_SIGN_PROBE_POLL: Duration = Duration::from_millis(25);
853
854/// A probe-workspace directory name unique to each individual CALL (F-8).
855///
856/// Per-*process* uniqueness is not enough: `cargo test` runs tests as
857/// parallel threads inside one process, so a name derived from the process
858/// id alone is shared by every concurrent probe. Two probes would collide,
859/// the loser's non-recursive `create_dir` would fail, and it would fail
860/// soft to `Unknown` — a flaky result that points at the probe rather than
861/// at the caller.
862///
863/// Three parts, `std` only (this crate adds no dependency): the process id,
864/// a process-wide counter incremented on every call, and a sub-millisecond
865/// time component. Extracted as its own function so the uniqueness property
866/// can be asserted directly rather than inferred from a probe result.
867fn probe_workspace_name() -> String {
868    use std::sync::atomic::{AtomicU64, Ordering};
869    static PROBE_SEQ: AtomicU64 = AtomicU64::new(0);
870
871    let seq = PROBE_SEQ.fetch_add(1, Ordering::Relaxed);
872    let nanos = std::time::SystemTime::now()
873        .duration_since(std::time::UNIX_EPOCH)
874        .map(|since| since.as_nanos())
875        .unwrap_or(0);
876    format!(
877        "devflow-sign-probe-{}-{}-{}",
878        std::process::id(),
879        seq,
880        nanos
881    )
882}
883
884/// What one run of the signing probe established. Five outcomes, mapped to
885/// fixed reason strings by class at the single call site — never composed
886/// from `ssh-keygen`'s own output (D-02, D-08).
887enum SignProbeOutcome {
888    /// The child exited zero: this key really can sign.
889    Signed,
890    /// The child exited non-zero: this key really cannot sign.
891    Rejected,
892    /// The child outlived [`SSH_SIGN_PROBE_TIMEOUT`] and was killed and
893    /// reaped.
894    TimedOut,
895    /// The child could not be spawned — `ssh-keygen` is absent.
896    ToolMissing,
897    /// The probe could not be set up or supervised at all (its workspace
898    /// could not be created, the payload could not be written, or the child
899    /// could not be polled). Fail-soft: an infrastructure problem is not
900    /// evidence about the key.
901    NotRun,
902}
903
904/// Removes its directory when dropped, so the workspace goes away on EVERY
905/// exit path from [`run_ssh_sign_probe`] — including an unwind (WR-07,
906/// 35-REVIEW).
907///
908/// The plain `remove_dir_all` statement this replaces covered every `return`
909/// inside `sign_probe_within`, which is what its comment was written for, but a
910/// panic anywhere in that function skipped it. Over many `release --check` runs
911/// on a long-lived host that is unbounded accumulation of
912/// `devflow-sign-probe-*` directories in `/tmp`.
913struct ProbeWorkspace(PathBuf);
914
915impl Drop for ProbeWorkspace {
916    fn drop(&mut self) {
917        let _ = std::fs::remove_dir_all(&self.0);
918    }
919}
920
921/// Sign throwaway bytes with the configured key and report only how that
922/// went. Creates a private workspace, runs the probe inside it, and removes
923/// the workspace on every exit path — including the timeout path, since
924/// `ssh-keygen -Y sign` writes its signature as a sibling of the payload
925/// (T-35-13: both live and die inside this one directory) — and including an
926/// unwind, via [`ProbeWorkspace`]'s `Drop`.
927fn run_ssh_sign_probe(key_path: &Path) -> SignProbeOutcome {
928    let workspace = std::env::temp_dir().join(probe_workspace_name());
929
930    // Non-recursive on purpose (T-35-12). `DirBuilder::create` FAILS when the
931    // path already exists — `recursive(true)` is never set — so a pre-planted
932    // directory or symlink cannot redirect where the payload is written;
933    // `create_dir_all` would accept one silently. `tempfile` is a
934    // dev-dependency of this crate and is unavailable to production code, and
935    // no dependency may be added, so this is `std` only.
936    //
937    // WR-07: mode 0o700, so "private" is implemented rather than merely
938    // claimed. `std::fs::create_dir` applies `0o777 & !umask` — typically
939    // 0o755, world-readable and world-traversable inside a shared
940    // `std::env::temp_dir()`. Nothing secret lands here (the payload is fixed
941    // bytes and `payload.sig` signs those bytes), so this was not an exposure
942    // of key material; it is that a future author extending the probe would
943    // read the comment rather than the mode bits.
944    if !create_probe_workspace(&workspace) {
945        return SignProbeOutcome::NotRun;
946    }
947    let _cleanup = ProbeWorkspace(workspace.clone());
948
949    sign_probe_within(&workspace, key_path)
950}
951
952/// Create the probe's workspace directory, owner-only and non-recursively.
953/// `false` means it could not be created — including because something was
954/// already there.
955///
956/// Split from [`run_ssh_sign_probe`] so both properties can be asserted on a
957/// directory that still exists: the probe removes its workspace before
958/// returning, so nothing downstream can inspect the mode bits (WR-07).
959fn create_probe_workspace(workspace: &Path) -> bool {
960    let mut builder = std::fs::DirBuilder::new();
961    #[cfg(unix)]
962    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
963    builder.create(workspace).is_ok()
964}
965
966/// The probe proper, with `workspace` already created and guaranteed to be
967/// removed by the caller.
968fn sign_probe_within(workspace: &Path, key_path: &Path) -> SignProbeOutcome {
969    // Bytes the probe generated itself, inside its own private directory
970    // (T-35-13). A viability check must never become an unauthorised
971    // signing operation over real content, so nothing from the operator's
972    // working tree is ever signed or even read here.
973    let payload = workspace.join("payload");
974    if std::fs::write(&payload, b"devflow signing viability probe\n").is_err() {
975        return SignProbeOutcome::NotRun;
976    }
977
978    let (Some(key_arg), Some(payload_arg)) = (key_path.to_str(), payload.to_str()) else {
979        return SignProbeOutcome::NotRun;
980    };
981
982    let mut command = Command::new("ssh-keygen");
983    command
984        .args([
985            "-Y",
986            "sign",
987            "-n",
988            SSH_SIGN_NAMESPACE,
989            "-f",
990            key_arg,
991            payload_arg,
992        ])
993        // D-01: closes the ASKPASS route, so an encrypted key cannot park an
994        // unattended preflight on an askpass helper. It does NOT close the
995        // /dev/tty route — see the `setsid` call below.
996        .env("SSH_ASKPASS_REQUIRE", "never")
997        .stdin(Stdio::null())
998        .stdout(Stdio::null())
999        // The child's stderr is discarded here and read by nobody: it
1000        // embeds the configured key path verbatim (`Couldn't load public
1001        // key ./does-not-exist.pub`), so reproducing any part of it in a
1002        // reason string would violate D-08's redaction contract. The exit
1003        // code is the sole verdict (D-02).
1004        .stderr(Stdio::null());
1005
1006    // SAFETY: `setsid` is a bare syscall and async-signal-safe, which is the
1007    // only requirement `pre_exec` imposes.
1008    //
1009    // Detach from any controlling terminal before exec. `SSH_ASKPASS_REQUIRE
1010    // =never` alone is NOT sufficient: OpenSSH only consults it once
1011    // `open("/dev/tty")` has failed, so on a host that HAS a controlling
1012    // terminal `ssh-keygen` prompts for the passphrase on the terminal
1013    // regardless of the variable and blocks there until the ceiling expires.
1014    // Measured on this host with a real pty: 10.06s (i.e. the whole
1015    // SSH_SIGN_PROBE_TIMEOUT) before the fix, 0.02s after. Dropping the
1016    // controlling terminal makes that `open` fail, which is the condition
1017    // the variable is gated on.
1018    //
1019    // The failure is ignored deliberately. `setsid` only fails when the
1020    // caller is already a process-group leader, which a freshly forked child
1021    // is not; and if it somehow did fail, the probe degrades to exactly the
1022    // pre-fix behaviour, which the wall-clock ceiling already bounds. Turning
1023    // it into a spawn error would be worse: `spawn` failure is classified as
1024    // absent tooling, so it would surface as a false "ssh-keygen not found".
1025    unsafe {
1026        std::os::unix::process::CommandExt::pre_exec(&mut command, || {
1027            libc::setsid();
1028            Ok(())
1029        });
1030    }
1031
1032    let mut child = match command.spawn() {
1033        Ok(child) => child,
1034        Err(_) => return SignProbeOutcome::ToolMissing,
1035    };
1036
1037    // Bounded wait, following `canary.rs`'s `reap` shape: poll until the
1038    // deadline, then kill and wait so no child is left behind.
1039    let deadline = Instant::now() + SSH_SIGN_PROBE_TIMEOUT;
1040    loop {
1041        match child.try_wait() {
1042            Ok(Some(status)) => {
1043                return if status.success() {
1044                    SignProbeOutcome::Signed
1045                } else {
1046                    SignProbeOutcome::Rejected
1047                };
1048            }
1049            Ok(None) => {}
1050            Err(_) => {
1051                // Could not poll: nothing was established about the key, so
1052                // reap the child and degrade rather than inventing a verdict.
1053                let _ = child.kill();
1054                let _ = child.wait();
1055                return SignProbeOutcome::NotRun;
1056            }
1057        }
1058        if Instant::now() >= deadline {
1059            break;
1060        }
1061        std::thread::sleep(SSH_SIGN_PROBE_POLL);
1062    }
1063    let _ = child.kill();
1064    let _ = child.wait();
1065    SignProbeOutcome::TimedOut
1066}
1067
1068/// `gpg.format == "ssh"` branch (Pattern 4): `user.signingkey` must be set.
1069/// Its value is classified by git's own prefix rules (D-01) into either an
1070/// inline key blob or a filesystem path; only a path value is required to
1071/// exist, and only a path value is probed (D-03).
1072///
1073/// Viability is then established by **performing the operation** — signing
1074/// throwaway bytes with `ssh-keygen -Y sign` — rather than by predicting it
1075/// from `ssh-add -l`. The predictor this replaced inferred viability from
1076/// agent membership, which is not a necessary condition for `git tag -s` to
1077/// succeed: an unencrypted private key sitting beside the configured public
1078/// key signs fine with no agent at all. That gap false-negatived live on
1079/// two separate release cuts (999.86). A probe cannot drift out of sync
1080/// with git's real behaviour because it has no independent behaviour.
1081///
1082/// The probe's exit code is the sole verdict (D-02) and its stderr is never
1083/// re-emitted; on success only the public key's `SHA256:` fingerprint is
1084/// reported, never the configured value in any form (D-08's redaction
1085/// contract, unchanged).
1086fn check_ssh_signing_viability(project_root: &Path) -> SigningViability {
1087    let Some(signingkey) = git_config(project_root, "user.signingkey") else {
1088        return SigningViability::NotViable {
1089            reason: "gpg.format=ssh but user.signingkey is not set".into(),
1090        };
1091    };
1092
1093    // Mirrors `man git-config`'s user.signingKey precedence (D-01): key::
1094    // form, then deprecated raw ssh- form, else a path. Never stat a path
1095    // for a prefix-matched value (D-02). Classification runs BEFORE the
1096    // value is treated as a filesystem path, so an inline value never
1097    // reaches the `.exists()` check below.
1098    if inline_signing_key_blob(&signingkey).is_some() {
1099        // D-03/A-17: inline values are not probed at all. Probing one would
1100        // mean materialising the blob to a temp file — measured to work,
1101        // declined on surface cost. The operator gets no verdict here
1102        // rather than a wrong one.
1103        return SigningViability::Unknown {
1104            reason: "cannot verify signing viability — an inline user.signingkey is not probed"
1105                .into(),
1106        };
1107    }
1108
1109    // Path branch keeps today's early return, byte-for-byte (D-12): the
1110    // `.exists()` check runs first and a missing file still returns the
1111    // existing missing-key-file `NotViable` before anything is spawned.
1112    let key_path = Path::new(&signingkey);
1113    if !key_path.exists() {
1114        return SigningViability::NotViable {
1115            reason: "user.signingkey is set but the key file does not exist".into(),
1116        };
1117    }
1118
1119    sign_probe_verdict(run_ssh_sign_probe(key_path), key_path)
1120}
1121
1122/// The probe outcome → operator-facing verdict mapping, split out from
1123/// [`check_ssh_signing_viability`] so it can be asserted for every variant
1124/// without spawning anything (WR-01). Forcing a real `TimedOut` needs a
1125/// 10-second wedged `ssh-keygen`; the classification that was wrong is right
1126/// here, and this is the level it can be pinned at.
1127///
1128/// Fixed reason strings keyed by failure class (D-02) — none is composed
1129/// from `ssh-keygen`'s output, and none names the configured key, a path,
1130/// or any part of the child's stderr. Every fail-soft class keeps the
1131/// file's existing "cannot verify signing viability — " prefix.
1132fn sign_probe_verdict(outcome: SignProbeOutcome, key_path: &Path) -> SigningViability {
1133    match outcome {
1134        SignProbeOutcome::Signed => SigningViability::Viable {
1135            fingerprint: public_key_fingerprint(key_path),
1136        },
1137        SignProbeOutcome::Rejected => SigningViability::NotViable {
1138            reason: "the configured signing key could not sign a test payload".into(),
1139        },
1140        // WR-01 (35-REVIEW): a timeout is a MEASUREMENT failure, and this
1141        // file argues that twice already — `NotRun`'s doc comment ("an
1142        // infrastructure problem is not evidence about the key") and 20d/D-06
1143        // (an unavailable tool yields `Unknown`, never a hard-fail
1144        // `NotViable`). D-01's own justification for the ceiling names a
1145        // wedged `ssh-agent` and a stalled PKCS11 provider; both are
1146        // infrastructure, and neither says anything about whether the key
1147        // signs. A FIDO/`sk-` key is the concrete case: `ssh-keygen -Y sign`
1148        // waits for a physical touch, the prompt reaches nobody (stdio is
1149        // nulled and `setsid` dropped the terminal), and ten seconds later a
1150        // key that `git tag -s` signs with fine would have hard-failed a
1151        // release cut — the 999.86 defect class reintroduced by the
1152        // replacement.
1153        SignProbeOutcome::TimedOut => SigningViability::Unknown {
1154            reason: "cannot verify signing viability — the signing probe did not finish \
1155                     within its time limit"
1156                .into(),
1157        },
1158        SignProbeOutcome::ToolMissing => SigningViability::Unknown {
1159            reason: "cannot verify signing viability — ssh-keygen not found".into(),
1160        },
1161        SignProbeOutcome::NotRun => SigningViability::Unknown {
1162            reason: "cannot verify signing viability — the signing probe could not be run".into(),
1163        },
1164    }
1165}
1166
1167/// `gpg.format` unset or `"openpgp"` branch (Pattern 4): verify a secret
1168/// key exists for `user.signingkey` via `gpg --list-secret-keys`.
1169fn check_gpg_signing_viability(project_root: &Path) -> SigningViability {
1170    let Some(signingkey) = git_config(project_root, "user.signingkey") else {
1171        return SigningViability::Unknown {
1172            reason: "cannot verify signing viability — user.signingkey is not set".into(),
1173        };
1174    };
1175    let output = match Command::new("gpg")
1176        .args(["--list-secret-keys", &signingkey])
1177        .output()
1178    {
1179        Ok(out) => out,
1180        Err(_) => {
1181            return SigningViability::Unknown {
1182                reason: "cannot verify signing viability — gpg not found".into(),
1183            };
1184        }
1185    };
1186    if output.status.success() {
1187        SigningViability::Viable {
1188            fingerprint: Some(signingkey),
1189        }
1190    } else {
1191        SigningViability::NotViable {
1192            reason: "no secret key found for the configured user.signingkey".into(),
1193        }
1194    }
1195}
1196
1197/// Tag-signing viability check (20d): branches on `git config gpg.format`
1198/// since the check is a genuinely different code path per format — a
1199/// GPG-only check would miss the `ssh_askpass` failure this project's own
1200/// release actually hit (Pattern 4). Fail-soft throughout: an absent tool
1201/// or unset config degrades to an actionable [`SigningViability::Unknown`],
1202/// never a crash.
1203pub fn check_signing_viability(project_root: &Path) -> SigningViability {
1204    match git_config(project_root, "gpg.format").as_deref() {
1205        Some("ssh") => check_ssh_signing_viability(project_root),
1206        _ => check_gpg_signing_viability(project_root),
1207    }
1208}
1209
1210fn stderr_or_status(output: &std::process::Output) -> String {
1211    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1212    if stderr.is_empty() {
1213        format!("exited with {}", output.status)
1214    } else {
1215        stderr
1216    }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use tempfile::TempDir;
1223
1224    /// Run a git command in `root`, asserting success.
1225    fn git(root: &Path, args: &[&str]) {
1226        let output = crate::test_support::git_command(root)
1227            .args(args)
1228            .output()
1229            .expect("spawn git");
1230        assert!(
1231            output.status.success(),
1232            "git {args:?} failed: {}",
1233            String::from_utf8_lossy(&output.stderr)
1234        );
1235    }
1236
1237    fn current_branch(root: &Path) -> String {
1238        let output = crate::test_support::git_command(root)
1239            .args(["rev-parse", "--abbrev-ref", "HEAD"])
1240            .output()
1241            .expect("rev-parse");
1242        String::from_utf8_lossy(&output.stdout).trim().to_string()
1243    }
1244
1245    fn commit_file(root: &Path, name: &str) {
1246        std::fs::write(root.join(name), name).unwrap();
1247        git(root, &["add", "."]);
1248        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
1249    }
1250
1251    /// Initialize a repo with `main` and `develop` branches and one commit.
1252    fn init_repo() -> TempDir {
1253        let dir = tempfile::tempdir().unwrap();
1254        let root = dir.path();
1255        git(root, &["init", "-q"]);
1256        git(root, &["config", "user.email", "test@example.com"]);
1257        git(root, &["config", "user.name", "Test"]);
1258        git(root, &["config", "commit.gpgsign", "false"]);
1259        git(root, &["config", "tag.gpgsign", "false"]);
1260        // Disable any globally-configured hooks (e.g. gitleaks) for isolation.
1261        git(root, &["config", "core.hooksPath", "/dev/null"]);
1262        commit_file(root, "README.md");
1263        git(root, &["branch", "-M", "main"]);
1264        git(root, &["checkout", "-q", "-b", "develop"]);
1265        dir
1266    }
1267
1268    fn flow(root: &Path) -> GitFlow {
1269        GitFlow::new(root)
1270    }
1271
1272    #[test]
1273    fn feature_start_branches_from_develop() {
1274        let repo = init_repo();
1275        let root = repo.path();
1276        let branch = flow(root)
1277            .feature_start(PhaseId::new(3))
1278            .expect("feature_start");
1279        assert_eq!(branch, "feature/phase-03");
1280        assert_eq!(current_branch(root), "feature/phase-03");
1281    }
1282
1283    #[test]
1284    fn list_feature_branches_reports_ahead_and_behind_semantics() {
1285        let repo = init_repo();
1286        let root = repo.path();
1287        let gf = flow(root);
1288
1289        gf.feature_start(PhaseId::new(12)).expect("feature_start");
1290        commit_file(root, "feature-one.txt");
1291        commit_file(root, "feature-two.txt");
1292        git(root, &["checkout", "-q", "develop"]);
1293        commit_file(root, "develop-only.txt");
1294
1295        let branches = gf.list_feature_branches().unwrap();
1296        let branch = branches
1297            .iter()
1298            .find(|branch| branch.name == "feature/phase-12")
1299            .unwrap();
1300
1301        assert_eq!(branch.ahead, 2);
1302        assert_eq!(branch.behind, 1);
1303    }
1304
1305    #[test]
1306    fn feature_finish_merges_into_develop_and_deletes() {
1307        let repo = init_repo();
1308        let root = repo.path();
1309        let gf = flow(root);
1310
1311        gf.feature_start(PhaseId::new(1)).expect("start");
1312        commit_file(root, "feature.txt");
1313
1314        let branch = gf.feature_finish(PhaseId::new(1)).expect("finish");
1315        assert_eq!(branch, "feature/phase-01");
1316        assert_eq!(current_branch(root), "develop");
1317
1318        // Branch is deleted and its work is now on develop.
1319        let branches = crate::test_support::git_command(root)
1320            .args(["branch"])
1321            .output()
1322            .unwrap();
1323        let listing = String::from_utf8_lossy(&branches.stdout);
1324        assert!(!listing.contains("feature/phase-01"));
1325        assert!(root.join("feature.txt").exists());
1326    }
1327
1328    #[test]
1329    fn release_start_and_finish_tags_main_and_merges_both() {
1330        let repo = init_repo();
1331        let root = repo.path();
1332        let gf = flow(root);
1333
1334        // Add work on develop so the release has content.
1335        commit_file(root, "work.txt");
1336        let branch = gf.release_start("1.2.0").expect("release_start");
1337        assert_eq!(branch, "release/1.2.0");
1338
1339        gf.release_finish("1.2.0").expect("release_finish");
1340        assert_eq!(current_branch(root), "develop");
1341
1342        // Tag exists.
1343        let tags = crate::test_support::git_command(root)
1344            .args(["tag"])
1345            .output()
1346            .unwrap();
1347        assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
1348
1349        // Release branch deleted.
1350        let branches = crate::test_support::git_command(root)
1351            .args(["branch"])
1352            .output()
1353            .unwrap();
1354        assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
1355    }
1356
1357    /// A global/repo `tag.gpgsign=true` must not turn `tag()`'s lightweight
1358    /// tag into an annotated+signed one — that would require a tag message
1359    /// and block on `$EDITOR`, silently hanging a headless, unattended run
1360    /// (Phase 13 dogfood finding: VersionBump hung on a live
1361    /// `devflow start --mode auto` run because the operator's global
1362    /// gitconfig sets `tag.gpgsign=true`).
1363    #[test]
1364    fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
1365        let repo = init_repo();
1366        let root = repo.path();
1367        // Simulate an operator whose global config signs tags by default —
1368        // override the test harness's own `tag.gpgsign false` to prove
1369        // `tag()`'s per-invocation `-c` override wins regardless.
1370        git(root, &["config", "tag.gpgsign", "true"]);
1371
1372        flow(root)
1373            .tag("v9.9.9")
1374            .expect("tag must not block on $EDITOR");
1375
1376        let tags = crate::test_support::git_command(root)
1377            .args(["tag", "-l"])
1378            .output()
1379            .unwrap();
1380        assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
1381
1382        // Confirm it's a lightweight tag (points directly at the commit),
1383        // not an annotated tag object (which `cat-file -t` would report as
1384        // "tag" rather than "commit").
1385        let obj_type = crate::test_support::git_command(root)
1386            .args(["cat-file", "-t", "v9.9.9"])
1387            .output()
1388            .unwrap();
1389        assert_eq!(
1390            String::from_utf8_lossy(&obj_type.stdout).trim(),
1391            "commit",
1392            "tag() must stay lightweight even when tag.gpgsign=true"
1393        );
1394    }
1395
1396    #[test]
1397    fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
1398        // The property that distinguishes commit_path from commit_all
1399        // (17-12, Task 2b): a hook using commit_path must never sweep in
1400        // unrelated dirty state.
1401        let repo = init_repo();
1402        let root = repo.path();
1403        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1404        std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
1405
1406        // Stage the unrelated file BEFORE calling commit_path. An untracked
1407        // file is excluded by any implementation and so proves nothing; an
1408        // already-staged one is the real failure mode — a bare `git commit`
1409        // writes the whole index and would sweep it in.
1410        crate::test_support::git_command(root)
1411            .args(["add", "unrelated.txt"])
1412            .status()
1413            .unwrap();
1414
1415        flow(root)
1416            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1417            .expect("commit_path");
1418
1419        let committed = crate::test_support::git_command(root)
1420            .args(["log", "-1", "--name-only", "--pretty=format:"])
1421            .output()
1422            .unwrap();
1423        let committed_files = String::from_utf8_lossy(&committed.stdout);
1424        assert!(committed_files.contains("CHANGELOG.md"));
1425        assert!(!committed_files.contains("unrelated.txt"));
1426
1427        let status = crate::test_support::git_command(root)
1428            .args(["status", "--porcelain"])
1429            .output()
1430            .unwrap();
1431        let status = String::from_utf8_lossy(&status.stdout);
1432        assert!(
1433            status.contains("A  unrelated.txt"),
1434            "unrelated.txt must remain staged-but-uncommitted, got: {status}"
1435        );
1436    }
1437
1438    /// `git rev-list --count HEAD`, parsed. Shared by the three tests below
1439    /// so a failure reports both counts instead of a bare assertion.
1440    fn rev_list_count(root: &Path) -> u32 {
1441        let output = crate::test_support::git_command(root)
1442            .args(["rev-list", "--count", "HEAD"])
1443            .output()
1444            .unwrap();
1445        assert!(output.status.success(), "git rev-list --count HEAD failed");
1446        String::from_utf8_lossy(&output.stdout)
1447            .trim()
1448            .parse::<u32>()
1449            .expect("rev-list --count HEAD must print an integer")
1450    }
1451
1452    /// 19b/D-16: `hooks::version_bump` (hooks.rs:242) calls `commit_path` and
1453    /// then tags whatever commit it last produced (hooks.rs:249). If a
1454    /// terminal-batch retry calls `commit_path` again with byte-identical
1455    /// content (the file untouched since the first call), a forced commit
1456    /// here means the release tag can end up naming a commit that contains
1457    /// nothing new. This pins the exact retry scenario: two calls, unchanged
1458    /// content, `git rev-list --count HEAD` must not move between them.
1459    #[test]
1460    fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1461        let repo = init_repo();
1462        let root = repo.path();
1463        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1464
1465        flow(root)
1466            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1467            .expect("first commit_path call");
1468        let n1 = rev_list_count(root);
1469
1470        // The file is not touched again -- this is the retry scenario, not
1471        // a second genuine change.
1472        flow(root)
1473            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1474            .expect("second commit_path call");
1475        let n2 = rev_list_count(root);
1476
1477        assert_eq!(
1478            n2, n1,
1479            "a repeat commit_path call on unchanged content must not add a \
1480             commit: n1={n1}, n2={n2}"
1481        );
1482    }
1483
1484    /// 19b/D-16, T-19-11: separates the "no commit" claim from the "no
1485    /// error" claim so a future change can't satisfy one by breaking the
1486    /// other. `hooks.rs` propagates `commit_path`'s `Result` with `?` at both
1487    /// call sites (changelog_append:225, version_bump:242) -- turning a
1488    /// genuine no-op into `Err` would stall the terminal hook batch (see
1489    /// T-19-11 in this plan's threat model), so both properties must hold
1490    /// simultaneously.
1491    #[test]
1492    fn commit_path_with_no_changes_returns_ok_without_committing() {
1493        let repo = init_repo();
1494        let root = repo.path();
1495        std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1496        flow(root)
1497            .commit_path("CHANGELOG.md", "docs: add changelog entry")
1498            .expect("initial commit_path");
1499        let n1 = rev_list_count(root);
1500
1501        // CHANGELOG.md is already committed and unmodified -- a single call
1502        // here has nothing to commit.
1503        let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1504        let n2 = rev_list_count(root);
1505
1506        assert!(
1507            result.is_ok(),
1508            "no-op call must return Ok(()), got: {result:?}"
1509        );
1510        assert_eq!(
1511            n2, n1,
1512            "no-op call must not create a commit: n1={n1}, n2={n2}"
1513        );
1514    }
1515
1516    /// Edge case the fix must NOT change: `commit_path` on a path that does
1517    /// not exist on disk still errors at the staging step (`git add` fails
1518    /// on an unknown pathspec). Asserted explicitly so the fix for the
1519    /// no-change case above cannot be over-applied into "commit_path never
1520    /// fails".
1521    #[test]
1522    fn commit_path_on_nonexistent_path_still_errors() {
1523        let repo = init_repo();
1524        let root = repo.path();
1525
1526        let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1527
1528        assert!(
1529            result.is_err(),
1530            "commit_path on an unknown pathspec must still error, got: {result:?}"
1531        );
1532    }
1533
1534    #[test]
1535    fn release_start_branches_from_current_head_not_develop() {
1536        let repo = init_repo();
1537        let root = repo.path();
1538        let gf = flow(root);
1539
1540        // Ship from a feature branch carrying a commit that is NOT on develop.
1541        gf.feature_start(PhaseId::new(5)).expect("feature_start");
1542        commit_file(root, "feature-only.txt");
1543        let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1544
1545        let branch = gf.release_start("2.0.0").expect("release_start");
1546        assert_eq!(branch, "release/2.0.0");
1547        assert_eq!(current_branch(root), "release/2.0.0");
1548
1549        // The release branch tip must descend from the feature commit — i.e.
1550        // the feature-only work is present, not dropped to develop's HEAD.
1551        let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1552        let is_ancestor = crate::test_support::git_command(root)
1553            .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1554            .output()
1555            .unwrap()
1556            .status
1557            .success();
1558        assert!(
1559            is_ancestor,
1560            "release branch must descend from the shipped feature commit"
1561        );
1562        assert!(root.join("feature-only.txt").exists());
1563    }
1564
1565    #[test]
1566    fn cleanup_merged_removes_merged_but_keeps_protected() {
1567        let repo = init_repo();
1568        let root = repo.path();
1569        let gf = flow(root);
1570
1571        // Create and merge a feature branch into develop.
1572        gf.feature_start(PhaseId::new(2)).expect("start");
1573        commit_file(root, "f.txt");
1574        gf.feature_finish(PhaseId::new(2)).expect("finish");
1575
1576        // Create an already-merged stray branch off develop.
1577        git(root, &["branch", "stale-merged"]);
1578
1579        let deleted = gf.cleanup_merged().expect("cleanup");
1580        assert!(deleted.contains(&"stale-merged".to_string()));
1581        // Protected branches survive.
1582        assert!(!deleted.contains(&"develop".to_string()));
1583        assert!(!deleted.contains(&"main".to_string()));
1584    }
1585
1586    /// WR-04 (13-REVIEW.md): `cleanup_merged` must compute "merged" relative
1587    /// to `develop` explicitly, not whatever the main checkout's current
1588    /// HEAD happens to be. If the main checkout is left on a divergent
1589    /// branch, an implicit-HEAD baseline would wrongly identify (and
1590    /// delete) a branch that's merged into that other branch but was never
1591    /// actually merged into `develop`.
1592    #[test]
1593    fn cleanup_merged_is_relative_to_develop_not_current_head() {
1594        let repo = init_repo();
1595        let root = repo.path();
1596        let gf = flow(root);
1597
1598        // `topic` diverges from develop with a unique commit develop never
1599        // sees, then `premature` branches off `topic`'s tip — so
1600        // `premature` is merged into `topic` but NOT into `develop`.
1601        git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1602        commit_file(root, "topic-only.txt");
1603        git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1604
1605        // Leave the main checkout on `topic` — NOT `develop` — before
1606        // calling cleanup_merged, mirroring an operator who forgot to
1607        // check out develop first. (`topic` itself is also technically
1608        // "merged into HEAD" under an implicit baseline since it IS HEAD,
1609        // which git's own `-d` correctly refuses as the checked-out branch
1610        // — so the call's overall Ok/Err is not itself decisive here; check
1611        // the actual side effect on `premature` instead.)
1612        git(root, &["checkout", "-q", "topic"]);
1613
1614        let _ = gf.cleanup_merged();
1615        assert!(
1616            gf.branch_exists("premature"),
1617            "premature is merged into topic (current HEAD) but not into \
1618             develop — it must survive cleanup_merged when the baseline is develop"
1619        );
1620    }
1621
1622    /// WR-03 (13-REVIEW.md), revised: `git branch --merged` prefixes a
1623    /// branch checked out in a linked worktree with `+ `. The prefix must be
1624    /// stripped positionally (not by trimming marker characters, which would
1625    /// mangle a branch legitimately named "+foo"), and a branch git refuses
1626    /// to delete — a worktree checkout can never be deleted, by design —
1627    /// must be skipped with a warning rather than aborting the sweep before
1628    /// the remaining merged branches.
1629    #[test]
1630    fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1631        let repo = init_repo();
1632        let root = repo.path();
1633        let gf = flow(root);
1634
1635        // Merge a branch into develop WITHOUT deleting it (feature_finish
1636        // deletes on merge, which would leave nothing to check out).
1637        git(
1638            root,
1639            &["checkout", "-q", "-b", "worktree-merged", "develop"],
1640        );
1641        commit_file(root, "g.txt");
1642        git(root, &["checkout", "-q", "develop"]);
1643        git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1644
1645        // Check the merged branch out in a linked worktree so
1646        // `git branch --merged` reports it with a `+ ` prefix.
1647        let wt_dir = tempfile::tempdir().unwrap();
1648        git(
1649            root,
1650            &[
1651                "worktree",
1652                "add",
1653                wt_dir.path().to_str().unwrap(),
1654                "worktree-merged",
1655            ],
1656        );
1657
1658        // A second merged branch that sorts after "worktree-merged" would be
1659        // reached only if the sweep survives the worktree refusal; "zz-" also
1660        // guards against luck in iteration order via the branch before it.
1661        git(root, &["branch", "aa-stale"]);
1662        git(root, &["branch", "zz-stale"]);
1663
1664        let deleted = gf
1665            .cleanup_merged()
1666            .expect("a skipped worktree branch must not abort the sweep");
1667        assert!(deleted.contains(&"aa-stale".to_string()));
1668        assert!(deleted.contains(&"zz-stale".to_string()));
1669        assert!(
1670            !deleted.contains(&"worktree-merged".to_string()),
1671            "worktree checkout cannot be deleted"
1672        );
1673        assert!(gf.branch_exists("worktree-merged"));
1674    }
1675
1676    /// The delete side must agree with the `--merged develop` listing: `-d`
1677    /// verifies merged-into-HEAD, so with the main checkout parked on a
1678    /// stale branch every genuinely-merged branch was refused as "not fully
1679    /// merged" — in exactly the scenario WR-04 exists for.
1680    #[test]
1681    fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1682        let repo = init_repo();
1683        let root = repo.path();
1684        let gf = flow(root);
1685
1686        // `old` is parked before the merge below, so nothing merged later is
1687        // reachable from HEAD while it's checked out.
1688        git(root, &["checkout", "-q", "-b", "old", "develop"]);
1689        git(root, &["checkout", "-q", "develop"]);
1690        git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1691        commit_file(root, "h.txt");
1692        git(root, &["checkout", "-q", "develop"]);
1693        git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1694        git(root, &["checkout", "-q", "old"]);
1695
1696        let deleted = gf.cleanup_merged().expect("cleanup");
1697        assert!(
1698            deleted.contains(&"merged-feature".to_string()),
1699            "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1700        );
1701        assert!(!gf.branch_exists("merged-feature"));
1702    }
1703
1704    #[test]
1705    fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1706        let repo = init_repo();
1707        let root = repo.path();
1708        let gf = flow(root);
1709
1710        // Create a feature branch with an unmerged commit.
1711        gf.feature_start(PhaseId::new(8)).expect("start");
1712        commit_file(root, "unmerged.txt");
1713        // Switch back to develop so the branch isn't checked out.
1714        git(root, &["checkout", "-q", "develop"]);
1715
1716        // -d would refuse (unmerged); force deletes it.
1717        assert!(gf.delete_branch("feature/phase-08", false).is_err());
1718        gf.delete_branch("feature/phase-08", true)
1719            .expect("force delete");
1720        let branches = crate::test_support::git_command(root)
1721            .args(["branch"])
1722            .output()
1723            .unwrap();
1724        assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1725
1726        // Protected branches are never deleted.
1727        assert!(gf.delete_branch("develop", true).is_err());
1728        assert!(gf.delete_branch("main", true).is_err());
1729    }
1730
1731    #[test]
1732    fn merge_of_missing_branch_is_an_error() {
1733        let repo = init_repo();
1734        let root = repo.path();
1735        // feature_finish for a phase that was never started: checkout develop
1736        // succeeds, but merging the nonexistent feature branch fails.
1737        let err = flow(root).feature_finish(PhaseId::new(99)).unwrap_err();
1738        assert!(matches!(err, GitError::Command(_)));
1739    }
1740
1741    // -----------------------------------------------------------------
1742    // 20d: publish-order helpers (pure, no I/O)
1743    // -----------------------------------------------------------------
1744
1745    #[test]
1746    fn workspace_member_paths_parses_multiline_array() {
1747        let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n";
1748        assert_eq!(
1749            workspace_member_paths(contents),
1750            vec![
1751                "crates/devflow-core".to_string(),
1752                "crates/devflow-cli".to_string()
1753            ]
1754        );
1755    }
1756
1757    #[test]
1758    fn package_name_reads_the_package_section() {
1759        let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1760        assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1761    }
1762
1763    #[test]
1764    fn member_depends_on_matches_dotted_workspace_shorthand() {
1765        let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1766        assert!(member_depends_on(contents, "devflow-core"));
1767        assert!(!member_depends_on(contents, "serde"));
1768    }
1769
1770    /// WR-03 (phase 20 review): the equally-valid expanded long-form TOML
1771    /// section syntax (`[dependencies.NAME]`) parses to a section header of
1772    /// `"dependencies.NAME"`, never equal to the plain `"dependencies"` the
1773    /// inline-table branch checks against — this must still be recognized
1774    /// as a dependency edge.
1775    #[test]
1776    fn member_depends_on_matches_long_form_dependency_section() {
1777        let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1778        assert!(member_depends_on(contents, "devflow-core"));
1779        assert!(member_depends_on(contents, "clap"));
1780        assert!(!member_depends_on(contents, "serde"));
1781    }
1782
1783    #[test]
1784    fn topo_sort_orders_dependency_before_dependent() {
1785        let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1786        let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1787        assert_eq!(
1788            topo_sort(names, edges),
1789            vec!["devflow-core".to_string(), "devflow".to_string()]
1790        );
1791    }
1792
1793    #[test]
1794    fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1795        // A genuine cyclic dependency would already fail `cargo build`
1796        // long before this check runs — this just proves no infinite loop.
1797        let names = vec!["a".to_string(), "b".to_string()];
1798        let edges = vec![
1799            ("a".to_string(), "b".to_string()),
1800            ("b".to_string(), "a".to_string()),
1801        ];
1802        let result = topo_sort(names, edges);
1803        assert_eq!(result.len(), 2);
1804    }
1805
1806    #[test]
1807    fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1808        let dir = tempfile::tempdir().unwrap();
1809        let root = dir.path();
1810        std::fs::write(
1811            root.join("Cargo.toml"),
1812            "[workspace]\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n",
1813        )
1814        .unwrap();
1815        std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1816        std::fs::write(
1817            root.join("crates/devflow-core/Cargo.toml"),
1818            "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1819        )
1820        .unwrap();
1821        std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1822        std::fs::write(
1823            root.join("crates/devflow-cli/Cargo.toml"),
1824            "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1825        )
1826        .unwrap();
1827
1828        assert_eq!(
1829            publish_order(root),
1830            vec!["devflow-core".to_string(), "devflow".to_string()]
1831        );
1832    }
1833
1834    /// WR-03 (phase 20 review): a workspace member manifest written with
1835    /// the long-form `[dependencies.devflow-core]` section (rather than the
1836    /// inline `[dependencies]\ndevflow-core.workspace = true` form) must
1837    /// still contribute its dependency edge to `publish_order`'s topo-sort
1838    /// — the release-safety-critical crates.io publish order this
1839    /// self-pin regression would otherwise silently get wrong.
1840    #[test]
1841    fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1842        let dir = tempfile::tempdir().unwrap();
1843        let root = dir.path();
1844        std::fs::write(
1845            root.join("Cargo.toml"),
1846            "[workspace]\nmembers = [\n    \"crates/devflow-core\",\n    \"crates/devflow-cli\",\n]\n",
1847        )
1848        .unwrap();
1849        std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1850        std::fs::write(
1851            root.join("crates/devflow-core/Cargo.toml"),
1852            "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1853        )
1854        .unwrap();
1855        std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1856        std::fs::write(
1857            root.join("crates/devflow-cli/Cargo.toml"),
1858            "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1859        )
1860        .unwrap();
1861
1862        assert_eq!(
1863            publish_order(root),
1864            vec!["devflow-core".to_string(), "devflow".to_string()],
1865            "the long-form dependency section must still order devflow-core before devflow"
1866        );
1867    }
1868
1869    // -----------------------------------------------------------------
1870    // 20d: origin/main ancestor check (no fetch)
1871    // -----------------------------------------------------------------
1872
1873    #[test]
1874    fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1875        let repo = init_repo();
1876        let root = repo.path();
1877        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1878    }
1879
1880    #[test]
1881    fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1882        let repo = init_repo();
1883        let root = repo.path();
1884        let head = crate::test_support::git_command(root)
1885            .args(["rev-parse", "HEAD"])
1886            .output()
1887            .unwrap();
1888        let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1889        git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1890        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1891    }
1892
1893    // -----------------------------------------------------------------
1894    // 27-01 (D-03): the scrubbing constructor holds under a hostile GIT_DIR
1895    // -----------------------------------------------------------------
1896
1897    /// D-03: a real spawned `git` process built through the constructor
1898    /// resolves the caller-supplied root even when `GIT_DIR` points at an
1899    /// unrelated repository — proven by a subprocess test, not by
1900    /// inspecting the `Command` object alone.
1901    #[test]
1902    fn hermetic_command_resolves_caller_root_even_under_a_hostile_git_dir() {
1903        let real_repo = init_repo();
1904        let real_root = real_repo.path();
1905
1906        let foreign_repo = TempDir::new().unwrap();
1907        git(foreign_repo.path(), &["init", "-q"]);
1908
1909        let output = git_command(real_root)
1910            .args(["rev-parse", "--show-toplevel"])
1911            // Hostile injection chained AFTER the constructor — the
1912            // strongest form of the claim: `--show-toplevel` must still
1913            // resolve `real_root`, not `foreign_repo`.
1914            .env("GIT_DIR", foreign_repo.path().join(".git"))
1915            .output()
1916            .expect("spawn git");
1917        assert!(
1918            output.status.success(),
1919            "rev-parse --show-toplevel failed: {}",
1920            String::from_utf8_lossy(&output.stderr)
1921        );
1922
1923        let resolved = std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim())
1924            .expect("canonicalize resolved toplevel");
1925        let expected = std::fs::canonicalize(real_root).expect("canonicalize real_root");
1926        assert_eq!(
1927            resolved, expected,
1928            "hermetic_command must resolve real_root even with a foreign GIT_DIR set"
1929        );
1930    }
1931
1932    /// D-03: `origin_main_ancestor_status` produces the correct answer
1933    /// under a hostile `GIT_DIR` where it previously did not. Setting a
1934    /// process-global env var is forbidden (Rust 2024 `unsafe`, unsound
1935    /// under threaded tests — Phase 25 D-14), so this proves the property
1936    /// the way the constructor guarantees it, in two parts: (a) the
1937    /// `Command` this code path builds via `git_command` is
1938    /// unconditionally scrubbed — no bypass parameter, no env-var check,
1939    /// no config lookup (D-01), asserted directly on the built `Command`;
1940    /// (b) the actual mechanism `origin_main_ancestor_status` now depends
1941    /// on — scrubbed, with nothing in production code re-adding `GIT_DIR`
1942    /// afterward — reaches the correct answer for a real spawn. (A literal
1943    /// unscrubbed `Command::new("git")` reproduction chaining a hostile
1944    /// `.env("GIT_DIR", foreign)` on top was deliberately NOT added here:
1945    /// verified empirically against this machine's git 2.55.0 that doing
1946    /// so genuinely redirects `merge-base --is-ancestor`'s ref resolution
1947    /// to the foreign repo — unlike `--show-toplevel` above, which falls
1948    /// back to cwd when `GIT_WORK_TREE` is unset — so re-adding it here
1949    /// would both prove nothing new beyond (a) and inflate git.rs's
1950    /// unscrubbed-call-site count past the 7 sites this task deliberately
1951    /// leaves for 27-02.)
1952    #[test]
1953    fn origin_main_ancestor_status_holds_under_a_hostile_git_dir() {
1954        let repo = init_repo();
1955        let root = repo.path();
1956        let head = crate::test_support::git_command(root)
1957            .args(["rev-parse", "HEAD"])
1958            .output()
1959            .unwrap();
1960        let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1961        git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1962
1963        // (a) unconditionally scrubbed.
1964        let cmd = git_command(root);
1965        assert!(
1966            cmd.get_envs()
1967                .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
1968            "origin_main_ancestor_status's own Command must mark GIT_DIR for removal"
1969        );
1970
1971        // (b) the actual, scrubbed mechanism reaches the correct answer.
1972        assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1973    }
1974
1975    // -----------------------------------------------------------------
1976    // 27-01: hermetic git command construction (moved from test_support,
1977    // now the canonical, always-compiled home — 999.37/999.39/27-01)
1978    // -----------------------------------------------------------------
1979
1980    /// The contract callers depend on, asserted on the built command rather
1981    /// than inferred: every redirecting variable is marked for removal.
1982    #[test]
1983    fn git_command_marks_every_redirecting_var_for_removal() {
1984        let cmd = git_command(Path::new("/tmp"));
1985        let removed: Vec<&str> = cmd
1986            .get_envs()
1987            .filter(|(_, value)| value.is_none())
1988            .filter_map(|(key, _)| key.to_str())
1989            .collect();
1990
1991        for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
1992            assert!(
1993                removed.contains(var),
1994                "{var} is not cleared by git_command — a fixture inheriting it \
1995                 would operate on that repository instead of its tempdir"
1996            );
1997        }
1998    }
1999
2000    /// GIT_EXEC_PATH must survive: clearing it can break git's own helper
2001    /// lookup on installations that rely on it, and it cannot redirect
2002    /// repository resolution.
2003    #[test]
2004    fn git_command_preserves_git_exec_path() {
2005        let cmd = git_command(Path::new("/tmp"));
2006        assert!(
2007            !cmd.get_envs()
2008                .any(|(key, value)| key == "GIT_EXEC_PATH" && value.is_none()),
2009            "GIT_EXEC_PATH must not be cleared"
2010        );
2011    }
2012
2013    /// Guards the hard-coded list against a git upgrade that adds a
2014    /// repository-local variable. If this fails, add the new name to
2015    /// `REPO_LOCAL_GIT_VARS` — do not delete the assertion.
2016    #[test]
2017    fn local_env_vars_match_git() {
2018        let output = git_command(Path::new("/tmp"))
2019            .args(["rev-parse", "--local-env-vars"])
2020            .output()
2021            .expect("run `git rev-parse --local-env-vars`");
2022        assert!(
2023            output.status.success(),
2024            "`git rev-parse --local-env-vars` failed"
2025        );
2026
2027        let mut from_git: Vec<String> = String::from_utf8_lossy(&output.stdout)
2028            .lines()
2029            .map(str::trim)
2030            .filter(|line| !line.is_empty())
2031            .map(str::to_string)
2032            .collect();
2033        let mut ours: Vec<String> = REPO_LOCAL_GIT_VARS
2034            .iter()
2035            .map(|v| (*v).to_string())
2036            .collect();
2037        from_git.sort();
2038        ours.sort();
2039
2040        assert_eq!(
2041            ours, from_git,
2042            "REPO_LOCAL_GIT_VARS has drifted from `git rev-parse --local-env-vars`"
2043        );
2044    }
2045
2046    // -----------------------------------------------------------------
2047    // 20d: signing-viability helpers
2048    // -----------------------------------------------------------------
2049
2050    /// Guards tests that temporarily override the process-global `HOME`
2051    /// env var (same idiom as `config.rs`'s test-local `ENV_MUTEX`) — this
2052    /// project's own dev machine sets `gpg.format=ssh` / `user.signingkey`
2053    /// GLOBALLY (the exact Pattern 4 research finding), so a hermetic test
2054    /// of the "unset" branch must isolate `$HOME/.gitconfig`, not just the
2055    /// repo-local config.
2056    static HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2057
2058    #[test]
2059    fn check_signing_viability_degrades_when_gpg_format_unset_and_no_signingkey() {
2060        // 20d/empty: no gpg.format, no user.signingkey — must degrade to an
2061        // actionable message, never panic.
2062        let _lock = HOME_ENV_MUTEX.lock().unwrap();
2063        let repo = init_repo();
2064        let root = repo.path();
2065        let fake_home = tempfile::tempdir().unwrap();
2066        let original_home = std::env::var_os("HOME");
2067        // SAFETY: serialized under HOME_ENV_MUTEX; restored below before
2068        // the guard drops.
2069        unsafe { std::env::set_var("HOME", fake_home.path()) };
2070
2071        let result = check_signing_viability(root);
2072
2073        // SAFETY: still serialized under HOME_ENV_MUTEX.
2074        match original_home {
2075            Some(home) => unsafe { std::env::set_var("HOME", home) },
2076            None => unsafe { std::env::remove_var("HOME") },
2077        }
2078
2079        match result {
2080            SigningViability::Unknown { reason } => {
2081                assert!(
2082                    reason.contains("user.signingkey"),
2083                    "unexpected reason: {reason}"
2084                );
2085            }
2086            other => panic!("expected Unknown (fail-soft), got: {other:?}"),
2087        }
2088    }
2089
2090    /// D-01/D-02/D-10: an inline `user.signingkey` value — either the
2091    /// `key::`-prefixed form or the raw deprecated `ssh-` compat form — must
2092    /// never be classified as a missing filesystem path. Git never stats an
2093    /// inline value, so this must never return the missing-key-file
2094    /// `NotViable`.
2095    ///
2096    /// This test deliberately keeps its narrow assertion — only that the
2097    /// missing-file reason is absent. It used to be narrow because the
2098    /// outcome depended on the host's ssh-agent state (D-10); it is narrow
2099    /// now because that is the single property it exists to guard, and
2100    /// `inline_signing_key_returns_unknown_without_probing` pins the exact
2101    /// arm. Leaving the assertion here narrow keeps one falsifier per test.
2102    #[test]
2103    fn check_signing_viability_never_reports_key_file_missing_for_inline_key() {
2104        const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
2105        let inline_values = [
2106            "key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
2107            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
2108        ];
2109        for value in inline_values {
2110            let repo = init_repo();
2111            let root = repo.path();
2112            git(root, &["config", "gpg.format", "ssh"]);
2113            git(root, &["config", "user.signingkey", value]);
2114
2115            let result = check_signing_viability(root);
2116
2117            if let SigningViability::NotViable { reason } = &result {
2118                assert_ne!(
2119                    reason, MISSING_FILE_REASON,
2120                    "inline signingkey value {value:?} incorrectly classified as a \
2121                     missing file: {result:?}"
2122                );
2123            }
2124        }
2125    }
2126
2127    /// D-01/D-02/D-03: a flat table over the pure classifier proving git's
2128    /// own prefix precedence — `key::` strip first, then the raw `ssh-`
2129    /// compat form, else a path. Non-`ssh-` algorithms (`ecdsa-`, `sk-`)
2130    /// reach the inline branch ONLY through `key::` (D-03) — a bare form of
2131    /// either is a path, matching git.
2132    #[test]
2133    fn inline_signing_key_blob_follows_git_prefix_precedence() {
2134        assert_eq!(
2135            inline_signing_key_blob("key::ssh-rsa AAAAB3 id"),
2136            Some("ssh-rsa AAAAB3 id")
2137        );
2138        assert_eq!(
2139            inline_signing_key_blob("key::ssh-ed25519 AAAAC3 id"),
2140            Some("ssh-ed25519 AAAAC3 id")
2141        );
2142        assert_eq!(
2143            inline_signing_key_blob("key::ecdsa-sha2-nistp256 AAAAE2 id"),
2144            Some("ecdsa-sha2-nistp256 AAAAE2 id")
2145        );
2146        assert_eq!(inline_signing_key_blob("key::"), Some(""));
2147        assert_eq!(
2148            inline_signing_key_blob("ssh-ed25519 AAAAC3 id"),
2149            Some("ssh-ed25519 AAAAC3 id")
2150        );
2151        assert_eq!(
2152            inline_signing_key_blob("  key::ssh-ed25519 AAAAC3 id  "),
2153            Some("ssh-ed25519 AAAAC3 id")
2154        );
2155        // D-02: a value that plausibly names an existing file is STILL
2156        // inline, because the classifier never stats it.
2157        assert_eq!(inline_signing_key_blob("ssh-key.pub"), Some("ssh-key.pub"));
2158        assert_eq!(
2159            inline_signing_key_blob("/home/operator/.ssh/id_ed25519.pub"),
2160            None
2161        );
2162        // D-03: bare, no `key::` prefix, so git treats these as paths and so
2163        // must DevFlow.
2164        assert_eq!(
2165            inline_signing_key_blob("ecdsa-sha2-nistp256 AAAAE2 id"),
2166            None
2167        );
2168        assert_eq!(
2169            inline_signing_key_blob("sk-ssh-ed25519@openssh.com AAAAG id"),
2170            None
2171        );
2172        assert_eq!(inline_signing_key_blob("ABCD1234"), None);
2173    }
2174
2175    /// D-03/D-12: values that neither start with `key::` nor `ssh-` still
2176    /// take the path branch and keep today's byte-for-byte behavior — the
2177    /// early `.exists()` return, which still fires before anything is
2178    /// spawned. What follows that early return is now the signing probe
2179    /// rather than the deleted `ssh-add` predictor; the guarantee under
2180    /// test is that a missing file is answered without reaching it at all.
2181    /// This is the D-03 falsifier: bare `ecdsa-`/`sk-` forms must NOT be
2182    /// treated as inline.
2183    #[test]
2184    fn check_signing_viability_still_reports_missing_file_for_a_path_value() {
2185        const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
2186        let path_values = [
2187            "/nonexistent/path/to/a/signing/key/that/does/not/exist",
2188            "ecdsa-sha2-nistp256 AAAAE2 devflow-fixture",
2189            "sk-ssh-ed25519@openssh.com AAAAG devflow-fixture",
2190        ];
2191        for value in path_values {
2192            let repo = init_repo();
2193            let root = repo.path();
2194            git(root, &["config", "gpg.format", "ssh"]);
2195            git(root, &["config", "user.signingkey", value]);
2196
2197            let result = check_signing_viability(root);
2198
2199            assert_eq!(
2200                result,
2201                SigningViability::NotViable {
2202                    reason: MISSING_FILE_REASON.to_string(),
2203                },
2204                "value {value:?} did not take the path branch: {result:?}"
2205            );
2206        }
2207    }
2208
2209    /// D-06: every inline-branch failure mode must degrade to `Unknown`,
2210    /// never a NEW hard fail introduced by this phase. That guarantee is
2211    /// what this test preserves; only the reasons it accepts changed.
2212    ///
2213    /// It used to pin a two-reason set built from the predictor's no-agent
2214    /// and agent-empty strings, neither of which the code can produce any
2215    /// more — a test that referenced the deleted mechanism through its
2216    /// output rather than its symbols, so nothing but a run would have
2217    /// caught it. Under D-03 an unparseable inline value classifies as
2218    /// inline and returns the single fixed inline reason without being
2219    /// probed at all.
2220    ///
2221    /// Its agent-independence note survives, now true by construction
2222    /// rather than by argument: these values never reach a probe, so no
2223    /// host's agent state can reach this result.
2224    #[test]
2225    fn check_signing_viability_never_hard_fails_on_an_unparseable_inline_key() {
2226        const INLINE_REASON: &str =
2227            "cannot verify signing viability — an inline user.signingkey is not probed";
2228        let unparseable_values = ["key::", "key::this is not a key at all"];
2229        for value in unparseable_values {
2230            let repo = init_repo();
2231            let root = repo.path();
2232            git(root, &["config", "gpg.format", "ssh"]);
2233            git(root, &["config", "user.signingkey", value]);
2234
2235            let result = check_signing_viability(root);
2236
2237            assert_eq!(
2238                result,
2239                SigningViability::Unknown {
2240                    reason: INLINE_REASON.into(),
2241                },
2242                "value {value:?} produced an unexpected hard fail: {result:?}"
2243            );
2244        }
2245    }
2246
2247    // -----------------------------------------------------------------
2248    // 35-03: the `ssh-keygen -Y sign` probe
2249    // -----------------------------------------------------------------
2250
2251    /// F-8: the probe workspace name must be unique per CALL, not per
2252    /// process. `cargo test` runs tests as parallel THREADS inside a single
2253    /// process, so a name derived from the process id is shared by every
2254    /// concurrent probe: two probes collide, the loser's non-recursive
2255    /// `create_dir` fails with an already-exists error, and it fails soft to
2256    /// `Unknown` — a flaky test whose failure points at the probe rather
2257    /// than at the harness.
2258    ///
2259    /// The two-thread half is load-bearing. The single-thread half alone
2260    /// passes against a name built from the process id plus a thread id,
2261    /// which is the near-miss fix this test exists to reject.
2262    #[test]
2263    fn probe_workspace_name_is_unique_per_call() {
2264        let first = probe_workspace_name();
2265        let second = probe_workspace_name();
2266        assert_ne!(
2267            first, second,
2268            "two successive calls on one thread produced the same probe workspace name"
2269        );
2270
2271        const PER_THREAD: usize = 64;
2272        let handles: Vec<_> = (0..2)
2273            .map(|_| {
2274                std::thread::spawn(|| {
2275                    (0..PER_THREAD)
2276                        .map(|_| probe_workspace_name())
2277                        .collect::<Vec<_>>()
2278                })
2279            })
2280            .collect();
2281        let mut names: Vec<String> = handles
2282            .into_iter()
2283            .flat_map(|handle| handle.join().expect("probe-name thread panicked"))
2284            .collect();
2285        let total = names.len();
2286        assert_eq!(total, 2 * PER_THREAD, "fixture did not produce every name");
2287        names.sort();
2288        names.dedup();
2289        assert_eq!(
2290            names.len(),
2291            total,
2292            "two concurrently spawned threads produced duplicate probe workspace names"
2293        );
2294    }
2295
2296    /// WR-07 (35-REVIEW): "creates a **private** workspace" must be
2297    /// implemented, not merely claimed. `std::fs::create_dir` applies
2298    /// `0o777 & !umask` — typically 0o755 — inside a shared
2299    /// `std::env::temp_dir()`, leaving the directory world-readable and
2300    /// world-traversable. Nothing secret lands in it, so this was never an
2301    /// exposure of key material; the hazard is a future author extending the
2302    /// probe on the strength of the comment.
2303    ///
2304    /// **The umask is neutralized, and that is the whole measurement.** Asserted
2305    /// naively this test is VACUOUS on any host whose umask is already 0o077 —
2306    /// `0o777 & !0o077` is 0o700, so a plain `std::fs::create_dir` produces the
2307    /// expected mode and the assertion passes against the unfixed code. That was
2308    /// observed here, not reasoned about: the first version of this test passed
2309    /// with the `DirBuilderExt::mode` call removed. Setting the umask to 0 makes
2310    /// a plain creation 0o777, so the two really do differ, and the sibling
2311    /// created that way is the negative control.
2312    ///
2313    /// The window spans two `create_dir` calls with no I/O between them. A
2314    /// concurrent test creating a file inside it would get a laxer mode than
2315    /// usual — every such file is a tempdir artifact in a test process, so there
2316    /// is no consequence beyond the mode bits themselves.
2317    ///
2318    /// The non-recursive refusal is asserted here too: `create_dir_all` would
2319    /// accept a pre-planted directory or symlink silently and redirect where the
2320    /// payload is written (T-35-12).
2321    #[test]
2322    fn the_probe_workspace_is_owner_only_and_refuses_an_existing_path() {
2323        use std::os::unix::fs::PermissionsExt;
2324
2325        let dir = tempfile::tempdir().unwrap();
2326        let workspace = dir.path().join("probe");
2327        let plain = dir.path().join("plain");
2328
2329        // SAFETY: `umask` is a plain syscall with no preconditions. Restored
2330        // immediately below, before any assertion can unwind past it.
2331        let previous_umask = unsafe { libc::umask(0) };
2332        let created = create_probe_workspace(&workspace);
2333        let plain_created = std::fs::create_dir(&plain).is_ok();
2334        // SAFETY: restoring the value the call above returned.
2335        unsafe {
2336            libc::umask(previous_umask);
2337        }
2338
2339        assert!(created, "the fixture needs the creation to succeed");
2340        assert!(plain_created, "the fixture needs the control to be created");
2341
2342        let control = std::fs::metadata(&plain).unwrap().permissions().mode() & 0o777;
2343        assert_eq!(
2344            control, 0o777,
2345            "NEGATIVE CONTROL: with the umask neutralized a default creation must be wide \
2346             open. If it is not, the umask window did not take and the assertion below \
2347             cannot distinguish the fix from the default"
2348        );
2349
2350        let mode = std::fs::metadata(&workspace).unwrap().permissions().mode() & 0o777;
2351        assert_eq!(
2352            mode, 0o700,
2353            "the workspace must be owner-only by request, not by whatever the umask happened \
2354             to strip"
2355        );
2356
2357        // The path now exists, so the same call must refuse it.
2358        assert!(
2359            !create_probe_workspace(&workspace),
2360            "a pre-planted directory or symlink must not be adopted — that is how a payload \
2361             gets written somewhere the probe did not choose"
2362        );
2363    }
2364
2365    /// WR-07's second half: "removes the workspace on **every** exit path" was
2366    /// a plain statement after the call, which an unwind skips. Repeated over
2367    /// many `release --check` runs on a long-lived host that is unbounded
2368    /// accumulation of `devflow-sign-probe-*` directories in `/tmp`.
2369    ///
2370    /// Driven through a real `catch_unwind` rather than by calling `drop`:
2371    /// dropping the guard by hand proves only that `Drop` removes a directory,
2372    /// which was never in question. The claim is that the removal survives a
2373    /// panic, and only an unwind establishes that.
2374    ///
2375    /// The control is the directory's existence before the panic — without it a
2376    /// test that never created anything would pass.
2377    ///
2378    /// # What this does NOT establish
2379    ///
2380    /// Its subject is [`ProbeWorkspace`], not [`run_ssh_sign_probe`]. Measured,
2381    /// not assumed: reverting `run_ssh_sign_probe` to the trailing
2382    /// `remove_dir_all` statement leaves this test PASSING. Nothing here can
2383    /// panic on demand inside `sign_probe_within`, so the link from the
2384    /// production function to the guard rests on there being exactly one
2385    /// construction site, checked by reading. A future refactor that stops
2386    /// binding the guard would not be caught here.
2387    #[test]
2388    fn the_probe_workspace_guard_removes_its_directory_on_unwind() {
2389        let dir = tempfile::tempdir().unwrap();
2390        let workspace = dir.path().join("probe");
2391        assert!(create_probe_workspace(&workspace));
2392        assert!(
2393            workspace.exists(),
2394            "premise: the directory must exist before the panic, or its later absence \
2395             establishes nothing"
2396        );
2397
2398        let panicked = std::panic::catch_unwind({
2399            let workspace = workspace.clone();
2400            move || {
2401                let _cleanup = ProbeWorkspace(workspace);
2402                panic!("the probe panicked mid-flight");
2403            }
2404        })
2405        .is_err();
2406
2407        assert!(panicked, "the fixture must actually unwind");
2408        assert!(
2409            !workspace.exists(),
2410            "a panic inside the probe must not leak its workspace into the shared temp dir"
2411        );
2412    }
2413
2414    /// WR-01 (35-REVIEW): a probe timeout is a measurement failure, so it
2415    /// must land on `Unknown`/`warn` beside the other two non-verdicts — not
2416    /// on a hard `NotViable`, which asserts something about the key and
2417    /// attaches `release --check`'s "resolve before attempting the signed
2418    /// release tag" hint to a key that may sign perfectly well.
2419    ///
2420    /// `Rejected` is the NC-4 negative control and is checked in the same
2421    /// function on purpose here: it is the one outcome that genuinely IS
2422    /// evidence about the key, so a mapping that returned `Unknown`
2423    /// unconditionally — the obvious over-correction — fails on it. If both
2424    /// halves agreed, this test would be measuring nothing.
2425    ///
2426    /// Asserted on the classification rather than by wedging a real
2427    /// `ssh-keygen` for ten seconds: the defect was in the mapping, and a
2428    /// wall-clock probe would make this a slow test of the timeout mechanism
2429    /// (which `SSH_SIGN_PROBE_TIMEOUT`'s own tests already cover) instead of
2430    /// a fast test of the verdict.
2431    #[test]
2432    fn a_probe_timeout_is_unknown_while_a_rejection_stays_not_viable() {
2433        // Never read: no arm below reaches `public_key_fingerprint`.
2434        let unused_key = Path::new("/nonexistent/devflow-wr01");
2435
2436        let timed_out = sign_probe_verdict(SignProbeOutcome::TimedOut, unused_key);
2437        match &timed_out {
2438            SigningViability::Unknown { reason } => assert!(
2439                reason.starts_with("cannot verify signing viability — "),
2440                "a non-verdict must carry the file's fail-soft prefix, got: {reason:?}"
2441            ),
2442            other => panic!(
2443                "a timeout establishes nothing about the key and must not be a hard \
2444                 verdict, got: {other:?}"
2445            ),
2446        }
2447
2448        let rejected = sign_probe_verdict(SignProbeOutcome::Rejected, unused_key);
2449        assert!(
2450            matches!(rejected, SigningViability::NotViable { .. }),
2451            "NEGATIVE CONTROL: a key that ran the probe and could not sign IS evidence \
2452             about the key and must stay a hard verdict, got: {rejected:?}"
2453        );
2454
2455        // The other two fail-soft classes, pinned in the same place so the
2456        // three "could not establish anything" outcomes cannot drift apart
2457        // again.
2458        for outcome in [SignProbeOutcome::ToolMissing, SignProbeOutcome::NotRun] {
2459            assert!(
2460                matches!(
2461                    sign_probe_verdict(outcome, unused_key),
2462                    SigningViability::Unknown { .. }
2463                ),
2464                "every measurement failure maps to Unknown"
2465            );
2466        }
2467    }
2468
2469    /// Generate a real ed25519 keypair at `stem`, with `passphrase` (empty
2470    /// for an unencrypted key). Returns the public half's path.
2471    fn generate_keypair(stem: &Path, passphrase: &str) -> PathBuf {
2472        let keygen = Command::new("ssh-keygen")
2473            .args([
2474                "-t",
2475                "ed25519",
2476                "-f",
2477                stem.to_str().unwrap(),
2478                "-N",
2479                passphrase,
2480                "-q",
2481            ])
2482            .output()
2483            .expect("spawn ssh-keygen");
2484        assert!(
2485            keygen.status.success(),
2486            "ssh-keygen fixture setup failed: {}",
2487            String::from_utf8_lossy(&keygen.stderr)
2488        );
2489        let pub_path = stem.with_extension("pub");
2490        assert!(pub_path.exists(), "ssh-keygen wrote no public key");
2491        pub_path
2492    }
2493
2494    /// Point a repo's `user.signingkey` at `key` under `gpg.format=ssh`.
2495    fn configure_ssh_signing(root: &Path, key: &Path) {
2496        git(root, &["config", "gpg.format", "ssh"]);
2497        git(root, &["config", "user.signingkey", key.to_str().unwrap()]);
2498    }
2499
2500    /// D-08's redaction contract, asserted on the rendered result: neither
2501    /// the reason nor the fingerprint may carry the configured key path, any
2502    /// private key material, or any fragment of `ssh-keygen`'s own stderr
2503    /// (which embeds the path verbatim — see the probe's own comment).
2504    fn assert_no_leak(result: &SigningViability, secret_dir: &Path) {
2505        let rendered = format!("{result:?}");
2506        assert!(
2507            !rendered.contains(secret_dir.to_str().unwrap()),
2508            "signing viability leaked a filesystem path: {rendered}"
2509        );
2510        for fragment in [
2511            "PRIVATE KEY",
2512            "No private key found",
2513            "Couldn't load public key",
2514            "Enter passphrase",
2515            "incorrect passphrase",
2516        ] {
2517            assert!(
2518                !rendered.contains(fragment),
2519                "signing viability leaked key material or ssh-keygen stderr ({fragment:?}): \
2520                 {rendered}"
2521            );
2522        }
2523    }
2524
2525    /// The headline case, and the exact live false negative 999.86 was filed
2526    /// for twice: a configured signing key whose unencrypted private sibling
2527    /// is on disk signs fine with NO agent involvement at all. The predictor
2528    /// this replaced asked `ssh-add -l` whether the agent held the key and
2529    /// reported `NotViable` when it did not — agent membership is simply not
2530    /// a necessary condition for `git tag -s` to succeed.
2531    ///
2532    /// This test therefore reads, sets and depends on NO agent state. The
2533    /// fixture key is generated fresh into a temporary directory, so no
2534    /// agent on any host can be holding it; a `Viable` verdict here is only
2535    /// reachable through the on-disk private key.
2536    #[test]
2537    fn ssh_signing_probe_reports_viable_with_on_disk_private_key() {
2538        let repo = init_repo();
2539        let root = repo.path();
2540        let keys = tempfile::tempdir().unwrap();
2541        let pub_key = generate_keypair(&keys.path().join("probe-key"), "");
2542        configure_ssh_signing(root, &pub_key);
2543
2544        let result = check_signing_viability(root);
2545
2546        match &result {
2547            SigningViability::Viable { fingerprint } => {
2548                let fingerprint = fingerprint
2549                    .as_deref()
2550                    .expect("Viable must carry the public key fingerprint");
2551                assert!(
2552                    fingerprint.starts_with("SHA256:"),
2553                    "unexpected fingerprint shape: {fingerprint}"
2554                );
2555            }
2556            other => panic!("expected Viable for an on-disk private key, got: {other:?}"),
2557        }
2558        assert_no_leak(&result, keys.path());
2559    }
2560
2561    /// NC-9, the negative control for the test above. Same fixture with the
2562    /// private half deleted, leaving only the `.pub` file: the verdict must
2563    /// FLIP to `NotViable`. Without this the positive assertion is vacuously
2564    /// true — a probe that returned `Viable` unconditionally would pass the
2565    /// positive case and fail here.
2566    #[test]
2567    fn ssh_signing_probe_reports_not_viable_without_a_private_key() {
2568        let repo = init_repo();
2569        let root = repo.path();
2570        let keys = tempfile::tempdir().unwrap();
2571        let stem = keys.path().join("probe-key");
2572        let pub_key = generate_keypair(&stem, "");
2573        std::fs::remove_file(&stem).expect("remove the private half");
2574        assert!(!stem.exists(), "fixture still has a private key");
2575        configure_ssh_signing(root, &pub_key);
2576
2577        let result = check_signing_viability(root);
2578
2579        assert_eq!(
2580            result,
2581            SigningViability::NotViable {
2582                reason: "the configured signing key could not sign a test payload".into(),
2583            },
2584            "expected the verdict to flip without a private key, got: {result:?}"
2585        );
2586        assert_no_leak(&result, keys.path());
2587    }
2588
2589    /// Build one raw `ssh-keygen -Y sign` invocation for NC-10.
2590    ///
2591    /// Deliberately drives the raw command rather than the probe: the
2592    /// observation must be of `SSH_ASKPASS_REQUIRE`'s effect, and a run
2593    /// through the probe would have its duration pinned by
2594    /// [`SSH_SIGN_PROBE_TIMEOUT`] instead — measuring the constant, not the
2595    /// variable.
2596    fn askpass_arm(dir: &Path, askpass_require: Option<&str>) -> std::process::Child {
2597        use std::os::unix::process::CommandExt;
2598
2599        let payload = dir.join(probe_workspace_name());
2600        std::fs::write(&payload, b"nc-10 payload\n").expect("write nc-10 payload");
2601
2602        let mut command = Command::new("ssh-keygen");
2603        command
2604            .args([
2605                "-Y",
2606                "sign",
2607                "-n",
2608                SSH_SIGN_NAMESPACE,
2609                "-f",
2610                dir.join("encrypted-key.pub").to_str().unwrap(),
2611                payload.to_str().unwrap(),
2612            ])
2613            .env("SSH_ASKPASS", dir.join("askpass.sh"))
2614            // `read_passphrase` only consults SSH_ASKPASS when DISPLAY or
2615            // SSH_ASKPASS is set; both arms get the same askpass route so
2616            // the ONLY difference between them is the variable under test.
2617            .env("DISPLAY", ":0")
2618            // Agent state is deliberately neither read nor cleared here.
2619            // The fixture key is generated microseconds earlier into a fresh
2620            // temporary directory, so no agent on any host can hold it, and
2621            // a test that reaches for agent state near a signing assertion
2622            // has reproduced the very premise that produced 999.86.
2623            .stdin(Stdio::null())
2624            .stdout(Stdio::null())
2625            .stderr(Stdio::null());
2626        match askpass_require {
2627            Some(value) => command.env("SSH_ASKPASS_REQUIRE", value),
2628            None => command.env_remove("SSH_ASKPASS_REQUIRE"),
2629        };
2630
2631        // SAFETY: `setsid` is a bare syscall and async-signal-safe, which is
2632        // the only requirement `pre_exec` imposes.
2633        //
2634        // This is load-bearing, not hygiene. With a controlling terminal
2635        // available, `ssh-keygen` prompts for the passphrase on `/dev/tty`
2636        // regardless of SSH_ASKPASS_REQUIRE, so BOTH arms would block and
2637        // the control would agree with its positive case for a reason that
2638        // has nothing to do with the variable. Dropping the terminal forces
2639        // the askpass route, which is the route the variable governs. It
2640        // also stops a killed child from leaving an operator's terminal
2641        // with echo disabled.
2642        unsafe {
2643            command.pre_exec(|| {
2644                if libc::setsid() == -1 {
2645                    return Err(std::io::Error::last_os_error());
2646                }
2647                Ok(())
2648            });
2649        }
2650        command.spawn().expect("spawn ssh-keygen for NC-10")
2651    }
2652
2653    /// Write the NC-10 fixture: an encrypted ed25519 key and an askpass
2654    /// helper that takes far longer than any observation window here.
2655    fn encrypted_key_fixture() -> tempfile::TempDir {
2656        let dir = tempfile::tempdir().unwrap();
2657        generate_keypair(&dir.path().join("encrypted-key"), "devflow-nc10-passphrase");
2658        let askpass = dir.path().join("askpass.sh");
2659        std::fs::write(
2660            &askpass,
2661            "#!/bin/sh\nsleep 5\necho devflow-nc10-passphrase\n",
2662        )
2663        .unwrap();
2664        let mut perms = std::fs::metadata(&askpass).unwrap().permissions();
2665        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
2666        std::fs::set_permissions(&askpass, perms).unwrap();
2667        dir
2668    }
2669
2670    /// Poll `child` for up to `window`, returning how long it took to exit
2671    /// or `None` if it was still running when the window closed.
2672    fn wait_bounded(child: &mut std::process::Child, window: Duration) -> Option<Duration> {
2673        let started = Instant::now();
2674        let deadline = started + window;
2675        loop {
2676            match child.try_wait().expect("poll nc-10 child") {
2677                Some(_) => return Some(started.elapsed()),
2678                None => {
2679                    if Instant::now() >= deadline {
2680                        return None;
2681                    }
2682                    std::thread::sleep(Duration::from_millis(5));
2683                }
2684            }
2685        }
2686    }
2687
2688    /// NC-10, positive arm: with `SSH_ASKPASS_REQUIRE=never` an encrypted
2689    /// key does NOT park on the askpass helper — it gives up promptly.
2690    #[test]
2691    fn ssh_signing_probe_does_not_block_on_an_encrypted_key() {
2692        let dir = encrypted_key_fixture();
2693        let mut child = askpass_arm(dir.path(), Some("never"));
2694        let elapsed = wait_bounded(&mut child, SSH_SIGN_PROBE_TIMEOUT / 2);
2695        if elapsed.is_none() {
2696            let _ = child.kill();
2697            let _ = child.wait();
2698        }
2699        let elapsed = elapsed.expect(
2700            "SSH_ASKPASS_REQUIRE=never did not stop ssh-keygen blocking on the askpass helper",
2701        );
2702        eprintln!("NC-10 non-blocking arm exited in {elapsed:?}");
2703        assert!(
2704            elapsed < Duration::from_secs(2),
2705            "the non-blocking arm took {elapsed:?}, which is too slow to calibrate a control"
2706        );
2707    }
2708
2709    /// NC-10's control (D-01). The env var, not the fixture and not the
2710    /// timeout, is what prevents the hang — so the SAME fixture is run with
2711    /// the variable OMITTED and must still be alive when the window closes.
2712    ///
2713    /// **The window is calibrated, not assumed (F-9).** The non-blocking arm
2714    /// runs first and its wall-clock exit is measured; the window is derived
2715    /// from that measurement at a stated multiple. An uncalibrated window
2716    /// shorter than the time `ssh-keygen` ordinarily takes to give up would
2717    /// report "blocked" for a reason that has nothing to do with the
2718    /// variable, and the control would pass while measuring the wrong thing.
2719    /// The window is also held well under [`SSH_SIGN_PROBE_TIMEOUT`], so the
2720    /// measurement is of the variable's effect rather than of the ceiling.
2721    ///
2722    /// A control that agrees with its positive case is a broken measurement,
2723    /// not evidence: if the blocking arm does NOT block, this test fails
2724    /// loudly rather than passing.
2725    #[test]
2726    fn encrypted_key_blocks_without_the_askpass_require_env_var() {
2727        const CALIBRATION_MULTIPLE: u32 = 8;
2728        const MIN_WINDOW: Duration = Duration::from_millis(1000);
2729
2730        let dir = encrypted_key_fixture();
2731
2732        // Arm 1 — measure. Same fixture, variable set.
2733        let mut baseline_child = askpass_arm(dir.path(), Some("never"));
2734        let baseline = wait_bounded(&mut baseline_child, SSH_SIGN_PROBE_TIMEOUT / 2);
2735        if baseline.is_none() {
2736            let _ = baseline_child.kill();
2737            let _ = baseline_child.wait();
2738        }
2739        let baseline = baseline.expect(
2740            "control uncalibrated: the non-blocking arm never exited, so there is no baseline \
2741             to derive an observation window from",
2742        );
2743
2744        // Derive the window from the measurement rather than assuming one.
2745        let window = std::cmp::max(baseline * CALIBRATION_MULTIPLE, MIN_WINDOW);
2746        assert!(
2747            window >= baseline * 4,
2748            "control uncalibrated: observation window {window:?} is not at least four times \
2749             the measured non-blocking exit of {baseline:?}"
2750        );
2751        assert!(
2752            window < SSH_SIGN_PROBE_TIMEOUT / 2,
2753            "control uncalibrated: observation window {window:?} is not comfortably under the \
2754             probe's own {SSH_SIGN_PROBE_TIMEOUT:?} ceiling, so it would measure the ceiling \
2755             rather than SSH_ASKPASS_REQUIRE"
2756        );
2757
2758        // Arm 2 — the control. Same fixture, variable omitted.
2759        let mut blocking_child = askpass_arm(dir.path(), None);
2760        let blocked = wait_bounded(&mut blocking_child, window);
2761        let _ = blocking_child.kill();
2762        let _ = blocking_child.wait();
2763
2764        eprintln!(
2765            "NC-10 calibration: non-blocking exit {baseline:?}, observation window {window:?} \
2766             ({CALIBRATION_MULTIPLE}x, floored at {MIN_WINDOW:?}), blocking arm {blocked:?}"
2767        );
2768        assert!(
2769            blocked.is_none(),
2770            "NC-10 control FAILED: with SSH_ASKPASS_REQUIRE omitted the child still exited in \
2771             {blocked:?}, inside the {window:?} window. A control that agrees with its positive \
2772             case is a broken measurement, not evidence — nothing here supports the conclusion \
2773             that the environment variable is what prevents the hang"
2774        );
2775    }
2776
2777    // ------------------------------------------------------------------
2778    // D8 / HARDEN-05 — the PRODUCTION probe drops its controlling terminal.
2779    //
2780    // The two NC-10 arms above install their OWN `setsid`, so both would pass
2781    // byte-unchanged if the production `pre_exec` were deleted: 35-03's
2782    // SUMMARY records exactly that, as `human_judgment: true`. The test below
2783    // is the missing guard. It runs the production probe from a child that has
2784    // ACQUIRED a pty as its controlling terminal, which is the only condition
2785    // under which the production `setsid` does anything observable at all.
2786    // ------------------------------------------------------------------
2787
2788    /// Carries the fixture key into the re-executed child.
2789    const TTY_PROBE_KEY_ENV: &str = "DEVFLOW_TTY_PROBE_KEY";
2790
2791    /// The child entrypoint's name, as libtest's `--exact` filter sees it.
2792    const TTY_PROBE_CHILD: &str = "git::tests::ssh_sign_probe_tty_child_entrypoint";
2793
2794    // Exit codes for that child. **None of them is 0, deliberately.** `cargo
2795    // test --exact <name>` exits 0 when the name matches nothing (CLAUDE.md;
2796    // this repo has already paid for it), so a renamed entrypoint would make
2797    // the measuring arm below "succeed" in milliseconds while running no probe
2798    // whatsoever. A 0 exit therefore means "the child never ran" and is
2799    // asserted against explicitly.
2800    const EXIT_PROBE_REJECTED: i32 = 42;
2801    const EXIT_PROBE_TIMED_OUT: i32 = 43;
2802    const EXIT_PROBE_OTHER: i32 = 44;
2803    const EXIT_NO_CONTROLLING_TTY: i32 = 97;
2804
2805    /// A pty pair whose fds are closed on every exit path, including unwind.
2806    ///
2807    /// Closing the master hangs up the line, which is also the backstop that
2808    /// stops anything still sitting on a passphrase prompt from outliving this
2809    /// test: the session leader's death sends `SIGHUP` to the foreground
2810    /// process group.
2811    struct Pty {
2812        master: libc::c_int,
2813        slave: libc::c_int,
2814    }
2815
2816    impl Pty {
2817        /// Allocate a pty pair. `O_NOCTTY` throughout — *this* process must
2818        /// not acquire the terminal; only the child spawned by
2819        /// [`spawn_owning_controlling_tty`] may, and only via an explicit
2820        /// `TIOCSCTTY`.
2821        fn open() -> Pty {
2822            // SAFETY: each call below is a bare libc entry point with
2823            // in-bounds arguments (`name` is sized and its length passed), and
2824            // the `Pty` is constructed as soon as the first fd exists, so its
2825            // `Drop` owns every descriptor from that point on — including
2826            // across the assertion unwinds between here and the return.
2827            unsafe {
2828                let master = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY);
2829                assert!(
2830                    master >= 0,
2831                    "posix_openpt failed: {}",
2832                    std::io::Error::last_os_error()
2833                );
2834                let mut pty = Pty { master, slave: -1 };
2835                assert!(
2836                    libc::grantpt(master) == 0,
2837                    "grantpt failed: {}",
2838                    std::io::Error::last_os_error()
2839                );
2840                assert!(
2841                    libc::unlockpt(master) == 0,
2842                    "unlockpt failed: {}",
2843                    std::io::Error::last_os_error()
2844                );
2845                let mut name = [0 as libc::c_char; 128];
2846                assert!(
2847                    libc::ptsname_r(master, name.as_mut_ptr(), name.len()) == 0,
2848                    "ptsname_r failed: {}",
2849                    std::io::Error::last_os_error()
2850                );
2851                let slave = libc::open(name.as_ptr(), libc::O_RDWR | libc::O_NOCTTY);
2852                assert!(
2853                    slave >= 0,
2854                    "opening the pty slave failed: {}",
2855                    std::io::Error::last_os_error()
2856                );
2857                pty.slave = slave;
2858                pty
2859            }
2860        }
2861    }
2862
2863    impl Drop for Pty {
2864        fn drop(&mut self) {
2865            // SAFETY: both descriptors were opened by `Pty::open` and are
2866            // closed exactly once, here.
2867            unsafe {
2868                if self.slave >= 0 {
2869                    libc::close(self.slave);
2870                }
2871                libc::close(self.master);
2872            }
2873        }
2874    }
2875
2876    /// Spawn `command` as the leader of a NEW session that has acquired
2877    /// `pty`'s slave as its **controlling terminal**.
2878    ///
2879    /// Both syscalls are checked here, unlike the production probe's
2880    /// deliberate ignore. A silently failed `TIOCSCTTY` would leave the child
2881    /// with no controlling terminal — the single condition under which every
2882    /// assertion in this test passes for the wrong reason — so a failure is
2883    /// returned as an error and surfaces as a loud `spawn` failure instead.
2884    fn spawn_owning_controlling_tty(command: &mut Command, pty: &Pty) -> std::process::Child {
2885        use std::os::unix::process::CommandExt;
2886
2887        let slave = pty.slave;
2888        // SAFETY: `setsid` and `ioctl` are bare syscalls and async-signal-safe,
2889        // which is the only requirement `pre_exec` imposes. `slave` is owned by
2890        // the `Pty` the caller holds for the child's whole lifetime, and it is
2891        // inherited across the fork because it was opened without `CLOEXEC`.
2892        unsafe {
2893            command.pre_exec(move || {
2894                if libc::setsid() == -1 {
2895                    return Err(std::io::Error::last_os_error());
2896                }
2897                if libc::ioctl(slave, libc::TIOCSCTTY, 0) == -1 {
2898                    return Err(std::io::Error::last_os_error());
2899                }
2900                Ok(())
2901            });
2902        }
2903        command
2904            .spawn()
2905            .expect("spawn a child owning the pty as its controlling terminal")
2906    }
2907
2908    /// Spawn a child GUARANTEED to have no controlling terminal, by putting it
2909    /// in a fresh session and giving it no pty to acquire.
2910    ///
2911    /// Arm 0 must not merely *assume* the ambient environment lacks a terminal
2912    /// — that assumption is environment-dependent and it is false under the
2913    /// pre-push gate, which runs `docker run --rm -t` (`check-in-container.sh`)
2914    /// and therefore hands the test binary a pty as its controlling terminal.
2915    /// Inheriting it made the control arm block on `/dev/tty`, which the
2916    /// calibration guard correctly reported as `control uncalibrated` rather
2917    /// than mis-attributing it to `setsid` — a hard red in the container while
2918    /// this same test passed on a terminal-less host.
2919    ///
2920    /// Detaching explicitly makes the arm mean the same thing in both places.
2921    fn spawn_detached_from_terminal(command: &mut Command) -> std::process::Child {
2922        use std::os::unix::process::CommandExt;
2923
2924        // SAFETY: `setsid` is a bare syscall and async-signal-safe, which is
2925        // the only requirement `pre_exec` imposes. A freshly forked child is
2926        // never already a process-group leader, so the call cannot fail for
2927        // the one reason `setsid` fails; it is still checked rather than
2928        // ignored, because a silent failure here would leave the child holding
2929        // an inherited terminal and turn this control back into the very
2930        // environment-dependent arm it exists to replace.
2931        unsafe {
2932            command.pre_exec(|| {
2933                if libc::setsid() == -1 {
2934                    return Err(std::io::Error::last_os_error());
2935                }
2936                Ok(())
2937            });
2938        }
2939        command
2940            .spawn()
2941            .expect("spawn a child detached from any controlling terminal")
2942    }
2943
2944    /// One raw `ssh-keygen -Y sign` against an encrypted key, mirroring the
2945    /// production probe's environment and stdio EXACTLY — and deliberately
2946    /// **not** calling `setsid` itself. Each arm decides its own session: arm 0
2947    /// via [`spawn_detached_from_terminal`] (fresh session, no pty), arm 1 via
2948    /// [`spawn_owning_controlling_tty`] (fresh session, then `TIOCSCTTY`). The
2949    /// only thing that differs between the two arms built from it is therefore
2950    /// whether the child holds a controlling terminal — and now that holds by
2951    /// construction rather than by inheritance from whatever spawned the tests.
2952    fn tty_control_arm(key_pub: &Path, payload: &Path) -> Command {
2953        let mut command = Command::new("ssh-keygen");
2954        command
2955            .args([
2956                "-Y",
2957                "sign",
2958                "-n",
2959                SSH_SIGN_NAMESPACE,
2960                "-f",
2961                key_pub.to_str().unwrap(),
2962                payload.to_str().unwrap(),
2963            ])
2964            .env("SSH_ASKPASS_REQUIRE", "never")
2965            .stdin(Stdio::null())
2966            .stdout(Stdio::null())
2967            .stderr(Stdio::null());
2968        command
2969    }
2970
2971    /// Re-entry point for the D8 test below: runs the **production** probe
2972    /// inside whatever session its caller placed this process in, and reports
2973    /// the verdict as a process exit code.
2974    ///
2975    /// A no-op unless [`TTY_PROBE_KEY_ENV`] is set, so an ordinary `cargo test`
2976    /// run spawns nothing and costs nothing here.
2977    ///
2978    /// The `/dev/tty` open is a **premise check, not hygiene**: without it a
2979    /// `TIOCSCTTY` that silently failed would leave this child with no
2980    /// terminal, the probe would return promptly for a reason having nothing to
2981    /// do with `setsid`, and the test would pass while measuring nothing.
2982    #[test]
2983    fn ssh_sign_probe_tty_child_entrypoint() {
2984        let Ok(key) = std::env::var(TTY_PROBE_KEY_ENV) else {
2985            return;
2986        };
2987
2988        let tty_path = std::ffi::CString::new("/dev/tty").unwrap();
2989        // SAFETY: `tty_path` is a valid NUL-terminated C string that outlives
2990        // the call, and the descriptor is closed on the one path that opens it.
2991        let tty = unsafe { libc::open(tty_path.as_ptr(), libc::O_RDWR) };
2992        if tty < 0 {
2993            std::process::exit(EXIT_NO_CONTROLLING_TTY);
2994        }
2995        // SAFETY: `tty` was just opened by this thread and is closed once.
2996        unsafe { libc::close(tty) };
2997
2998        std::process::exit(match run_ssh_sign_probe(Path::new(&key)) {
2999            SignProbeOutcome::Rejected => EXIT_PROBE_REJECTED,
3000            SignProbeOutcome::TimedOut => EXIT_PROBE_TIMED_OUT,
3001            _ => EXIT_PROBE_OTHER,
3002        });
3003    }
3004
3005    /// **D8 (HARDEN-05): the production signing probe is not captured by a
3006    /// controlling terminal's `/dev/tty` passphrase prompt.**
3007    ///
3008    /// `SSH_ASKPASS_REQUIRE=never` is not sufficient on its own — OpenSSH only
3009    /// consults it after `open("/dev/tty")` has already failed. The production
3010    /// `pre_exec`/`setsid` is what makes that open fail. Delete it and this
3011    /// test fails: the probe blocks on the terminal until its own 10 s ceiling.
3012    ///
3013    /// Three arms, and the first two are a **matched pair that must disagree**:
3014    ///
3015    /// | arm | terminal | `setsid` | required result |
3016    /// |---|---|---|---|
3017    /// | 0 — baseline/control | none | none | exits promptly |
3018    /// | 1 — premise | pty, acquired | none | still blocked when the window closes |
3019    /// | 2 — measurement | pty, acquired | production's | exits promptly, with a real verdict |
3020    ///
3021    /// If arms 0 and 1 agreed, the harness would have established nothing —
3022    /// either the terminal never took effect, or this OpenSSH build does not
3023    /// use it — and arm 2 would be fast for a reason unrelated to `setsid`.
3024    /// Arm 1 therefore runs BEFORE the measurement and fails as a PREMISE
3025    /// failure, not as a regression.
3026    ///
3027    /// The observation window is derived from arm 0's measured exit, at a
3028    /// stated multiple, following NC-10's calibration shape; every wait is
3029    /// bounded and every child is killed and reaped on every path.
3030    #[test]
3031    fn the_signing_probe_is_not_captured_by_a_controlling_terminal() {
3032        const CALIBRATION_MULTIPLE: u32 = 8;
3033        const MIN_WINDOW: Duration = Duration::from_millis(1000);
3034        /// Bound for the production arm: far above a real verdict (tens of
3035        /// milliseconds plus one process spawn) and far below the probe's own
3036        /// [`SSH_SIGN_PROBE_TIMEOUT`], so exceeding it means "ran to the
3037        /// ceiling on the terminal", not "this host is slow".
3038        const PROBE_ARM_CAP: Duration = Duration::from_millis(3000);
3039
3040        let dir = tempfile::tempdir().unwrap();
3041        let key_pub = generate_keypair(&dir.path().join("tty-key"), "devflow-d8-passphrase");
3042        let payload = dir.path().join("payload");
3043        std::fs::write(&payload, b"devflow d8 tty payload\n").unwrap();
3044
3045        // --- Arm 0: the paired control. Same command, same environment, NO
3046        // controlling terminal. `readpassphrase` falls back to a nulled stdin
3047        // and gives up at once. The child is detached into its own session
3048        // explicitly rather than trusting the ambient environment to lack a
3049        // terminal — see `spawn_detached_from_terminal`.
3050        let mut baseline_child =
3051            spawn_detached_from_terminal(&mut tty_control_arm(&key_pub, &payload));
3052        let baseline = wait_bounded(&mut baseline_child, SSH_SIGN_PROBE_TIMEOUT / 2);
3053        if baseline.is_none() {
3054            let _ = baseline_child.kill();
3055            let _ = baseline_child.wait();
3056        }
3057        let baseline = baseline.expect(
3058            "control uncalibrated: ssh-keygen blocked with NO controlling terminal, so nothing \
3059             measured below can be attributed to the terminal",
3060        );
3061
3062        let window = std::cmp::max(baseline * CALIBRATION_MULTIPLE, MIN_WINDOW);
3063        assert!(
3064            window >= baseline * 4,
3065            "control uncalibrated: observation window {window:?} is not at least four times the \
3066             measured no-terminal exit of {baseline:?}"
3067        );
3068        assert!(
3069            window < SSH_SIGN_PROBE_TIMEOUT / 2,
3070            "control uncalibrated: observation window {window:?} is not comfortably under the \
3071             probe's own {SSH_SIGN_PROBE_TIMEOUT:?} ceiling, so it would measure the ceiling"
3072        );
3073
3074        // --- Arm 1: the premise, and the other half of the pair. Same command
3075        // again, WITH the pty acquired as a controlling terminal and no
3076        // `setsid`: it must still be blocked when the window closes.
3077        let blocked = {
3078            let pty = Pty::open();
3079            let mut child =
3080                spawn_owning_controlling_tty(&mut tty_control_arm(&key_pub, &payload), &pty);
3081            let blocked = wait_bounded(&mut child, window);
3082            let _ = child.kill();
3083            let _ = child.wait();
3084            blocked
3085        };
3086        assert!(
3087            blocked.is_none(),
3088            "PREMISE FAILED: with a controlling terminal and no setsid, ssh-keygen exited in \
3089             {blocked:?} — the same result as the no-terminal control ({baseline:?}). Either the \
3090             pty was never acquired or this build does not consult /dev/tty, and either way the \
3091             arm below would be fast for a reason unrelated to the production setsid. A control \
3092             that agrees with its positive case is a broken measurement, not evidence"
3093        );
3094
3095        // --- Arm 2: the measurement. The PRODUCTION probe, same fixture, same
3096        // kind of controlling terminal — re-executed as a child because only a
3097        // separate process can be made a session leader.
3098        let (elapsed, status) = {
3099            let pty = Pty::open();
3100            let mut command = Command::new(
3101                std::env::current_exe().expect("locate this test binary for re-execution"),
3102            );
3103            command
3104                .args([TTY_PROBE_CHILD, "--exact", "--test-threads=1"])
3105                .env(TTY_PROBE_KEY_ENV, &key_pub)
3106                .stdin(Stdio::null())
3107                .stdout(Stdio::null())
3108                .stderr(Stdio::null());
3109            let mut child = spawn_owning_controlling_tty(&mut command, &pty);
3110            let elapsed = wait_bounded(&mut child, PROBE_ARM_CAP);
3111            if elapsed.is_none() {
3112                let _ = child.kill();
3113                let _ = child.wait();
3114            }
3115            let status = elapsed.map(|_| child.wait().expect("reap the production probe arm"));
3116            (elapsed, status)
3117        };
3118        let code = status.and_then(|status| status.code());
3119        eprintln!(
3120            "D8: no-terminal baseline {baseline:?}; window {window:?} \
3121             ({CALIBRATION_MULTIPLE}x, floored at {MIN_WINDOW:?}); with-terminal control \
3122             {blocked:?}; production probe {elapsed:?} exiting {code:?}"
3123        );
3124
3125        let elapsed = elapsed.unwrap_or_else(|| {
3126            panic!(
3127                "REGRESSION: the production signing probe did not return within {PROBE_ARM_CAP:?} \
3128                 while its caller held a controlling terminal. That is the pre-setsid behaviour — \
3129                 it is parked on /dev/tty waiting for a passphrase nobody can type, and will stay \
3130                 there until SSH_SIGN_PROBE_TIMEOUT ({SSH_SIGN_PROBE_TIMEOUT:?}) expires. The \
3131                 no-terminal control exited in {baseline:?}, so the fixture and the environment \
3132                 are not what changed"
3133            )
3134        });
3135        assert_ne!(
3136            code,
3137            Some(EXIT_NO_CONTROLLING_TTY),
3138            "PREMISE FAILED: the re-executed child could not open /dev/tty, so it never held a \
3139             controlling terminal and its {elapsed:?} says nothing about setsid"
3140        );
3141        assert_ne!(
3142            code,
3143            Some(0),
3144            "the child exited 0, which no path in ssh_sign_probe_tty_child_entrypoint does: \
3145             `--exact {TTY_PROBE_CHILD}` matched no test, so no probe ran at all"
3146        );
3147        assert_eq!(
3148            code,
3149            Some(EXIT_PROBE_REJECTED),
3150            "the probe returned in {elapsed:?} but with the wrong verdict: {} means it hit its \
3151             own ceiling and {} means it never reached one",
3152            EXIT_PROBE_TIMED_OUT,
3153            EXIT_PROBE_OTHER
3154        );
3155    }
3156
3157    /// D-03/A-17: an inline `user.signingkey` returns `Unknown` with the
3158    /// fixed inline reason and is never probed.
3159    ///
3160    /// Run with a NORMAL `PATH`, so `ssh-keygen` is present throughout. That
3161    /// is what makes this a proof of "never probed" rather than of "failed
3162    /// to probe" — the surface test that removes the tooling cannot tell the
3163    /// two apart.
3164    #[test]
3165    fn inline_signing_key_returns_unknown_without_probing() {
3166        const INLINE_REASON: &str =
3167            "cannot verify signing viability — an inline user.signingkey is not probed";
3168        let keys = tempfile::tempdir().unwrap();
3169        let pub_key = generate_keypair(&keys.path().join("inline-key"), "");
3170        let blob = std::fs::read_to_string(&pub_key)
3171            .unwrap()
3172            .trim()
3173            .to_string();
3174
3175        for value in [format!("key::{blob}"), blob.clone()] {
3176            let repo = init_repo();
3177            let root = repo.path();
3178            git(root, &["config", "gpg.format", "ssh"]);
3179            git(root, &["config", "user.signingkey", &value]);
3180
3181            let result = check_signing_viability(root);
3182
3183            assert_eq!(
3184                result,
3185                SigningViability::Unknown {
3186                    reason: INLINE_REASON.into(),
3187                },
3188                "inline value {value:?} was not routed to the unprobed Unknown arm: {result:?}"
3189            );
3190            assert_no_leak(&result, keys.path());
3191        }
3192    }
3193}