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}
45
46#[derive(Debug, thiserror::Error)]
48pub enum HookError {
49 #[error(transparent)]
51 Git(#[from] crate::git::GitError),
52 #[error(transparent)]
54 Version(#[from] version::VersionError),
55 #[error("hook I/O failed: {0}")]
57 Io(#[from] std::io::Error),
58}
59
60impl Hook {
61 pub fn run(&self, ctx: &HookContext) -> Result<(), HookError> {
63 match self {
64 Hook::BranchCreate => branch_create(ctx),
65 Hook::BranchCleanup => branch_cleanup(ctx),
66 Hook::DocsUpdate => docs_update(ctx),
67 Hook::Merge => merge_feature(ctx),
68 Hook::ChangelogAppend => changelog_append(ctx),
69 Hook::VersionBump => version_bump(ctx),
70 }
71 }
72}
73
74pub fn hooks_for_transition(from: Stage, to: Stage) -> Vec<Hook> {
80 match (from, to) {
81 (Stage::Validate, Stage::Ship) => vec![Hook::DocsUpdate, Hook::ChangelogAppend],
82 _ => Vec::new(),
83 }
84}
85
86pub fn hooks_after_ship() -> Vec<Hook> {
88 vec![Hook::Merge, Hook::VersionBump, Hook::BranchCleanup]
89}
90
91fn branch_create(ctx: &HookContext) -> Result<(), HookError> {
92 let git = GitFlow::new(&ctx.project_root);
93 let branch = git.feature_start(ctx.phase)?;
94 info!("BranchCreate: created {branch}");
95 Ok(())
96}
97
98fn branch_cleanup(ctx: &HookContext) -> Result<(), HookError> {
99 let git = GitFlow::new(&ctx.project_root);
100 let branch = format!("{}phase-{:02}", ctx.git_flow.feature_prefix, ctx.phase);
101 if git.branch_exists(&branch) {
102 match git.delete_branch(&branch, false) {
104 Ok(()) => info!("BranchCleanup: deleted {branch}"),
105 Err(err) => {
106 let message = err.to_string();
107 if message.contains("not fully merged") || message.contains("not yet merged") {
108 warn!(
109 "BranchCleanup: feature branch {branch} is not merged yet — left in place"
110 );
111 } else {
112 warn!("BranchCleanup: could not delete {branch}: {err}");
113 }
114 }
115 }
116 }
117 Ok(())
118}
119
120fn merge_feature(ctx: &HookContext) -> Result<(), HookError> {
121 let git = GitFlow::new(&ctx.project_root);
122 let branch = format!("{}phase-{:02}", ctx.git_flow.feature_prefix, ctx.phase);
123 if !git.branch_exists(&branch) {
124 return Err(crate::git::GitError::Command(format!(
125 "feature branch `{branch}` is missing; refusing to report an unproven merge"
126 ))
127 .into());
128 }
129 if git.is_merged_into_develop(ctx.phase) {
130 info!("Merge: {branch} is already merged; nothing to merge");
131 crate::events::emit(
132 &ctx.project_root,
133 ctx.phase,
134 "merge_result",
135 serde_json::json!({"merged": false, "branch": branch}),
136 );
137 return Ok(());
138 }
139
140 git.merge_feature_into_develop(ctx.phase)?;
141 info!("Merge: merged {branch} into develop");
142 crate::events::emit(
143 &ctx.project_root,
144 ctx.phase,
145 "merge_result",
146 serde_json::json!({"merged": true, "branch": branch}),
147 );
148 Ok(())
149}
150
151fn docs_update(ctx: &HookContext) -> Result<(), HookError> {
152 let output = Command::new("sh")
153 .arg("-c")
154 .arg("cargo doc --no-deps 2>&1")
155 .current_dir(&ctx.project_root)
156 .output();
157 match output {
158 Ok(out) if out.status.success() => {
159 let git = GitFlow::new(&ctx.project_root);
161 if let Err(err) = git.commit_all("docs: update generated docs") {
162 warn!("DocsUpdate: commit failed: {err}");
163 } else {
164 info!("DocsUpdate: docs regenerated and committed");
165 }
166 }
167 Ok(_) => warn!("DocsUpdate: cargo doc reported a failure; skipping commit"),
168 Err(err) => warn!("DocsUpdate: could not run cargo doc: {err}"),
169 }
170 Ok(())
171}
172
173fn changelog_append(ctx: &HookContext) -> Result<(), HookError> {
174 let version = version::compute_version(&ctx.project_root)
175 .map(|v| v.to_string())
176 .unwrap_or_else(|_| "unreleased".to_string());
177 let path = ctx.project_root.join("CHANGELOG.md");
178 let existing = std::fs::read_to_string(&path).unwrap_or_default();
179 let updated = crate::ship::prepend_changelog(&existing, &version, &today());
180 std::fs::write(&path, updated)?;
181 info!("ChangelogAppend: wrote entry for {version}");
182 Ok(())
183}
184
185fn version_bump(ctx: &HookContext) -> Result<(), HookError> {
186 let version = version::compute_version(&ctx.project_root)?;
187 if has_version_file(&ctx.project_root) {
189 let path = version::write_version(&ctx.project_root, &version)?;
190 info!("VersionBump: wrote {version} to {}", path.display());
191 } else {
192 warn!("VersionBump: no supported version file; tagging only");
193 }
194 let git = GitFlow::new(&ctx.project_root);
195 let tag = format!("v{version}");
196 git.tag(&tag)?;
197 info!("VersionBump: tagged {tag}");
198 Ok(())
199}
200
201fn today() -> String {
203 Command::new("date")
204 .arg("+%Y-%m-%d")
205 .output()
206 .ok()
207 .filter(|o| o.status.success())
208 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
209 .filter(|s| !s.is_empty())
210 .unwrap_or_else(|| "unreleased".to_string())
211}
212
213pub fn has_version_file(project_root: &Path) -> bool {
216 version::detect_version_file(project_root).is_some()
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn git(root: &Path, args: &[&str]) {
224 let ok = Command::new("git")
225 .args(args)
226 .current_dir(root)
227 .output()
228 .unwrap()
229 .status
230 .success();
231 assert!(ok, "git {args:?} failed");
232 }
233
234 fn init_repo(root: &Path) {
235 git(root, &["init", "-q"]);
236 git(root, &["config", "user.email", "test@example.com"]);
237 git(root, &["config", "user.name", "Test"]);
238 git(root, &["config", "commit.gpgsign", "false"]);
239 git(root, &["config", "tag.gpgsign", "false"]);
240 git(root, &["config", "core.hooksPath", "/dev/null"]);
241 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
242 git(root, &["add", "."]);
243 git(root, &["commit", "-q", "-m", "init"]);
244 git(root, &["branch", "-M", "main"]);
245 git(root, &["checkout", "-q", "-b", "develop"]);
246 }
247
248 fn ctx(root: &Path, stage: Stage) -> HookContext {
249 HookContext {
250 phase: 11,
251 project_root: root.to_path_buf(),
252 stage,
253 git_flow: GitFlowConfig::default(),
254 }
255 }
256
257 #[test]
258 fn transition_map_finalizes_docs_and_changelog_before_ship() {
259 assert_eq!(
260 hooks_for_transition(Stage::Validate, Stage::Ship),
261 vec![Hook::DocsUpdate, Hook::ChangelogAppend]
262 );
263 assert!(hooks_for_transition(Stage::Define, Stage::Plan).is_empty());
264 assert!(hooks_for_transition(Stage::Code, Stage::Validate).is_empty());
265 }
266
267 #[test]
268 fn validate_to_ship_hooks_append_changelog() {
269 let dir = tempfile::tempdir().unwrap();
270 init_repo(dir.path());
271 let context = ctx(dir.path(), Stage::Ship);
272
273 for hook in hooks_for_transition(Stage::Validate, Stage::Ship) {
274 hook.run(&context).unwrap();
275 }
276
277 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
278 assert!(changelog.contains("## "));
279 }
280
281 #[test]
282 fn after_ship_runs_version_and_cleanup() {
283 assert_eq!(
284 hooks_after_ship(),
285 vec![Hook::Merge, Hook::VersionBump, Hook::BranchCleanup]
286 );
287 }
288
289 #[test]
290 fn branch_create_makes_feature_branch() {
291 let dir = tempfile::tempdir().unwrap();
292 init_repo(dir.path());
293 Hook::BranchCreate
294 .run(&ctx(dir.path(), Stage::Define))
295 .unwrap();
296 assert!(GitFlow::new(dir.path()).branch_exists("feature/phase-11"));
297 }
298
299 #[test]
300 fn changelog_append_writes_entry() {
301 let dir = tempfile::tempdir().unwrap();
302 init_repo(dir.path());
303 Hook::ChangelogAppend
304 .run(&ctx(dir.path(), Stage::Ship))
305 .unwrap();
306 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
307 assert!(changelog.contains("# Changelog"));
308 }
309
310 #[test]
311 fn version_bump_tags_repo() {
312 let dir = tempfile::tempdir().unwrap();
313 init_repo(dir.path());
314 let expected = format!("v{}", version::compute_version(dir.path()).unwrap());
317 Hook::VersionBump
318 .run(&ctx(dir.path(), Stage::Ship))
319 .unwrap();
320 let tags = Command::new("git")
321 .arg("tag")
322 .current_dir(dir.path())
323 .output()
324 .unwrap();
325 assert!(String::from_utf8_lossy(&tags.stdout).contains(&expected));
326 }
327
328 #[test]
329 fn terminal_hooks_version_post_merge_develop() {
330 let dir = tempfile::tempdir().unwrap();
331 init_repo(dir.path());
332 git(dir.path(), &["checkout", "-q", "-b", "feature/phase-11"]);
333 std::fs::write(dir.path().join("feature.txt"), "phase work\n").unwrap();
334 git(dir.path(), &["add", "feature.txt"]);
335 git(dir.path(), &["commit", "-q", "-m", "phase work"]);
336
337 let feature_tip = git_output(dir.path(), &["rev-parse", "feature/phase-11"]);
338 let pre_merge_count = git_output(dir.path(), &["rev-list", "--count", "HEAD"]);
339
340 let context = ctx(dir.path(), Stage::Ship);
341 for hook in hooks_after_ship() {
342 hook.run(&context).unwrap();
343 }
344
345 git(
346 dir.path(),
347 &["merge-base", "--is-ancestor", &feature_tip, "develop"],
348 );
349 let post_merge_count = git_output(dir.path(), &["rev-list", "--count", "develop"]);
350 assert_ne!(pre_merge_count, post_merge_count);
351
352 let tag = git_output(dir.path(), &["tag", "--points-at", "develop"]);
353 assert_eq!(tag, format!("v2.0.{post_merge_count}"));
354 assert_ne!(tag, format!("v2.0.{pre_merge_count}"));
355 }
356
357 #[test]
358 fn merge_succeeds_while_feature_branch_is_checked_out_in_linked_worktree() {
359 let dir = tempfile::tempdir().unwrap();
360 let repo = dir.path().join("repo");
361 let worktree = dir.path().join("phase-worktree");
362 std::fs::create_dir_all(&repo).unwrap();
363 init_repo(&repo);
364 git(
365 &repo,
366 &[
367 "worktree",
368 "add",
369 "-q",
370 "-b",
371 "feature/phase-11",
372 worktree.to_str().unwrap(),
373 "develop",
374 ],
375 );
376 std::fs::write(worktree.join("feature.txt"), "phase work\n").unwrap();
377 git(&worktree, &["add", "feature.txt"]);
378 git(&worktree, &["commit", "-q", "-m", "phase work"]);
379
380 Hook::Merge.run(&ctx(&repo, Stage::Ship)).unwrap();
381
382 git(
383 &repo,
384 &["merge-base", "--is-ancestor", "feature/phase-11", "develop"],
385 );
386 assert!(GitFlow::new(&repo).branch_exists("feature/phase-11"));
387 }
388
389 #[test]
390 fn branch_cleanup_is_fail_soft_when_branch_absent() {
391 let dir = tempfile::tempdir().unwrap();
392 init_repo(dir.path());
393 Hook::BranchCleanup
395 .run(&ctx(dir.path(), Stage::Ship))
396 .unwrap();
397 }
398
399 #[test]
400 fn merge_fails_closed_when_branch_absent() {
401 let dir = tempfile::tempdir().unwrap();
402 init_repo(dir.path());
403 let error = Hook::Merge.run(&ctx(dir.path(), Stage::Ship)).unwrap_err();
405 assert!(error.to_string().contains("unproven merge"));
406 }
407
408 fn git_output(root: &Path, args: &[&str]) -> String {
409 let output = Command::new("git")
410 .args(args)
411 .current_dir(root)
412 .output()
413 .unwrap();
414 assert!(output.status.success(), "git {args:?} failed");
415 String::from_utf8_lossy(&output.stdout).trim().to_string()
416 }
417}