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) -> Option<String> {
142 let base_ref = base_ref_for(git, dir, base_branch);
143 let merged = git.run(
144 dir,
145 &["branch", "--merged", &base_ref, "--format=%(refname:short)"],
146 );
147 if !merged.success {
148 return None;
149 }
150 let merged: HashSet<&str> = merged
151 .stdout
152 .lines()
153 .map(str::trim)
154 .filter(|l| !l.is_empty())
155 .collect();
156 git.run(
157 dir,
158 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
159 )
160 .stdout
161 .lines()
162 .map(str::trim)
163 .filter(|l| !l.is_empty())
164 .find(|b| !is_integration(b, base_branch) && !merged.contains(*b))
165 .map(str::to_string)
166}
167
168fn remote_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> Option<String> {
171 git.run(dir, &["ls-remote", "--heads", "origin"])
172 .stdout
173 .lines()
174 .filter_map(|l| {
175 l.split_once("refs/heads/")
176 .map(|(_, b)| b.trim().to_string())
177 })
178 .find(|b| !is_integration(b, base_branch))
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum BranchVerdict {
186 OnFeature,
188 IntegrationClean,
190 DetachedHead,
192 BaseUnresolved,
194 LocalUnmerged(String),
196 RemoteFeature(String),
198}
199
200impl BranchVerdict {
201 pub fn passed(&self) -> bool {
203 matches!(
204 self,
205 BranchVerdict::OnFeature | BranchVerdict::IntegrationClean
206 )
207 }
208
209 pub fn reason(&self) -> String {
212 match self {
213 BranchVerdict::OnFeature | BranchVerdict::IntegrationClean => String::new(),
214 BranchVerdict::DetachedHead => {
215 "detached HEAD — not on any branch (commits are easily lost here)".to_string()
216 }
217 BranchVerdict::BaseUnresolved => {
218 "base branch unresolved — set gkit.baseBranch or fetch origin/main|master"
219 .to_string()
220 }
221 BranchVerdict::LocalUnmerged(b) => {
222 format!(
223 "local branch '{b}' is not merged into base (team rule: your unfinished work)"
224 )
225 }
226 BranchVerdict::RemoteFeature(b) => {
227 format!("remote has feature branch '{b}' (solo rule: every remote branch is yours)")
228 }
229 }
230 }
231}
232
233pub fn branch_verdict(
243 git: &dyn Git,
244 dir: &Path,
245 base_branch: &str,
246 rule: BranchRule,
247) -> BranchVerdict {
248 if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
250 return BranchVerdict::DetachedHead;
251 }
252 let cur = current_branch(git, dir);
253 if !is_integration(&cur, base_branch) {
254 return BranchVerdict::OnFeature; }
256 match rule {
257 BranchRule::Team => match local_unmerged_feature(git, dir, base_branch) {
258 Some(b) => BranchVerdict::LocalUnmerged(b),
259 None => BranchVerdict::IntegrationClean,
260 },
261 BranchRule::Solo => match remote_feature(git, dir, base_branch) {
262 Some(b) => BranchVerdict::RemoteFeature(b),
263 None => BranchVerdict::IntegrationClean,
264 },
265 }
266}
267
268pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str, rule: BranchRule) -> bool {
270 branch_verdict(git, dir, base_branch, rule).passed()
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum RuleId {
277 Committed,
278 AllCommitsPushed,
279 BranchesHaveRemote,
280 NotBehindRemote,
281 CorrectBranch,
282}
283
284impl RuleId {
285 pub const ALL: [RuleId; 5] = [
287 RuleId::Committed,
288 RuleId::AllCommitsPushed,
289 RuleId::BranchesHaveRemote,
290 RuleId::NotBehindRemote,
291 RuleId::CorrectBranch,
292 ];
293
294 pub fn num(self) -> u8 {
296 match self {
297 RuleId::Committed => 1,
298 RuleId::AllCommitsPushed => 2,
299 RuleId::BranchesHaveRemote => 3,
300 RuleId::NotBehindRemote => 4,
301 RuleId::CorrectBranch => 5,
302 }
303 }
304
305 pub fn tag(self) -> String {
307 format!("R{}", self.num())
308 }
309
310 pub fn key(self) -> &'static str {
312 match self {
313 RuleId::Committed => "committed",
314 RuleId::AllCommitsPushed => "all-commits-pushed",
315 RuleId::BranchesHaveRemote => "branches-have-remote",
316 RuleId::NotBehindRemote => "not-behind-remote",
317 RuleId::CorrectBranch => "correct-branch",
318 }
319 }
320
321 pub fn description(self) -> &'static str {
323 match self {
324 RuleId::Committed => {
325 "no uncommitted changes in the working tree (git status -s is empty)"
326 }
327 RuleId::AllCommitsPushed => {
328 "every local commit exists on some remote (nothing unpushed)"
329 }
330 RuleId::BranchesHaveRemote => "every local branch has a remote-tracking counterpart",
331 RuleId::NotBehindRemote => {
332 "the current branch is not behind its remote (no pull needed)"
333 }
334 RuleId::CorrectBranch => {
335 "parked on a safe branch: a feature branch always passes; on an integration \
336 branch the team rule (default) flags a local branch unmerged into base, while \
337 the solo rule (gkit.solo=true) flags any remote feature branch; detached HEAD \
338 or an unresolved base always fail"
339 }
340 }
341 }
342
343 pub fn examples(self) -> &'static [(&'static str, &'static str)] {
346 match self {
347 RuleId::Committed => &[
348 ("clean working tree", "PASS (nothing to commit)"),
349 ("edited file, not committed", "FAIL (commit or stash it)"),
350 ("staged but uncommitted file", "FAIL (still uncommitted)"),
351 ],
352 RuleId::AllCommitsPushed => &[
353 ("every commit pushed", "PASS"),
354 ("local-only commit on any branch", "FAIL (push it)"),
355 ("amended commit not force-pushed", "FAIL (push the rewrite)"),
356 ],
357 RuleId::BranchesHaveRemote => &[
358 ("every local branch tracks a remote", "PASS"),
359 (
360 "local 'wip' branch never pushed",
361 "FAIL (push or delete it)",
362 ),
363 ],
364 RuleId::NotBehindRemote => &[
365 ("up to date with origin", "PASS"),
366 ("no matching remote branch", "PASS (nothing to be behind)"),
367 ("origin has commits you don't", "FAIL (pull --rebase)"),
368 ],
369 RuleId::CorrectBranch => &[
370 ("on a feature branch", "PASS (actively on your work)"),
371 (
372 "on base/main, all local branches merged",
373 "PASS (parked clean)",
374 ),
375 (
376 "on base/main, local 'wip' unmerged",
377 "FAIL (team: unfinished work)",
378 ),
379 (
380 "on base/main, remote feature branch exists",
381 "FAIL (solo only)",
382 ),
383 ("detached HEAD", "FAIL (risky resting state)"),
384 ],
385 }
386 }
387
388 pub fn from_num(n: u8) -> Option<RuleId> {
390 RuleId::ALL.into_iter().find(|r| r.num() == n)
391 }
392}
393
394#[derive(Debug, Clone)]
396pub struct RepoStatus {
397 pub branch: String,
398 pub committed: bool,
399 pub all_commits_pushed: bool,
400 pub branches_have_remote: bool,
401 pub not_behind_remote: bool,
402 pub correct_branch: bool,
403 pub branch_verdict: BranchVerdict,
406 pub base: ResolvedBase,
409 pub rule: BranchRule,
412 pub problem: Option<String>,
417}
418
419impl RepoStatus {
420 pub fn unusable(reason: impl Into<String>) -> Self {
423 RepoStatus {
424 branch: String::new(),
425 committed: false,
426 all_commits_pushed: false,
427 branches_have_remote: false,
428 not_behind_remote: false,
429 correct_branch: false,
430 branch_verdict: BranchVerdict::BaseUnresolved,
431 base: ResolvedBase::unresolved(),
432 rule: BranchRule::Team,
433 problem: Some(reason.into()),
434 }
435 }
436
437 pub fn ok(&self) -> bool {
439 self.problem.is_none()
440 && self.committed
441 && self.all_commits_pushed
442 && self.branches_have_remote
443 && self.not_behind_remote
444 && self.correct_branch
445 }
446
447 pub fn rule_passed(&self, rule: RuleId) -> bool {
449 match rule {
450 RuleId::Committed => self.committed,
451 RuleId::AllCommitsPushed => self.all_commits_pushed,
452 RuleId::BranchesHaveRemote => self.branches_have_remote,
453 RuleId::NotBehindRemote => self.not_behind_remote,
454 RuleId::CorrectBranch => self.correct_branch,
455 }
456 }
457
458 pub fn failure_reason(&self, rule: RuleId) -> Option<String> {
461 if self.rule_passed(rule) {
462 return None;
463 }
464 Some(match rule {
465 RuleId::Committed => "uncommitted changes in the working tree".to_string(),
466 RuleId::AllCommitsPushed => "local commits are not pushed to any remote".to_string(),
467 RuleId::BranchesHaveRemote => {
468 "a local branch has no remote-tracking counterpart".to_string()
469 }
470 RuleId::NotBehindRemote => "the branch is behind its remote (run git pull)".to_string(),
471 RuleId::CorrectBranch => self.branch_verdict.reason(),
472 })
473 }
474}
475
476pub fn evaluate(git: &dyn Git, dir: &Path, base: &ResolvedBase, solo: bool) -> RepoStatus {
481 let rule = BranchRule::from_solo(solo);
482 let verdict = match &base.name {
483 Some(b) => branch_verdict(git, dir, b, rule),
484 None => BranchVerdict::BaseUnresolved,
485 };
486 let correct_branch = verdict.passed();
487 RepoStatus {
488 branch: current_branch(git, dir),
489 committed: committed(git, dir),
490 all_commits_pushed: all_commits_pushed(git, dir),
491 branches_have_remote: branches_have_remote(git, dir),
492 not_behind_remote: not_behind_remote(git, dir),
493 correct_branch,
494 branch_verdict: verdict,
495 base: base.clone(),
496 rule,
497 problem: None,
498 }
499}
500
501#[derive(Debug, Clone)]
504pub struct RuleReport {
505 pub id: RuleId,
506 pub passed: bool,
507 pub facts: Vec<(String, String)>,
509 pub verdict: String,
511}
512
513pub fn rule_report(
517 git: &dyn Git,
518 dir: &Path,
519 base: &ResolvedBase,
520 solo: bool,
521 id: RuleId,
522) -> RuleReport {
523 let lines = |out: crate::git::GitOutput| -> Vec<String> {
524 out.stdout
525 .lines()
526 .map(str::trim)
527 .filter(|l| !l.is_empty())
528 .map(str::to_string)
529 .collect()
530 };
531 let or_none = |v: &[String]| {
532 if v.is_empty() {
533 "(none)".to_string()
534 } else {
535 v.join(", ")
536 }
537 };
538
539 let mut facts: Vec<(String, String)> = Vec::new();
540 let (passed, verdict) = match id {
541 RuleId::Committed => {
542 let dirty = lines(git.run(dir, &["status", "-s"]));
543 for f in &dirty {
544 facts.push(("dirty".to_string(), f.clone()));
545 }
546 if dirty.is_empty() {
547 (true, "PASS — working tree clean".to_string())
548 } else {
549 (
550 false,
551 format!("FAIL — {} uncommitted change(s)", dirty.len()),
552 )
553 }
554 }
555 RuleId::AllCommitsPushed => {
556 let unpushed = lines(git.run(
557 dir,
558 &["log", "--oneline", "--branches", "--not", "--remotes"],
559 ));
560 for c in &unpushed {
561 facts.push(("unpushed".to_string(), c.clone()));
562 }
563 if unpushed.is_empty() {
564 (true, "PASS — nothing unpushed".to_string())
565 } else {
566 (
567 false,
568 format!("FAIL — {} commit(s) not on any remote", unpushed.len()),
569 )
570 }
571 }
572 RuleId::BranchesHaveRemote => {
573 let remotes: HashSet<String> = git
574 .run(
575 dir,
576 &[
577 "for-each-ref",
578 "--format=%(refname:short)",
579 "refs/remotes/origin/*",
580 ],
581 )
582 .stdout
583 .lines()
584 .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
585 .filter(|b| b != "HEAD")
586 .collect();
587 let locals = lines(git.run(
588 dir,
589 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
590 ));
591 facts.push(("local branches".to_string(), or_none(&locals)));
592 let missing: Vec<String> = locals
593 .iter()
594 .filter(|b| !remotes.contains(*b))
595 .cloned()
596 .collect();
597 if missing.is_empty() {
598 (
599 true,
600 "PASS — every local branch tracks a remote".to_string(),
601 )
602 } else {
603 facts.push(("missing remote".to_string(), missing.join(", ")));
604 (
605 false,
606 format!("FAIL — no remote for: {}", missing.join(", ")),
607 )
608 }
609 }
610 RuleId::NotBehindRemote => {
611 let cur = current_branch(git, dir);
612 facts.push((
613 "branch".to_string(),
614 if cur.is_empty() {
615 "(detached)".to_string()
616 } else {
617 cur.clone()
618 },
619 ));
620 if cur.is_empty() {
621 (true, "PASS — no branch".to_string())
622 } else {
623 let remote_ref = format!("refs/remotes/origin/{cur}");
624 if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
625 facts.push(("remote branch".to_string(), "none".to_string()));
626 (true, "PASS — no matching remote branch".to_string())
627 } else {
628 let range = format!("origin/{cur}...{cur}");
629 let behind = git
630 .run(dir, &["rev-list", "--left-right", "--count", &range])
631 .trimmed()
632 .split_whitespace()
633 .next()
634 .and_then(|s| s.parse::<u64>().ok())
635 .unwrap_or(0);
636 facts.push(("behind by".to_string(), behind.to_string()));
637 if behind == 0 {
638 (true, "PASS — up to date with origin".to_string())
639 } else {
640 (
641 false,
642 format!("FAIL — behind by {behind} commit(s); pull --rebase"),
643 )
644 }
645 }
646 }
647 }
648 RuleId::CorrectBranch => {
649 let rule = BranchRule::from_solo(solo);
650 let cur = current_branch(git, dir);
651 let verdict_enum = match &base.name {
652 Some(b) => branch_verdict(git, dir, b, rule),
653 None => BranchVerdict::BaseUnresolved,
654 };
655 let locals = lines(git.run(
656 dir,
657 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
658 ));
659 facts.push((
660 "branch".to_string(),
661 if cur.is_empty() {
662 "(detached)".to_string()
663 } else {
664 cur.clone()
665 },
666 ));
667 facts.push(("base".to_string(), base.describe()));
668 facts.push(("rule".to_string(), rule.describe().to_string()));
669 facts.push(("local branches".to_string(), or_none(&locals)));
670 if verdict_enum.passed() {
671 (true, "PASS — parked safely".to_string())
672 } else {
673 (false, format!("FAIL — {}", verdict_enum.reason()))
674 }
675 }
676 };
677 RuleReport {
678 id,
679 passed,
680 facts,
681 verdict,
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::git::test_support::FakeGit;
689 use std::path::Path;
690
691 fn d() -> &'static Path {
692 Path::new("/x")
693 }
694
695 #[test]
696 fn committed_is_true_when_status_clean() {
697 assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
698 assert!(!committed(
699 &FakeGit::new().ok("status -s", " M file.rs"),
700 d()
701 ));
702 }
703
704 #[test]
705 fn pushed_is_true_when_no_unpushed_commits() {
706 let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
707 assert!(all_commits_pushed(&clean, d()));
708 let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
709 assert!(!all_commits_pushed(&dirty, d()));
710 }
711
712 #[test]
713 fn branches_have_remote_checks_every_local() {
714 let ok = FakeGit::new()
715 .ok(
716 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
717 "origin/dev\norigin/main\norigin/HEAD",
718 )
719 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
720 assert!(branches_have_remote(&ok, d()));
721
722 let missing = FakeGit::new()
723 .ok(
724 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
725 "origin/dev",
726 )
727 .ok(
728 "for-each-ref --format=%(refname:short) refs/heads/*",
729 "dev\nlocal-only",
730 );
731 assert!(!branches_have_remote(&missing, d()));
732 }
733
734 #[test]
735 fn not_behind_true_when_no_remote_branch() {
736 let g = FakeGit::new()
737 .ok("rev-parse --abbrev-ref HEAD", "dev")
738 .fail("show-ref --quiet refs/remotes/origin/dev");
739 assert!(not_behind_remote(&g, d()));
740 }
741
742 #[test]
743 fn not_behind_reflects_left_count() {
744 let aligned = FakeGit::new()
745 .ok("rev-parse --abbrev-ref HEAD", "dev")
746 .ok("show-ref --quiet refs/remotes/origin/dev", "")
747 .ok("rev-list --left-right --count origin/dev...dev", "0\t3");
748 assert!(not_behind_remote(&aligned, d()));
749
750 let behind = FakeGit::new()
751 .ok("rev-parse --abbrev-ref HEAD", "dev")
752 .ok("show-ref --quiet refs/remotes/origin/dev", "")
753 .ok("rev-list --left-right --count origin/dev...dev", "2\t0");
754 assert!(!not_behind_remote(&behind, d()));
755 }
756
757 fn on_integration(cur: &str, local_heads: &str, merged: &str) -> FakeGit {
760 FakeGit::new()
761 .ok("symbolic-ref --short HEAD", cur)
762 .ok("rev-parse --abbrev-ref HEAD", cur)
763 .ok("show-ref --verify --quiet refs/heads/dev", "")
764 .ok("branch --merged dev --format=%(refname:short)", merged)
765 .ok(
766 "for-each-ref --format=%(refname:short) refs/heads/*",
767 local_heads,
768 )
769 }
770
771 #[test]
772 fn correct_branch_detached_head_fails() {
773 let g = FakeGit::new().fail("symbolic-ref --short HEAD");
775 assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
776 assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
777 }
778
779 #[test]
780 fn correct_branch_on_feature_is_fine() {
781 let g = FakeGit::new()
782 .ok("symbolic-ref --short HEAD", "feature-x")
783 .ok("rev-parse --abbrev-ref HEAD", "feature-x");
784 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
785 assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
786 }
787
788 #[test]
789 fn team_rule_ignores_others_remote_branches() {
790 let g = on_integration("dev", "dev", "dev");
794 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
795 }
796
797 #[test]
798 fn team_rule_flags_local_unmerged_feature() {
799 let g = on_integration("dev", "dev\nfeature-x", "dev");
801 assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
802 }
803
804 #[test]
805 fn team_rule_allows_local_merged_feature() {
806 let g = on_integration("dev", "dev\nfeature-x", "dev\nfeature-x");
808 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
809 }
810
811 #[test]
812 fn solo_rule_flags_remote_feature_branch() {
813 let g = on_integration("dev", "dev", "dev").ok(
816 "ls-remote --heads origin",
817 "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
818 );
819 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
820 assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
821 }
822
823 #[test]
824 fn solo_rule_passes_when_remote_is_integration_only() {
825 let g = on_integration("dev", "dev", "dev").ok(
827 "ls-remote --heads origin",
828 "aaa\trefs/heads/dev\nbbb\trefs/heads/main",
829 );
830 assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
831 }
832
833 #[test]
834 fn evaluate_all_clear() {
835 let g = FakeGit::new()
836 .ok("rev-parse --abbrev-ref HEAD", "dev")
837 .ok("status -s", "")
838 .ok("log --oneline --branches --not --remotes", "")
839 .ok(
840 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
841 "origin/dev",
842 )
843 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
844 .ok("show-ref --quiet refs/remotes/origin/dev", "")
845 .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
846 .ok("symbolic-ref --short HEAD", "dev")
848 .ok("show-ref --verify --quiet refs/heads/dev", "")
849 .ok("branch --merged dev --format=%(refname:short)", "dev");
850 let base = ResolvedBase {
851 name: Some("dev".into()),
852 source: crate::config::BaseSource::Config,
853 };
854 let st = evaluate(&g, d(), &base, false);
855 assert!(st.ok(), "expected all-clear, got {st:?}");
856 assert_eq!(st.branch, "dev");
857 }
858
859 #[test]
860 fn unresolved_base_fails_correct_branch() {
861 let g = FakeGit::new()
864 .ok("rev-parse --abbrev-ref HEAD", "feature-x")
865 .ok("status -s", "")
866 .ok("log --oneline --branches --not --remotes", "")
867 .ok(
868 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
869 "origin/feature-x",
870 )
871 .ok(
872 "for-each-ref --format=%(refname:short) refs/heads/*",
873 "feature-x",
874 )
875 .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
876 .ok(
877 "rev-list --left-right --count origin/feature-x...feature-x",
878 "0\t0",
879 );
880 let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false);
881 assert!(!st.correct_branch);
882 assert!(!st.ok());
883 }
884
885 #[test]
888 fn verdict_detached_head() {
889 let g = FakeGit::new().fail("symbolic-ref --short HEAD");
890 assert_eq!(
891 branch_verdict(&g, d(), "dev", BranchRule::Team),
892 BranchVerdict::DetachedHead
893 );
894 }
895
896 #[test]
897 fn verdict_on_feature_branch() {
898 let g = FakeGit::new()
899 .ok("symbolic-ref --short HEAD", "feature-x")
900 .ok("rev-parse --abbrev-ref HEAD", "feature-x");
901 assert_eq!(
902 branch_verdict(&g, d(), "dev", BranchRule::Team),
903 BranchVerdict::OnFeature
904 );
905 }
906
907 #[test]
908 fn verdict_team_names_the_unmerged_local_branch() {
909 let g = on_integration("dev", "dev\nfeature-x", "dev");
910 assert_eq!(
911 branch_verdict(&g, d(), "dev", BranchRule::Team),
912 BranchVerdict::LocalUnmerged("feature-x".into())
913 );
914 }
915
916 #[test]
917 fn verdict_team_clean_integration() {
918 let g = on_integration("dev", "dev", "dev");
919 assert_eq!(
920 branch_verdict(&g, d(), "dev", BranchRule::Team),
921 BranchVerdict::IntegrationClean
922 );
923 }
924
925 #[test]
926 fn verdict_solo_names_the_remote_feature_branch() {
927 let g = on_integration("dev", "dev", "dev").ok(
928 "ls-remote --heads origin",
929 "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
930 );
931 assert_eq!(
932 branch_verdict(&g, d(), "dev", BranchRule::Solo),
933 BranchVerdict::RemoteFeature("alice-x".into())
934 );
935 }
936
937 #[test]
938 fn verdict_reason_is_empty_only_when_passing() {
939 assert!(BranchVerdict::OnFeature.reason().is_empty());
940 assert!(BranchVerdict::IntegrationClean.reason().is_empty());
941 assert!(BranchVerdict::DetachedHead
942 .reason()
943 .contains("detached HEAD"));
944 assert!(BranchVerdict::BaseUnresolved
945 .reason()
946 .contains("unresolved"));
947 assert!(BranchVerdict::LocalUnmerged("x".into())
948 .reason()
949 .contains("'x'"));
950 assert!(BranchVerdict::RemoteFeature("x".into())
951 .reason()
952 .contains("'x'"));
953 }
954
955 #[test]
956 fn evaluate_unresolved_sets_base_unresolved_verdict() {
957 let g = FakeGit::new()
960 .ok("rev-parse --abbrev-ref HEAD", "feature-x")
961 .ok("status -s", "")
962 .ok("log --oneline --branches --not --remotes", "")
963 .ok(
964 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
965 "origin/feature-x",
966 )
967 .ok(
968 "for-each-ref --format=%(refname:short) refs/heads/*",
969 "feature-x",
970 )
971 .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
972 .ok(
973 "rev-list --left-right --count origin/feature-x...feature-x",
974 "0\t0",
975 );
976 let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false);
977 assert_eq!(st.branch_verdict, BranchVerdict::BaseUnresolved);
978 assert_eq!(
979 st.failure_reason(RuleId::CorrectBranch),
980 Some(BranchVerdict::BaseUnresolved.reason())
981 );
982 }
983
984 #[test]
987 fn rule_ids_are_stable_and_round_trip() {
988 let nums: Vec<u8> = RuleId::ALL.iter().map(|r| r.num()).collect();
989 assert_eq!(nums, vec![1, 2, 3, 4, 5]);
990 for r in RuleId::ALL {
991 assert_eq!(RuleId::from_num(r.num()), Some(r));
992 assert_eq!(r.tag(), format!("R{}", r.num()));
993 assert!(!r.key().is_empty() && !r.description().is_empty());
994 }
995 assert_eq!(RuleId::from_num(0), None);
996 assert_eq!(RuleId::from_num(6), None);
997 assert_eq!(RuleId::CorrectBranch.key(), "correct-branch");
998 }
999
1000 #[test]
1001 fn failure_reason_is_some_only_for_failing_rules() {
1002 let g = FakeGit::new()
1004 .ok("rev-parse --abbrev-ref HEAD", "dev")
1005 .ok("status -s", " M file.txt") .ok("log --oneline --branches --not --remotes", "")
1007 .ok(
1008 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1009 "origin/dev",
1010 )
1011 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
1012 .ok("show-ref --quiet refs/remotes/origin/dev", "")
1013 .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
1014 .ok("symbolic-ref --short HEAD", "dev")
1015 .ok("show-ref --verify --quiet refs/heads/dev", "")
1016 .ok("branch --merged dev --format=%(refname:short)", "dev");
1017 let base = ResolvedBase {
1018 name: Some("dev".into()),
1019 source: crate::config::BaseSource::Config,
1020 };
1021 let st = evaluate(&g, d(), &base, false);
1022 assert!(st.failure_reason(RuleId::Committed).is_some());
1023 assert!(st.failure_reason(RuleId::AllCommitsPushed).is_none());
1024 assert!(st.failure_reason(RuleId::CorrectBranch).is_none());
1025 }
1026
1027 #[test]
1030 fn every_rule_has_examples() {
1031 for r in RuleId::ALL {
1032 assert!(!r.examples().is_empty(), "{:?} has no examples", r);
1033 }
1034 }
1035
1036 fn dev_base() -> ResolvedBase {
1037 ResolvedBase {
1038 name: Some("dev".into()),
1039 source: crate::config::BaseSource::Config,
1040 }
1041 }
1042
1043 #[test]
1044 fn rule_report_r5_names_unmerged_branch_and_lists_state() {
1045 let g = on_integration("dev", "dev\nfeature-x", "dev");
1047 let rep = rule_report(&g, d(), &dev_base(), false, RuleId::CorrectBranch);
1048 assert!(!rep.passed);
1049 assert!(
1050 rep.verdict.contains("feature-x"),
1051 "verdict: {}",
1052 rep.verdict
1053 );
1054 let facts: std::collections::HashMap<_, _> = rep.facts.iter().cloned().collect();
1056 assert_eq!(facts.get("branch").map(String::as_str), Some("dev"));
1057 assert!(facts.contains_key("base"));
1058 assert!(facts.get("local branches").unwrap().contains("feature-x"));
1059 }
1060
1061 #[test]
1062 fn rule_report_r1_lists_dirty_files() {
1063 let g = FakeGit::new()
1064 .ok("rev-parse --abbrev-ref HEAD", "dev")
1065 .ok("status -s", " M a.txt\n?? b.txt");
1066 let rep = rule_report(&g, d(), &dev_base(), false, RuleId::Committed);
1067 assert!(!rep.passed);
1068 let dirty: Vec<&str> = rep
1069 .facts
1070 .iter()
1071 .filter(|(l, _)| l == "dirty")
1072 .map(|(_, v)| v.as_str())
1073 .collect();
1074 assert_eq!(dirty, vec!["M a.txt", "?? b.txt"]);
1075 }
1076
1077 #[test]
1078 fn rule_report_r4_shows_behind_count() {
1079 let g = FakeGit::new()
1080 .ok("rev-parse --abbrev-ref HEAD", "dev")
1081 .ok("show-ref --quiet refs/remotes/origin/dev", "")
1082 .ok("rev-list --left-right --count origin/dev...dev", "3\t0");
1083 let rep = rule_report(&g, d(), &dev_base(), false, RuleId::NotBehindRemote);
1084 assert!(!rep.passed);
1085 let facts: std::collections::HashMap<_, _> = rep.facts.iter().cloned().collect();
1086 assert_eq!(facts.get("behind by").map(String::as_str), Some("3"));
1087 assert!(
1088 rep.verdict.contains("behind by 3"),
1089 "verdict: {}",
1090 rep.verdict
1091 );
1092 }
1093}