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 = Command::new("git")
284 .args(args)
285 .current_dir(root)
286 .output()
287 .unwrap()
288 .status
289 .success();
290 assert!(ok, "git {args:?} failed");
291 }
292
293 fn init_repo(root: &Path) {
294 init_repo_with_options(root, true);
295 }
296
297 fn init_repo_with_options(root: &Path, write_version_file: bool) {
304 git(root, &["init", "-q"]);
305 git(root, &["config", "user.email", "test@example.com"]);
306 git(root, &["config", "user.name", "Test"]);
307 git(root, &["config", "commit.gpgsign", "false"]);
308 git(root, &["config", "tag.gpgsign", "false"]);
309 git(root, &["config", "core.hooksPath", "/dev/null"]);
310 if write_version_file {
311 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
312 } else {
313 std::fs::write(root.join("README.md"), "no version file in this repo\n").unwrap();
314 }
315 git(root, &["add", "."]);
316 git(root, &["commit", "-q", "-m", "init"]);
317 git(root, &["branch", "-M", "main"]);
318 git(root, &["checkout", "-q", "-b", "develop"]);
319 }
320
321 fn ctx(root: &Path, stage: Stage) -> HookContext {
322 HookContext {
323 phase: 11,
324 project_root: root.to_path_buf(),
325 stage,
326 git_flow: GitFlowConfig::default(),
327 shipped_version: None,
328 }
329 }
330
331 #[test]
332 fn transition_map_finalizes_docs_only_before_ship() {
333 assert_eq!(
338 hooks_for_transition(Stage::Validate, Stage::Ship),
339 vec![Hook::DocsUpdate]
340 );
341 assert!(hooks_for_transition(Stage::Define, Stage::Plan).is_empty());
342 assert!(hooks_for_transition(Stage::Code, Stage::Validate).is_empty());
343 }
344
345 #[test]
346 fn validate_to_ship_hooks_do_not_touch_changelog() {
347 let dir = tempfile::tempdir().unwrap();
348 init_repo(dir.path());
349 let mut context = ctx(dir.path(), Stage::Ship);
350
351 for hook in hooks_for_transition(Stage::Validate, Stage::Ship) {
352 hook.run(&mut context).unwrap();
353 }
354
355 assert!(!dir.path().join("CHANGELOG.md").exists());
356 }
357
358 #[test]
359 fn after_ship_runs_version_changelog_then_cleanup() {
360 assert_eq!(
365 hooks_after_ship(),
366 vec![
367 Hook::Merge,
368 Hook::VersionBump,
369 Hook::ChangelogAppend,
370 Hook::BranchCleanup,
371 ]
372 );
373 }
374
375 #[test]
376 fn branch_create_makes_feature_branch() {
377 let dir = tempfile::tempdir().unwrap();
378 init_repo(dir.path());
379 Hook::BranchCreate
380 .run(&mut ctx(dir.path(), Stage::Define))
381 .unwrap();
382 assert!(GitFlow::new(dir.path()).branch_exists("feature/phase-11"));
383 }
384
385 #[test]
386 fn changelog_append_writes_entry() {
387 let dir = tempfile::tempdir().unwrap();
388 init_repo(dir.path());
389 Hook::ChangelogAppend
390 .run(&mut ctx(dir.path(), Stage::Ship))
391 .unwrap();
392 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
393 assert!(changelog.contains("# Changelog"));
394 }
395
396 #[test]
397 fn changelog_append_commits_its_own_write() {
398 let dir = tempfile::tempdir().unwrap();
402 init_repo(dir.path());
403 Hook::ChangelogAppend
404 .run(&mut ctx(dir.path(), Stage::Ship))
405 .unwrap();
406
407 let status = git_output(dir.path(), &["status", "--porcelain"]);
408 assert!(status.is_empty(), "expected clean tree, got: {status}");
409
410 let committed_files = git_output(dir.path(), &["log", "-1", "--name-only"]);
411 assert!(
412 committed_files.contains("CHANGELOG.md"),
413 "expected CHANGELOG.md in the latest commit, got: {committed_files}"
414 );
415 }
416
417 #[test]
418 fn version_bump_tags_repo() {
419 let dir = tempfile::tempdir().unwrap();
420 init_repo(dir.path());
421 let expected = format!("v{}", version::compute_version(dir.path()).unwrap());
424 Hook::VersionBump
425 .run(&mut ctx(dir.path(), Stage::Ship))
426 .unwrap();
427 let tags = Command::new("git")
428 .arg("tag")
429 .current_dir(dir.path())
430 .output()
431 .unwrap();
432 assert!(String::from_utf8_lossy(&tags.stdout).contains(&expected));
433 }
434
435 #[test]
436 fn terminal_hooks_version_post_merge_develop() {
437 let dir = tempfile::tempdir().unwrap();
438 init_repo(dir.path());
439 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
440 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
441 git(dir.path(), &["add", "feature.txt"]);
442 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
443
444 let feature_tip = git_output(dir.path(), &["rev-parse", "feature/phase-11"]);
445 let pre_merge_count = git_output(dir.path(), &["rev-list", "--count", "HEAD"]);
446
447 let mut context = ctx(dir.path(), Stage::Ship);
448 for hook in hooks_after_ship() {
449 hook.run(&mut context).unwrap();
450 }
451
452 git(
453 dir.path(),
454 &["merge-base", "--is-ancestor", &feature_tip, "develop"],
455 );
456 let post_merge_count = git_output(dir.path(), &["rev-list", "--count", "develop"]);
457 assert_ne!(pre_merge_count, post_merge_count);
458
459 let all_tags = git_output(dir.path(), &["tag"]);
464 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
465 let tag = all_tags.trim().to_string();
466 let version_file_version = version::read_version(dir.path()).unwrap().to_string();
467 assert_eq!(tag, format!("v{version_file_version}"));
468
469 let develop_tip = git_output(dir.path(), &["rev-parse", "develop"]);
472 let tag_commit = git_output(dir.path(), &["rev-parse", &format!("{tag}^{{commit}}")]);
473 assert_ne!(develop_tip, tag_commit);
474 }
475
476 #[test]
477 fn after_ship_batch_changelog_tag_and_version_file_agree_and_tree_is_clean() {
478 let dir = tempfile::tempdir().unwrap();
487 init_repo(dir.path());
488 std::fs::write(dir.path().join(".gitignore"), ".devflow/\n").unwrap();
492 git(dir.path(), &["add", ".gitignore"]);
493 git(dir.path(), &["commit", "-q", "-m", "add gitignore"]);
494 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
495 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
496 git(dir.path(), &["add", "feature.txt"]);
497 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
498
499 let mut context = ctx(dir.path(), Stage::Ship);
500 for hook in hooks_after_ship() {
501 hook.run(&mut context).unwrap();
502 }
503
504 let all_tags = git_output(dir.path(), &["tag"]);
506 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
507 let tag = all_tags.trim().to_string();
508
509 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
510 let changelog_version = changelog
511 .lines()
512 .find(|l| l.starts_with("## "))
513 .and_then(|l| l.trim_start_matches("## ").split(' ').next())
514 .unwrap()
515 .to_string();
516
517 let version_file_version = version::read_version(dir.path()).unwrap().to_string();
518
519 assert_eq!(
520 tag,
521 format!("v{changelog_version}"),
522 "tag must match the changelog heading version"
523 );
524 assert_eq!(
525 changelog_version, version_file_version,
526 "changelog heading must match the version file's version"
527 );
528
529 let status = git_output(dir.path(), &["status", "--porcelain"]);
532 assert!(status.is_empty(), "expected clean tree, got: {status}");
533 let committed_files = git_output(dir.path(), &["log", "-1", "--name-only"]);
534 assert!(
535 committed_files.contains("CHANGELOG.md"),
536 "expected CHANGELOG.md in the latest commit, got: {committed_files}"
537 );
538 }
539
540 #[test]
541 fn after_ship_batch_with_no_version_file_keeps_tag_and_changelog_in_sync() {
542 let dir = tempfile::tempdir().unwrap();
551 init_repo_with_options(dir.path(), false);
552 std::fs::write(dir.path().join(".gitignore"), ".devflow/\n").unwrap();
555 git(dir.path(), &["add", ".gitignore"]);
556 git(dir.path(), &["commit", "-q", "-m", "add gitignore"]);
557 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
558 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
559 git(dir.path(), &["add", "feature.txt"]);
560 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
561
562 let mut context = ctx(dir.path(), Stage::Ship);
563 for hook in hooks_after_ship() {
564 hook.run(&mut context).unwrap();
565 }
566
567 let all_tags = git_output(dir.path(), &["tag"]);
568 assert_eq!(all_tags.lines().count(), 1, "expected exactly one tag");
569 let tag = all_tags.trim().to_string();
570 let tag_version = tag
571 .strip_prefix('v')
572 .expect("tag should be prefixed with v")
573 .to_string();
574
575 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
576 let changelog_version = changelog
577 .lines()
578 .find(|l| l.starts_with("## "))
579 .and_then(|l| l.trim_start_matches("## ").split(' ').next())
580 .unwrap()
581 .to_string();
582
583 assert_ne!(
584 changelog_version, "unreleased",
585 "changelog heading must name the tagged version, not fall back to the literal"
586 );
587 assert_eq!(
588 changelog_version, tag_version,
589 "changelog heading must match the git tag ({tag}) even with no version file"
590 );
591 }
592
593 #[test]
594 fn merge_succeeds_while_feature_branch_is_checked_out_in_linked_worktree() {
595 let dir = tempfile::tempdir().unwrap();
596 let repo = dir.path().join("repo");
597 let worktree = dir.path().join("phase-worktree");
598 std::fs::create_dir_all(&repo).unwrap();
599 init_repo(&repo);
600 git(
601 &repo,
602 &[
603 "worktree",
604 "add",
605 "-q",
606 "-b",
607 "feature/phase-11",
608 worktree.to_str().unwrap(),
609 "develop",
610 ],
611 );
612 std::fs::write(worktree.join("feature.txt"), "phase work\n").unwrap();
613 git(&worktree, &["add", "feature.txt"]);
614 git(&worktree, &["commit", "-q", "-m", "phase work"]);
615
616 Hook::Merge.run(&mut ctx(&repo, Stage::Ship)).unwrap();
617
618 git(
619 &repo,
620 &["merge-base", "--is-ancestor", "feature/phase-11", "develop"],
621 );
622 assert!(GitFlow::new(&repo).branch_exists("feature/phase-11"));
623 }
624
625 #[test]
626 fn branch_cleanup_is_fail_soft_when_branch_absent() {
627 let dir = tempfile::tempdir().unwrap();
628 init_repo(dir.path());
629 Hook::BranchCleanup
631 .run(&mut ctx(dir.path(), Stage::Ship))
632 .unwrap();
633 }
634
635 #[test]
636 fn merge_fails_closed_when_branch_absent() {
637 let dir = tempfile::tempdir().unwrap();
638 init_repo(dir.path());
639 let error = Hook::Merge
641 .run(&mut ctx(dir.path(), Stage::Ship))
642 .unwrap_err();
643 assert!(error.to_string().contains("unproven merge"));
644 }
645
646 fn git_output(root: &Path, args: &[&str]) -> String {
647 let output = Command::new("git")
648 .args(args)
649 .current_dir(root)
650 .output()
651 .unwrap();
652 assert!(output.status.success(), "git {args:?} failed");
653 String::from_utf8_lossy(&output.stdout).trim().to_string()
654 }
655}