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