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}
51
52#[derive(Debug, thiserror::Error)]
54pub enum HookError {
55 #[error(transparent)]
57 Git(#[from] crate::git::GitError),
58 #[error(transparent)]
60 Version(#[from] version::VersionError),
61 #[error("hook I/O failed: {0}")]
63 Io(#[from] std::io::Error),
64}
65
66impl Hook {
67 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
80pub 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
97pub 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 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
143fn 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 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 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 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 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 ctx.shipped_version = Some(version.to_string());
294 info!("VersionBump: tagged {tag}");
295 Ok(())
296}
297
298fn 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
310pub 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 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 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 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 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 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 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 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 let dir = tempfile::tempdir().unwrap();
523 init_repo(dir.path());
524 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 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 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 let dir = tempfile::tempdir().unwrap();
587 init_repo_with_options(dir.path(), false);
588 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 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 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 #[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 #[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}