use crate::check::Outcome;
use crate::git;
use crate::ui::{error_sign, highlight, valid_sign, warning_sign};
fn count_after(s: &str, word: &str) -> Option<(u64, usize)> {
let i = s.find(word)?;
let after = &s[i + word.len()..];
let trimmed = after.trim_start_matches([' ', '\t']);
if trimmed.len() == after.len() {
return None; }
let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
let consumed = i + word.len() + (after.len() - trimmed.len()) + digits.len();
Some((digits.parse().ok()?, consumed))
}
pub fn divergence(status: &str) -> Option<(u64, u64)> {
for line in status.lines() {
let mut rest = line;
while let Some((ahead, used)) = count_after(rest, "ahead") {
let tail = &rest[used..];
if let Some(t) = tail.strip_prefix(',') {
if let Some((behind, _)) = count_after(t, "behind") {
if t.trim_start_matches([' ', '\t']).starts_with("behind") {
return Some((ahead, behind));
}
}
}
rest = tail;
}
}
None
}
pub fn behind_count(status: &str) -> Option<u64> {
status
.lines()
.find_map(|line| count_after(line, "behind").map(|(n, _)| n))
}
pub fn lists_branch(branch_list: &str, name: &str) -> bool {
branch_list.lines().any(|line| {
let stripped = line.trim_end_matches(['\r']);
let body = stripped.trim_start_matches([' ', '\t', '*', '+']);
!body.is_empty() && body.len() < stripped.len() && body == name
})
}
pub fn ahead_count(rev_list: &str) -> Option<u64> {
rev_list.split_whitespace().next()?.parse().ok()
}
pub fn run(_args: &[std::ffi::OsString]) -> Outcome {
if !git::stdout(&["status", "--porcelain"])
.unwrap_or_default()
.is_empty()
{
println!(
"{} Uncommitted changes — skipping pre-push pull-rebase.",
warning_sign()
);
return Outcome::Passed;
}
let Some(upstream) =
git::stdout(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
else {
return Outcome::Passed;
};
let Some(current) = git::stdout(&["symbolic-ref", "--short", "HEAD"]) else {
return Outcome::Passed; };
let remote =
git::stdout(&["config", "--get", &format!("branch.{current}.remote")]).unwrap_or_default();
let is_a_real_remote = !remote.is_empty()
&& git::stdout(&["remote"])
.unwrap_or_default()
.lines()
.any(|r| r == remote);
if !is_a_real_remote {
println!(
"{} {upstream} is not a remote-tracking branch — skipping sync.",
warning_sign()
);
return Outcome::Passed;
}
let branch = upstream
.strip_prefix(&format!("{remote}/"))
.unwrap_or(&upstream);
if !git::succeeds(&["ls-remote", "--exit-code", "--heads", &remote, branch]) {
println!(
"{} Upstream {upstream} no longer exists on the remote (merged + auto-deleted?) — skipping sync.", warning_sign()
);
return Outcome::Passed;
}
let status = git::stdout(&["status", "-sb"]).unwrap_or_default();
if let Some((ahead, behind)) = divergence(&status) {
println!(
"{} Branch and upstream have diverged ({ahead} ahead, {behind} behind) — not auto-rebasing.",
warning_sign()
);
println!(
" Rebased or amended locally? That is expected — push with {}.",
highlight("git push --force-with-lease")
);
println!(
" Someone else pushed here? Reconcile first with {} (or {}).",
highlight("git pull --rebase"),
highlight("git merge")
);
} else if behind_count(&status).unwrap_or(0) > 0
&& !git::succeeds(&["pull", "--rebase", &remote, branch])
{
let _ = git::succeeds(&["rebase", "--abort"]);
println!(
"{} pull --rebase hit conflicts (rebase aborted, tree restored).",
error_sign()
);
println!(" Resolve manually: {}", highlight("git pull --rebase"));
return Outcome::Failed;
} else {
println!("{} Branch is in sync with its upstream", valid_sign());
}
let branch_list = git::stdout_raw(&["branch"])
.map(|out| String::from_utf8_lossy(&out).into_owned())
.unwrap_or_default();
let default_branch = if lists_branch(&branch_list, "main") {
"main"
} else if lists_branch(&branch_list, "master") {
"master"
} else {
return Outcome::Passed;
};
let _ = git::succeeds(&["fetch", &remote, default_branch]);
let range = format!("{remote}/{default_branch}...HEAD");
if let Some(n) =
git::stdout(&["rev-list", "--left-right", "--count", &range]).and_then(|s| ahead_count(&s))
{
if n > 0 {
println!(
"{} {remote}/{default_branch} is ahead by {n} commit(s).",
warning_sign()
);
println!(
" Consider before merging: {}",
highlight(&format!("git merge {remote}/{default_branch}"))
);
}
}
Outcome::Passed
}
#[cfg(test)]
mod tests {
use super::{ahead_count, behind_count, divergence, lists_branch};
#[test]
fn detects_divergence_only_when_both_counts_are_present() {
assert!(divergence("## feat/x...origin/feat/x [ahead 1, behind 2]").is_some());
assert!(divergence("## a...b [ahead 12, behind 3]").is_some());
assert!(divergence("## feat/x...origin/feat/x [ahead 3]").is_none());
assert!(divergence("## feat/x...origin/feat/x [behind 2]").is_none());
assert!(divergence("## feat/x...origin/feat/x").is_none());
assert!(divergence("## ahead-of-time...origin/x").is_none());
}
#[test]
fn reports_how_far_apart_the_two_are() {
assert_eq!(
divergence("## feat/x...origin/feat/x [ahead 1, behind 2]"),
Some((1, 2))
);
assert_eq!(
divergence("## a...b [ahead 12, behind 34]"),
Some((12, 34))
);
}
#[test]
fn branch_names_are_not_mistaken_for_counts() {
assert!(divergence("## ahead 3...origin/behind 4").is_none());
assert!(divergence("## a...b [ahead3, behind4]").is_none());
assert!(divergence("## ahead12...origin/behind34").is_none());
assert!(divergence("## a...b [ahead 3, xbehind 4]").is_none());
assert!(divergence("## ahead-of/behind...origin/ahead-of/behind").is_none());
}
#[test]
fn recognises_git_branch_lines() {
let list = " feat/x\n* main\n master-ish\n";
assert!(lists_branch(list, "main"));
assert!(!lists_branch(list, "master")); assert!(!lists_branch(list, "feat")); assert!(lists_branch(list, "feat/x"));
let elsewhere = "+ main\n* feat/x\n";
assert!(
lists_branch(elsewhere, "main"),
"a branch checked out in another worktree is still a branch"
);
assert!(!lists_branch("main\n", "main"));
assert!(!lists_branch("## main...origin/main\n", "main"));
assert!(!lists_branch("main\n* feat/x\n", "main"));
assert!(lists_branch(" main\n* feat/x\n", "main"));
}
#[test]
fn parses_the_full_ahead_count() {
assert_eq!(ahead_count("12\t3"), Some(12));
assert_eq!(ahead_count("0\t5"), Some(0));
assert_eq!(ahead_count(""), None);
}
#[test]
fn behind_count_does_not_require_ahead() {
assert_eq!(
behind_count("## feat/x...origin/feat/x [behind 2]"),
Some(2)
);
assert_eq!(
behind_count("## feat/x...origin/feat/x [ahead 1, behind 2]"),
Some(2)
);
}
#[test]
fn behind_count_is_none_when_there_is_nothing_behind() {
assert_eq!(behind_count("## feat/x...origin/feat/x [ahead 3]"), None);
assert_eq!(behind_count("## feat/x...origin/feat/x"), None);
assert_eq!(behind_count(""), None);
}
#[test]
fn behind_count_is_not_fooled_by_a_branch_name() {
assert!(behind_count("## my-behind-thing...origin/my-behind-thing").is_none());
assert!(behind_count("## a...b [behind4]").is_none());
}
}