| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389 |
- use std::path::Path;
- use std::process::Command;
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum BranchFreshness {
- Fresh,
- Stale {
- commits_behind: usize,
- missing_fixes: Vec<String>,
- },
- Diverged {
- ahead: usize,
- behind: usize,
- },
- }
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub enum StaleBranchPolicy {
- AutoRebase,
- AutoMergeForward,
- WarnOnly,
- Block,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum StaleBranchEvent {
- BranchStaleAgainstMain {
- branch: String,
- commits_behind: usize,
- missing_fixes: Vec<String>,
- },
- RebaseAttempted {
- branch: String,
- result: String,
- },
- MergeForwardAttempted {
- branch: String,
- result: String,
- },
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum StaleBranchAction {
- Noop,
- Warn { message: String },
- Block { message: String },
- Rebase,
- MergeForward,
- }
- pub fn check_freshness(branch: &str, main_ref: &str) -> BranchFreshness {
- check_freshness_in(branch, main_ref, Path::new("."))
- }
- pub fn apply_policy(freshness: &BranchFreshness, policy: StaleBranchPolicy) -> StaleBranchAction {
- match freshness {
- BranchFreshness::Fresh => StaleBranchAction::Noop,
- BranchFreshness::Stale {
- commits_behind,
- missing_fixes,
- } => match policy {
- StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
- message: format!(
- "Branch is {commits_behind} commit(s) behind main. Missing fixes: {}",
- if missing_fixes.is_empty() {
- "(none)".to_string()
- } else {
- missing_fixes.join("; ")
- }
- ),
- },
- StaleBranchPolicy::Block => StaleBranchAction::Block {
- message: format!(
- "Branch is {commits_behind} commit(s) behind main and must be updated before proceeding."
- ),
- },
- StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
- StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
- },
- BranchFreshness::Diverged { ahead, behind } => match policy {
- StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
- message: format!(
- "Branch has diverged: {ahead} commit(s) ahead, {behind} commit(s) behind main."
- ),
- },
- StaleBranchPolicy::Block => StaleBranchAction::Block {
- message: format!(
- "Branch has diverged ({ahead} ahead, {behind} behind) and must be reconciled before proceeding."
- ),
- },
- StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
- StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
- },
- }
- }
- pub(crate) fn check_freshness_in(
- branch: &str,
- main_ref: &str,
- repo_path: &Path,
- ) -> BranchFreshness {
- let behind = rev_list_count(main_ref, branch, repo_path);
- let ahead = rev_list_count(branch, main_ref, repo_path);
- if behind == 0 {
- return BranchFreshness::Fresh;
- }
- if ahead > 0 {
- return BranchFreshness::Diverged { ahead, behind };
- }
- let missing_fixes = missing_fix_subjects(main_ref, branch, repo_path);
- BranchFreshness::Stale {
- commits_behind: behind,
- missing_fixes,
- }
- }
- fn rev_list_count(a: &str, b: &str, repo_path: &Path) -> usize {
- let output = Command::new("git")
- .args(["rev-list", "--count", &format!("{b}..{a}")])
- .current_dir(repo_path)
- .output();
- match output {
- Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
- .trim()
- .parse::<usize>()
- .unwrap_or(0),
- _ => 0,
- }
- }
- fn missing_fix_subjects(a: &str, b: &str, repo_path: &Path) -> Vec<String> {
- let output = Command::new("git")
- .args(["log", "--format=%s", &format!("{b}..{a}")])
- .current_dir(repo_path)
- .output();
- match output {
- Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
- .lines()
- .filter(|l| !l.is_empty())
- .map(String::from)
- .collect(),
- _ => Vec::new(),
- }
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- use std::fs;
- use std::time::{SystemTime, UNIX_EPOCH};
- fn temp_dir() -> std::path::PathBuf {
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("time should be after epoch")
- .as_nanos();
- std::env::temp_dir().join(format!("runtime-stale-branch-{nanos}"))
- }
- fn init_repo(path: &Path) {
- fs::create_dir_all(path).expect("create repo dir");
- run(path, &["init", "--quiet", "-b", "main"]);
- run(path, &["config", "user.email", "tests@example.com"]);
- run(path, &["config", "user.name", "Stale Branch Tests"]);
- fs::write(path.join("init.txt"), "initial\n").expect("write init file");
- run(path, &["add", "."]);
- run(path, &["commit", "-m", "initial commit", "--quiet"]);
- }
- fn run(cwd: &Path, args: &[&str]) {
- let status = Command::new("git")
- .args(args)
- .current_dir(cwd)
- .status()
- .unwrap_or_else(|e| panic!("git {} failed to execute: {e}", args.join(" ")));
- assert!(
- status.success(),
- "git {} exited with {status}",
- args.join(" ")
- );
- }
- fn commit_file(repo: &Path, name: &str, msg: &str) {
- fs::write(repo.join(name), format!("{msg}\n")).expect("write file");
- run(repo, &["add", name]);
- run(repo, &["commit", "-m", msg, "--quiet"]);
- }
- #[test]
- fn fresh_branch_passes() {
- let root = temp_dir();
- init_repo(&root);
- // given
- run(&root, &["checkout", "-b", "topic"]);
- // when
- let freshness = check_freshness_in("topic", "main", &root);
- // then
- assert_eq!(freshness, BranchFreshness::Fresh);
- fs::remove_dir_all(&root).expect("cleanup");
- }
- #[test]
- fn fresh_branch_ahead_of_main_still_fresh() {
- let root = temp_dir();
- init_repo(&root);
- // given
- run(&root, &["checkout", "-b", "topic"]);
- commit_file(&root, "feature.txt", "add feature");
- // when
- let freshness = check_freshness_in("topic", "main", &root);
- // then
- assert_eq!(freshness, BranchFreshness::Fresh);
- fs::remove_dir_all(&root).expect("cleanup");
- }
- #[test]
- fn stale_branch_detected_with_correct_behind_count_and_missing_fixes() {
- let root = temp_dir();
- init_repo(&root);
- // given
- run(&root, &["checkout", "-b", "topic"]);
- run(&root, &["checkout", "main"]);
- commit_file(&root, "fix1.txt", "fix: resolve timeout");
- commit_file(&root, "fix2.txt", "fix: handle null pointer");
- // when
- let freshness = check_freshness_in("topic", "main", &root);
- // then
- match freshness {
- BranchFreshness::Stale {
- commits_behind,
- missing_fixes,
- } => {
- assert_eq!(commits_behind, 2);
- assert_eq!(missing_fixes.len(), 2);
- assert_eq!(missing_fixes[0], "fix: handle null pointer");
- assert_eq!(missing_fixes[1], "fix: resolve timeout");
- }
- other => panic!("expected Stale, got {other:?}"),
- }
- fs::remove_dir_all(&root).expect("cleanup");
- }
- #[test]
- fn diverged_branch_detection() {
- let root = temp_dir();
- init_repo(&root);
- // given
- run(&root, &["checkout", "-b", "topic"]);
- commit_file(&root, "topic_work.txt", "topic work");
- run(&root, &["checkout", "main"]);
- commit_file(&root, "main_fix.txt", "main fix");
- // when
- let freshness = check_freshness_in("topic", "main", &root);
- // then
- match freshness {
- BranchFreshness::Diverged { ahead, behind } => {
- assert_eq!(ahead, 1);
- assert_eq!(behind, 1);
- }
- other => panic!("expected Diverged, got {other:?}"),
- }
- fs::remove_dir_all(&root).expect("cleanup");
- }
- #[test]
- fn policy_noop_for_fresh_branch() {
- // given
- let freshness = BranchFreshness::Fresh;
- // when
- let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
- // then
- assert_eq!(action, StaleBranchAction::Noop);
- }
- #[test]
- fn policy_warn_for_stale_branch() {
- // given
- let freshness = BranchFreshness::Stale {
- commits_behind: 3,
- missing_fixes: vec!["fix: timeout".into(), "fix: null ptr".into()],
- };
- // when
- let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
- // then
- match action {
- StaleBranchAction::Warn { message } => {
- assert!(message.contains("3 commit(s) behind"));
- assert!(message.contains("fix: timeout"));
- assert!(message.contains("fix: null ptr"));
- }
- other => panic!("expected Warn, got {other:?}"),
- }
- }
- #[test]
- fn policy_block_for_stale_branch() {
- // given
- let freshness = BranchFreshness::Stale {
- commits_behind: 1,
- missing_fixes: vec!["hotfix".into()],
- };
- // when
- let action = apply_policy(&freshness, StaleBranchPolicy::Block);
- // then
- match action {
- StaleBranchAction::Block { message } => {
- assert!(message.contains("1 commit(s) behind"));
- }
- other => panic!("expected Block, got {other:?}"),
- }
- }
- #[test]
- fn policy_auto_rebase_for_stale_branch() {
- // given
- let freshness = BranchFreshness::Stale {
- commits_behind: 2,
- missing_fixes: vec![],
- };
- // when
- let action = apply_policy(&freshness, StaleBranchPolicy::AutoRebase);
- // then
- assert_eq!(action, StaleBranchAction::Rebase);
- }
- #[test]
- fn policy_auto_merge_forward_for_diverged_branch() {
- // given
- let freshness = BranchFreshness::Diverged {
- ahead: 5,
- behind: 2,
- };
- // when
- let action = apply_policy(&freshness, StaleBranchPolicy::AutoMergeForward);
- // then
- assert_eq!(action, StaleBranchAction::MergeForward);
- }
- #[test]
- fn policy_warn_for_diverged_branch() {
- // given
- let freshness = BranchFreshness::Diverged {
- ahead: 3,
- behind: 1,
- };
- // when
- let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
- // then
- match action {
- StaleBranchAction::Warn { message } => {
- assert!(message.contains("diverged"));
- assert!(message.contains("3 commit(s) ahead"));
- assert!(message.contains("1 commit(s) behind"));
- }
- other => panic!("expected Warn, got {other:?}"),
- }
- }
- }
|