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 {
67 let cur = current_branch(git, dir);
68 if cur.is_empty() {
69 return false;
70 }
71 let remote_ref = format!("refs/remotes/origin/{cur}");
72 if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
73 return false;
74 }
75 let range = format!("origin/{cur}...{cur}");
76 let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
77 out.trimmed()
79 .split_whitespace()
80 .next()
81 .and_then(|s| s.parse::<u64>().ok())
82 .map(|behind| behind == 0)
83 .unwrap_or(false)
84}
85
86fn is_integration(branch: &str, base_branch: &str) -> bool {
89 branch == base_branch || branch == "main" || branch == "master"
90}
91
92fn base_ref_for(git: &dyn Git, dir: &Path, base_branch: &str) -> String {
97 let local = format!("refs/heads/{base_branch}");
98 if git
99 .run(dir, &["show-ref", "--verify", "--quiet", &local])
100 .success
101 {
102 base_branch.to_string()
103 } else {
104 format!("origin/{base_branch}")
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum BranchRule {
113 Team,
116 Solo,
120}
121
122impl BranchRule {
123 pub fn from_solo(solo: bool) -> Self {
124 if solo {
125 BranchRule::Solo
126 } else {
127 BranchRule::Team
128 }
129 }
130
131 pub fn describe(&self) -> &'static str {
134 match self {
135 BranchRule::Team => "team (gkit.solo off) — flags a local branch unmerged into base",
136 BranchRule::Solo => "solo (gkit.solo on) — flags any feature branch on the remote",
137 }
138 }
139}
140
141fn local_unmerged_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> Option<String> {
145 let base_ref = base_ref_for(git, dir, base_branch);
146 let merged = git.run(
147 dir,
148 &["branch", "--merged", &base_ref, "--format=%(refname:short)"],
149 );
150 if !merged.success {
151 return None;
152 }
153 let merged: HashSet<&str> = merged
154 .stdout
155 .lines()
156 .map(str::trim)
157 .filter(|l| !l.is_empty())
158 .collect();
159 git.run(
160 dir,
161 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
162 )
163 .stdout
164 .lines()
165 .map(str::trim)
166 .filter(|l| !l.is_empty())
167 .find(|b| !is_integration(b, base_branch) && !merged.contains(*b))
168 .map(str::to_string)
169}
170
171fn remote_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> Option<String> {
174 git.run(dir, &["ls-remote", "--heads", "origin"])
175 .stdout
176 .lines()
177 .filter_map(|l| {
178 l.split_once("refs/heads/")
179 .map(|(_, b)| b.trim().to_string())
180 })
181 .find(|b| !is_integration(b, base_branch))
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum BranchVerdict {
189 OnFeature,
191 IntegrationClean,
193 DetachedHead,
195 BaseUnresolved,
197 LocalUnmerged(String),
199 RemoteFeature(String),
201}
202
203impl BranchVerdict {
204 pub fn passed(&self) -> bool {
206 matches!(
207 self,
208 BranchVerdict::OnFeature | BranchVerdict::IntegrationClean
209 )
210 }
211
212 pub fn reason(&self) -> String {
215 match self {
216 BranchVerdict::OnFeature | BranchVerdict::IntegrationClean => String::new(),
217 BranchVerdict::DetachedHead => {
218 "detached HEAD — not on any branch (commits are easily lost here)".to_string()
219 }
220 BranchVerdict::BaseUnresolved => {
221 "base branch unresolved — set gkit.baseBranch or fetch origin/main|master"
222 .to_string()
223 }
224 BranchVerdict::LocalUnmerged(b) => {
225 format!(
226 "local branch '{b}' is not merged into base (team rule: your unfinished work)"
227 )
228 }
229 BranchVerdict::RemoteFeature(b) => {
230 format!("remote has feature branch '{b}' (solo rule: every remote branch is yours)")
231 }
232 }
233 }
234}
235
236pub fn branch_verdict(
246 git: &dyn Git,
247 dir: &Path,
248 base_branch: &str,
249 rule: BranchRule,
250) -> BranchVerdict {
251 if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
253 return BranchVerdict::DetachedHead;
254 }
255 let cur = current_branch(git, dir);
256 if !is_integration(&cur, base_branch) {
257 return BranchVerdict::OnFeature; }
259 match rule {
260 BranchRule::Team => match local_unmerged_feature(git, dir, base_branch) {
261 Some(b) => BranchVerdict::LocalUnmerged(b),
262 None => BranchVerdict::IntegrationClean,
263 },
264 BranchRule::Solo => match remote_feature(git, dir, base_branch) {
265 Some(b) => BranchVerdict::RemoteFeature(b),
266 None => BranchVerdict::IntegrationClean,
267 },
268 }
269}
270
271pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str, rule: BranchRule) -> bool {
273 branch_verdict(git, dir, base_branch, rule).passed()
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum BehindKind {
279 Diverged,
281 Stale,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum BaseSyncVerdict {
291 NotApplicable,
293 Current,
295 Behind {
297 kind: BehindKind,
298 ahead: u64,
299 behind: u64,
300 base: String,
301 allowed: bool,
302 },
303 Undeterminable { why: String },
305}
306
307impl BaseSyncVerdict {
308 pub fn passed(&self) -> bool {
310 match self {
311 BaseSyncVerdict::NotApplicable | BaseSyncVerdict::Current => true,
312 BaseSyncVerdict::Behind { allowed, .. } => *allowed,
313 BaseSyncVerdict::Undeterminable { .. } => false,
314 }
315 }
316
317 pub fn reason(&self) -> String {
320 match self {
321 BaseSyncVerdict::Behind {
322 kind,
323 ahead,
324 behind,
325 base,
326 allowed: false,
327 } => match kind {
328 BehindKind::Diverged => format!(
329 "diverged from base '{base}': {ahead} ahead, {behind} behind — rebase onto base"
330 ),
331 BehindKind::Stale => format!(
332 "behind base '{base}' by {behind} (no unique commits — switch to base & delete)"
333 ),
334 },
335 BaseSyncVerdict::Undeterminable { why } => why.clone(),
336 _ => String::new(),
337 }
338 }
339
340 pub fn marker(&self) -> Option<String> {
344 match self {
345 BaseSyncVerdict::Behind {
346 kind,
347 allowed: true,
348 ..
349 } => Some(match kind {
350 BehindKind::Diverged => "(diverged, allowed by gkit.allowDiverged)".to_string(),
351 BehindKind::Stale => {
352 "(behind base, merged, allowed by gkit.allowDiverged)".to_string()
353 }
354 }),
355 _ => None,
356 }
357 }
358}
359
360pub fn base_sync_verdict(
368 git: &dyn Git,
369 dir: &Path,
370 base_branch: &str,
371 allow_diverged: bool,
372) -> BaseSyncVerdict {
373 if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
375 return BaseSyncVerdict::Undeterminable {
376 why: "detached HEAD — not on a branch (can't compare to base)".to_string(),
377 };
378 }
379 let cur = current_branch(git, dir);
380 if is_integration(&cur, base_branch) {
381 return BaseSyncVerdict::NotApplicable;
382 }
383 let local = format!("refs/heads/{base_branch}");
386 let base_ref = if git
387 .run(dir, &["show-ref", "--verify", "--quiet", &local])
388 .success
389 {
390 base_branch.to_string()
391 } else {
392 let remote = format!("refs/remotes/origin/{base_branch}");
393 if !git
394 .run(dir, &["show-ref", "--verify", "--quiet", &remote])
395 .success
396 {
397 return BaseSyncVerdict::Undeterminable {
398 why: format!(
399 "base '{base_branch}' not found locally or on origin — fetch or set gkit.baseBranch"
400 ),
401 };
402 }
403 format!("origin/{base_branch}")
404 };
405 let range = format!("{base_ref}...HEAD");
408 let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
409 let mut it = out.trimmed().split_whitespace();
410 let counts = (
411 it.next().and_then(|s| s.parse::<u64>().ok()),
412 it.next().and_then(|s| s.parse::<u64>().ok()),
413 );
414 let (behind, ahead) = match counts {
415 (Some(b), Some(a)) => (b, a),
416 _ => {
417 return BaseSyncVerdict::Undeterminable {
418 why: format!("could not compare to base '{base_branch}'"),
419 }
420 }
421 };
422 if behind == 0 {
423 BaseSyncVerdict::Current
424 } else {
425 BaseSyncVerdict::Behind {
426 kind: if ahead > 0 {
427 BehindKind::Diverged
428 } else {
429 BehindKind::Stale
430 },
431 ahead,
432 behind,
433 base: base_branch.to_string(),
434 allowed: allow_diverged,
435 }
436 }
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum RuleId {
443 Committed,
444 AllCommitsPushed,
445 BranchesHaveRemote,
446 NotBehindRemote,
447 CorrectBranch,
448 NotBehindBase,
449}
450
451impl RuleId {
452 pub const ALL: [RuleId; 6] = [
454 RuleId::Committed,
455 RuleId::AllCommitsPushed,
456 RuleId::BranchesHaveRemote,
457 RuleId::NotBehindRemote,
458 RuleId::CorrectBranch,
459 RuleId::NotBehindBase,
460 ];
461
462 pub fn num(self) -> u8 {
464 match self {
465 RuleId::Committed => 1,
466 RuleId::AllCommitsPushed => 2,
467 RuleId::BranchesHaveRemote => 3,
468 RuleId::NotBehindRemote => 4,
469 RuleId::CorrectBranch => 5,
470 RuleId::NotBehindBase => 6,
471 }
472 }
473
474 pub fn tag(self) -> String {
476 format!("R{}", self.num())
477 }
478
479 pub fn key(self) -> &'static str {
481 match self {
482 RuleId::Committed => "committed",
483 RuleId::AllCommitsPushed => "all-commits-pushed",
484 RuleId::BranchesHaveRemote => "branches-have-remote",
485 RuleId::NotBehindRemote => "not-behind-remote",
486 RuleId::CorrectBranch => "correct-branch",
487 RuleId::NotBehindBase => "not-behind-base",
488 }
489 }
490
491 pub fn description(self) -> &'static str {
493 match self {
494 RuleId::Committed => {
495 "no uncommitted changes in the working tree (git status -s is empty)"
496 }
497 RuleId::AllCommitsPushed => {
498 "every local commit exists on some remote (nothing unpushed)"
499 }
500 RuleId::BranchesHaveRemote => "every local branch has a remote-tracking counterpart",
501 RuleId::NotBehindRemote => {
502 "the current branch tracks a remote and is not behind it (no pull needed); \
503 fail-closed — a detached/unborn HEAD or a missing remote-tracking branch fails \
504 rather than passing vacuously"
505 }
506 RuleId::CorrectBranch => {
507 "parked on a safe branch: a feature branch always passes; on an integration \
508 branch the team rule (default) flags a local branch unmerged into base, while \
509 the solo rule (gkit.solo=true) flags any remote feature branch; detached HEAD \
510 or an unresolved base always fail"
511 }
512 RuleId::NotBehindBase => {
513 "on a feature branch, not behind the integration base (the base-side twin of \
514 not-behind-remote): fails when the branch is behind base — diverged (also ahead, \
515 rebase) or merged/stale (no unique commits, switch to base & delete). Integration \
516 branches are skipped; fail-closed on detached HEAD or a base whose ref can't be \
517 located. Suppress with git config gkit.allowDiverged true (still shown as a marker)"
518 }
519 }
520 }
521
522 pub fn examples(self) -> &'static [(&'static str, &'static str)] {
525 match self {
526 RuleId::Committed => &[
527 ("clean working tree", "PASS (nothing to commit)"),
528 ("edited file, not committed", "FAIL (commit or stash it)"),
529 ("staged but uncommitted file", "FAIL (still uncommitted)"),
530 ],
531 RuleId::AllCommitsPushed => &[
532 ("every commit pushed", "PASS"),
533 ("local-only commit on any branch", "FAIL (push it)"),
534 ("amended commit not force-pushed", "FAIL (push the rewrite)"),
535 ],
536 RuleId::BranchesHaveRemote => &[
537 ("every local branch tracks a remote", "PASS"),
538 (
539 "local 'wip' branch never pushed",
540 "FAIL (push or delete it)",
541 ),
542 ],
543 RuleId::NotBehindRemote => &[
544 ("up to date with origin", "PASS"),
545 ("no remote-tracking branch", "FAIL (push it / fix tracking)"),
546 ("origin has commits you don't", "FAIL (pull --rebase)"),
547 ],
548 RuleId::CorrectBranch => &[
549 ("on a feature branch", "PASS (actively on your work)"),
550 (
551 "on base/main, all local branches merged",
552 "PASS (parked clean)",
553 ),
554 (
555 "on base/main, local 'wip' unmerged",
556 "FAIL (team: unfinished work)",
557 ),
558 (
559 "on base/main, remote feature branch exists",
560 "FAIL (solo only)",
561 ),
562 ("detached HEAD", "FAIL (risky resting state)"),
563 ],
564 RuleId::NotBehindBase => &[
565 ("feature 2 ahead, 0 behind base", "PASS (on top of base)"),
566 ("feature 1 ahead, 2 behind base", "FAIL (diverged — rebase)"),
567 (
568 "feature 0 ahead, 3 behind base",
569 "FAIL (merged/stale — delete)",
570 ),
571 ("on base/main/master", "PASS (integration branch skipped)"),
572 (
573 "gkit.allowDiverged=true, diverged",
574 "PASS (tolerated, marked)",
575 ),
576 ],
577 }
578 }
579
580 pub fn from_num(n: u8) -> Option<RuleId> {
582 RuleId::ALL.into_iter().find(|r| r.num() == n)
583 }
584}
585
586#[derive(Debug, Clone)]
588pub struct RepoStatus {
589 pub branch: String,
590 pub committed: bool,
591 pub all_commits_pushed: bool,
592 pub branches_have_remote: bool,
593 pub not_behind_remote: bool,
594 pub correct_branch: bool,
595 pub branch_verdict: BranchVerdict,
598 pub base: ResolvedBase,
601 pub base_sync: BaseSyncVerdict,
605 pub rule: BranchRule,
608 pub problem: Option<String>,
613}
614
615impl RepoStatus {
616 pub fn unusable(reason: impl Into<String>) -> Self {
619 RepoStatus {
620 branch: String::new(),
621 committed: false,
622 all_commits_pushed: false,
623 branches_have_remote: false,
624 not_behind_remote: false,
625 correct_branch: false,
626 branch_verdict: BranchVerdict::BaseUnresolved,
627 base: ResolvedBase::unresolved(),
628 base_sync: BaseSyncVerdict::NotApplicable,
629 rule: BranchRule::Team,
630 problem: Some(reason.into()),
631 }
632 }
633
634 pub fn ok(&self) -> bool {
636 self.problem.is_none()
637 && self.committed
638 && self.all_commits_pushed
639 && self.branches_have_remote
640 && self.not_behind_remote
641 && self.correct_branch
642 && self.base_sync.passed()
643 }
644
645 pub fn rule_passed(&self, rule: RuleId) -> bool {
647 match rule {
648 RuleId::Committed => self.committed,
649 RuleId::AllCommitsPushed => self.all_commits_pushed,
650 RuleId::BranchesHaveRemote => self.branches_have_remote,
651 RuleId::NotBehindRemote => self.not_behind_remote,
652 RuleId::CorrectBranch => self.correct_branch,
653 RuleId::NotBehindBase => self.base_sync.passed(),
654 }
655 }
656
657 pub fn failure_reason(&self, rule: RuleId) -> Option<String> {
660 if self.rule_passed(rule) {
661 return None;
662 }
663 Some(match rule {
664 RuleId::Committed => "uncommitted changes in the working tree".to_string(),
665 RuleId::AllCommitsPushed => "local commits are not pushed to any remote".to_string(),
666 RuleId::BranchesHaveRemote => {
667 "a local branch has no remote-tracking counterpart".to_string()
668 }
669 RuleId::NotBehindRemote => {
670 "the branch is behind its remote, or has no remote-tracking branch to compare \
671 (push it / pull --rebase)"
672 .to_string()
673 }
674 RuleId::CorrectBranch => self.branch_verdict.reason(),
675 RuleId::NotBehindBase => self.base_sync.reason(),
676 })
677 }
678}
679
680pub fn evaluate(
688 git: &dyn Git,
689 dir: &Path,
690 base: &ResolvedBase,
691 solo: bool,
692 allow_diverged: bool,
693) -> RepoStatus {
694 let rule = BranchRule::from_solo(solo);
695 let verdict = match &base.name {
696 Some(b) => branch_verdict(git, dir, b, rule),
697 None => BranchVerdict::BaseUnresolved,
698 };
699 let base_sync = match &base.name {
702 Some(b) => base_sync_verdict(git, dir, b, allow_diverged),
703 None => BaseSyncVerdict::Undeterminable {
704 why: "base unresolved — set gkit.baseBranch or fetch origin/main|master".to_string(),
705 },
706 };
707 let correct_branch = verdict.passed();
708 RepoStatus {
709 branch: current_branch(git, dir),
710 committed: committed(git, dir),
711 all_commits_pushed: all_commits_pushed(git, dir),
712 branches_have_remote: branches_have_remote(git, dir),
713 not_behind_remote: not_behind_remote(git, dir),
714 correct_branch,
715 branch_verdict: verdict,
716 base: base.clone(),
717 base_sync,
718 rule,
719 problem: None,
720 }
721}
722
723#[derive(Debug, Clone)]
726pub struct RuleReport {
727 pub id: RuleId,
728 pub passed: bool,
729 pub facts: Vec<(String, String)>,
731 pub verdict: String,
733}
734
735pub fn rule_report(
739 git: &dyn Git,
740 dir: &Path,
741 base: &ResolvedBase,
742 solo: bool,
743 allow_diverged: bool,
744 id: RuleId,
745) -> RuleReport {
746 let lines = |out: crate::git::GitOutput| -> Vec<String> {
747 out.stdout
748 .lines()
749 .map(str::trim)
750 .filter(|l| !l.is_empty())
751 .map(str::to_string)
752 .collect()
753 };
754 let or_none = |v: &[String]| {
755 if v.is_empty() {
756 "(none)".to_string()
757 } else {
758 v.join(", ")
759 }
760 };
761
762 let mut facts: Vec<(String, String)> = Vec::new();
763 let (passed, verdict) = match id {
764 RuleId::Committed => {
765 let dirty = lines(git.run(dir, &["status", "-s"]));
766 for f in &dirty {
767 facts.push(("dirty".to_string(), f.clone()));
768 }
769 if dirty.is_empty() {
770 (true, "PASS — working tree clean".to_string())
771 } else {
772 (
773 false,
774 format!("FAIL — {} uncommitted change(s)", dirty.len()),
775 )
776 }
777 }
778 RuleId::AllCommitsPushed => {
779 let unpushed = lines(git.run(
780 dir,
781 &["log", "--oneline", "--branches", "--not", "--remotes"],
782 ));
783 for c in &unpushed {
784 facts.push(("unpushed".to_string(), c.clone()));
785 }
786 if unpushed.is_empty() {
787 (true, "PASS — nothing unpushed".to_string())
788 } else {
789 (
790 false,
791 format!("FAIL — {} commit(s) not on any remote", unpushed.len()),
792 )
793 }
794 }
795 RuleId::BranchesHaveRemote => {
796 let remotes: HashSet<String> = git
797 .run(
798 dir,
799 &[
800 "for-each-ref",
801 "--format=%(refname:short)",
802 "refs/remotes/origin/*",
803 ],
804 )
805 .stdout
806 .lines()
807 .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
808 .filter(|b| b != "HEAD")
809 .collect();
810 let locals = lines(git.run(
811 dir,
812 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
813 ));
814 facts.push(("local branches".to_string(), or_none(&locals)));
815 let missing: Vec<String> = locals
816 .iter()
817 .filter(|b| !remotes.contains(*b))
818 .cloned()
819 .collect();
820 if missing.is_empty() {
821 (
822 true,
823 "PASS — every local branch tracks a remote".to_string(),
824 )
825 } else {
826 facts.push(("missing remote".to_string(), missing.join(", ")));
827 (
828 false,
829 format!("FAIL — no remote for: {}", missing.join(", ")),
830 )
831 }
832 }
833 RuleId::NotBehindRemote => {
834 let cur = current_branch(git, dir);
835 facts.push((
836 "branch".to_string(),
837 if cur.is_empty() {
838 "(detached)".to_string()
839 } else {
840 cur.clone()
841 },
842 ));
843 if cur.is_empty() {
844 (
845 false,
846 "FAIL — no current branch (detached/unborn); can't compare to a remote"
847 .to_string(),
848 )
849 } else {
850 let remote_ref = format!("refs/remotes/origin/{cur}");
851 if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
852 facts.push(("remote branch".to_string(), "none".to_string()));
853 (
854 false,
855 "FAIL — no remote-tracking branch (push it / fix tracking)".to_string(),
856 )
857 } else {
858 let range = format!("origin/{cur}...{cur}");
859 let behind = git
860 .run(dir, &["rev-list", "--left-right", "--count", &range])
861 .trimmed()
862 .split_whitespace()
863 .next()
864 .and_then(|s| s.parse::<u64>().ok())
865 .unwrap_or(0);
866 facts.push(("behind by".to_string(), behind.to_string()));
867 if behind == 0 {
868 (true, "PASS — up to date with origin".to_string())
869 } else {
870 (
871 false,
872 format!("FAIL — behind by {behind} commit(s); pull --rebase"),
873 )
874 }
875 }
876 }
877 }
878 RuleId::CorrectBranch => {
879 let rule = BranchRule::from_solo(solo);
880 let cur = current_branch(git, dir);
881 let verdict_enum = match &base.name {
882 Some(b) => branch_verdict(git, dir, b, rule),
883 None => BranchVerdict::BaseUnresolved,
884 };
885 let locals = lines(git.run(
886 dir,
887 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
888 ));
889 facts.push((
890 "branch".to_string(),
891 if cur.is_empty() {
892 "(detached)".to_string()
893 } else {
894 cur.clone()
895 },
896 ));
897 facts.push(("base".to_string(), base.describe()));
898 facts.push(("rule".to_string(), rule.describe().to_string()));
899 facts.push(("local branches".to_string(), or_none(&locals)));
900 if verdict_enum.passed() {
901 (true, "PASS — parked safely".to_string())
902 } else {
903 (false, format!("FAIL — {}", verdict_enum.reason()))
904 }
905 }
906 RuleId::NotBehindBase => {
907 let cur = current_branch(git, dir);
908 facts.push((
909 "branch".to_string(),
910 if cur.is_empty() {
911 "(detached)".to_string()
912 } else {
913 cur.clone()
914 },
915 ));
916 facts.push(("base".to_string(), base.describe()));
917 let verdict = match &base.name {
918 Some(b) => base_sync_verdict(git, dir, b, allow_diverged),
919 None => BaseSyncVerdict::Undeterminable {
920 why: "base unresolved — set gkit.baseBranch or fetch origin/main|master"
921 .to_string(),
922 },
923 };
924 if let BaseSyncVerdict::Behind { ahead, behind, .. } = &verdict {
925 facts.push(("ahead of base".to_string(), ahead.to_string()));
926 facts.push(("behind base".to_string(), behind.to_string()));
927 }
928 match &verdict {
929 BaseSyncVerdict::NotApplicable => (
930 true,
931 "PASS — on an integration branch (not feature work)".to_string(),
932 ),
933 BaseSyncVerdict::Current => (
934 true,
935 "PASS — feature branch is current with base".to_string(),
936 ),
937 BaseSyncVerdict::Behind {
938 kind,
939 ahead,
940 behind,
941 base: b,
942 allowed: true,
943 } => {
944 let what = match kind {
945 BehindKind::Diverged => {
946 format!("diverged from '{b}' ({ahead} ahead, {behind} behind)")
947 }
948 BehindKind::Stale => format!("behind '{b}' by {behind} (merged/stale)"),
949 };
950 (
951 true,
952 format!("PASS — {what} but allowed by gkit.allowDiverged"),
953 )
954 }
955 BaseSyncVerdict::Behind { allowed: false, .. }
956 | BaseSyncVerdict::Undeterminable { .. } => {
957 (false, format!("FAIL — {}", verdict.reason()))
958 }
959 }
960 }
961 };
962 RuleReport {
963 id,
964 passed,
965 facts,
966 verdict,
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973 use crate::git::test_support::FakeGit;
974 use std::path::Path;
975
976 fn d() -> &'static Path {
977 Path::new("/x")
978 }
979
980 #[test]
981 fn committed_is_true_when_status_clean() {
982 assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
983 assert!(!committed(
984 &FakeGit::new().ok("status -s", " M file.rs"),
985 d()
986 ));
987 }
988
989 #[test]
990 fn pushed_is_true_when_no_unpushed_commits() {
991 let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
992 assert!(all_commits_pushed(&clean, d()));
993 let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
994 assert!(!all_commits_pushed(&dirty, d()));
995 }
996
997 #[test]
998 fn branches_have_remote_checks_every_local() {
999 let ok = FakeGit::new()
1000 .ok(
1001 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1002 "origin/dev\norigin/main\norigin/HEAD",
1003 )
1004 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
1005 assert!(branches_have_remote(&ok, d()));
1006
1007 let missing = FakeGit::new()
1008 .ok(
1009 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1010 "origin/dev",
1011 )
1012 .ok(
1013 "for-each-ref --format=%(refname:short) refs/heads/*",
1014 "dev\nlocal-only",
1015 );
1016 assert!(!branches_have_remote(&missing, d()));
1017 }
1018
1019 #[test]
1020 fn not_behind_false_when_no_remote_branch() {
1021 let g = FakeGit::new()
1024 .ok("rev-parse --abbrev-ref HEAD", "dev")
1025 .fail("show-ref --quiet refs/remotes/origin/dev");
1026 assert!(!not_behind_remote(&g, d()));
1027 }
1028
1029 #[test]
1030 fn not_behind_false_when_detached_or_unborn() {
1031 let g = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "");
1033 assert!(!not_behind_remote(&g, d()));
1034 }
1035
1036 #[test]
1037 fn not_behind_reflects_left_count() {
1038 let aligned = FakeGit::new()
1039 .ok("rev-parse --abbrev-ref HEAD", "dev")
1040 .ok("show-ref --quiet refs/remotes/origin/dev", "")
1041 .ok("rev-list --left-right --count origin/dev...dev", "0\t3");
1042 assert!(not_behind_remote(&aligned, d()));
1043
1044 let behind = FakeGit::new()
1045 .ok("rev-parse --abbrev-ref HEAD", "dev")
1046 .ok("show-ref --quiet refs/remotes/origin/dev", "")
1047 .ok("rev-list --left-right --count origin/dev...dev", "2\t0");
1048 assert!(!not_behind_remote(&behind, d()));
1049 }
1050
1051 fn on_integration(cur: &str, local_heads: &str, merged: &str) -> FakeGit {
1054 FakeGit::new()
1055 .ok("symbolic-ref --short HEAD", cur)
1056 .ok("rev-parse --abbrev-ref HEAD", cur)
1057 .ok("show-ref --verify --quiet refs/heads/dev", "")
1058 .ok("branch --merged dev --format=%(refname:short)", merged)
1059 .ok(
1060 "for-each-ref --format=%(refname:short) refs/heads/*",
1061 local_heads,
1062 )
1063 }
1064
1065 #[test]
1066 fn correct_branch_detached_head_fails() {
1067 let g = FakeGit::new().fail("symbolic-ref --short HEAD");
1069 assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
1070 assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
1071 }
1072
1073 #[test]
1074 fn correct_branch_on_feature_is_fine() {
1075 let g = FakeGit::new()
1076 .ok("symbolic-ref --short HEAD", "feature-x")
1077 .ok("rev-parse --abbrev-ref HEAD", "feature-x");
1078 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
1079 assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
1080 }
1081
1082 #[test]
1083 fn team_rule_ignores_others_remote_branches() {
1084 let g = on_integration("dev", "dev", "dev");
1088 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
1089 }
1090
1091 #[test]
1092 fn team_rule_flags_local_unmerged_feature() {
1093 let g = on_integration("dev", "dev\nfeature-x", "dev");
1095 assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
1096 }
1097
1098 #[test]
1099 fn team_rule_allows_local_merged_feature() {
1100 let g = on_integration("dev", "dev\nfeature-x", "dev\nfeature-x");
1102 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
1103 }
1104
1105 #[test]
1106 fn solo_rule_flags_remote_feature_branch() {
1107 let g = on_integration("dev", "dev", "dev").ok(
1110 "ls-remote --heads origin",
1111 "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
1112 );
1113 assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
1114 assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
1115 }
1116
1117 #[test]
1118 fn solo_rule_passes_when_remote_is_integration_only() {
1119 let g = on_integration("dev", "dev", "dev").ok(
1121 "ls-remote --heads origin",
1122 "aaa\trefs/heads/dev\nbbb\trefs/heads/main",
1123 );
1124 assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
1125 }
1126
1127 #[test]
1128 fn evaluate_all_clear() {
1129 let g = FakeGit::new()
1130 .ok("rev-parse --abbrev-ref HEAD", "dev")
1131 .ok("status -s", "")
1132 .ok("log --oneline --branches --not --remotes", "")
1133 .ok(
1134 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1135 "origin/dev",
1136 )
1137 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
1138 .ok("show-ref --quiet refs/remotes/origin/dev", "")
1139 .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
1140 .ok("symbolic-ref --short HEAD", "dev")
1142 .ok("show-ref --verify --quiet refs/heads/dev", "")
1143 .ok("branch --merged dev --format=%(refname:short)", "dev");
1144 let base = ResolvedBase {
1145 name: Some("dev".into()),
1146 source: crate::config::BaseSource::Config,
1147 };
1148 let st = evaluate(&g, d(), &base, false, false);
1149 assert!(st.ok(), "expected all-clear, got {st:?}");
1150 assert_eq!(st.branch, "dev");
1151 }
1152
1153 #[test]
1154 fn unresolved_base_fails_correct_branch() {
1155 let g = FakeGit::new()
1158 .ok("rev-parse --abbrev-ref HEAD", "feature-x")
1159 .ok("status -s", "")
1160 .ok("log --oneline --branches --not --remotes", "")
1161 .ok(
1162 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1163 "origin/feature-x",
1164 )
1165 .ok(
1166 "for-each-ref --format=%(refname:short) refs/heads/*",
1167 "feature-x",
1168 )
1169 .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
1170 .ok(
1171 "rev-list --left-right --count origin/feature-x...feature-x",
1172 "0\t0",
1173 );
1174 let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false, false);
1175 assert!(!st.correct_branch);
1176 assert!(!st.ok());
1177 }
1178
1179 #[test]
1182 fn verdict_detached_head() {
1183 let g = FakeGit::new().fail("symbolic-ref --short HEAD");
1184 assert_eq!(
1185 branch_verdict(&g, d(), "dev", BranchRule::Team),
1186 BranchVerdict::DetachedHead
1187 );
1188 }
1189
1190 #[test]
1191 fn verdict_on_feature_branch() {
1192 let g = FakeGit::new()
1193 .ok("symbolic-ref --short HEAD", "feature-x")
1194 .ok("rev-parse --abbrev-ref HEAD", "feature-x");
1195 assert_eq!(
1196 branch_verdict(&g, d(), "dev", BranchRule::Team),
1197 BranchVerdict::OnFeature
1198 );
1199 }
1200
1201 #[test]
1202 fn verdict_team_names_the_unmerged_local_branch() {
1203 let g = on_integration("dev", "dev\nfeature-x", "dev");
1204 assert_eq!(
1205 branch_verdict(&g, d(), "dev", BranchRule::Team),
1206 BranchVerdict::LocalUnmerged("feature-x".into())
1207 );
1208 }
1209
1210 #[test]
1211 fn verdict_team_clean_integration() {
1212 let g = on_integration("dev", "dev", "dev");
1213 assert_eq!(
1214 branch_verdict(&g, d(), "dev", BranchRule::Team),
1215 BranchVerdict::IntegrationClean
1216 );
1217 }
1218
1219 #[test]
1220 fn verdict_solo_names_the_remote_feature_branch() {
1221 let g = on_integration("dev", "dev", "dev").ok(
1222 "ls-remote --heads origin",
1223 "aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
1224 );
1225 assert_eq!(
1226 branch_verdict(&g, d(), "dev", BranchRule::Solo),
1227 BranchVerdict::RemoteFeature("alice-x".into())
1228 );
1229 }
1230
1231 #[test]
1232 fn verdict_reason_is_empty_only_when_passing() {
1233 assert!(BranchVerdict::OnFeature.reason().is_empty());
1234 assert!(BranchVerdict::IntegrationClean.reason().is_empty());
1235 assert!(BranchVerdict::DetachedHead
1236 .reason()
1237 .contains("detached HEAD"));
1238 assert!(BranchVerdict::BaseUnresolved
1239 .reason()
1240 .contains("unresolved"));
1241 assert!(BranchVerdict::LocalUnmerged("x".into())
1242 .reason()
1243 .contains("'x'"));
1244 assert!(BranchVerdict::RemoteFeature("x".into())
1245 .reason()
1246 .contains("'x'"));
1247 }
1248
1249 #[test]
1250 fn evaluate_unresolved_sets_base_unresolved_verdict() {
1251 let g = FakeGit::new()
1254 .ok("rev-parse --abbrev-ref HEAD", "feature-x")
1255 .ok("status -s", "")
1256 .ok("log --oneline --branches --not --remotes", "")
1257 .ok(
1258 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1259 "origin/feature-x",
1260 )
1261 .ok(
1262 "for-each-ref --format=%(refname:short) refs/heads/*",
1263 "feature-x",
1264 )
1265 .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
1266 .ok(
1267 "rev-list --left-right --count origin/feature-x...feature-x",
1268 "0\t0",
1269 );
1270 let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false, false);
1271 assert_eq!(st.branch_verdict, BranchVerdict::BaseUnresolved);
1272 assert_eq!(
1273 st.failure_reason(RuleId::CorrectBranch),
1274 Some(BranchVerdict::BaseUnresolved.reason())
1275 );
1276 }
1277
1278 #[test]
1281 fn rule_ids_are_stable_and_round_trip() {
1282 let nums: Vec<u8> = RuleId::ALL.iter().map(|r| r.num()).collect();
1283 assert_eq!(nums, vec![1, 2, 3, 4, 5, 6]);
1284 for r in RuleId::ALL {
1285 assert_eq!(RuleId::from_num(r.num()), Some(r));
1286 assert_eq!(r.tag(), format!("R{}", r.num()));
1287 assert!(!r.key().is_empty() && !r.description().is_empty());
1288 }
1289 assert_eq!(RuleId::from_num(0), None);
1290 assert_eq!(RuleId::from_num(7), None);
1291 assert_eq!(RuleId::CorrectBranch.key(), "correct-branch");
1292 assert_eq!(RuleId::NotBehindBase.key(), "not-behind-base");
1293 assert_eq!(RuleId::from_num(6), Some(RuleId::NotBehindBase));
1294 }
1295
1296 #[test]
1297 fn failure_reason_is_some_only_for_failing_rules() {
1298 let g = FakeGit::new()
1300 .ok("rev-parse --abbrev-ref HEAD", "dev")
1301 .ok("status -s", " M file.txt") .ok("log --oneline --branches --not --remotes", "")
1303 .ok(
1304 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1305 "origin/dev",
1306 )
1307 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
1308 .ok("show-ref --quiet refs/remotes/origin/dev", "")
1309 .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
1310 .ok("symbolic-ref --short HEAD", "dev")
1311 .ok("show-ref --verify --quiet refs/heads/dev", "")
1312 .ok("branch --merged dev --format=%(refname:short)", "dev");
1313 let base = ResolvedBase {
1314 name: Some("dev".into()),
1315 source: crate::config::BaseSource::Config,
1316 };
1317 let st = evaluate(&g, d(), &base, false, false);
1318 assert!(st.failure_reason(RuleId::Committed).is_some());
1319 assert!(st.failure_reason(RuleId::AllCommitsPushed).is_none());
1320 assert!(st.failure_reason(RuleId::CorrectBranch).is_none());
1321 }
1322
1323 #[test]
1326 fn every_rule_has_examples() {
1327 for r in RuleId::ALL {
1328 assert!(!r.examples().is_empty(), "{:?} has no examples", r);
1329 }
1330 }
1331
1332 fn dev_base() -> ResolvedBase {
1333 ResolvedBase {
1334 name: Some("dev".into()),
1335 source: crate::config::BaseSource::Config,
1336 }
1337 }
1338
1339 #[test]
1340 fn rule_report_r5_names_unmerged_branch_and_lists_state() {
1341 let g = on_integration("dev", "dev\nfeature-x", "dev");
1343 let rep = rule_report(&g, d(), &dev_base(), false, false, RuleId::CorrectBranch);
1344 assert!(!rep.passed);
1345 assert!(
1346 rep.verdict.contains("feature-x"),
1347 "verdict: {}",
1348 rep.verdict
1349 );
1350 let facts: std::collections::HashMap<_, _> = rep.facts.iter().cloned().collect();
1352 assert_eq!(facts.get("branch").map(String::as_str), Some("dev"));
1353 assert!(facts.contains_key("base"));
1354 assert!(facts.get("local branches").unwrap().contains("feature-x"));
1355 }
1356
1357 #[test]
1358 fn rule_report_r1_lists_dirty_files() {
1359 let g = FakeGit::new()
1360 .ok("rev-parse --abbrev-ref HEAD", "dev")
1361 .ok("status -s", " M a.txt\n?? b.txt");
1362 let rep = rule_report(&g, d(), &dev_base(), false, false, RuleId::Committed);
1363 assert!(!rep.passed);
1364 let dirty: Vec<&str> = rep
1365 .facts
1366 .iter()
1367 .filter(|(l, _)| l == "dirty")
1368 .map(|(_, v)| v.as_str())
1369 .collect();
1370 assert_eq!(dirty, vec!["M a.txt", "?? b.txt"]);
1371 }
1372
1373 #[test]
1374 fn rule_report_r4_shows_behind_count() {
1375 let g = FakeGit::new()
1376 .ok("rev-parse --abbrev-ref HEAD", "dev")
1377 .ok("show-ref --quiet refs/remotes/origin/dev", "")
1378 .ok("rev-list --left-right --count origin/dev...dev", "3\t0");
1379 let rep = rule_report(&g, d(), &dev_base(), false, false, RuleId::NotBehindRemote);
1380 assert!(!rep.passed);
1381 let facts: std::collections::HashMap<_, _> = rep.facts.iter().cloned().collect();
1382 assert_eq!(facts.get("behind by").map(String::as_str), Some("3"));
1383 assert!(
1384 rep.verdict.contains("behind by 3"),
1385 "verdict: {}",
1386 rep.verdict
1387 );
1388 }
1389
1390 fn on_feature_vs_base(cur: &str, counts: &str) -> FakeGit {
1395 FakeGit::new()
1396 .ok("symbolic-ref --short HEAD", cur)
1397 .ok("rev-parse --abbrev-ref HEAD", cur)
1398 .fail("show-ref --verify --quiet refs/heads/dev")
1399 .ok("show-ref --verify --quiet refs/remotes/origin/dev", "")
1400 .ok("rev-list --left-right --count origin/dev...HEAD", counts)
1401 }
1402
1403 #[test]
1404 fn base_sync_diverged_fails() {
1405 let v = base_sync_verdict(&on_feature_vs_base("feature-x", "2\t1"), d(), "dev", false);
1407 assert!(!v.passed());
1408 assert!(matches!(
1409 v,
1410 BaseSyncVerdict::Behind {
1411 kind: BehindKind::Diverged,
1412 ahead: 1,
1413 behind: 2,
1414 allowed: false,
1415 ..
1416 }
1417 ));
1418 assert!(v.reason().contains("diverged from base 'dev'"));
1419 }
1420
1421 #[test]
1422 fn base_sync_pure_ahead_passes() {
1423 let v = base_sync_verdict(&on_feature_vs_base("feature-x", "0\t3"), d(), "dev", false);
1425 assert!(v.passed());
1426 assert_eq!(v, BaseSyncVerdict::Current);
1427 }
1428
1429 #[test]
1430 fn base_sync_merged_stale_fails() {
1431 let v = base_sync_verdict(&on_feature_vs_base("feature-x", "2\t0"), d(), "dev", false);
1433 assert!(!v.passed());
1434 assert!(matches!(
1435 v,
1436 BaseSyncVerdict::Behind {
1437 kind: BehindKind::Stale,
1438 ..
1439 }
1440 ));
1441 assert!(v.reason().contains("behind base 'dev'"));
1442 }
1443
1444 #[test]
1445 fn base_sync_even_passes() {
1446 let v = base_sync_verdict(&on_feature_vs_base("feature-x", "0\t0"), d(), "dev", false);
1447 assert!(v.passed());
1448 }
1449
1450 #[test]
1451 fn base_sync_integration_branch_not_applicable() {
1452 let g = FakeGit::new()
1454 .ok("symbolic-ref --short HEAD", "dev")
1455 .ok("rev-parse --abbrev-ref HEAD", "dev");
1456 let v = base_sync_verdict(&g, d(), "dev", false);
1457 assert_eq!(v, BaseSyncVerdict::NotApplicable);
1458 assert!(v.passed());
1459 assert!(v.marker().is_none());
1460 }
1461
1462 #[test]
1463 fn base_sync_detached_is_undeterminable() {
1464 let g = FakeGit::new().fail("symbolic-ref --short HEAD");
1465 let v = base_sync_verdict(&g, d(), "dev", false);
1466 assert!(!v.passed());
1467 assert!(matches!(v, BaseSyncVerdict::Undeterminable { .. }));
1468 assert!(v.reason().contains("detached"));
1469 }
1470
1471 #[test]
1472 fn base_sync_absent_base_ref_is_undeterminable() {
1473 let g = FakeGit::new()
1475 .ok("symbolic-ref --short HEAD", "feature-x")
1476 .ok("rev-parse --abbrev-ref HEAD", "feature-x")
1477 .fail("show-ref --verify --quiet refs/heads/dev")
1478 .fail("show-ref --verify --quiet refs/remotes/origin/dev");
1479 let v = base_sync_verdict(&g, d(), "dev", false);
1480 assert!(!v.passed());
1481 assert!(v.reason().contains("not found"));
1482 }
1483
1484 #[test]
1485 fn base_sync_allow_diverged_suppresses_to_marked_pass() {
1486 let v = base_sync_verdict(&on_feature_vs_base("feature-x", "2\t1"), d(), "dev", true);
1488 assert!(v.passed());
1489 assert!(matches!(v, BaseSyncVerdict::Behind { allowed: true, .. }));
1490 let marker = v.marker().expect("allowed divergence has a marker");
1491 assert!(marker.contains("allowed by gkit.allowDiverged"));
1492 assert!(marker.contains("diverged"));
1493 }
1494
1495 #[test]
1496 fn evaluate_unresolved_base_fails_r6_independently() {
1497 let g = FakeGit::new()
1499 .ok("rev-parse --abbrev-ref HEAD", "feature-x")
1500 .ok("status -s", "")
1501 .ok("log --oneline --branches --not --remotes", "")
1502 .ok(
1503 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
1504 "origin/feature-x",
1505 )
1506 .ok(
1507 "for-each-ref --format=%(refname:short) refs/heads/*",
1508 "feature-x",
1509 )
1510 .ok("show-ref --quiet refs/remotes/origin/feature-x", "")
1511 .ok(
1512 "rev-list --left-right --count origin/feature-x...feature-x",
1513 "0\t0",
1514 );
1515 let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false, false);
1516 assert!(!st.rule_passed(RuleId::NotBehindBase));
1517 assert!(st.failure_reason(RuleId::NotBehindBase).is_some());
1518 }
1519}