use crate::config::ResolvedBase;
use crate::git::Git;
use std::collections::HashSet;
use std::path::Path;
pub fn current_branch(git: &dyn Git, dir: &Path) -> String {
git.run(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
.trimmed()
.to_string()
}
pub fn committed(git: &dyn Git, dir: &Path) -> bool {
git.run(dir, &["status", "-s"]).trimmed().is_empty()
}
pub fn all_commits_pushed(git: &dyn Git, dir: &Path) -> bool {
git.run(
dir,
&["log", "--oneline", "--branches", "--not", "--remotes"],
)
.trimmed()
.is_empty()
}
pub fn branches_have_remote(git: &dyn Git, dir: &Path) -> bool {
let remotes: HashSet<String> = git
.run(
dir,
&[
"for-each-ref",
"--format=%(refname:short)",
"refs/remotes/origin/*",
],
)
.stdout
.lines()
.filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
.filter(|b| b != "HEAD")
.collect();
git.run(
dir,
&["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
)
.stdout
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.all(|local| remotes.contains(local))
}
pub fn not_behind_remote(git: &dyn Git, dir: &Path) -> bool {
let cur = current_branch(git, dir);
if cur.is_empty() {
return true;
}
let remote_ref = format!("refs/remotes/origin/{cur}");
if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
return true;
}
let range = format!("origin/{cur}...{cur}");
let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
out.trimmed()
.split_whitespace()
.next()
.and_then(|s| s.parse::<u64>().ok())
.map(|behind| behind == 0)
.unwrap_or(true)
}
fn is_integration(branch: &str, base_branch: &str) -> bool {
branch == base_branch || branch == "main" || branch == "master"
}
fn base_ref_for(git: &dyn Git, dir: &Path, base_branch: &str) -> String {
let local = format!("refs/heads/{base_branch}");
if git
.run(dir, &["show-ref", "--verify", "--quiet", &local])
.success
{
base_branch.to_string()
} else {
format!("origin/{base_branch}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchRule {
Team,
Solo,
}
impl BranchRule {
pub fn from_solo(solo: bool) -> Self {
if solo {
BranchRule::Solo
} else {
BranchRule::Team
}
}
pub fn describe(&self) -> &'static str {
match self {
BranchRule::Team => "team (gkit.solo off) — flags a local branch unmerged into base",
BranchRule::Solo => "solo (gkit.solo on) — flags any feature branch on the remote",
}
}
}
fn local_unmerged_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
let base_ref = base_ref_for(git, dir, base_branch);
let merged = git.run(
dir,
&["branch", "--merged", &base_ref, "--format=%(refname:short)"],
);
if !merged.success {
return false;
}
let merged: HashSet<&str> = merged
.stdout
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
git.run(
dir,
&["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
)
.stdout
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.any(|b| !is_integration(b, base_branch) && !merged.contains(b))
}
fn remote_has_feature(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
git.run(dir, &["ls-remote", "--heads", "origin"])
.stdout
.lines()
.filter_map(|l| {
l.split_once("refs/heads/")
.map(|(_, b)| b.trim().to_string())
})
.any(|b| !is_integration(&b, base_branch))
}
pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str, rule: BranchRule) -> bool {
if !git.run(dir, &["symbolic-ref", "--short", "HEAD"]).success {
return false;
}
let cur = current_branch(git, dir);
if !is_integration(&cur, base_branch) {
return true; }
match rule {
BranchRule::Team => !local_unmerged_feature(git, dir, base_branch),
BranchRule::Solo => !remote_has_feature(git, dir, base_branch),
}
}
#[derive(Debug, Clone)]
pub struct RepoStatus {
pub branch: String,
pub committed: bool,
pub all_commits_pushed: bool,
pub branches_have_remote: bool,
pub not_behind_remote: bool,
pub correct_branch: bool,
pub base: ResolvedBase,
pub rule: BranchRule,
pub problem: Option<String>,
}
impl RepoStatus {
pub fn unusable(reason: impl Into<String>) -> Self {
RepoStatus {
branch: String::new(),
committed: false,
all_commits_pushed: false,
branches_have_remote: false,
not_behind_remote: false,
correct_branch: false,
base: ResolvedBase::unresolved(),
rule: BranchRule::Team,
problem: Some(reason.into()),
}
}
pub fn ok(&self) -> bool {
self.problem.is_none()
&& self.committed
&& self.all_commits_pushed
&& self.branches_have_remote
&& self.not_behind_remote
&& self.correct_branch
}
}
pub fn evaluate(git: &dyn Git, dir: &Path, base: &ResolvedBase, solo: bool) -> RepoStatus {
let rule = BranchRule::from_solo(solo);
let correct_branch = match &base.name {
Some(b) => correct_branch(git, dir, b, rule),
None => false,
};
RepoStatus {
branch: current_branch(git, dir),
committed: committed(git, dir),
all_commits_pushed: all_commits_pushed(git, dir),
branches_have_remote: branches_have_remote(git, dir),
not_behind_remote: not_behind_remote(git, dir),
correct_branch,
base: base.clone(),
rule,
problem: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::test_support::FakeGit;
use std::path::Path;
fn d() -> &'static Path {
Path::new("/x")
}
#[test]
fn committed_is_true_when_status_clean() {
assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
assert!(!committed(
&FakeGit::new().ok("status -s", " M file.rs"),
d()
));
}
#[test]
fn pushed_is_true_when_no_unpushed_commits() {
let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
assert!(all_commits_pushed(&clean, d()));
let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
assert!(!all_commits_pushed(&dirty, d()));
}
#[test]
fn branches_have_remote_checks_every_local() {
let ok = FakeGit::new()
.ok(
"for-each-ref --format=%(refname:short) refs/remotes/origin/*",
"origin/dev\norigin/main\norigin/HEAD",
)
.ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
assert!(branches_have_remote(&ok, d()));
let missing = FakeGit::new()
.ok(
"for-each-ref --format=%(refname:short) refs/remotes/origin/*",
"origin/dev",
)
.ok(
"for-each-ref --format=%(refname:short) refs/heads/*",
"dev\nlocal-only",
);
assert!(!branches_have_remote(&missing, d()));
}
#[test]
fn not_behind_true_when_no_remote_branch() {
let g = FakeGit::new()
.ok("rev-parse --abbrev-ref HEAD", "dev")
.fail("show-ref --quiet refs/remotes/origin/dev");
assert!(not_behind_remote(&g, d()));
}
#[test]
fn not_behind_reflects_left_count() {
let aligned = FakeGit::new()
.ok("rev-parse --abbrev-ref HEAD", "dev")
.ok("show-ref --quiet refs/remotes/origin/dev", "")
.ok("rev-list --left-right --count origin/dev...dev", "0\t3");
assert!(not_behind_remote(&aligned, d()));
let behind = FakeGit::new()
.ok("rev-parse --abbrev-ref HEAD", "dev")
.ok("show-ref --quiet refs/remotes/origin/dev", "")
.ok("rev-list --left-right --count origin/dev...dev", "2\t0");
assert!(!not_behind_remote(&behind, d()));
}
fn on_integration(cur: &str, local_heads: &str, merged: &str) -> FakeGit {
FakeGit::new()
.ok("symbolic-ref --short HEAD", cur)
.ok("rev-parse --abbrev-ref HEAD", cur)
.ok("show-ref --verify --quiet refs/heads/dev", "")
.ok("branch --merged dev --format=%(refname:short)", merged)
.ok(
"for-each-ref --format=%(refname:short) refs/heads/*",
local_heads,
)
}
#[test]
fn correct_branch_detached_head_fails() {
let g = FakeGit::new().fail("symbolic-ref --short HEAD");
assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
}
#[test]
fn correct_branch_on_feature_is_fine() {
let g = FakeGit::new()
.ok("symbolic-ref --short HEAD", "feature-x")
.ok("rev-parse --abbrev-ref HEAD", "feature-x");
assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
}
#[test]
fn team_rule_ignores_others_remote_branches() {
let g = on_integration("dev", "dev", "dev");
assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
}
#[test]
fn team_rule_flags_local_unmerged_feature() {
let g = on_integration("dev", "dev\nfeature-x", "dev");
assert!(!correct_branch(&g, d(), "dev", BranchRule::Team));
}
#[test]
fn team_rule_allows_local_merged_feature() {
let g = on_integration("dev", "dev\nfeature-x", "dev\nfeature-x");
assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
}
#[test]
fn solo_rule_flags_remote_feature_branch() {
let g = on_integration("dev", "dev", "dev").ok(
"ls-remote --heads origin",
"aaa\trefs/heads/dev\nbbb\trefs/heads/alice-x",
);
assert!(correct_branch(&g, d(), "dev", BranchRule::Team));
assert!(!correct_branch(&g, d(), "dev", BranchRule::Solo));
}
#[test]
fn solo_rule_passes_when_remote_is_integration_only() {
let g = on_integration("dev", "dev", "dev").ok(
"ls-remote --heads origin",
"aaa\trefs/heads/dev\nbbb\trefs/heads/main",
);
assert!(correct_branch(&g, d(), "dev", BranchRule::Solo));
}
#[test]
fn evaluate_all_clear() {
let g = FakeGit::new()
.ok("rev-parse --abbrev-ref HEAD", "dev")
.ok("status -s", "")
.ok("log --oneline --branches --not --remotes", "")
.ok(
"for-each-ref --format=%(refname:short) refs/remotes/origin/*",
"origin/dev",
)
.ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
.ok("show-ref --quiet refs/remotes/origin/dev", "")
.ok("rev-list --left-right --count origin/dev...dev", "0\t0")
.ok("symbolic-ref --short HEAD", "dev")
.ok("show-ref --verify --quiet refs/heads/dev", "")
.ok("branch --merged dev --format=%(refname:short)", "dev");
let base = ResolvedBase {
name: Some("dev".into()),
source: crate::config::BaseSource::Config,
};
let st = evaluate(&g, d(), &base, false);
assert!(st.ok(), "expected all-clear, got {st:?}");
assert_eq!(st.branch, "dev");
}
#[test]
fn unresolved_base_fails_correct_branch() {
let g = FakeGit::new()
.ok("rev-parse --abbrev-ref HEAD", "feature-x")
.ok("status -s", "")
.ok("log --oneline --branches --not --remotes", "")
.ok(
"for-each-ref --format=%(refname:short) refs/remotes/origin/*",
"origin/feature-x",
)
.ok(
"for-each-ref --format=%(refname:short) refs/heads/*",
"feature-x",
)
.ok("show-ref --quiet refs/remotes/origin/feature-x", "")
.ok(
"rev-list --left-right --count origin/feature-x...feature-x",
"0\t0",
);
let st = evaluate(&g, d(), &ResolvedBase::unresolved(), false);
assert!(!st.correct_branch);
assert!(!st.ok());
}
}