1use crate::config::ResolvedBase;
6use crate::git::Git;
7use std::collections::HashSet;
8use std::path::Path;
9
10pub fn current_branch(git: &dyn Git, dir: &Path) -> String {
12 git.run(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
13 .trimmed()
14 .to_string()
15}
16
17pub fn committed(git: &dyn Git, dir: &Path) -> bool {
19 git.run(dir, &["status", "-s"]).trimmed().is_empty()
20}
21
22pub fn all_commits_pushed(git: &dyn Git, dir: &Path) -> bool {
25 git.run(
26 dir,
27 &["log", "--oneline", "--branches", "--not", "--remotes"],
28 )
29 .trimmed()
30 .is_empty()
31}
32
33pub fn branches_have_remote(git: &dyn Git, dir: &Path) -> bool {
35 let remotes: HashSet<String> = git
36 .run(
37 dir,
38 &[
39 "for-each-ref",
40 "--format=%(refname:short)",
41 "refs/remotes/origin/*",
42 ],
43 )
44 .stdout
45 .lines()
46 .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
47 .filter(|b| b != "HEAD")
48 .collect();
49
50 git.run(
51 dir,
52 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
53 )
54 .stdout
55 .lines()
56 .map(str::trim)
57 .filter(|l| !l.is_empty())
58 .all(|local| remotes.contains(local))
59}
60
61pub fn not_behind_remote(git: &dyn Git, dir: &Path) -> bool {
64 let cur = current_branch(git, dir);
65 if cur.is_empty() {
66 return true;
67 }
68 let remote_ref = format!("refs/remotes/origin/{cur}");
69 if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
70 return true;
71 }
72 let range = format!("origin/{cur}...{cur}");
73 let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
74 out.trimmed()
76 .split_whitespace()
77 .next()
78 .and_then(|s| s.parse::<u64>().ok())
79 .map(|behind| behind == 0)
80 .unwrap_or(true)
81}
82
83fn is_integration(branch: &str, base_branch: &str) -> bool {
86 branch == base_branch || branch == "main" || branch == "master"
87}
88
89fn base_ref_for(git: &dyn Git, dir: &Path, base_branch: &str) -> String {
94 let local = format!("refs/heads/{base_branch}");
95 if git
96 .run(dir, &["show-ref", "--verify", "--quiet", &local])
97 .success
98 {
99 base_branch.to_string()
100 } else {
101 format!("origin/{base_branch}")
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum BranchRule {
110 Team,
113 Solo,
117}
118
119impl BranchRule {
120 pub fn from_solo(solo: bool) -> Self {
121 if solo {
122 BranchRule::Solo
123 } else {
124 BranchRule::Team
125 }
126 }
127
128 pub fn describe(&self) -> &'static str {
131 match self {
132 BranchRule::Team => "team (gkit.solo off) — flags a local branch unmerged into base",
133 BranchRule::Solo => "solo (gkit.solo on) — flags any feature branch on the remote",
134 }
135 }
136}
137
138fn local_unmerged_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
141 let base_ref = base_ref_for(git, dir, base_branch);
142 let merged = git.run(
143 dir,
144 &["branch", "--merged", &base_ref, "--format=%(refname:short)"],
145 );
146 if !merged.success {
147 return false;
148 }
149 let merged: HashSet<&str> = merged
150 .stdout
151 .lines()
152 .map(str::trim)
153 .filter(|l| !l.is_empty())
154 .collect();
155 git.run(
156 dir,
157 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
158 )
159 .stdout
160 .lines()
161 .map(str::trim)
162 .filter(|l| !l.is_empty())
163 .any(|b| !is_integration(b, base_branch) && !merged.contains(b))
164}
165
166fn remote_has_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
168 git.run(dir, &["ls-remote", "--heads", "origin"])
169 .stdout
170 .lines()
171 .filter_map(|l| {
172 l.split_once("refs/heads/")
173 .map(|(_, b)| b.trim().to_string())
174 })
175 .any(|b| !is_integration(&b, base_branch))
176}
177
178pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str, rule: BranchRule) -> bool {
187 if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
189 return false;
190 }
191 let cur = current_branch(git, dir);
192 if !is_integration(&cur, base_branch) {
193 return true; }
195 match rule {
196 BranchRule::Team => !local_unmerged_feature(git, dir, base_branch),
197 BranchRule::Solo => !remote_has_feature(git, dir, base_branch),
198 }
199}
200
201#[derive(Debug, Clone)]
203pub struct RepoStatus {
204 pub branch: String,
205 pub committed: bool,
206 pub all_commits_pushed: bool,
207 pub branches_have_remote: bool,
208 pub not_behind_remote: bool,
209 pub correct_branch: bool,
210 pub base: ResolvedBase,
213 pub rule: BranchRule,
216 pub problem: Option<String>,
221}
222
223impl RepoStatus {
224 pub fn unusable(reason: impl Into<String>) -> Self {
227 RepoStatus {
228 branch: String::new(),
229 committed: false,
230 all_commits_pushed: false,
231 branches_have_remote: false,
232 not_behind_remote: false,
233 correct_branch: false,
234 base: ResolvedBase::unresolved(),
235 rule: BranchRule::Team,
236 problem: Some(reason.into()),
237 }
238 }
239
240 pub fn ok(&self) -> bool {
242 self.problem.is_none()
243 && self.committed
244 && self.all_commits_pushed
245 && self.branches_have_remote
246 && self.not_behind_remote
247 && self.correct_branch
248 }
249}
250
251pub fn evaluate(git: &dyn Git, dir: &Path, base: &ResolvedBase, solo: bool) -> RepoStatus {
256 let rule = BranchRule::from_solo(solo);
257 let correct_branch = match &base.name {
258 Some(b) => correct_branch(git, dir, b, rule),
259 None => false,
260 };
261 RepoStatus {
262 branch: current_branch(git, dir),
263 committed: committed(git, dir),
264 all_commits_pushed: all_commits_pushed(git, dir),
265 branches_have_remote: branches_have_remote(git, dir),
266 not_behind_remote: not_behind_remote(git, dir),
267 correct_branch,
268 base: base.clone(),
269 rule,
270 problem: None,
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::git::test_support::FakeGit;
278 use std::path::Path;
279
280 fn d() -> &'static Path {
281 Path::new("/x")
282 }
283
284 #[test]
285 fn committed_is_true_when_status_clean() {
286 assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
287 assert!(!committed(
288 &FakeGit::new().ok("status -s", " M file.rs"),
289 d()
290 ));
291 }
292
293 #[test]
294 fn pushed_is_true_when_no_unpushed_commits() {
295 let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
296 assert!(all_commits_pushed(&clean, d()));
297 let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
298 assert!(!all_commits_pushed(&dirty, d()));
299 }
300
301 #[test]
302 fn branches_have_remote_checks_every_local() {
303 let ok = FakeGit::new()
304 .ok(
305 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
306 "origin/dev\norigin/main\norigin/HEAD",
307 )
308 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
309 assert!(branches_have_remote(&ok, d()));
310
311 let missing = FakeGit::new()
312 .ok(
313 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
314 "origin/dev",
315 )
316 .ok(
317 "for-each-ref --format=%(refname:short) refs/heads/*",
318 "dev\nlocal-only",
319 );
320 assert!(!branches_have_remote(&missing, d()));
321 }
322
323 #[test]
324 fn not_behind_true_when_no_remote_branch() {
325 let g = FakeGit::new()
326 .ok("rev-parse --abbrev-ref HEAD", "dev")
327 .fail("show-ref --quiet refs/remotes/origin/dev");
328 assert!(not_behind_remote(&g, d()));
329 }
330
331 #[test]
332 fn not_behind_reflects_left_count() {
333 let aligned = FakeGit::new()
334 .ok("rev-parse --abbrev-ref HEAD", "dev")
335 .ok("show-ref --quiet refs/remotes/origin/dev", "")
336 .ok("rev-list --left-right --count origin/dev...dev", "0\t3");
337 assert!(not_behind_remote(&aligned, d()));
338
339 let behind = FakeGit::new()
340 .ok("rev-parse --abbrev-ref HEAD", "dev")
341 .ok("show-ref --quiet refs/remotes/origin/dev", "")
342 .ok("rev-list --left-right --count origin/dev...dev", "2\t0");
343 assert!(!not_behind_remote(&behind, d()));
344 }
345
346 fn on_integration(cur: &str, local_heads: &str, merged: &str) -> FakeGit {
349 FakeGit::new()
350 .ok("symbolic-ref --short HEAD", cur)
351 .ok("rev-parse --abbrev-ref HEAD", cur)
352 .ok("show-ref --verify --quiet refs/heads/dev", "")
353 .ok("branch --merged dev --format=%(refname:short)", merged)
354 .ok(
355 "for-each-ref --format=%(refname:short) refs/heads/*",
356 local_heads,
357 )
358 }
359
360 #[test]
361 fn correct_branch_detached_head_fails() {
362 let g = FakeGit::new().fail("symbolic-ref --short HEAD");
364 assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
365 assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
366 }
367
368 #[test]
369 fn correct_branch_on_feature_is_fine() {
370 let g = FakeGit::new()
371 .ok("symbolic-ref --short HEAD", "feature-x")
372 .ok("rev-parse --abbrev-ref HEAD", "feature-x");
373 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
374 assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
375 }
376
377 #[test]
378 fn team_rule_ignores_others_remote_branches() {
379 let g = on_integration("dev", "dev", "dev");
383 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
384 }
385
386 #[test]
387 fn team_rule_flags_local_unmerged_feature() {
388 let g = on_integration("dev", "dev\nfeature-x", "dev");
390 assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
391 }
392
393 #[test]
394 fn team_rule_allows_local_merged_feature() {
395 let g = on_integration("dev", "dev\nfeature-x", "dev\nfeature-x");
397 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
398 }
399
400 #[test]
401 fn solo_rule_flags_remote_feature_branch() {
402 let g = on_integration("dev", "dev", "dev").ok(
405 "ls-remote --heads origin",
406 "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
407 );
408 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
409 assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
410 }
411
412 #[test]
413 fn solo_rule_passes_when_remote_is_integration_only() {
414 let g = on_integration("dev", "dev", "dev").ok(
416 "ls-remote --heads origin",
417 "aaa\trefs/heads/dev\nbbb\trefs/heads/main",
418 );
419 assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
420 }
421
422 #[test]
423 fn evaluate_all_clear() {
424 let g = FakeGit::new()
425 .ok("rev-parse --abbrev-ref HEAD", "dev")
426 .ok("status -s", "")
427 .ok("log --oneline --branches --not --remotes", "")
428 .ok(
429 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
430 "origin/dev",
431 )
432 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
433 .ok("show-ref --quiet refs/remotes/origin/dev", "")
434 .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
435 .ok("symbolic-ref --short HEAD", "dev")
437 .ok("show-ref --verify --quiet refs/heads/dev", "")
438 .ok("branch --merged dev --format=%(refname:short)", "dev");
439 let base = ResolvedBase {
440 name: Some("dev".into()),
441 source: crate::config::BaseSource::Config,
442 };
443 let st = evaluate(&g, d(), &base, false);
444 assert!(st.ok(), "expected all-clear, got {st:?}");
445 assert_eq!(st.branch, "dev");
446 }
447
448 #[test]
449 fn unresolved_base_fails_correct_branch() {
450 let g = FakeGit::new()
453 .ok("rev-parse --abbrev-ref HEAD", "feature-x")
454 .ok("status -s", "")
455 .ok("log --oneline --branches --not --remotes", "")
456 .ok(
457 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
458 "origin/feature-x",
459 )
460 .ok(
461 "for-each-ref --format=%(refname:short) refs/heads/*",
462 "feature-x",
463 )
464 .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
465 .ok(
466 "rev-list --left-right --count origin/feature-x...feature-x",
467 "0\t0",
468 );
469 let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false);
470 assert!(!st.correct_branch);
471 assert!(!st.ok());
472 }
473}