mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
Workspace-wide verification now preflights the current branch against main so stale or diverged branches surface missing commits before broad cargo tests run. The lane failure taxonomy is also collapsed to the blocker classes the roadmap lane needs so automation can branch on a smaller, stable set of categories. Constraint: Broad workspace tests should not run when main is ahead and would produce stale-branch noise Rejected: Run workspace tests unconditionally | makes stale-branch failures indistinguishable from real regressions Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep workspace-test preflight scoped to broad test commands until command classification grows more precise Tested: cargo test -p runtime stale_branch -- --nocapture; cargo test -p tools lane_failure_taxonomy_normalizes_common_blockers -- --nocapture; cargo test -p tools bash_workspace_tests_are_blocked_when_branch_is_behind_main -- --nocapture; cargo test -p tools bash_targeted_tests_skip_branch_preflight -- --nocapture Not-tested: clean worktree cargo test --workspace still fails on pre-existing rusty-claude-cli tests default_permission_mode_uses_project_config_when_env_is_unset and single_word_slash_command_names_return_guidance_instead_of_hitting_prompt_mode
417 lines
12 KiB
Rust
417 lines
12 KiB
Rust
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,
|
|
missing_fixes: Vec<String>,
|
|
},
|
|
}
|
|
|
|
#[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,
|
|
missing_fixes,
|
|
} => match policy {
|
|
StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
|
|
message: format!(
|
|
"Branch has diverged: {ahead} commit(s) ahead, {behind} commit(s) behind main. Missing fixes: {}",
|
|
format_missing_fixes(missing_fixes)
|
|
),
|
|
},
|
|
StaleBranchPolicy::Block => StaleBranchAction::Block {
|
|
message: format!(
|
|
"Branch has diverged ({ahead} ahead, {behind} behind) and must be reconciled before proceeding. Missing fixes: {}",
|
|
format_missing_fixes(missing_fixes)
|
|
),
|
|
},
|
|
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,
|
|
missing_fixes: missing_fix_subjects(main_ref, branch, repo_path),
|
|
};
|
|
}
|
|
|
|
let missing_fixes = missing_fix_subjects(main_ref, branch, repo_path);
|
|
BranchFreshness::Stale {
|
|
commits_behind: behind,
|
|
missing_fixes,
|
|
}
|
|
}
|
|
|
|
fn format_missing_fixes(missing_fixes: &[String]) -> String {
|
|
if missing_fixes.is_empty() {
|
|
"(none)".to_string()
|
|
} else {
|
|
missing_fixes.join("; ")
|
|
}
|
|
}
|
|
|
|
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,
|
|
missing_fixes,
|
|
} => {
|
|
assert_eq!(ahead, 1);
|
|
assert_eq!(behind, 1);
|
|
assert_eq!(missing_fixes, vec!["main fix".to_string()]);
|
|
}
|
|
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,
|
|
missing_fixes: vec!["fix: merge main".into()],
|
|
};
|
|
|
|
// 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,
|
|
missing_fixes: vec!["main hotfix".into()],
|
|
};
|
|
|
|
// 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"));
|
|
assert!(message.contains("main hotfix"));
|
|
}
|
|
other => panic!("expected Warn, got {other:?}"),
|
|
}
|
|
}
|
|
}
|