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> {
144 let git = GitFlow::new(&ctx.project_root);
145 let branch = format!("{}phase-{:02}", ctx.git_flow.feature_prefix, ctx.phase);
146 if !git.branch_exists(&branch) {
147 return Err(crate::git::GitError::Command(format!(
148 "feature branch `{branch}` is missing; refusing to report an unproven merge"
149 ))
150 .into());
151 }
152 if git.is_merged_into_develop(ctx.phase) {
153 info!("Merge: {branch} is already merged; nothing to merge");
154 crate::events::emit(
155 &ctx.project_root,
156 ctx.phase,
157 "merge_result",
158 serde_json::json!({"merged": false, "branch": branch}),
159 );
160 return Ok(());
161 }
162
163 git.merge_feature_into_develop(ctx.phase)?;
164 info!("Merge: merged {branch} into develop");
165 crate::events::emit(
166 &ctx.project_root,
167 ctx.phase,
168 "merge_result",
169 serde_json::json!({"merged": true, "branch": branch}),
170 );
171 Ok(())
172}
173
174fn docs_update(ctx: &HookContext) -> Result<(), HookError> {
175 let output = Command::new("sh")
176 .arg("-c")
177 .arg("cargo doc --no-deps 2>&1")
178 .current_dir(&ctx.project_root)
179 .output();
180 match output {
181 Ok(out) if out.status.success() => {
182 let git = GitFlow::new(&ctx.project_root);
184 if let Err(err) = git.commit_all("docs: update generated docs") {
185 warn!("DocsUpdate: commit failed: {err}");
186 } else {
187 info!("DocsUpdate: docs regenerated and committed");
188 }
189 }
190 Ok(_) => warn!("DocsUpdate: cargo doc reported a failure; skipping commit"),
191 Err(err) => warn!("DocsUpdate: could not run cargo doc: {err}"),
192 }
193 Ok(())
194}
195
196fn changelog_append(ctx: &mut HookContext) -> Result<(), HookError> {
197 let version = ctx.shipped_version.clone().unwrap_or_else(|| {
206 version::read_version(&ctx.project_root)
207 .map(|v| v.to_string())
208 .unwrap_or_else(|_| "unreleased".to_string())
209 });
210 let path = ctx.project_root.join("CHANGELOG.md");
211 let existing = std::fs::read_to_string(&path).unwrap_or_default();
212 let updated = crate::ship::prepend_changelog(&existing, &version, &today());
213 std::fs::write(&path, updated)?;
214 let git = GitFlow::new(&ctx.project_root);
222 git.commit_path(
223 "CHANGELOG.md",
224 &format!("docs: add changelog entry for {version}"),
225 )?;
226 info!("ChangelogAppend: wrote and committed entry for {version}");
227 Ok(())
228}
229
230fn version_bump(ctx: &mut HookContext) -> Result<(), HookError> {
231 let version = version::compute_version(&ctx.project_root)?;
232 let git = GitFlow::new(&ctx.project_root);
233 if has_version_file(&ctx.project_root) {
240 let path = version::write_version(&ctx.project_root, &version)?;
241 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
242 git.commit_path(name, &format!("chore: bump version to {version}"))?;
243 }
244 info!("VersionBump: wrote {version} to {}", path.display());
245 } else {
246 warn!("VersionBump: no supported version file; tagging only");
247 }
248 let tag = format!("v{version}");
249 git.tag(&tag)?;
250 ctx.shipped_version = Some(version.to_string());
256 info!("VersionBump: tagged {tag}");
257 Ok(())
258}
259
260fn today() -> String {
262 Command::new("date")
263 .arg("+%Y-%m-%d")
264 .output()
265 .ok()
266 .filter(|o| o.status.success())
267 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
268 .filter(|s| !s.is_empty())
269 .unwrap_or_else(|| "unreleased".to_string())
270}
271
272pub fn has_version_file(project_root: &Path) -> bool {
275 version::detect_version_file(project_root).is_some()
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn git(root: &Path, args: &[&str]) {
283 let ok = crate::test_support::git_command(root)
284 .args(args)
285 .output()
286 .unwrap()
287 .status
288 .success();
289 assert!(ok, "git {args:?} failed");
290 }
291
292 fn init_repo(root: &Path) {
293 init_repo_with_options(root, true);
294 }
295
296 fn init_repo_with_options(root: &Path, write_version_file: bool) {
303 git(root, &["init", "-q"]);
304 git(root, &["config", "user.email", "test@example.com"]);
305 git(root, &["config", "user.name", "Test"]);
306 git(root, &["config", "commit.gpgsign", "false"]);
307 git(root, &["config", "tag.gpgsign", "false"]);
308 git(root, &["config", "core.hooksPath", "/dev/null"]);
309 if write_version_file {
310 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
311 } else {
312 std::fs::write(root.join("README.md"), "no version file in this repo\n").unwrap();
313 }
314 git(root, &["add", "."]);
315 git(root, &["commit", "-q", "-m", "init"]);
316 git(root, &["branch", "-M", "main"]);
317 git(root, &["checkout", "-q", "-b", "develop"]);
318 }
319
320 fn ctx(root: &Path, stage: Stage) -> HookContext {
321 HookContext {
322 phase: 11,
323 project_root: root.to_path_buf(),
324 stage,
325 git_flow: GitFlowConfig::default(),
326 shipped_version: None,
327 }
328 }
329
330 #[test]
331 fn transition_map_finalizes_docs_only_before_ship() {
332 assert_eq!(
337 hooks_for_transition(Stage::Validate, Stage::Ship),
338 vec![Hook::DocsUpdate]
339 );
340 assert!(hooks_for_transition(Stage::Define, Stage::Plan).is_empty());
341 assert!(hooks_for_transition(Stage::Code, Stage::Validate).is_empty());
342 }
343
344 #[test]
345 fn validate_to_ship_hooks_do_not_touch_changelog() {
346 let dir = tempfile::tempdir().unwrap();
347 init_repo(dir.path());
348 let mut context = ctx(dir.path(), Stage::Ship);
349
350 for hook in hooks_for_transition(Stage::Validate, Stage::Ship) {
351 hook.run(&mut context).unwrap();
352 }
353
354 assert!(!dir.path().join("CHANGELOG.md").exists());
355 }
356
357 #[test]
358 fn after_ship_runs_version_changelog_then_cleanup() {
359 assert_eq!(
364 hooks_after_ship(),
365 vec![
366 Hook::Merge,
367 Hook::VersionBump,
368 Hook::ChangelogAppend,
369 Hook::BranchCleanup,
370 ]
371 );
372 }
373
374 #[test]
375 fn branch_create_makes_feature_branch() {
376 let dir = tempfile::tempdir().unwrap();
377 init_repo(dir.path());
378 Hook::BranchCreate
379 .run(&mut ctx(dir.path(), Stage::Define))
380 .unwrap();
381 assert!(GitFlow::new(dir.path()).branch_exists("feature/phase-11"));
382 }
383
384 #[test]
385 fn changelog_append_writes_entry() {
386 let dir = tempfile::tempdir().unwrap();
387 init_repo(dir.path());
388 Hook::ChangelogAppend
389 .run(&mut ctx(dir.path(), Stage::Ship))
390 .unwrap();
391 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
392 assert!(changelog.contains("# Changelog"));
393 }
394
395 #[test]
396 fn changelog_append_commits_its_own_write() {
397 let dir = tempfile::tempdir().unwrap();
401 init_repo(dir.path());
402 Hook::ChangelogAppend
403 .run(&mut ctx(dir.path(), Stage::Ship))
404 .unwrap();
405
406 let status = git_output(dir.path(), &["status", "--porcelain"]);
407 assert!(status.is_empty(), "expected clean tree, got: {status}");
408
409 let committed_files = git_output(dir.path(), &["log", "-1", "--name-only"]);
410 assert!(
411 committed_files.contains("CHANGELOG.md"),
412 "expected CHANGELOG.md in the latest commit, got: {committed_files}"
413 );
414 }
415
416 #[test]
417 fn version_bump_tags_repo() {
418 let dir = tempfile::tempdir().unwrap();
419 init_repo(dir.path());
420 let expected = format!("v{}", version::compute_version(dir.path()).unwrap());
423 Hook::VersionBump
424 .run(&mut ctx(dir.path(), Stage::Ship))
425 .unwrap();
426 let tags = crate::test_support::git_command(dir.path())
427 .arg("tag")
428 .output()
429 .unwrap();
430 assert!(String::from_utf8_lossy(&tags.stdout).contains(&expected));
431 }
432
433 #[test]
434 fn terminal_hooks_version_post_merge_develop() {
435 let dir = tempfile::tempdir().unwrap();
436 init_repo(dir.path());
437 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
438 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
439 git(dir.path(), &["add", "feature.txt"]);
440 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
441
442 let feature_tip = git_output(dir.path(), &["rev-parse", "feature/phase-11"]);
443 let pre_merge_count = git_output(dir.path(), &["rev-list", "--count", "HEAD"]);
444
445 let mut context = ctx(dir.path(), Stage::Ship);
446 for hook in hooks_after_ship() {
447 hook.run(&mut context).unwrap();
448 }
449
450 git(
451 dir.path(),
452 &["merge-base", "--is-ancestor", &feature_tip, "develop"],
453 );
454 let post_merge_count = git_output(dir.path(), &["rev-list", "--count", "develop"]);
455 assert_ne!(pre_merge_count, post_merge_count);
456
457 let all_tags = git_output(dir.path(), &["tag"]);
462 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
463 let tag = all_tags.trim().to_string();
464 let version_file_version = version::read_version(dir.path()).unwrap().to_string();
465 assert_eq!(tag, format!("v{version_file_version}"));
466
467 let develop_tip = git_output(dir.path(), &["rev-parse", "develop"]);
470 let tag_commit = git_output(dir.path(), &["rev-parse", &format!("{tag}^{{commit}}")]);
471 assert_ne!(develop_tip, tag_commit);
472 }
473
474 #[test]
475 fn after_ship_batch_changelog_tag_and_version_file_agree_and_tree_is_clean() {
476 let dir = tempfile::tempdir().unwrap();
485 init_repo(dir.path());
486 std::fs::write(dir.path().join(".gitignore"), ".devflow/\n").unwrap();
490 git(dir.path(), &["add", ".gitignore"]);
491 git(dir.path(), &["commit", "-q", "-m", "add gitignore"]);
492 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
493 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
494 git(dir.path(), &["add", "feature.txt"]);
495 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
496
497 let mut context = ctx(dir.path(), Stage::Ship);
498 for hook in hooks_after_ship() {
499 hook.run(&mut context).unwrap();
500 }
501
502 let all_tags = git_output(dir.path(), &["tag"]);
504 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
505 let tag = all_tags.trim().to_string();
506
507 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
508 let changelog_version = changelog
509 .lines()
510 .find(|l| l.starts_with("## "))
511 .and_then(|l| l.trim_start_matches("## ").split(' ').next())
512 .unwrap()
513 .to_string();
514
515 let version_file_version = version::read_version(dir.path()).unwrap().to_string();
516
517 assert_eq!(
518 tag,
519 format!("v{changelog_version}"),
520 "tag must match the changelog heading version"
521 );
522 assert_eq!(
523 changelog_version, version_file_version,
524 "changelog heading must match the version file's version"
525 );
526
527 let status = git_output(dir.path(), &["status", "--porcelain"]);
530 assert!(status.is_empty(), "expected clean tree, got: {status}");
531 let committed_files = git_output(dir.path(), &["log", "-1", "--name-only"]);
532 assert!(
533 committed_files.contains("CHANGELOG.md"),
534 "expected CHANGELOG.md in the latest commit, got: {committed_files}"
535 );
536 }
537
538 #[test]
539 fn after_ship_batch_with_no_version_file_keeps_tag_and_changelog_in_sync() {
540 let dir = tempfile::tempdir().unwrap();
549 init_repo_with_options(dir.path(), false);
550 std::fs::write(dir.path().join(".gitignore"), ".devflow/\n").unwrap();
553 git(dir.path(), &["add", ".gitignore"]);
554 git(dir.path(), &["commit", "-q", "-m", "add gitignore"]);
555 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
556 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
557 git(dir.path(), &["add", "feature.txt"]);
558 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
559
560 let mut context = ctx(dir.path(), Stage::Ship);
561 for hook in hooks_after_ship() {
562 hook.run(&mut context).unwrap();
563 }
564
565 let all_tags = git_output(dir.path(), &["tag"]);
566 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
567 let tag = all_tags.trim().to_string();
568 let tag_version = tag
569 .strip_prefix('v')
570 .expect("tag should be prefixed with v")
571 .to_string();
572
573 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
574 let changelog_version = changelog
575 .lines()
576 .find(|l| l.starts_with("## "))
577 .and_then(|l| l.trim_start_matches("## ").split(' ').next())
578 .unwrap()
579 .to_string();
580
581 assert_ne!(
582 changelog_version, "unreleased",
583 "changelog heading must name the tagged version, not fall back to the literal"
584 );
585 assert_eq!(
586 changelog_version, tag_version,
587 "changelog heading must match the git tag ({tag}) even with no version file"
588 );
589 }
590
591 #[test]
592 fn merge_succeeds_while_feature_branch_is_checked_out_in_linked_worktree() {
593 let dir = tempfile::tempdir().unwrap();
594 let repo = dir.path().join("repo");
595 let worktree = dir.path().join("phase-worktree");
596 std::fs::create_dir_all(&repo).unwrap();
597 init_repo(&repo);
598 git(
599 &repo,
600 &[
601 "worktree",
602 "add",
603 "-q",
604 "-b",
605 "feature/phase-11",
606 worktree.to_str().unwrap(),
607 "develop",
608 ],
609 );
610 std::fs::write(worktree.join("feature.txt"), "phase work\n").unwrap();
611 git(&worktree, &["add", "feature.txt"]);
612 git(&worktree, &["commit", "-q", "-m", "phase work"]);
613
614 Hook::Merge.run(&mut ctx(&repo, Stage::Ship)).unwrap();
615
616 git(
617 &repo,
618 &["merge-base", "--is-ancestor", "feature/phase-11", "develop"],
619 );
620 assert!(GitFlow::new(&repo).branch_exists("feature/phase-11"));
621 }
622
623 #[test]
624 fn branch_cleanup_is_fail_soft_when_branch_absent() {
625 let dir = tempfile::tempdir().unwrap();
626 init_repo(dir.path());
627 Hook::BranchCleanup
629 .run(&mut ctx(dir.path(), Stage::Ship))
630 .unwrap();
631 }
632
633 #[test]
634 fn merge_fails_closed_when_branch_absent() {
635 let dir = tempfile::tempdir().unwrap();
636 init_repo(dir.path());
637 let error = Hook::Merge
639 .run(&mut ctx(dir.path(), Stage::Ship))
640 .unwrap_err();
641 assert!(error.to_string().contains("unproven merge"));
642 }
643
644 fn git_output(root: &Path, args: &[&str]) -> String {
645 let output = crate::test_support::git_command(root)
646 .args(args)
647 .output()
648 .unwrap();
649 assert!(output.status.success(), "git {args:?} failed");
650 String::from_utf8_lossy(&output.stdout).trim().to_string()
651 }
652}