use crate::config::GitFlowConfig;
use crate::git::GitFlow;
use crate::stage::Stage;
use crate::version;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hook {
BranchCreate,
BranchCleanup,
DocsUpdate,
ChangelogAppend,
VersionBump,
}
#[derive(Debug, Clone)]
pub struct HookContext {
pub phase: u32,
pub project_root: PathBuf,
pub stage: Stage,
pub git_flow: GitFlowConfig,
}
#[derive(Debug, thiserror::Error)]
pub enum HookError {
#[error(transparent)]
Git(#[from] crate::git::GitError),
#[error(transparent)]
Version(#[from] version::VersionError),
#[error("hook I/O failed: {0}")]
Io(#[from] std::io::Error),
}
impl Hook {
pub fn run(&self, ctx: &HookContext) -> Result<(), HookError> {
match self {
Hook::BranchCreate => branch_create(ctx),
Hook::BranchCleanup => branch_cleanup(ctx),
Hook::DocsUpdate => docs_update(ctx),
Hook::ChangelogAppend => changelog_append(ctx),
Hook::VersionBump => version_bump(ctx),
}
}
}
pub fn hooks_for_transition(from: Stage, to: Stage) -> Vec<Hook> {
match (from, to) {
(Stage::Validate, Stage::Ship) => vec![Hook::DocsUpdate, Hook::ChangelogAppend],
_ => Vec::new(),
}
}
pub fn hooks_after_ship() -> Vec<Hook> {
vec![Hook::VersionBump, Hook::BranchCleanup]
}
fn branch_create(ctx: &HookContext) -> Result<(), HookError> {
let git = GitFlow::new(&ctx.project_root);
let branch = git.feature_start(ctx.phase)?;
info!("BranchCreate: created {branch}");
Ok(())
}
fn branch_cleanup(ctx: &HookContext) -> Result<(), HookError> {
let git = GitFlow::new(&ctx.project_root);
let branch = format!("{}phase-{:02}", ctx.git_flow.feature_prefix, ctx.phase);
if git.branch_exists(&branch) {
match git.delete_branch(&branch, false) {
Ok(()) => info!("BranchCleanup: deleted {branch}"),
Err(err) => {
let message = err.to_string();
if message.contains("not fully merged") || message.contains("not yet merged") {
warn!(
"BranchCleanup: feature branch {branch} is not merged yet — left in place"
);
} else {
warn!("BranchCleanup: could not delete {branch}: {err}");
}
}
}
}
Ok(())
}
fn docs_update(ctx: &HookContext) -> Result<(), HookError> {
let output = Command::new("sh")
.arg("-c")
.arg("cargo doc --no-deps 2>&1")
.current_dir(&ctx.project_root)
.output();
match output {
Ok(out) if out.status.success() => {
let git = GitFlow::new(&ctx.project_root);
if let Err(err) = git.commit_all("docs: update generated docs") {
warn!("DocsUpdate: commit failed: {err}");
} else {
info!("DocsUpdate: docs regenerated and committed");
}
}
Ok(_) => warn!("DocsUpdate: cargo doc reported a failure; skipping commit"),
Err(err) => warn!("DocsUpdate: could not run cargo doc: {err}"),
}
Ok(())
}
fn changelog_append(ctx: &HookContext) -> Result<(), HookError> {
let version = version::compute_version(&ctx.project_root)
.map(|v| v.to_string())
.unwrap_or_else(|_| "unreleased".to_string());
let path = ctx.project_root.join("CHANGELOG.md");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let updated = crate::ship::prepend_changelog(&existing, &version, &today());
std::fs::write(&path, updated)?;
info!("ChangelogAppend: wrote entry for {version}");
Ok(())
}
fn version_bump(ctx: &HookContext) -> Result<(), HookError> {
let version = version::compute_version(&ctx.project_root)?;
match version::write_version(&ctx.project_root, &version) {
Ok(path) => info!("VersionBump: wrote {version} to {}", path.display()),
Err(err) => warn!("VersionBump: could not write version file: {err}"),
}
let git = GitFlow::new(&ctx.project_root);
let tag = format!("v{version}");
match git.tag(&tag) {
Ok(()) => info!("VersionBump: tagged {tag}"),
Err(err) => warn!("VersionBump: could not tag {tag}: {err}"),
}
Ok(())
}
fn today() -> String {
Command::new("date")
.arg("+%Y-%m-%d")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unreleased".to_string())
}
pub fn has_version_file(project_root: &Path) -> bool {
version::detect_version_file(project_root).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
fn git(root: &Path, args: &[&str]) {
let ok = Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
}
fn init_repo(root: &Path) {
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "test@example.com"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "tag.gpgsign", "false"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "init"]);
git(root, &["branch", "-M", "main"]);
git(root, &["checkout", "-q", "-b", "develop"]);
}
fn ctx(root: &Path, stage: Stage) -> HookContext {
HookContext {
phase: 11,
project_root: root.to_path_buf(),
stage,
git_flow: GitFlowConfig::default(),
}
}
#[test]
fn transition_map_finalizes_docs_and_changelog_before_ship() {
assert_eq!(
hooks_for_transition(Stage::Validate, Stage::Ship),
vec![Hook::DocsUpdate, Hook::ChangelogAppend]
);
assert!(hooks_for_transition(Stage::Define, Stage::Plan).is_empty());
assert!(hooks_for_transition(Stage::Code, Stage::Validate).is_empty());
}
#[test]
fn validate_to_ship_hooks_append_changelog() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let context = ctx(dir.path(), Stage::Ship);
for hook in hooks_for_transition(Stage::Validate, Stage::Ship) {
hook.run(&context).unwrap();
}
let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
assert!(changelog.contains("## "));
}
#[test]
fn after_ship_runs_version_and_cleanup() {
assert_eq!(
hooks_after_ship(),
vec![Hook::VersionBump, Hook::BranchCleanup]
);
}
#[test]
fn branch_create_makes_feature_branch() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
Hook::BranchCreate
.run(&ctx(dir.path(), Stage::Define))
.unwrap();
assert!(GitFlow::new(dir.path()).branch_exists("feature/phase-11"));
}
#[test]
fn changelog_append_writes_entry() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
Hook::ChangelogAppend
.run(&ctx(dir.path(), Stage::Ship))
.unwrap();
let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
assert!(changelog.contains("# Changelog"));
}
#[test]
fn version_bump_tags_repo() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let expected = format!("v{}", version::compute_version(dir.path()).unwrap());
Hook::VersionBump
.run(&ctx(dir.path(), Stage::Ship))
.unwrap();
let tags = Command::new("git")
.arg("tag")
.current_dir(dir.path())
.output()
.unwrap();
assert!(String::from_utf8_lossy(&tags.stdout).contains(&expected));
}
#[test]
fn branch_cleanup_is_fail_soft_when_branch_absent() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
Hook::BranchCleanup
.run(&ctx(dir.path(), Stage::Ship))
.unwrap();
}
}