1use crate::config::GitFlowConfig;
9use crate::git::GitFlow;
10use crate::phase_id::PhaseId;
11use crate::stage::Stage;
12use crate::version;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use tracing::{info, warn};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Hook {
20 BranchCreate,
22 BranchCleanup,
24 DocsUpdate,
26 Merge,
28 ChangelogAppend,
30 VersionBump,
32}
33
34#[derive(Debug, Clone)]
36pub struct HookContext {
37 pub phase: PhaseId,
39 pub project_root: PathBuf,
41 pub stage: Stage,
43 pub git_flow: GitFlowConfig,
45 pub shipped_version: Option<String>,
51 pub shipped_changelog_body: Option<String>,
60}
61
62#[derive(Debug, thiserror::Error)]
64pub enum HookError {
65 #[error(transparent)]
67 Git(#[from] crate::git::GitError),
68 #[error(transparent)]
70 Version(#[from] version::VersionError),
71 #[error("hook I/O failed: {0}")]
73 Io(#[from] std::io::Error),
74}
75
76impl Hook {
77 pub fn run(&self, ctx: &mut HookContext) -> Result<(), HookError> {
79 match self {
80 Hook::BranchCreate => branch_create(ctx),
81 Hook::BranchCleanup => branch_cleanup(ctx),
82 Hook::DocsUpdate => docs_update(ctx),
83 Hook::Merge => merge_feature(ctx),
84 Hook::ChangelogAppend => changelog_append(ctx),
85 Hook::VersionBump => version_bump(ctx),
86 }
87 }
88}
89
90pub fn hooks_for_transition(from: Stage, to: Stage) -> Vec<Hook> {
101 match (from, to) {
102 (Stage::Validate, Stage::Ship) => vec![Hook::DocsUpdate],
103 _ => Vec::new(),
104 }
105}
106
107pub fn hooks_after_ship() -> Vec<Hook> {
116 vec![
117 Hook::Merge,
118 Hook::VersionBump,
119 Hook::ChangelogAppend,
120 Hook::BranchCleanup,
121 ]
122}
123
124fn branch_create(ctx: &HookContext) -> Result<(), HookError> {
125 let git = GitFlow::new(&ctx.project_root);
126 let branch = git.feature_start(ctx.phase)?;
127 info!("BranchCreate: created {branch}");
128 Ok(())
129}
130
131fn branch_cleanup(ctx: &HookContext) -> Result<(), HookError> {
132 let git = GitFlow::new(&ctx.project_root);
133 let branch = format!(
134 "{}phase-{}",
135 ctx.git_flow.feature_prefix,
136 ctx.phase.padded()
137 );
138 if git.branch_exists(&branch) {
139 match git.delete_branch(&branch, false) {
141 Ok(()) => info!("BranchCleanup: deleted {branch}"),
142 Err(err) => {
143 let message = err.to_string();
144 if message.contains("not fully merged") || message.contains("not yet merged") {
145 warn!(
146 "BranchCleanup: feature branch {branch} is not merged yet — left in place"
147 );
148 } else {
149 warn!("BranchCleanup: could not delete {branch}: {err}");
150 }
151 }
152 }
153 }
154 Ok(())
155}
156
157fn merge_feature(ctx: &HookContext) -> Result<(), HookError> {
181 let git = GitFlow::new(&ctx.project_root);
182 let branch = format!(
183 "{}phase-{}",
184 ctx.git_flow.feature_prefix,
185 ctx.phase.padded()
186 );
187 if !git.branch_exists(&branch) {
188 return Err(crate::git::GitError::Command(format!(
189 "feature branch `{branch}` is missing; refusing to report an unproven merge"
190 ))
191 .into());
192 }
193 if git.is_merged_into_develop(ctx.phase) {
194 info!("Merge: {branch} is already merged; nothing to merge");
195 crate::events::emit(
196 &ctx.project_root,
197 ctx.phase,
198 "merge_result",
199 serde_json::json!({"merged": false, "branch": branch}),
200 );
201 return Ok(());
202 }
203
204 git.merge_feature_into_develop(ctx.phase)?;
205
206 if !git.is_merged_into_develop(ctx.phase) {
207 crate::events::emit(
208 &ctx.project_root,
209 ctx.phase,
210 "merge_result",
211 serde_json::json!({"merged": false, "branch": branch}),
212 );
213 return Err(crate::git::GitError::Command(format!(
214 "merge of `{branch}` reported success but the branch is still not an ancestor of \
215 develop; refusing to report an unproven merge"
216 ))
217 .into());
218 }
219
220 info!("Merge: merged {branch} into develop");
221 crate::events::emit(
222 &ctx.project_root,
223 ctx.phase,
224 "merge_result",
225 serde_json::json!({"merged": true, "branch": branch}),
226 );
227 Ok(())
228}
229
230fn docs_update(ctx: &HookContext) -> Result<(), HookError> {
231 let output = Command::new("sh")
232 .arg("-c")
233 .arg("cargo doc --no-deps 2>&1")
234 .current_dir(&ctx.project_root)
235 .output();
236 match output {
237 Ok(out) if out.status.success() => {
238 let git = GitFlow::new(&ctx.project_root);
240 if let Err(err) = git.commit_all("docs: update generated docs") {
241 warn!("DocsUpdate: commit failed: {err}");
242 } else {
243 info!("DocsUpdate: docs regenerated and committed");
244 }
245 }
246 Ok(_) => warn!("DocsUpdate: cargo doc reported a failure; skipping commit"),
247 Err(err) => warn!("DocsUpdate: could not run cargo doc: {err}"),
248 }
249 Ok(())
250}
251
252fn changelog_append(ctx: &mut HookContext) -> Result<(), HookError> {
253 let version = ctx.shipped_version.clone().unwrap_or_else(|| {
262 version::read_version(&ctx.project_root)
263 .map(|v| v.to_string())
264 .unwrap_or_else(|_| "unreleased".to_string())
265 });
266 let body = ctx.shipped_changelog_body.as_deref().unwrap_or("");
267 let path = ctx.project_root.join("CHANGELOG.md");
268 let existing = std::fs::read_to_string(&path).unwrap_or_default();
269 let updated = crate::ship::prepend_changelog(&existing, &version, &today(), body);
270 std::fs::write(&path, updated)?;
271 let git = GitFlow::new(&ctx.project_root);
279 git.commit_path(
280 "CHANGELOG.md",
281 &format!("docs: add changelog entry for {version}"),
282 )?;
283 info!("ChangelogAppend: wrote and committed entry for {version}");
284 Ok(())
285}
286
287fn version_bump(ctx: &mut HookContext) -> Result<(), HookError> {
288 let version = version::compute_version(&ctx.project_root)?;
289 let git = GitFlow::new(&ctx.project_root);
290
291 match version::reachable_semver_baseline(&ctx.project_root) {
300 Ok(baseline) => {
301 let range_start = match &baseline {
302 Some(tag) => version::release_range_start(&ctx.project_root, &format!("v{tag}")),
303 None => Ok(String::new()),
304 };
305 match range_start.and_then(|range_start| {
306 version::changelog_sections(&ctx.project_root, &range_start)
307 }) {
308 Ok(sections) => {
309 ctx.shipped_changelog_body = Some(version::render_changelog_body(§ions));
310 }
311 Err(err) => {
312 warn!("VersionBump: could not compute changelog body: {err}");
313 }
314 }
315 }
316 Err(err) => {
317 warn!("VersionBump: could not resolve changelog baseline: {err}");
318 }
319 }
320
321 if has_version_file(&ctx.project_root) {
328 let path = version::write_version(&ctx.project_root, &version)?;
329 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
330 git.commit_path(name, &format!("chore: bump version to {version}"))?;
331 }
332 info!("VersionBump: wrote {version} to {}", path.display());
333 } else {
334 warn!("VersionBump: no supported version file; tagging only");
335 }
336 let tag = format!("v{version}");
337 git.tag(&tag)?;
338 ctx.shipped_version = Some(version.to_string());
344 info!("VersionBump: tagged {tag}");
345 Ok(())
346}
347
348fn today() -> String {
350 Command::new("date")
351 .arg("+%Y-%m-%d")
352 .output()
353 .ok()
354 .filter(|o| o.status.success())
355 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
356 .filter(|s| !s.is_empty())
357 .unwrap_or_else(|| "unreleased".to_string())
358}
359
360pub fn has_version_file(project_root: &Path) -> bool {
363 version::detect_version_file(project_root).is_some()
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 fn git(root: &Path, args: &[&str]) {
371 let ok = crate::test_support::git_command(root)
372 .args(args)
373 .output()
374 .unwrap()
375 .status
376 .success();
377 assert!(ok, "git {args:?} failed");
378 }
379
380 fn init_repo(root: &Path) {
381 init_repo_with_options(root, true);
382 }
383
384 fn init_repo_with_options(root: &Path, write_version_file: bool) {
391 git(root, &["init", "-q"]);
392 git(root, &["config", "user.email", "test@example.com"]);
393 git(root, &["config", "user.name", "Test"]);
394 git(root, &["config", "commit.gpgsign", "false"]);
395 git(root, &["config", "tag.gpgsign", "false"]);
396 git(root, &["config", "core.hooksPath", "/dev/null"]);
397 if write_version_file {
398 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
399 } else {
400 std::fs::write(root.join("README.md"), "no version file in this repo\n").unwrap();
401 }
402 git(root, &["add", "."]);
403 git(root, &["commit", "-q", "-m", "init"]);
404 git(root, &["branch", "-M", "main"]);
405 git(root, &["checkout", "-q", "-b", "develop"]);
406 }
407
408 fn ctx(root: &Path, stage: Stage) -> HookContext {
409 HookContext {
410 phase: PhaseId::new(11),
411 project_root: root.to_path_buf(),
412 stage,
413 git_flow: GitFlowConfig::default(),
414 shipped_version: None,
415 shipped_changelog_body: None,
416 }
417 }
418
419 #[test]
420 fn transition_map_finalizes_docs_only_before_ship() {
421 assert_eq!(
426 hooks_for_transition(Stage::Validate, Stage::Ship),
427 vec![Hook::DocsUpdate]
428 );
429 assert!(hooks_for_transition(Stage::Define, Stage::Plan).is_empty());
430 assert!(hooks_for_transition(Stage::Code, Stage::Validate).is_empty());
431 }
432
433 #[test]
434 fn validate_to_ship_hooks_do_not_touch_changelog() {
435 let dir = tempfile::tempdir().unwrap();
436 init_repo(dir.path());
437 let mut context = ctx(dir.path(), Stage::Ship);
438
439 for hook in hooks_for_transition(Stage::Validate, Stage::Ship) {
440 hook.run(&mut context).unwrap();
441 }
442
443 assert!(!dir.path().join("CHANGELOG.md").exists());
444 }
445
446 #[test]
447 fn after_ship_runs_version_changelog_then_cleanup() {
448 assert_eq!(
453 hooks_after_ship(),
454 vec![
455 Hook::Merge,
456 Hook::VersionBump,
457 Hook::ChangelogAppend,
458 Hook::BranchCleanup,
459 ]
460 );
461 }
462
463 #[test]
464 fn branch_create_makes_feature_branch() {
465 let dir = tempfile::tempdir().unwrap();
466 init_repo(dir.path());
467 Hook::BranchCreate
468 .run(&mut ctx(dir.path(), Stage::Define))
469 .unwrap();
470 assert!(GitFlow::new(dir.path()).branch_exists("feature/phase-11"));
471 }
472
473 #[test]
474 fn changelog_append_writes_entry() {
475 let dir = tempfile::tempdir().unwrap();
476 init_repo(dir.path());
477 Hook::ChangelogAppend
478 .run(&mut ctx(dir.path(), Stage::Ship))
479 .unwrap();
480 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
481 assert!(changelog.contains("# Changelog"));
482 }
483
484 #[test]
485 fn changelog_append_commits_its_own_write() {
486 let dir = tempfile::tempdir().unwrap();
490 init_repo(dir.path());
491 Hook::ChangelogAppend
492 .run(&mut ctx(dir.path(), Stage::Ship))
493 .unwrap();
494
495 let status = git_output(dir.path(), &["status", "--porcelain"]);
496 assert!(status.is_empty(), "expected clean tree, got: {status}");
497
498 let committed_files = git_output(dir.path(), &["log", "-1", "--name-only"]);
499 assert!(
500 committed_files.contains("CHANGELOG.md"),
501 "expected CHANGELOG.md in the latest commit, got: {committed_files}"
502 );
503 }
504
505 #[test]
506 fn version_bump_tags_repo() {
507 let dir = tempfile::tempdir().unwrap();
508 init_repo(dir.path());
509 let expected = format!("v{}", version::compute_version(dir.path()).unwrap());
512 Hook::VersionBump
513 .run(&mut ctx(dir.path(), Stage::Ship))
514 .unwrap();
515 let tags = crate::test_support::git_command(dir.path())
516 .arg("tag")
517 .output()
518 .unwrap();
519 assert!(String::from_utf8_lossy(&tags.stdout).contains(&expected));
520 }
521
522 #[test]
523 fn terminal_hooks_version_post_merge_develop() {
524 let dir = tempfile::tempdir().unwrap();
525 init_repo(dir.path());
526 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
527 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
528 git(dir.path(), &["add", "feature.txt"]);
529 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
530
531 let feature_tip = git_output(dir.path(), &["rev-parse", "feature/phase-11"]);
532 let pre_merge_count = git_output(dir.path(), &["rev-list", "--count", "HEAD"]);
533
534 let mut context = ctx(dir.path(), Stage::Ship);
535 for hook in hooks_after_ship() {
536 hook.run(&mut context).unwrap();
537 }
538
539 git(
540 dir.path(),
541 &["merge-base", "--is-ancestor", &feature_tip, "develop"],
542 );
543 let post_merge_count = git_output(dir.path(), &["rev-list", "--count", "develop"]);
544 assert_ne!(pre_merge_count, post_merge_count);
545
546 let all_tags = git_output(dir.path(), &["tag"]);
551 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
552 let tag = all_tags.trim().to_string();
553 let version_file_version = version::read_version(dir.path()).unwrap().to_string();
554 assert_eq!(tag, format!("v{version_file_version}"));
555
556 let develop_tip = git_output(dir.path(), &["rev-parse", "develop"]);
559 let tag_commit = git_output(dir.path(), &["rev-parse", &format!("{tag}^{{commit}}")]);
560 assert_ne!(develop_tip, tag_commit);
561 }
562
563 #[test]
564 fn after_ship_batch_changelog_tag_and_version_file_agree_and_tree_is_clean() {
565 let dir = tempfile::tempdir().unwrap();
574 init_repo(dir.path());
575 std::fs::write(dir.path().join(".gitignore"), ".devflow/\n").unwrap();
579 git(dir.path(), &["add", ".gitignore"]);
580 git(dir.path(), &["commit", "-q", "-m", "add gitignore"]);
581 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
582 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
583 git(dir.path(), &["add", "feature.txt"]);
584 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
585
586 let mut context = ctx(dir.path(), Stage::Ship);
587 for hook in hooks_after_ship() {
588 hook.run(&mut context).unwrap();
589 }
590
591 let all_tags = git_output(dir.path(), &["tag"]);
593 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
594 let tag = all_tags.trim().to_string();
595
596 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
597 let changelog_version = changelog
598 .lines()
599 .find(|l| l.starts_with("## "))
600 .and_then(|l| l.trim_start_matches("## ").split(' ').next())
601 .unwrap()
602 .to_string();
603
604 let version_file_version = version::read_version(dir.path()).unwrap().to_string();
605
606 assert_eq!(
607 tag,
608 format!("v{changelog_version}"),
609 "tag must match the changelog heading version"
610 );
611 assert_eq!(
612 changelog_version, version_file_version,
613 "changelog heading must match the version file's version"
614 );
615
616 let status = git_output(dir.path(), &["status", "--porcelain"]);
619 assert!(status.is_empty(), "expected clean tree, got: {status}");
620 let committed_files = git_output(dir.path(), &["log", "-1", "--name-only"]);
621 assert!(
622 committed_files.contains("CHANGELOG.md"),
623 "expected CHANGELOG.md in the latest commit, got: {committed_files}"
624 );
625 }
626
627 #[test]
628 fn after_ship_batch_with_no_version_file_keeps_tag_and_changelog_in_sync() {
629 let dir = tempfile::tempdir().unwrap();
638 init_repo_with_options(dir.path(), false);
639 std::fs::write(dir.path().join(".gitignore"), ".devflow/\n").unwrap();
642 git(dir.path(), &["add", ".gitignore"]);
643 git(dir.path(), &["commit", "-q", "-m", "add gitignore"]);
644 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
645 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
646 git(dir.path(), &["add", "feature.txt"]);
647 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
648
649 let mut context = ctx(dir.path(), Stage::Ship);
650 for hook in hooks_after_ship() {
651 hook.run(&mut context).unwrap();
652 }
653
654 let all_tags = git_output(dir.path(), &["tag"]);
655 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
656 let tag = all_tags.trim().to_string();
657 let tag_version = tag
658 .strip_prefix('v')
659 .expect("tag should be prefixed with v")
660 .to_string();
661
662 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
663 let changelog_version = changelog
664 .lines()
665 .find(|l| l.starts_with("## "))
666 .and_then(|l| l.trim_start_matches("## ").split(' ').next())
667 .unwrap()
668 .to_string();
669
670 assert_ne!(
671 changelog_version, "unreleased",
672 "changelog heading must name the tagged version, not fall back to the literal"
673 );
674 assert_eq!(
675 changelog_version, tag_version,
676 "changelog heading must match the git tag ({tag}) even with no version file"
677 );
678 }
679
680 #[test]
681 fn merge_succeeds_while_feature_branch_is_checked_out_in_linked_worktree() {
682 let dir = tempfile::tempdir().unwrap();
683 let repo = dir.path().join("repo");
684 let worktree = dir.path().join("phase-worktree");
685 std::fs::create_dir_all(&repo).unwrap();
686 init_repo(&repo);
687 git(
688 &repo,
689 &[
690 "worktree",
691 "add",
692 "-q",
693 "-b",
694 "feature/phase-11",
695 worktree.to_str().unwrap(),
696 "develop",
697 ],
698 );
699 std::fs::write(worktree.join("feature.txt"), "phase work\n").unwrap();
700 git(&worktree, &["add", "feature.txt"]);
701 git(&worktree, &["commit", "-q", "-m", "phase work"]);
702
703 Hook::Merge.run(&mut ctx(&repo, Stage::Ship)).unwrap();
704
705 git(
706 &repo,
707 &["merge-base", "--is-ancestor", "feature/phase-11", "develop"],
708 );
709 assert!(GitFlow::new(&repo).branch_exists("feature/phase-11"));
710 }
711
712 #[test]
713 fn branch_cleanup_is_fail_soft_when_branch_absent() {
714 let dir = tempfile::tempdir().unwrap();
715 init_repo(dir.path());
716 Hook::BranchCleanup
718 .run(&mut ctx(dir.path(), Stage::Ship))
719 .unwrap();
720 }
721
722 #[test]
723 fn merge_fails_closed_when_branch_absent() {
724 let dir = tempfile::tempdir().unwrap();
725 init_repo(dir.path());
726 let error = Hook::Merge
728 .run(&mut ctx(dir.path(), Stage::Ship))
729 .unwrap_err();
730 assert!(error.to_string().contains("unproven merge"));
731 }
732
733 #[test]
738 fn merge_through_hook_records_true_merged_result_after_ancestry_reconfirmed() {
739 let dir = tempfile::tempdir().unwrap();
740 init_repo(dir.path());
741 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
742 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
743 git(dir.path(), &["add", "feature.txt"]);
744 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
745 git(dir.path(), &["checkout", "-q", "develop"]);
746
747 Hook::Merge.run(&mut ctx(dir.path(), Stage::Ship)).unwrap();
748
749 assert!(GitFlow::new(dir.path()).is_merged_into_develop(PhaseId::new(11)));
750 let last = crate::events::last_event_for_phase(dir.path(), PhaseId::new(11))
751 .expect("merge_result event recorded");
752 assert_eq!(last["event"], "merge_result");
753 assert_eq!(last["merged"], true);
754 assert_eq!(last["branch"], "feature/phase-11");
755 }
756
757 #[test]
761 fn merge_fails_closed_when_branch_absent_emits_no_merge_result_event() {
762 let dir = tempfile::tempdir().unwrap();
763 init_repo(dir.path());
764
765 let _ = Hook::Merge.run(&mut ctx(dir.path(), Stage::Ship));
766
767 assert!(
768 crate::events::last_event_for_phase(dir.path(), PhaseId::new(11)).is_none(),
769 "a missing feature branch must short-circuit before any event is emitted"
770 );
771 }
772
773 fn git_output(root: &Path, args: &[&str]) -> String {
774 let output = crate::test_support::git_command(root)
775 .args(args)
776 .output()
777 .unwrap();
778 assert!(output.status.success(), "git {args:?} failed");
779 String::from_utf8_lossy(&output.stdout).trim().to_string()
780 }
781
782 #[test]
790 fn changelog_append_writes_the_generated_body_end_to_end() {
791 let dir = tempfile::tempdir().unwrap();
792 init_repo(dir.path());
793 git(
794 dir.path(),
795 &[
796 "commit",
797 "--allow-empty",
798 "-q",
799 "-m",
800 "feat: add the widget endpoint",
801 ],
802 );
803
804 let mut context = ctx(dir.path(), Stage::Ship);
805 Hook::VersionBump.run(&mut context).unwrap();
806 Hook::ChangelogAppend.run(&mut context).unwrap();
807
808 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
809 assert!(
810 changelog.contains("### Added"),
811 "expected a ### Added section, got: {changelog}"
812 );
813 assert!(
814 changelog.contains("add the widget endpoint"),
815 "expected the feat commit's subject, got: {changelog}"
816 );
817 }
818}