1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Hook {
19 BranchCreate,
21 BranchCleanup,
23 DocsUpdate,
25 Merge,
27 ChangelogAppend,
29 VersionBump,
31}
32
33#[derive(Debug, Clone)]
35pub struct HookContext {
36 pub phase: u32,
38 pub project_root: PathBuf,
40 pub stage: Stage,
42 pub git_flow: GitFlowConfig,
44 pub shipped_version: Option<String>,
50 pub shipped_changelog_body: Option<String>,
59}
60
61#[derive(Debug, thiserror::Error)]
63pub enum HookError {
64 #[error(transparent)]
66 Git(#[from] crate::git::GitError),
67 #[error(transparent)]
69 Version(#[from] version::VersionError),
70 #[error("hook I/O failed: {0}")]
72 Io(#[from] std::io::Error),
73}
74
75impl Hook {
76 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
89pub 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
106pub 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 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
152fn 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 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 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 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 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(§ions));
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 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 ctx.shipped_version = Some(version.to_string());
335 info!("VersionBump: tagged {tag}");
336 Ok(())
337}
338
339fn 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
351pub 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 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 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 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 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 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 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 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 let dir = tempfile::tempdir().unwrap();
565 init_repo(dir.path());
566 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 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 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 let dir = tempfile::tempdir().unwrap();
629 init_repo_with_options(dir.path(), false);
630 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 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 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 #[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 #[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 #[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}