Skip to main content

devflow_core/
git.rs

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