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