stale_branch.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. use std::path::Path;
  2. use std::process::Command;
  3. #[derive(Debug, Clone, PartialEq, Eq)]
  4. pub enum BranchFreshness {
  5. Fresh,
  6. Stale {
  7. commits_behind: usize,
  8. missing_fixes: Vec<String>,
  9. },
  10. Diverged {
  11. ahead: usize,
  12. behind: usize,
  13. },
  14. }
  15. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  16. pub enum StaleBranchPolicy {
  17. AutoRebase,
  18. AutoMergeForward,
  19. WarnOnly,
  20. Block,
  21. }
  22. #[derive(Debug, Clone, PartialEq, Eq)]
  23. pub enum StaleBranchEvent {
  24. BranchStaleAgainstMain {
  25. branch: String,
  26. commits_behind: usize,
  27. missing_fixes: Vec<String>,
  28. },
  29. RebaseAttempted {
  30. branch: String,
  31. result: String,
  32. },
  33. MergeForwardAttempted {
  34. branch: String,
  35. result: String,
  36. },
  37. }
  38. #[derive(Debug, Clone, PartialEq, Eq)]
  39. pub enum StaleBranchAction {
  40. Noop,
  41. Warn { message: String },
  42. Block { message: String },
  43. Rebase,
  44. MergeForward,
  45. }
  46. pub fn check_freshness(branch: &str, main_ref: &str) -> BranchFreshness {
  47. check_freshness_in(branch, main_ref, Path::new("."))
  48. }
  49. pub fn apply_policy(freshness: &BranchFreshness, policy: StaleBranchPolicy) -> StaleBranchAction {
  50. match freshness {
  51. BranchFreshness::Fresh => StaleBranchAction::Noop,
  52. BranchFreshness::Stale {
  53. commits_behind,
  54. missing_fixes,
  55. } => match policy {
  56. StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
  57. message: format!(
  58. "Branch is {commits_behind} commit(s) behind main. Missing fixes: {}",
  59. if missing_fixes.is_empty() {
  60. "(none)".to_string()
  61. } else {
  62. missing_fixes.join("; ")
  63. }
  64. ),
  65. },
  66. StaleBranchPolicy::Block => StaleBranchAction::Block {
  67. message: format!(
  68. "Branch is {commits_behind} commit(s) behind main and must be updated before proceeding."
  69. ),
  70. },
  71. StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
  72. StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
  73. },
  74. BranchFreshness::Diverged { ahead, behind } => match policy {
  75. StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
  76. message: format!(
  77. "Branch has diverged: {ahead} commit(s) ahead, {behind} commit(s) behind main."
  78. ),
  79. },
  80. StaleBranchPolicy::Block => StaleBranchAction::Block {
  81. message: format!(
  82. "Branch has diverged ({ahead} ahead, {behind} behind) and must be reconciled before proceeding."
  83. ),
  84. },
  85. StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
  86. StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
  87. },
  88. }
  89. }
  90. pub(crate) fn check_freshness_in(
  91. branch: &str,
  92. main_ref: &str,
  93. repo_path: &Path,
  94. ) -> BranchFreshness {
  95. let behind = rev_list_count(main_ref, branch, repo_path);
  96. let ahead = rev_list_count(branch, main_ref, repo_path);
  97. if behind == 0 {
  98. return BranchFreshness::Fresh;
  99. }
  100. if ahead > 0 {
  101. return BranchFreshness::Diverged { ahead, behind };
  102. }
  103. let missing_fixes = missing_fix_subjects(main_ref, branch, repo_path);
  104. BranchFreshness::Stale {
  105. commits_behind: behind,
  106. missing_fixes,
  107. }
  108. }
  109. fn rev_list_count(a: &str, b: &str, repo_path: &Path) -> usize {
  110. let output = Command::new("git")
  111. .args(["rev-list", "--count", &format!("{b}..{a}")])
  112. .current_dir(repo_path)
  113. .output();
  114. match output {
  115. Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
  116. .trim()
  117. .parse::<usize>()
  118. .unwrap_or(0),
  119. _ => 0,
  120. }
  121. }
  122. fn missing_fix_subjects(a: &str, b: &str, repo_path: &Path) -> Vec<String> {
  123. let output = Command::new("git")
  124. .args(["log", "--format=%s", &format!("{b}..{a}")])
  125. .current_dir(repo_path)
  126. .output();
  127. match output {
  128. Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
  129. .lines()
  130. .filter(|l| !l.is_empty())
  131. .map(String::from)
  132. .collect(),
  133. _ => Vec::new(),
  134. }
  135. }
  136. #[cfg(test)]
  137. mod tests {
  138. use super::*;
  139. use std::fs;
  140. use std::time::{SystemTime, UNIX_EPOCH};
  141. fn temp_dir() -> std::path::PathBuf {
  142. let nanos = SystemTime::now()
  143. .duration_since(UNIX_EPOCH)
  144. .expect("time should be after epoch")
  145. .as_nanos();
  146. std::env::temp_dir().join(format!("runtime-stale-branch-{nanos}"))
  147. }
  148. fn init_repo(path: &Path) {
  149. fs::create_dir_all(path).expect("create repo dir");
  150. run(path, &["init", "--quiet", "-b", "main"]);
  151. run(path, &["config", "user.email", "tests@example.com"]);
  152. run(path, &["config", "user.name", "Stale Branch Tests"]);
  153. fs::write(path.join("init.txt"), "initial\n").expect("write init file");
  154. run(path, &["add", "."]);
  155. run(path, &["commit", "-m", "initial commit", "--quiet"]);
  156. }
  157. fn run(cwd: &Path, args: &[&str]) {
  158. let status = Command::new("git")
  159. .args(args)
  160. .current_dir(cwd)
  161. .status()
  162. .unwrap_or_else(|e| panic!("git {} failed to execute: {e}", args.join(" ")));
  163. assert!(
  164. status.success(),
  165. "git {} exited with {status}",
  166. args.join(" ")
  167. );
  168. }
  169. fn commit_file(repo: &Path, name: &str, msg: &str) {
  170. fs::write(repo.join(name), format!("{msg}\n")).expect("write file");
  171. run(repo, &["add", name]);
  172. run(repo, &["commit", "-m", msg, "--quiet"]);
  173. }
  174. #[test]
  175. fn fresh_branch_passes() {
  176. let root = temp_dir();
  177. init_repo(&root);
  178. // given
  179. run(&root, &["checkout", "-b", "topic"]);
  180. // when
  181. let freshness = check_freshness_in("topic", "main", &root);
  182. // then
  183. assert_eq!(freshness, BranchFreshness::Fresh);
  184. fs::remove_dir_all(&root).expect("cleanup");
  185. }
  186. #[test]
  187. fn fresh_branch_ahead_of_main_still_fresh() {
  188. let root = temp_dir();
  189. init_repo(&root);
  190. // given
  191. run(&root, &["checkout", "-b", "topic"]);
  192. commit_file(&root, "feature.txt", "add feature");
  193. // when
  194. let freshness = check_freshness_in("topic", "main", &root);
  195. // then
  196. assert_eq!(freshness, BranchFreshness::Fresh);
  197. fs::remove_dir_all(&root).expect("cleanup");
  198. }
  199. #[test]
  200. fn stale_branch_detected_with_correct_behind_count_and_missing_fixes() {
  201. let root = temp_dir();
  202. init_repo(&root);
  203. // given
  204. run(&root, &["checkout", "-b", "topic"]);
  205. run(&root, &["checkout", "main"]);
  206. commit_file(&root, "fix1.txt", "fix: resolve timeout");
  207. commit_file(&root, "fix2.txt", "fix: handle null pointer");
  208. // when
  209. let freshness = check_freshness_in("topic", "main", &root);
  210. // then
  211. match freshness {
  212. BranchFreshness::Stale {
  213. commits_behind,
  214. missing_fixes,
  215. } => {
  216. assert_eq!(commits_behind, 2);
  217. assert_eq!(missing_fixes.len(), 2);
  218. assert_eq!(missing_fixes[0], "fix: handle null pointer");
  219. assert_eq!(missing_fixes[1], "fix: resolve timeout");
  220. }
  221. other => panic!("expected Stale, got {other:?}"),
  222. }
  223. fs::remove_dir_all(&root).expect("cleanup");
  224. }
  225. #[test]
  226. fn diverged_branch_detection() {
  227. let root = temp_dir();
  228. init_repo(&root);
  229. // given
  230. run(&root, &["checkout", "-b", "topic"]);
  231. commit_file(&root, "topic_work.txt", "topic work");
  232. run(&root, &["checkout", "main"]);
  233. commit_file(&root, "main_fix.txt", "main fix");
  234. // when
  235. let freshness = check_freshness_in("topic", "main", &root);
  236. // then
  237. match freshness {
  238. BranchFreshness::Diverged { ahead, behind } => {
  239. assert_eq!(ahead, 1);
  240. assert_eq!(behind, 1);
  241. }
  242. other => panic!("expected Diverged, got {other:?}"),
  243. }
  244. fs::remove_dir_all(&root).expect("cleanup");
  245. }
  246. #[test]
  247. fn policy_noop_for_fresh_branch() {
  248. // given
  249. let freshness = BranchFreshness::Fresh;
  250. // when
  251. let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
  252. // then
  253. assert_eq!(action, StaleBranchAction::Noop);
  254. }
  255. #[test]
  256. fn policy_warn_for_stale_branch() {
  257. // given
  258. let freshness = BranchFreshness::Stale {
  259. commits_behind: 3,
  260. missing_fixes: vec!["fix: timeout".into(), "fix: null ptr".into()],
  261. };
  262. // when
  263. let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
  264. // then
  265. match action {
  266. StaleBranchAction::Warn { message } => {
  267. assert!(message.contains("3 commit(s) behind"));
  268. assert!(message.contains("fix: timeout"));
  269. assert!(message.contains("fix: null ptr"));
  270. }
  271. other => panic!("expected Warn, got {other:?}"),
  272. }
  273. }
  274. #[test]
  275. fn policy_block_for_stale_branch() {
  276. // given
  277. let freshness = BranchFreshness::Stale {
  278. commits_behind: 1,
  279. missing_fixes: vec!["hotfix".into()],
  280. };
  281. // when
  282. let action = apply_policy(&freshness, StaleBranchPolicy::Block);
  283. // then
  284. match action {
  285. StaleBranchAction::Block { message } => {
  286. assert!(message.contains("1 commit(s) behind"));
  287. }
  288. other => panic!("expected Block, got {other:?}"),
  289. }
  290. }
  291. #[test]
  292. fn policy_auto_rebase_for_stale_branch() {
  293. // given
  294. let freshness = BranchFreshness::Stale {
  295. commits_behind: 2,
  296. missing_fixes: vec![],
  297. };
  298. // when
  299. let action = apply_policy(&freshness, StaleBranchPolicy::AutoRebase);
  300. // then
  301. assert_eq!(action, StaleBranchAction::Rebase);
  302. }
  303. #[test]
  304. fn policy_auto_merge_forward_for_diverged_branch() {
  305. // given
  306. let freshness = BranchFreshness::Diverged {
  307. ahead: 5,
  308. behind: 2,
  309. };
  310. // when
  311. let action = apply_policy(&freshness, StaleBranchPolicy::AutoMergeForward);
  312. // then
  313. assert_eq!(action, StaleBranchAction::MergeForward);
  314. }
  315. #[test]
  316. fn policy_warn_for_diverged_branch() {
  317. // given
  318. let freshness = BranchFreshness::Diverged {
  319. ahead: 3,
  320. behind: 1,
  321. };
  322. // when
  323. let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
  324. // then
  325. match action {
  326. StaleBranchAction::Warn { message } => {
  327. assert!(message.contains("diverged"));
  328. assert!(message.contains("3 commit(s) ahead"));
  329. assert!(message.contains("1 commit(s) behind"));
  330. }
  331. other => panic!("expected Warn, got {other:?}"),
  332. }
  333. }
  334. }