diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 1c01a3f..0a8da29 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -20,6 +20,7 @@ mod remote; pub mod sandbox; mod session; mod sse; +mod stale_branch; pub mod task_registry; pub mod team_cron_registry; mod usage; @@ -99,6 +100,10 @@ pub use session::{ ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError, SessionFork, }; +pub use stale_branch::{ + apply_policy, check_freshness, BranchFreshness, StaleBranchAction, StaleBranchEvent, + StaleBranchPolicy, +}; pub use sse::{IncrementalSseParser, SseEvent}; pub use worker_boot::{ Worker, WorkerEvent, WorkerEventKind, WorkerFailure, WorkerFailureKind, WorkerReadySnapshot, diff --git a/rust/crates/runtime/src/stale_branch.rs b/rust/crates/runtime/src/stale_branch.rs new file mode 100644 index 0000000..c558212 --- /dev/null +++ b/rust/crates/runtime/src/stale_branch.rs @@ -0,0 +1,389 @@ +use std::path::Path; +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BranchFreshness { + Fresh, + Stale { + commits_behind: usize, + missing_fixes: Vec, + }, + 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, + }, + 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::() + .unwrap_or(0), + _ => 0, + } +} + +fn missing_fix_subjects(a: &str, b: &str, repo_path: &Path) -> Vec { + 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:?}"), + } + } +}