use std::path::{Path, PathBuf};
use std::process::Command;
use super::floor_d4::{D4_REMEDY, program_positions};
const FALLBACK_DEFAULTS: &[&str] = &["main", "master"];
const PUSH_VALUE_OPTS: &[&str] = &["push-option", "repo", "receive-pack", "exec"];
const PUSH_LONG_OPTS: &[&str] = &[
"all",
"branches",
"prune",
"mirror",
"dry-run",
"porcelain",
"delete",
"tags",
"follow-tags",
"no-follow-tags",
"signed",
"no-signed",
"atomic",
"no-atomic",
"push-option",
"receive-pack",
"exec",
"force-with-lease",
"no-force-with-lease",
"force",
"no-force",
"force-if-includes",
"no-force-if-includes",
"repo",
"set-upstream",
"thin",
"no-thin",
"quiet",
"verbose",
"progress",
"no-progress",
"recurse-submodules",
"no-recurse-submodules",
"verify",
"no-verify",
"ipv4",
"ipv6",
];
fn resolve_long(name: &str) -> Option<&'static str> {
if let Some(exact) = PUSH_LONG_OPTS.iter().find(|o| **o == name) {
return Some(exact);
}
let mut matches = PUSH_LONG_OPTS.iter().filter(|o| o.starts_with(name));
match (matches.next(), matches.next()) {
(Some(only), None) if !name.is_empty() => Some(only),
_ => None,
}
}
pub(crate) trait GitProbe {
fn default_branch(&self, dir: &Path, remote: &str) -> Option<String>;
fn current_branch(&self, dir: &Path) -> Option<String>;
}
pub(crate) struct LiveGit;
impl LiveGit {
fn read(dir: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.ok()?;
let text = String::from_utf8(out.stdout).ok()?;
let text = text.trim();
(out.status.success() && !text.is_empty()).then(|| text.to_string())
}
}
impl GitProbe for LiveGit {
fn default_branch(&self, dir: &Path, remote: &str) -> Option<String> {
let head = format!("refs/remotes/{remote}/HEAD");
let full = Self::read(dir, &["symbolic-ref", "--quiet", &head])?;
full.strip_prefix(&format!("refs/remotes/{remote}/"))
.map(str::to_string)
}
fn current_branch(&self, dir: &Path) -> Option<String> {
Self::read(dir, &["symbolic-ref", "--quiet", "--short", "HEAD"])
}
}
#[derive(Default)]
struct Push {
force: bool,
mirror: bool,
all: bool,
operands: Vec<String>,
}
pub(super) fn force_push_reason(
argv: &[String],
dir: Option<&Path>,
git: &dyn GitProbe,
) -> Option<String> {
for (g, program) in program_positions(argv) {
if program != "git" {
continue;
}
let Some(p) = (g + 1..argv.len()).find(|&i| argv[i] == "push") else {
continue;
};
let dir = dir.map(|d| git_dir(d, &argv[g + 1..p]));
let push = parse(&argv[p + 1..]);
if let Some(branch) = forced_default(&push, dir.as_deref(), git) {
return Some(format!(
"Hard-floor deny (#8878 D4): this `git push` force-updates `{branch}`, the \
default branch (or cannot rule it out). Push to a feature branch, or push \
without force. {D4_REMEDY}"
));
}
}
None
}
fn git_dir(dir: &Path, globals: &[String]) -> PathBuf {
let mut out = dir.to_path_buf();
for pair in globals.windows(2) {
if pair[0] == "-C" {
out = out.join(&pair[1]);
}
}
out
}
fn parse(tail: &[String]) -> Push {
let mut push = Push::default();
let mut i = 0;
while i < tail.len() {
let tok = tail[i].as_str();
i += 1;
if tok == "--" {
push.operands.extend(tail[i..].iter().cloned());
break;
}
if let Some(long) = tok.strip_prefix("--") {
let (name, attached) = long
.split_once('=')
.map_or((long, false), |(n, _)| (n, true));
match resolve_long(name) {
Some("force" | "force-with-lease") => push.force = true,
Some("no-force") => push.force = false,
Some("mirror") => push.mirror = true,
Some("all" | "branches") => push.all = true,
Some(opt) if !attached && PUSH_VALUE_OPTS.contains(&opt) => i += 1,
Some(_) => {}
None => push.force = true,
}
continue;
}
match tok {
"-o" => i += 1,
t if t.starts_with('-') && t.len() > 1 => {
let cluster = &t[1..];
let before_o = cluster.split('o').next().unwrap_or_default();
push.force |= before_o.contains('f');
if cluster.ends_with('o') {
i += 1;
}
}
_ => push.operands.push(tok.to_string()),
}
}
push
}
fn forced_default(push: &Push, dir: Option<&Path>, git: &dyn GitProbe) -> Option<String> {
let remote = push.operands.first().map_or("origin", String::as_str);
let defaults: Vec<String> = dir
.filter(|_| is_remote_name(remote))
.and_then(|d| git.default_branch(d, remote))
.map_or_else(
|| FALLBACK_DEFAULTS.iter().map(|s| s.to_string()).collect(),
|b| vec![b],
);
let first_default = || defaults.first().cloned().unwrap_or_default();
if push.mirror || (push.force && push.all) {
return Some(first_default());
}
let current = || dir.and_then(|d| git.current_branch(d));
let refspecs = push.operands.get(1..).unwrap_or_default();
if refspecs.is_empty() {
if !push.force {
return None;
}
return match current() {
Some(branch) if !defaults.contains(&branch) => None,
Some(branch) => Some(branch),
None => Some(first_default()),
};
}
for spec in refspecs {
let plus = spec.starts_with('+');
if !(plus || push.force) {
continue;
}
let spec = spec.trim_start_matches('+');
let dst = spec.rsplit_once(':').map_or(spec, |(_, dst)| dst);
let dst = dst.strip_prefix("refs/heads/").unwrap_or(dst);
if dst.contains('*') {
return Some(first_default());
}
let dst = if dst == "HEAD" || dst == "@" || dst.is_empty() {
match current() {
Some(branch) => branch,
None => return Some(first_default()),
}
} else {
dst.to_string()
};
if defaults.contains(&dst) {
return Some(dst);
}
}
None
}
fn is_remote_name(remote: &str) -> bool {
!remote.is_empty() && !remote.contains(['/', ':', '\\']) && !remote.starts_with('.')
}