use crate::auto_push_single_flight::{PushGit, SingleFlightError, run_query};
use crate::config_snapshot::{ConfigSnapshot, Lookup};
use std::ffi::OsString;
use crate::auto_push_refspec_judging::{Judged, judge, parse_refspec};
const NOTE_PREFIX: &str = "monochromatic-git-policy-cli: auto-push: ";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Destination {
pub remote: String,
pub merge_ref: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum NamedPush {
Push {
remote: String,
targets: Vec<String>,
},
Matching {
remote: String,
},
Skip(String),
}
struct PushSettings {
branch: String,
push_remote: String,
tracked_remote: String,
merges: Vec<String>,
mode: String,
refspecs: Vec<String>,
mirror: bool,
}
fn argv(parts: &[&str]) -> Vec<OsString> {
return parts.iter().map(OsString::from).collect();
}
fn values_of(snapshot: &ConfigSnapshot, key: &str) -> Vec<String> {
let mut values: Vec<String> = Vec::new();
for entry in snapshot.entries() {
if entry.key != key {
continue;
}
values.push(match entry.value.as_deref() {
Some(bytes) => String::from_utf8_lossy(bytes).into_owned(),
None => String::new(),
});
}
return values;
}
fn last_of(snapshot: &ConfigSnapshot, key: &str) -> String {
return values_of(snapshot, key).pop().unwrap_or_default();
}
fn read_push_settings(git: &PushGit, branch_ref: &str) -> Result<Option<PushSettings>, SingleFlightError> {
let Some(name) = branch_ref.strip_prefix("refs/heads/") else {
return Ok(None);
};
let arguments = argv(&["config", "--list", "-z", "--show-scope"]);
let listing = run_query(git, &arguments)?;
if listing.code != Some(0) {
return Err(SingleFlightError::Git { arguments: String::from("config --list -z --show-scope"), code: listing.code, stderr: String::from_utf8_lossy(&listing.stderr).into_owned() });
}
let Some(snapshot) = ConfigSnapshot::parse(&listing.stdout, true) else {
return Err(SingleFlightError::Git {
arguments: String::from("config --list -z --show-scope"),
code: listing.code,
stderr: String::from("the listing is not in the shape `scope NUL key LF value NUL`"),
});
};
let tracked_remote = last_of(&snapshot, &format!("branch.{name}.remote"));
let mut push_remote = last_of(&snapshot, &format!("branch.{name}.pushremote"));
if push_remote.is_empty() {
push_remote = last_of(&snapshot, "remote.pushdefault");
}
if push_remote.is_empty() {
push_remote.clone_from(&tracked_remote);
}
if push_remote.is_empty() || push_remote == "." {
return Ok(None);
}
return Ok(Some(PushSettings {
branch: name.to_owned(),
refspecs: values_of(&snapshot, &format!("remote.{push_remote}.push")),
mirror: matches!(snapshot.bool_value(&format!("remote.{push_remote}.mirror")), Lookup::Found(true)),
merges: values_of(&snapshot, &format!("branch.{name}.merge")),
mode: last_of(&snapshot, "push.default"),
push_remote,
tracked_remote,
}));
}
fn note(branch: &str, reason: &str) -> String {
return format!("{NOTE_PREFIX}{branch} was not pushed: {reason} Nothing was pushed or changed; the commit is saved locally.");
}
fn skip(branch: &str, reason: &str) -> NamedPush {
return NamedPush::Skip(note(branch, reason));
}
enum ByMode {
Target(String),
Matching,
Refused(NamedPush),
}
fn upstream_target(settings: &PushSettings, triangular: bool, mode_name: &str) -> ByMode {
let branch = settings.branch.as_str();
if triangular {
return ByMode::Refused(skip(branch, &format!(
"`push.default` is `{mode_name}` but its push remote `{}` is not the remote it tracks (`{}`), so Git refuses to say which \
branch to update. Set `push.default` to `simple` or `current`, or push it with `git push {} {branch}`.",
settings.push_remote, settings.tracked_remote, settings.push_remote
)));
}
let [merge] = settings.merges.as_slice() else {
return ByMode::Refused(skip(branch, &format!(
"`branch.{branch}.merge` has {} values and `push.default` is `{}`, so Git refuses to pick one upstream branch (\"multiple upstream \
branches, refusing to push\"). Keep one `branch.{branch}.merge` value, or set `push.default` to `current`.",
settings.merges.len(),
if mode_name.is_empty() { "simple" } else { mode_name }
)));
};
if !merge.starts_with("refs/heads/") || merge == "refs/heads/" {
return ByMode::Refused(skip(branch, &format!(
"it tracks `{merge}`, which is not a branch under `refs/heads`. Push it yourself with `git push {} {branch}`.",
settings.push_remote
)));
}
return ByMode::Target(merge.clone());
}
fn by_mode(branch_ref: &str, settings: &PushSettings) -> ByMode {
let branch = settings.branch.as_str();
let mode = settings.mode.as_str();
let triangular = settings.push_remote != settings.tracked_remote;
return match mode {
"current" => ByMode::Target(branch_ref.to_owned()),
"" | "simple" if triangular => ByMode::Target(branch_ref.to_owned()),
"" | "simple" => upstream_target(settings, triangular, ""),
"upstream" | "tracking" => upstream_target(settings, triangular, mode),
"matching" => ByMode::Matching,
"nothing" => ByMode::Refused(skip(branch, &format!(
"`push.default` is `nothing`, so Git pushes no branch without a refspec. Set `push.default` to `simple` or `current`, or push it \
with `git push {} {branch}`.",
settings.push_remote
))),
other => ByMode::Refused(skip(branch, &format!(
"`push.default` is `{other}`, a value Git does not accept. Set it to `simple`, `current`, `upstream` or `matching`."
))),
};
}
pub fn read_destination(git: &PushGit, branch_ref: &str) -> Result<Option<Destination>, SingleFlightError> {
let Some(settings) = read_push_settings(git, branch_ref)? else {
return Ok(None);
};
if settings.mirror || !settings.refspecs.is_empty() {
return Ok(None);
}
return Ok(match by_mode(branch_ref, &settings) {
ByMode::Target(merge_ref) => Some(Destination { remote: settings.push_remote, merge_ref }),
ByMode::Matching | ByMode::Refused(_) => None,
});
}
pub fn named_push(git: &PushGit, branch_ref: &str) -> Result<NamedPush, SingleFlightError> {
let Some(settings) = read_push_settings(git, branch_ref)? else {
let branch = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
return Ok(skip(branch, "it has no remote to push to (its remote is `.`, a local branch, or none is set). Push it yourself with `git push`."));
};
if settings.mirror {
return Ok(skip(&settings.branch, &format!(
"`remote.{0}.mirror` is true: Git mirrors every ref to `{0}` (and deletes the ones it lacks) and refuses a branch pushed by name, \
which is all this service pushes. Run `git push` yourself to mirror, or unset `remote.{0}.mirror`.",
settings.push_remote
)));
}
if !settings.refspecs.is_empty() {
return named_by_refspecs(git, branch_ref, &settings);
}
return Ok(match by_mode(branch_ref, &settings) {
ByMode::Target(target) => NamedPush::Push { remote: settings.push_remote.clone(), targets: vec![target] },
ByMode::Matching => NamedPush::Matching { remote: settings.push_remote.clone() },
ByMode::Refused(skipped) => skipped,
});
}
fn named_by_refspecs(git: &PushGit, branch_ref: &str, settings: &PushSettings) -> Result<NamedPush, SingleFlightError> {
let branch = settings.branch.as_str();
let remote = settings.push_remote.as_str();
let mut patterns: Vec<String> = Vec::new();
for text in &settings.refspecs {
if let Some(item) = parse_refspec(text)
&& !item.negative
{
patterns.push(item.source);
}
}
patterns.sort();
patterns.dedup();
let mut query: Vec<&str> = vec!["for-each-ref", "--format=%(refname)"];
query.extend(patterns.iter().map(String::as_str));
let asked = run_query(git, &argv(&query))?;
if asked.code != Some(0) {
return Err(SingleFlightError::Git {
arguments: query.join(" "),
code: asked.code,
stderr: String::from_utf8_lossy(&asked.stderr).into_owned(),
});
}
let local_refs: Vec<String> = String::from_utf8_lossy(&asked.stdout).lines().map(str::to_owned).collect();
return Ok(match judge(branch_ref, remote, &settings.refspecs, &local_refs) {
Judged::Targets(targets) if !targets.is_empty() => NamedPush::Push { remote: remote.to_owned(), targets },
Judged::Targets(_) => skip(branch, &format!(
"no `remote.{remote}.push` refspec sends it anywhere, so Git pushes other refs or nothing for it. Add a refspec for it, or push it \
with `git push {remote} {branch}`."
)),
Judged::Refused(reason) => skip(branch, &format!("{reason} Fix `remote.{remote}.push`, or push it with `git push {remote} {branch}`.")),
Judged::Unjudged(text) => skip(branch, &format!(
"`remote.{remote}.push` has `{text}`, a form this service does not interpret (it reads full ref names and one `*` per side). \
Push it yourself with `git push`."
)),
});
}
#[cfg(test)]
#[path = "auto_push_destination_tests.rs"]
mod tests;