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"
}
pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
let cur = current_branch(git, dir);
if !is_integration(&cur, base_branch) {
return true; }
let has_feature = 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));
!has_feature
}
#[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 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,
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_branch: &str) -> RepoStatus {
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: correct_branch(git, dir, base_branch),
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()));
}
#[test]
fn correct_branch_only_flags_base_with_features() {
let on_base_with_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "dev").ok(
"ls-remote --heads origin",
"aaa\trefs/heads/dev\nbbb\trefs/heads/feature-x",
);
assert!(!correct_branch(&on_base_with_feature, d(), "dev"));
let on_base_no_feature = FakeGit::new()
.ok("rev-parse --abbrev-ref HEAD", "dev")
.ok("ls-remote --heads origin", "aaa\trefs/heads/dev");
assert!(correct_branch(&on_base_no_feature, d(), "dev"));
let on_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "feature-x");
assert!(correct_branch(&on_feature, d(), "dev"));
let dev_plus_main = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "dev").ok(
"ls-remote --heads origin",
"aaa\trefs/heads/dev\nbbb\trefs/heads/main",
);
assert!(correct_branch(&dev_plus_main, d(), "dev"));
let on_main_with_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "main").ok(
"ls-remote --heads origin",
"aaa\trefs/heads/main\nbbb\trefs/heads/feature-y",
);
assert!(!correct_branch(&on_main_with_feature, d(), "dev"));
}
#[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("ls-remote --heads origin", "aaa\trefs/heads/dev");
let st = evaluate(&g, d(), "dev");
assert!(st.ok(), "expected all-clear, got {st:?}");
assert_eq!(st.branch, "dev");
}
}