Skip to main content

devflow_core/
hooks.rs

1//! Stage-transition hooks.
2//!
3//! Branching, docs, changelog, and version bumps are no longer workflow stages
4//! (as they were in v0.x). They are *hooks* that fire at specific stage
5//! transitions. [`hooks_for_transition`] maps a `(from, to)` stage move to the
6//! hooks that should run, and [`Hook::run`] executes one.
7
8use crate::config::GitFlowConfig;
9use crate::git::GitFlow;
10use crate::phase_id::PhaseId;
11use crate::stage::Stage;
12use crate::version;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use tracing::{info, warn};
16
17/// A side-effecting action that fires at a stage transition.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Hook {
20    /// Create the `feature/phase-NN` branch from develop.
21    BranchCreate,
22    /// Delete the merged feature branch after Ship.
23    BranchCleanup,
24    /// Regenerate and commit docs.
25    DocsUpdate,
26    /// Merge the phase feature branch into develop before release bookkeeping.
27    Merge,
28    /// Append a CHANGELOG entry.
29    ChangelogAppend,
30    /// Compute and write the next version, then tag it.
31    VersionBump,
32}
33
34/// Context passed to every hook.
35#[derive(Debug, Clone)]
36pub struct HookContext {
37    /// Phase the workflow is on.
38    pub phase: PhaseId,
39    /// Project root.
40    pub project_root: PathBuf,
41    /// Stage the workflow is entering.
42    pub stage: Stage,
43    /// Git-flow branch model.
44    pub git_flow: GitFlowConfig,
45    /// The version `VersionBump` actually tagged, set once it runs (GAP-7).
46    /// `ChangelogAppend` reads this instead of re-deriving the version from
47    /// disk, so the changelog heading and the git tag never desync — in
48    /// particular when there is no version file and `version::read_version`
49    /// would otherwise error and fall back to the `unreleased` literal.
50    pub shipped_version: Option<String>,
51    /// The Keep-a-Changelog-grouped body `VersionBump` computed, set once it
52    /// runs (D-12, T-26-11). `ChangelogAppend` reads this instead of
53    /// re-deriving it from live git state, for the same reason
54    /// `shipped_version` must be handed forward rather than re-derived:
55    /// once `VersionBump` has created the release tag, the range this body
56    /// was computed over collapses to empty (the tag is now the baseline),
57    /// so a re-derivation after the fact would silently produce an empty
58    /// changelog entry.
59    pub shipped_changelog_body: Option<String>,
60}
61
62/// Errors produced by hooks.
63#[derive(Debug, thiserror::Error)]
64pub enum HookError {
65    /// A git-flow operation failed.
66    #[error(transparent)]
67    Git(#[from] crate::git::GitError),
68    /// A version operation failed.
69    #[error(transparent)]
70    Version(#[from] version::VersionError),
71    /// Filesystem operation failed.
72    #[error("hook I/O failed: {0}")]
73    Io(#[from] std::io::Error),
74}
75
76impl Hook {
77    /// Run this hook against the given context.
78    pub fn run(&self, ctx: &mut HookContext) -> Result<(), HookError> {
79        match self {
80            Hook::BranchCreate => branch_create(ctx),
81            Hook::BranchCleanup => branch_cleanup(ctx),
82            Hook::DocsUpdate => docs_update(ctx),
83            Hook::Merge => merge_feature(ctx),
84            Hook::ChangelogAppend => changelog_append(ctx),
85            Hook::VersionBump => version_bump(ctx),
86        }
87    }
88}
89
90/// Which hooks fire when moving `from` → `to`.
91///
92/// - Validate → Ship: docs are finalized before shipping.
93/// - Ship → (done): merge + version bump + changelog + branch cleanup.
94/// - everything else: none.
95///
96/// `ChangelogAppend` deliberately does NOT run here (WR-04, 17-12): a
97/// changelog heading naming a release is only true once `VersionBump` has
98/// actually cut the tag, and `VersionBump` runs in [`hooks_after_ship`],
99/// strictly after this transition.
100pub fn hooks_for_transition(from: Stage, to: Stage) -> Vec<Hook> {
101    match (from, to) {
102        (Stage::Validate, Stage::Ship) => vec![Hook::DocsUpdate],
103        _ => Vec::new(),
104    }
105}
106
107/// Hooks that fire after Ship completes (the workflow's terminal transition).
108///
109/// `ChangelogAppend` runs strictly after `VersionBump` (WR-04, 17-12) — the
110/// entry must describe the version `VersionBump` actually wrote and tagged,
111/// never a version computed independently of it. It runs before
112/// `BranchCleanup` so a changelog failure still stops short of deleting the
113/// feature branch (`run_checkout_hooks`' terminal-batch fail-fast breaks on
114/// the first error in this batch).
115pub fn hooks_after_ship() -> Vec<Hook> {
116    vec![
117        Hook::Merge,
118        Hook::VersionBump,
119        Hook::ChangelogAppend,
120        Hook::BranchCleanup,
121    ]
122}
123
124fn branch_create(ctx: &HookContext) -> Result<(), HookError> {
125    let git = GitFlow::new(&ctx.project_root);
126    let branch = git.feature_start(ctx.phase)?;
127    info!("BranchCreate: created {branch}");
128    Ok(())
129}
130
131fn branch_cleanup(ctx: &HookContext) -> Result<(), HookError> {
132    let git = GitFlow::new(&ctx.project_root);
133    let branch = format!(
134        "{}phase-{}",
135        ctx.git_flow.feature_prefix,
136        ctx.phase.padded()
137    );
138    if git.branch_exists(&branch) {
139        // Non-force cleanup is intentional: never discard unmerged work.
140        match git.delete_branch(&branch, false) {
141            Ok(()) => info!("BranchCleanup: deleted {branch}"),
142            Err(err) => {
143                let message = err.to_string();
144                if message.contains("not fully merged") || message.contains("not yet merged") {
145                    warn!(
146                        "BranchCleanup: feature branch {branch} is not merged yet — left in place"
147                    );
148                } else {
149                    warn!("BranchCleanup: could not delete {branch}: {err}");
150                }
151            }
152        }
153    }
154    Ok(())
155}
156
157/// Merge the phase's feature branch into develop, then re-assert ancestry
158/// (23-06 / T-23-62) before reporting success.
159///
160/// **The post-merge ancestry re-check runs here — immediately after
161/// `merge_feature_into_develop` returns `Ok`, while the feature branch still
162/// exists — because this is the only place in `hooks_after_ship` where the
163/// assertion is both meaningful and safe.** `BranchCleanup` runs later in the
164/// same batch and deletes the branch; after that, an ancestry check fails
165/// closed on an absent branch (`git.rs:89-92`: "an absent branch is not proof
166/// of a merge") and would report `false` for every successfully shipped
167/// phase, so this check can never be moved after the batch without inverting
168/// its meaning.
169///
170/// **No-rollback policy, stated here because it must not be re-derived
171/// later:** on the ancestry re-check's failure path below, `merge_feature`
172/// does NOT undo the merge. `git merge --no-ff` has already committed on
173/// `develop` by the time the re-check runs, and automatically resetting a
174/// shared integration branch is a far more dangerous operation than the
175/// inconsistency it would be papering over. Instead, this returns `Err`; the
176/// containing `run_checkout_hooks` batch fails; `finish_workflow_with_gate_timeout`
177/// reopens an actionable Ship gate whose context tells a human to resolve the
178/// git error, and the operator decides. Plan 23-10's recovery-path artifact
179/// must know this exact state.
180fn merge_feature(ctx: &HookContext) -> Result<(), HookError> {
181    let git = GitFlow::new(&ctx.project_root);
182    let branch = format!(
183        "{}phase-{}",
184        ctx.git_flow.feature_prefix,
185        ctx.phase.padded()
186    );
187    if !git.branch_exists(&branch) {
188        return Err(crate::git::GitError::Command(format!(
189            "feature branch `{branch}` is missing; refusing to report an unproven merge"
190        ))
191        .into());
192    }
193    if git.is_merged_into_develop(ctx.phase) {
194        info!("Merge: {branch} is already merged; nothing to merge");
195        crate::events::emit(
196            &ctx.project_root,
197            ctx.phase,
198            "merge_result",
199            serde_json::json!({"merged": false, "branch": branch}),
200        );
201        return Ok(());
202    }
203
204    git.merge_feature_into_develop(ctx.phase)?;
205
206    if !git.is_merged_into_develop(ctx.phase) {
207        crate::events::emit(
208            &ctx.project_root,
209            ctx.phase,
210            "merge_result",
211            serde_json::json!({"merged": false, "branch": branch}),
212        );
213        return Err(crate::git::GitError::Command(format!(
214            "merge of `{branch}` reported success but the branch is still not an ancestor of \
215             develop; refusing to report an unproven merge"
216        ))
217        .into());
218    }
219
220    info!("Merge: merged {branch} into develop");
221    crate::events::emit(
222        &ctx.project_root,
223        ctx.phase,
224        "merge_result",
225        serde_json::json!({"merged": true, "branch": branch}),
226    );
227    Ok(())
228}
229
230fn docs_update(ctx: &HookContext) -> Result<(), HookError> {
231    let output = Command::new("sh")
232        .arg("-c")
233        .arg("cargo doc --no-deps 2>&1")
234        .current_dir(&ctx.project_root)
235        .output();
236    match output {
237        Ok(out) if out.status.success() => {
238            // Commit any doc changes; ignore "nothing to commit".
239            let git = GitFlow::new(&ctx.project_root);
240            if let Err(err) = git.commit_all("docs: update generated docs") {
241                warn!("DocsUpdate: commit failed: {err}");
242            } else {
243                info!("DocsUpdate: docs regenerated and committed");
244            }
245        }
246        Ok(_) => warn!("DocsUpdate: cargo doc reported a failure; skipping commit"),
247        Err(err) => warn!("DocsUpdate: could not run cargo doc: {err}"),
248    }
249    Ok(())
250}
251
252fn changelog_append(ctx: &mut HookContext) -> Result<(), HookError> {
253    // Prefer the version VersionBump (which runs immediately before this
254    // hook in hooks_after_ship()) actually tagged, handed through
255    // batch-scoped context state (GAP-7) — this is the only source that's
256    // correct with no version file present. Fall back to
257    // version::read_version (deliberately NOT version::compute_version,
258    // which recomputes MINOR from the live git tag count that VersionBump's
259    // own tag just incremented, yielding a version one higher than the tag
260    // actually cut — WR-04, 17-12), then to the `unreleased` literal.
261    let version = ctx.shipped_version.clone().unwrap_or_else(|| {
262        version::read_version(&ctx.project_root)
263            .map(|v| v.to_string())
264            .unwrap_or_else(|_| "unreleased".to_string())
265    });
266    let body = ctx.shipped_changelog_body.as_deref().unwrap_or("");
267    let path = ctx.project_root.join("CHANGELOG.md");
268    let existing = std::fs::read_to_string(&path).unwrap_or_default();
269    let updated = crate::ship::prepend_changelog(&existing, &version, &today(), body);
270    std::fs::write(&path, updated)?;
271    // Commit the write. Round 2's WR-04 finding: this hook used to write and
272    // never commit, and docs_update — the only committing hook — ran first
273    // in the old (Validate→Ship) batch order, so the entry was left dirty
274    // and lost when Merge/BranchCleanup ran. Scoped to CHANGELOG.md (not
275    // commit_all) so this hook never sweeps in unrelated dirty state. A
276    // failed commit propagates as an error so the terminal batch's fail-fast
277    // stops BranchCleanup from running against an uncommitted entry.
278    let git = GitFlow::new(&ctx.project_root);
279    git.commit_path(
280        "CHANGELOG.md",
281        &format!("docs: add changelog entry for {version}"),
282    )?;
283    info!("ChangelogAppend: wrote and committed entry for {version}");
284    Ok(())
285}
286
287fn version_bump(ctx: &mut HookContext) -> Result<(), HookError> {
288    let version = version::compute_version(&ctx.project_root)?;
289    let git = GitFlow::new(&ctx.project_root);
290
291    // D-12/T-26-11: compute the changelog body BEFORE `git.tag(&tag)` below.
292    // Once that tag exists, `reachable_semver_baseline` resolves to it and
293    // the range this body is computed over collapses to empty — the same
294    // desync class WR-04/17-12 already documented for `shipped_version`. A
295    // failure to compute the body must not abort the version bump: the
296    // fallback line `prepend_changelog` substitutes for an empty body is a
297    // correct degraded outcome, and a version bump must not fail on a
298    // changelog-content problem.
299    match version::reachable_semver_baseline(&ctx.project_root) {
300        Ok(baseline) => {
301            let range_start = match &baseline {
302                Some(tag) => version::release_range_start(&ctx.project_root, &format!("v{tag}")),
303                None => Ok(String::new()),
304            };
305            match range_start.and_then(|range_start| {
306                version::changelog_sections(&ctx.project_root, &range_start)
307            }) {
308                Ok(sections) => {
309                    ctx.shipped_changelog_body = Some(version::render_changelog_body(&sections));
310                }
311                Err(err) => {
312                    warn!("VersionBump: could not compute changelog body: {err}");
313                }
314            }
315        }
316        Err(err) => {
317            warn!("VersionBump: could not resolve changelog baseline: {err}");
318        }
319    }
320
321    // Write the computed version into the version file when one exists, and
322    // commit that write before tagging (17-12: previously left uncommitted,
323    // so the tag named a version the tagged commit itself didn't contain,
324    // and the working tree stayed dirty through the rest of the terminal
325    // batch — the same "write without committing" defect WR-04 named for
326    // ChangelogAppend, just not called out there).
327    if has_version_file(&ctx.project_root) {
328        let path = version::write_version(&ctx.project_root, &version)?;
329        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
330            git.commit_path(name, &format!("chore: bump version to {version}"))?;
331        }
332        info!("VersionBump: wrote {version} to {}", path.display());
333    } else {
334        warn!("VersionBump: no supported version file; tagging only");
335    }
336    let tag = format!("v{version}");
337    git.tag(&tag)?;
338    // Hand the tagged version to ChangelogAppend via batch-scoped context
339    // state (GAP-7) — on both branches above, since both tag. Without this,
340    // ChangelogAppend re-derives the version from disk and, with no version
341    // file, falls back to the `unreleased` literal while the tag names a
342    // real version.
343    ctx.shipped_version = Some(version.to_string());
344    info!("VersionBump: tagged {tag}");
345    Ok(())
346}
347
348/// Today's date as YYYY-MM-DD (best-effort via the `date` command).
349fn today() -> String {
350    Command::new("date")
351        .arg("+%Y-%m-%d")
352        .output()
353        .ok()
354        .filter(|o| o.status.success())
355        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
356        .filter(|s| !s.is_empty())
357        .unwrap_or_else(|| "unreleased".to_string())
358}
359
360/// Whether a project has a version file, used by callers to decide if a version
361/// bump is meaningful.
362pub fn has_version_file(project_root: &Path) -> bool {
363    version::detect_version_file(project_root).is_some()
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    fn git(root: &Path, args: &[&str]) {
371        let ok = crate::test_support::git_command(root)
372            .args(args)
373            .output()
374            .unwrap()
375            .status
376            .success();
377        assert!(ok, "git {args:?} failed");
378    }
379
380    fn init_repo(root: &Path) {
381        init_repo_with_options(root, true);
382    }
383
384    /// Same as [`init_repo`], but lets a test choose whether a version file
385    /// gets written. `init_repo` unconditionally wrote `Cargo.toml`, which
386    /// made `version_bump`'s no-version-file `else` branch unreachable from
387    /// the batch tests (GAP-7). `init_repo` delegates here with `true`, so
388    /// its observable effect for every existing test is unchanged byte for
389    /// byte.
390    fn init_repo_with_options(root: &Path, write_version_file: bool) {
391        git(root, &["init", "-q"]);
392        git(root, &["config", "user.email", "test@example.com"]);
393        git(root, &["config", "user.name", "Test"]);
394        git(root, &["config", "commit.gpgsign", "false"]);
395        git(root, &["config", "tag.gpgsign", "false"]);
396        git(root, &["config", "core.hooksPath", "/dev/null"]);
397        if write_version_file {
398            std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
399        } else {
400            std::fs::write(root.join("README.md"), "no version file in this repo\n").unwrap();
401        }
402        git(root, &["add", "."]);
403        git(root, &["commit", "-q", "-m", "init"]);
404        git(root, &["branch", "-M", "main"]);
405        git(root, &["checkout", "-q", "-b", "develop"]);
406    }
407
408    fn ctx(root: &Path, stage: Stage) -> HookContext {
409        HookContext {
410            phase: PhaseId::new(11),
411            project_root: root.to_path_buf(),
412            stage,
413            git_flow: GitFlowConfig::default(),
414            shipped_version: None,
415            shipped_changelog_body: None,
416        }
417    }
418
419    #[test]
420    fn transition_map_finalizes_docs_only_before_ship() {
421        // WR-04 (17-12): ChangelogAppend no longer fires here — a changelog
422        // heading naming a release can't be true before VersionBump (which
423        // runs in hooks_after_ship, strictly after this transition) cuts the
424        // tag it describes.
425        assert_eq!(
426            hooks_for_transition(Stage::Validate, Stage::Ship),
427            vec![Hook::DocsUpdate]
428        );
429        assert!(hooks_for_transition(Stage::Define, Stage::Plan).is_empty());
430        assert!(hooks_for_transition(Stage::Code, Stage::Validate).is_empty());
431    }
432
433    #[test]
434    fn validate_to_ship_hooks_do_not_touch_changelog() {
435        let dir = tempfile::tempdir().unwrap();
436        init_repo(dir.path());
437        let mut context = ctx(dir.path(), Stage::Ship);
438
439        for hook in hooks_for_transition(Stage::Validate, Stage::Ship) {
440            hook.run(&mut context).unwrap();
441        }
442
443        assert!(!dir.path().join("CHANGELOG.md").exists());
444    }
445
446    #[test]
447    fn after_ship_runs_version_changelog_then_cleanup() {
448        // WR-04 (17-12): ChangelogAppend strictly after VersionBump (so it
449        // can read back the version VersionBump just tagged), and before
450        // BranchCleanup (so a changelog failure still stops short of
451        // deleting the feature branch).
452        assert_eq!(
453            hooks_after_ship(),
454            vec![
455                Hook::Merge,
456                Hook::VersionBump,
457                Hook::ChangelogAppend,
458                Hook::BranchCleanup,
459            ]
460        );
461    }
462
463    #[test]
464    fn branch_create_makes_feature_branch() {
465        let dir = tempfile::tempdir().unwrap();
466        init_repo(dir.path());
467        Hook::BranchCreate
468            .run(&mut ctx(dir.path(), Stage::Define))
469            .unwrap();
470        assert!(GitFlow::new(dir.path()).branch_exists("feature/phase-11"));
471    }
472
473    #[test]
474    fn changelog_append_writes_entry() {
475        let dir = tempfile::tempdir().unwrap();
476        init_repo(dir.path());
477        Hook::ChangelogAppend
478            .run(&mut ctx(dir.path(), Stage::Ship))
479            .unwrap();
480        let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
481        assert!(changelog.contains("# Changelog"));
482    }
483
484    #[test]
485    fn changelog_append_commits_its_own_write() {
486        // WR-04 (Round 2, 17-12): changelog_append must not leave its write
487        // uncommitted — that's what let the entry get orphaned when
488        // BranchCleanup ran before it.
489        let dir = tempfile::tempdir().unwrap();
490        init_repo(dir.path());
491        Hook::ChangelogAppend
492            .run(&mut ctx(dir.path(), Stage::Ship))
493            .unwrap();
494
495        let status = git_output(dir.path(), &["status", "--porcelain"]);
496        assert!(status.is_empty(), "expected clean tree, got: {status}");
497
498        let committed_files = git_output(dir.path(), &["log", "-1", "--name-only"]);
499        assert!(
500            committed_files.contains("CHANGELOG.md"),
501            "expected CHANGELOG.md in the latest commit, got: {committed_files}"
502        );
503    }
504
505    #[test]
506    fn version_bump_tags_repo() {
507        let dir = tempfile::tempdir().unwrap();
508        init_repo(dir.path());
509        // Hybrid SemVer: major 2 (Cargo.toml), minor 0 (no tags), patch from
510        // the commit count since the last tag — one `init` commit → v2.0.1.
511        let expected = format!("v{}", version::compute_version(dir.path()).unwrap());
512        Hook::VersionBump
513            .run(&mut ctx(dir.path(), Stage::Ship))
514            .unwrap();
515        let tags = crate::test_support::git_command(dir.path())
516            .arg("tag")
517            .output()
518            .unwrap();
519        assert!(String::from_utf8_lossy(&tags.stdout).contains(&expected));
520    }
521
522    #[test]
523    fn terminal_hooks_version_post_merge_develop() {
524        let dir = tempfile::tempdir().unwrap();
525        init_repo(dir.path());
526        git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
527        std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
528        git(dir.path(), &["add", "feature.txt"]);
529        git(dir.path(), &["commit", "-q", "-m", "phase work"]);
530
531        let feature_tip = git_output(dir.path(), &["rev-parse", "feature/phase-11"]);
532        let pre_merge_count = git_output(dir.path(), &["rev-list", "--count", "HEAD"]);
533
534        let mut context = ctx(dir.path(), Stage::Ship);
535        for hook in hooks_after_ship() {
536            hook.run(&mut context).unwrap();
537        }
538
539        git(
540            dir.path(),
541            &["merge-base", "--is-ancestor", &feature_tip, "develop"],
542        );
543        let post_merge_count = git_output(dir.path(), &["rev-list", "--count", "develop"]);
544        assert_ne!(pre_merge_count, post_merge_count);
545
546        // Exactly one tag was created, and it names the version VersionBump
547        // actually wrote to the version file (not a raw rev-list count,
548        // which would now also include VersionBump's own commit and
549        // ChangelogAppend's — both introduced by 17-12).
550        let all_tags = git_output(dir.path(), &["tag"]);
551        assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
552        let tag = all_tags.trim().to_string();
553        let version_file_version = version::read_version(dir.path()).unwrap().to_string();
554        assert_eq!(tag, format!("v{version_file_version}"));
555
556        // The tag no longer points at develop's tip — ChangelogAppend's
557        // commit (17-12) lands after it.
558        let develop_tip = git_output(dir.path(), &["rev-parse", "develop"]);
559        let tag_commit = git_output(dir.path(), &["rev-parse", &format!("{tag}^{{commit}}")]);
560        assert_ne!(develop_tip, tag_commit);
561    }
562
563    #[test]
564    fn after_ship_batch_changelog_tag_and_version_file_agree_and_tree_is_clean() {
565        // Full regression for WR-04 (17-12): drives the whole hooks_after_ship
566        // batch and asserts three-way agreement between the changelog
567        // heading, the created git tag, and the version file's version —
568        // plus the Round 2 WR-04 commit requirement (clean tree, CHANGELOG.md
569        // present in a commit). Must fail against pre-17-12 main: the old
570        // batch order never ran ChangelogAppend here at all (it fired at
571        // Validate→Ship, before any tag existed), so CHANGELOG.md would not
572        // exist after running only hooks_after_ship().
573        let dir = tempfile::tempdir().unwrap();
574        init_repo(dir.path());
575        // Merge fires events::emit, which creates .devflow/ — gitignored in
576        // every real project (WR-11); mirror that here so the clean-tree
577        // assertion below checks hook writes, not test-fixture telemetry.
578        std::fs::write(dir.path().join(".gitignore"), ".devflow/\n").unwrap();
579        git(dir.path(), &["add", ".gitignore"]);
580        git(dir.path(), &["commit", "-q", "-m", "add gitignore"]);
581        git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
582        std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
583        git(dir.path(), &["add", "feature.txt"]);
584        git(dir.path(), &["commit", "-q", "-m", "phase work"]);
585
586        let mut context = ctx(dir.path(), Stage::Ship);
587        for hook in hooks_after_ship() {
588            hook.run(&mut context).unwrap();
589        }
590
591        // Exactly one tag was created by this batch (init_repo creates none).
592        let all_tags = git_output(dir.path(), &["tag"]);
593        assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
594        let tag = all_tags.trim().to_string();
595
596        let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
597        let changelog_version = changelog
598            .lines()
599            .find(|l| l.starts_with("## "))
600            .and_then(|l| l.trim_start_matches("## ").split(' ').next())
601            .unwrap()
602            .to_string();
603
604        let version_file_version = version::read_version(dir.path()).unwrap().to_string();
605
606        assert_eq!(
607            tag,
608            format!("v{changelog_version}"),
609            "tag must match the changelog heading version"
610        );
611        assert_eq!(
612            changelog_version, version_file_version,
613            "changelog heading must match the version file's version"
614        );
615
616        // Round 2 WR-04: the changelog write must be committed, and the
617        // working tree must be clean after the full batch.
618        let status = git_output(dir.path(), &["status", "--porcelain"]);
619        assert!(status.is_empty(), "expected clean tree, got: {status}");
620        let committed_files = git_output(dir.path(), &["log", "-1", "--name-only"]);
621        assert!(
622            committed_files.contains("CHANGELOG.md"),
623            "expected CHANGELOG.md in the latest commit, got: {committed_files}"
624        );
625    }
626
627    #[test]
628    fn after_ship_batch_with_no_version_file_keeps_tag_and_changelog_in_sync() {
629        // GAP-7: with no version file, version_bump takes the `else` branch
630        // (warns, tags only) and still tags v{compute_version()}.
631        // changelog_append then calls version::read_version, which errors
632        // with no version file present, and falls back to the literal
633        // "unreleased" -- desyncing the tag from the changelog heading.
634        // init_repo unconditionally writes Cargo.toml, so this branch is
635        // unreachable from the other batch tests; init_repo_with_options
636        // reaches it without changing init_repo's own behavior.
637        let dir = tempfile::tempdir().unwrap();
638        init_repo_with_options(dir.path(), false);
639        // Mirror the existing batch test's .gitignore / feature-branch setup
640        // so the run is comparable.
641        std::fs::write(dir.path().join(".gitignore"), ".devflow/\n").unwrap();
642        git(dir.path(), &["add", ".gitignore"]);
643        git(dir.path(), &["commit", "-q", "-m", "add gitignore"]);
644        git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
645        std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
646        git(dir.path(), &["add", "feature.txt"]);
647        git(dir.path(), &["commit", "-q", "-m", "phase work"]);
648
649        let mut context = ctx(dir.path(), Stage::Ship);
650        for hook in hooks_after_ship() {
651            hook.run(&mut context).unwrap();
652        }
653
654        let all_tags = git_output(dir.path(), &["tag"]);
655        assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
656        let tag = all_tags.trim().to_string();
657        let tag_version = tag
658            .strip_prefix('v')
659            .expect("tag should be prefixed with v")
660            .to_string();
661
662        let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
663        let changelog_version = changelog
664            .lines()
665            .find(|l| l.starts_with("## "))
666            .and_then(|l| l.trim_start_matches("## ").split(' ').next())
667            .unwrap()
668            .to_string();
669
670        assert_ne!(
671            changelog_version, "unreleased",
672            "changelog heading must name the tagged version, not fall back to the literal"
673        );
674        assert_eq!(
675            changelog_version, tag_version,
676            "changelog heading must match the git tag ({tag}) even with no version file"
677        );
678    }
679
680    #[test]
681    fn merge_succeeds_while_feature_branch_is_checked_out_in_linked_worktree() {
682        let dir = tempfile::tempdir().unwrap();
683        let repo = dir.path().join("repo");
684        let worktree = dir.path().join("phase-worktree");
685        std::fs::create_dir_all(&repo).unwrap();
686        init_repo(&repo);
687        git(
688            &repo,
689            &[
690                "worktree",
691                "add",
692                "-q",
693                "-b",
694                "feature/phase-11",
695                worktree.to_str().unwrap(),
696                "develop",
697            ],
698        );
699        std::fs::write(worktree.join("feature.txt"), "phase work\n").unwrap();
700        git(&worktree, &["add", "feature.txt"]);
701        git(&worktree, &["commit", "-q", "-m", "phase work"]);
702
703        Hook::Merge.run(&mut ctx(&repo, Stage::Ship)).unwrap();
704
705        git(
706            &repo,
707            &["merge-base", "--is-ancestor", "feature/phase-11", "develop"],
708        );
709        assert!(GitFlow::new(&repo).branch_exists("feature/phase-11"));
710    }
711
712    #[test]
713    fn branch_cleanup_is_fail_soft_when_branch_absent() {
714        let dir = tempfile::tempdir().unwrap();
715        init_repo(dir.path());
716        // No feature branch exists — cleanup must still succeed.
717        Hook::BranchCleanup
718            .run(&mut ctx(dir.path(), Stage::Ship))
719            .unwrap();
720    }
721
722    #[test]
723    fn merge_fails_closed_when_branch_absent() {
724        let dir = tempfile::tempdir().unwrap();
725        init_repo(dir.path());
726        // Branch absence cannot prove that phase work reached develop.
727        let error = Hook::Merge
728            .run(&mut ctx(dir.path(), Stage::Ship))
729            .unwrap_err();
730        assert!(error.to_string().contains("unproven merge"));
731    }
732
733    /// 23-06 Task 2 acceptance: a real merge through the hook, with the new
734    /// post-merge ancestry re-check present, still succeeds and still
735    /// records a `merge_result` event with `merged: true` — proving the
736    /// added assertion is a no-op on the happy path it re-confirms.
737    #[test]
738    fn merge_through_hook_records_true_merged_result_after_ancestry_reconfirmed() {
739        let dir = tempfile::tempdir().unwrap();
740        init_repo(dir.path());
741        git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
742        std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
743        git(dir.path(), &["add", "feature.txt"]);
744        git(dir.path(), &["commit", "-q", "-m", "phase work"]);
745        git(dir.path(), &["checkout", "-q", "develop"]);
746
747        Hook::Merge.run(&mut ctx(dir.path(), Stage::Ship)).unwrap();
748
749        assert!(GitFlow::new(dir.path()).is_merged_into_develop(PhaseId::new(11)));
750        let last = crate::events::last_event_for_phase(dir.path(), PhaseId::new(11))
751            .expect("merge_result event recorded");
752        assert_eq!(last["event"], "merge_result");
753        assert_eq!(last["merged"], true);
754        assert_eq!(last["branch"], "feature/phase-11");
755    }
756
757    /// 23-06 Task 2: the pre-existing missing-branch refusal is unchanged —
758    /// it still short-circuits before the merge (and before the new
759    /// post-condition) ever runs, so it never even reaches the event log.
760    #[test]
761    fn merge_fails_closed_when_branch_absent_emits_no_merge_result_event() {
762        let dir = tempfile::tempdir().unwrap();
763        init_repo(dir.path());
764
765        let _ = Hook::Merge.run(&mut ctx(dir.path(), Stage::Ship));
766
767        assert!(
768            crate::events::last_event_for_phase(dir.path(), PhaseId::new(11)).is_none(),
769            "a missing feature branch must short-circuit before any event is emitted"
770        );
771    }
772
773    fn git_output(root: &Path, args: &[&str]) -> String {
774        let output = crate::test_support::git_command(root)
775            .args(args)
776            .output()
777            .unwrap();
778        assert!(output.status.success(), "git {args:?} failed");
779        String::from_utf8_lossy(&output.stdout).trim().to_string()
780    }
781
782    /// D-12 end-to-end: `VersionBump` computes and hands forward the
783    /// changelog body, `ChangelogAppend` writes it — a real `feat:` commit
784    /// produces a `### Added` section naming that commit's subject in the
785    /// actual `CHANGELOG.md` file the hook wrote (a file contract, not an
786    /// internal return value). Reverting only `version_bump`'s body-capture
787    /// hunk makes this fail on the `### Added` assertion below, not on a
788    /// compile error or a fixture panic.
789    #[test]
790    fn changelog_append_writes_the_generated_body_end_to_end() {
791        let dir = tempfile::tempdir().unwrap();
792        init_repo(dir.path());
793        git(
794            dir.path(),
795            &[
796                "commit",
797                "--allow-empty",
798                "-q",
799                "-m",
800                "feat: add the widget endpoint",
801            ],
802        );
803
804        let mut context = ctx(dir.path(), Stage::Ship);
805        Hook::VersionBump.run(&mut context).unwrap();
806        Hook::ChangelogAppend.run(&mut context).unwrap();
807
808        let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
809        assert!(
810            changelog.contains("### Added"),
811            "expected a ### Added section, got: {changelog}"
812        );
813        assert!(
814            changelog.contains("add the widget endpoint"),
815            "expected the feat commit's subject, got: {changelog}"
816        );
817    }
818}