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}
45
46/// Errors produced by hooks.
47#[derive(Debug, thiserror::Error)]
48pub enum HookError {
49    /// A git-flow operation failed.
50    #[error(transparent)]
51    Git(#[from] crate::git::GitError),
52    /// A version operation failed.
53    #[error(transparent)]
54    Version(#[from] version::VersionError),
55    /// Filesystem operation failed.
56    #[error("hook I/O failed: {0}")]
57    Io(#[from] std::io::Error),
58}
59
60impl Hook {
61    /// Run this hook against the given context.
62    pub fn run(&self, ctx: &HookContext) -> Result<(), HookError> {
63        match self {
64            Hook::BranchCreate => branch_create(ctx),
65            Hook::BranchCleanup => branch_cleanup(ctx),
66            Hook::DocsUpdate => docs_update(ctx),
67            Hook::Merge => merge_feature(ctx),
68            Hook::ChangelogAppend => changelog_append(ctx),
69            Hook::VersionBump => version_bump(ctx),
70        }
71    }
72}
73
74/// Which hooks fire when moving `from` → `to`.
75///
76/// - Validate → Ship: docs + changelog are finalized before shipping.
77/// - Ship → (done): merge + version bump + branch cleanup.
78/// - everything else: none.
79pub fn hooks_for_transition(from: Stage, to: Stage) -> Vec<Hook> {
80    match (from, to) {
81        (Stage::Validate, Stage::Ship) => vec![Hook::DocsUpdate, Hook::ChangelogAppend],
82        _ => Vec::new(),
83    }
84}
85
86/// Hooks that fire after Ship completes (the workflow's terminal transition).
87pub fn hooks_after_ship() -> Vec<Hook> {
88    vec![Hook::Merge, Hook::VersionBump, Hook::BranchCleanup]
89}
90
91fn branch_create(ctx: &HookContext) -> Result<(), HookError> {
92    let git = GitFlow::new(&ctx.project_root);
93    let branch = git.feature_start(ctx.phase)?;
94    info!("BranchCreate: created {branch}");
95    Ok(())
96}
97
98fn branch_cleanup(ctx: &HookContext) -> Result<(), HookError> {
99    let git = GitFlow::new(&ctx.project_root);
100    let branch = format!("{}phase-{:02}", ctx.git_flow.feature_prefix, ctx.phase);
101    if git.branch_exists(&branch) {
102        // Non-force cleanup is intentional: never discard unmerged work.
103        match git.delete_branch(&branch, false) {
104            Ok(()) => info!("BranchCleanup: deleted {branch}"),
105            Err(err) => {
106                let message = err.to_string();
107                if message.contains("not fully merged") || message.contains("not yet merged") {
108                    warn!(
109                        "BranchCleanup: feature branch {branch} is not merged yet — left in place"
110                    );
111                } else {
112                    warn!("BranchCleanup: could not delete {branch}: {err}");
113                }
114            }
115        }
116    }
117    Ok(())
118}
119
120fn merge_feature(ctx: &HookContext) -> Result<(), HookError> {
121    let git = GitFlow::new(&ctx.project_root);
122    let branch = format!("{}phase-{:02}", ctx.git_flow.feature_prefix, ctx.phase);
123    if !git.branch_exists(&branch) {
124        return Err(crate::git::GitError::Command(format!(
125            "feature branch `{branch}` is missing; refusing to report an unproven merge"
126        ))
127        .into());
128    }
129    if git.is_merged_into_develop(ctx.phase) {
130        info!("Merge: {branch} is already merged; nothing to merge");
131        crate::events::emit(
132            &ctx.project_root,
133            ctx.phase,
134            "merge_result",
135            serde_json::json!({"merged": false, "branch": branch}),
136        );
137        return Ok(());
138    }
139
140    git.merge_feature_into_develop(ctx.phase)?;
141    info!("Merge: merged {branch} into develop");
142    crate::events::emit(
143        &ctx.project_root,
144        ctx.phase,
145        "merge_result",
146        serde_json::json!({"merged": true, "branch": branch}),
147    );
148    Ok(())
149}
150
151fn docs_update(ctx: &HookContext) -> Result<(), HookError> {
152    let output = Command::new("sh")
153        .arg("-c")
154        .arg("cargo doc --no-deps 2>&1")
155        .current_dir(&ctx.project_root)
156        .output();
157    match output {
158        Ok(out) if out.status.success() => {
159            // Commit any doc changes; ignore "nothing to commit".
160            let git = GitFlow::new(&ctx.project_root);
161            if let Err(err) = git.commit_all("docs: update generated docs") {
162                warn!("DocsUpdate: commit failed: {err}");
163            } else {
164                info!("DocsUpdate: docs regenerated and committed");
165            }
166        }
167        Ok(_) => warn!("DocsUpdate: cargo doc reported a failure; skipping commit"),
168        Err(err) => warn!("DocsUpdate: could not run cargo doc: {err}"),
169    }
170    Ok(())
171}
172
173fn changelog_append(ctx: &HookContext) -> Result<(), HookError> {
174    let version = version::compute_version(&ctx.project_root)
175        .map(|v| v.to_string())
176        .unwrap_or_else(|_| "unreleased".to_string());
177    let path = ctx.project_root.join("CHANGELOG.md");
178    let existing = std::fs::read_to_string(&path).unwrap_or_default();
179    let updated = crate::ship::prepend_changelog(&existing, &version, &today());
180    std::fs::write(&path, updated)?;
181    info!("ChangelogAppend: wrote entry for {version}");
182    Ok(())
183}
184
185fn version_bump(ctx: &HookContext) -> Result<(), HookError> {
186    let version = version::compute_version(&ctx.project_root)?;
187    // Write the computed version into the version file when one exists.
188    if has_version_file(&ctx.project_root) {
189        let path = version::write_version(&ctx.project_root, &version)?;
190        info!("VersionBump: wrote {version} to {}", path.display());
191    } else {
192        warn!("VersionBump: no supported version file; tagging only");
193    }
194    let git = GitFlow::new(&ctx.project_root);
195    let tag = format!("v{version}");
196    git.tag(&tag)?;
197    info!("VersionBump: tagged {tag}");
198    Ok(())
199}
200
201/// Today's date as YYYY-MM-DD (best-effort via the `date` command).
202fn today() -> String {
203    Command::new("date")
204        .arg("+%Y-%m-%d")
205        .output()
206        .ok()
207        .filter(|o| o.status.success())
208        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
209        .filter(|s| !s.is_empty())
210        .unwrap_or_else(|| "unreleased".to_string())
211}
212
213/// Whether a project has a version file, used by callers to decide if a version
214/// bump is meaningful.
215pub fn has_version_file(project_root: &Path) -> bool {
216    version::detect_version_file(project_root).is_some()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn git(root: &Path, args: &[&str]) {
224        let ok = Command::new("git")
225            .args(args)
226            .current_dir(root)
227            .output()
228            .unwrap()
229            .status
230            .success();
231        assert!(ok, "git {args:?} failed");
232    }
233
234    fn init_repo(root: &Path) {
235        git(root, &["init", "-q"]);
236        git(root, &["config", "user.email", "test@example.com"]);
237        git(root, &["config", "user.name", "Test"]);
238        git(root, &["config", "commit.gpgsign", "false"]);
239        git(root, &["config", "tag.gpgsign", "false"]);
240        git(root, &["config", "core.hooksPath", "/dev/null"]);
241        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
242        git(root, &["add", "."]);
243        git(root, &["commit", "-q", "-m", "init"]);
244        git(root, &["branch", "-M", "main"]);
245        git(root, &["checkout", "-q", "-b", "develop"]);
246    }
247
248    fn ctx(root: &Path, stage: Stage) -> HookContext {
249        HookContext {
250            phase: 11,
251            project_root: root.to_path_buf(),
252            stage,
253            git_flow: GitFlowConfig::default(),
254        }
255    }
256
257    #[test]
258    fn transition_map_finalizes_docs_and_changelog_before_ship() {
259        assert_eq!(
260            hooks_for_transition(Stage::Validate, Stage::Ship),
261            vec![Hook::DocsUpdate, Hook::ChangelogAppend]
262        );
263        assert!(hooks_for_transition(Stage::Define, Stage::Plan).is_empty());
264        assert!(hooks_for_transition(Stage::Code, Stage::Validate).is_empty());
265    }
266
267    #[test]
268    fn validate_to_ship_hooks_append_changelog() {
269        let dir = tempfile::tempdir().unwrap();
270        init_repo(dir.path());
271        let context = ctx(dir.path(), Stage::Ship);
272
273        for hook in hooks_for_transition(Stage::Validate, Stage::Ship) {
274            hook.run(&context).unwrap();
275        }
276
277        let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
278        assert!(changelog.contains("## "));
279    }
280
281    #[test]
282    fn after_ship_runs_version_and_cleanup() {
283        assert_eq!(
284            hooks_after_ship(),
285            vec![Hook::Merge, Hook::VersionBump, Hook::BranchCleanup]
286        );
287    }
288
289    #[test]
290    fn branch_create_makes_feature_branch() {
291        let dir = tempfile::tempdir().unwrap();
292        init_repo(dir.path());
293        Hook::BranchCreate
294            .run(&ctx(dir.path(), Stage::Define))
295            .unwrap();
296        assert!(GitFlow::new(dir.path()).branch_exists("feature/phase-11"));
297    }
298
299    #[test]
300    fn changelog_append_writes_entry() {
301        let dir = tempfile::tempdir().unwrap();
302        init_repo(dir.path());
303        Hook::ChangelogAppend
304            .run(&ctx(dir.path(), Stage::Ship))
305            .unwrap();
306        let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
307        assert!(changelog.contains("# Changelog"));
308    }
309
310    #[test]
311    fn version_bump_tags_repo() {
312        let dir = tempfile::tempdir().unwrap();
313        init_repo(dir.path());
314        // Hybrid SemVer: major 2 (Cargo.toml), minor 0 (no tags), patch from
315        // the commit count since the last tag — one `init` commit → v2.0.1.
316        let expected = format!("v{}", version::compute_version(dir.path()).unwrap());
317        Hook::VersionBump
318            .run(&ctx(dir.path(), Stage::Ship))
319            .unwrap();
320        let tags = Command::new("git")
321            .arg("tag")
322            .current_dir(dir.path())
323            .output()
324            .unwrap();
325        assert!(String::from_utf8_lossy(&tags.stdout).contains(&expected));
326    }
327
328    #[test]
329    fn terminal_hooks_version_post_merge_develop() {
330        let dir = tempfile::tempdir().unwrap();
331        init_repo(dir.path());
332        git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
333        std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
334        git(dir.path(), &["add", "feature.txt"]);
335        git(dir.path(), &["commit", "-q", "-m", "phase work"]);
336
337        let feature_tip = git_output(dir.path(), &["rev-parse", "feature/phase-11"]);
338        let pre_merge_count = git_output(dir.path(), &["rev-list", "--count", "HEAD"]);
339
340        let context = ctx(dir.path(), Stage::Ship);
341        for hook in hooks_after_ship() {
342            hook.run(&context).unwrap();
343        }
344
345        git(
346            dir.path(),
347            &["merge-base", "--is-ancestor", &feature_tip, "develop"],
348        );
349        let post_merge_count = git_output(dir.path(), &["rev-list", "--count", "develop"]);
350        assert_ne!(pre_merge_count, post_merge_count);
351
352        let tag = git_output(dir.path(), &["tag", "--points-at", "develop"]);
353        assert_eq!(tag, format!("v2.0.{post_merge_count}"));
354        assert_ne!(tag, format!("v2.0.{pre_merge_count}"));
355    }
356
357    #[test]
358    fn merge_succeeds_while_feature_branch_is_checked_out_in_linked_worktree() {
359        let dir = tempfile::tempdir().unwrap();
360        let repo = dir.path().join("repo");
361        let worktree = dir.path().join("phase-worktree");
362        std::fs::create_dir_all(&repo).unwrap();
363        init_repo(&repo);
364        git(
365            &repo,
366            &[
367                "worktree",
368                "add",
369                "-q",
370                "-b",
371                "feature/phase-11",
372                worktree.to_str().unwrap(),
373                "develop",
374            ],
375        );
376        std::fs::write(worktree.join("feature.txt"), "phase work\n").unwrap();
377        git(&worktree, &["add", "feature.txt"]);
378        git(&worktree, &["commit", "-q", "-m", "phase work"]);
379
380        Hook::Merge.run(&ctx(&repo, Stage::Ship)).unwrap();
381
382        git(
383            &repo,
384            &["merge-base", "--is-ancestor", "feature/phase-11", "develop"],
385        );
386        assert!(GitFlow::new(&repo).branch_exists("feature/phase-11"));
387    }
388
389    #[test]
390    fn branch_cleanup_is_fail_soft_when_branch_absent() {
391        let dir = tempfile::tempdir().unwrap();
392        init_repo(dir.path());
393        // No feature branch exists — cleanup must still succeed.
394        Hook::BranchCleanup
395            .run(&ctx(dir.path(), Stage::Ship))
396            .unwrap();
397    }
398
399    #[test]
400    fn merge_fails_closed_when_branch_absent() {
401        let dir = tempfile::tempdir().unwrap();
402        init_repo(dir.path());
403        // Branch absence cannot prove that phase work reached develop.
404        let error = Hook::Merge.run(&ctx(dir.path(), Stage::Ship)).unwrap_err();
405        assert!(error.to_string().contains("unproven merge"));
406    }
407
408    fn git_output(root: &Path, args: &[&str]) -> String {
409        let output = Command::new("git")
410            .args(args)
411            .current_dir(root)
412            .output()
413            .unwrap();
414        assert!(output.status.success(), "git {args:?} failed");
415        String::from_utf8_lossy(&output.stdout).trim().to_string()
416    }
417}