use crate::auto_push_single_flight::{PushGit, SingleFlightError, run_query};
use crate::config_snapshot::{ConfigSnapshot, Lookup};
use std::collections::BTreeMap;
use std::ffi::OsString;
const NOTE_PREFIX: &str = "cli-git: 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> {
return snapshot
.entries()
.iter()
.filter(|entry| {
return entry.key == key;
})
.map(|entry| {
return entry.value.as_deref().map(|bytes| { return String::from_utf8_lossy(bytes).into_owned(); }).unwrap_or_default();
})
.collect();
}
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)?;
let unreadable = |stderr: String| -> SingleFlightError {
return SingleFlightError::Git { arguments: String::from("config --list -z --show-scope"), code: listing.code, stderr };
};
if listing.code != Some(0) {
return Err(unreadable(String::from_utf8_lossy(&listing.stderr).into_owned()));
}
let Some(snapshot) = ConfigSnapshot::parse(&listing.stdout, true) else {
return Err(unreadable(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 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;
let upstream = |mode_name: &str| -> ByMode {
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());
};
return match mode {
"current" => ByMode::Target(branch_ref.to_owned()),
"" | "simple" if triangular => ByMode::Target(branch_ref.to_owned()),
"" | "simple" => upstream(""),
"upstream" | "tracking" => upstream(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,
});
}
struct Refspec {
negative: bool,
source: String,
destination: Option<String>,
pattern: bool,
}
fn parse_refspec(text: &str) -> Option<Refspec> {
let stars = |side: &str| -> usize { return side.matches('*').count(); };
if let Some(source) = text.strip_prefix('^') {
let negative_ok = source.starts_with("refs/") && !source.contains(':') && stars(source) <= 1;
return negative_ok.then(|| { return Refspec { negative: true, source: source.to_owned(), destination: None, pattern: stars(source) == 1 }; });
}
let spec = text.strip_prefix('+').unwrap_or(text);
let (source, destination) = match spec.split_once(':') {
Some((source, destination)) => (source, Some(destination)),
None => (spec, None),
};
if !source.starts_with("refs/") || destination.is_some_and(|side| { return !side.starts_with("refs/"); }) {
return None;
}
let pattern = stars(source) == 1;
let sides_agree = stars(source) == destination.map_or(stars(source), stars);
if stars(source) > 1 || !sides_agree {
return None;
}
return Some(Refspec { negative: false, source: source.to_owned(), destination: destination.map(str::to_owned), pattern });
}
fn capture<'a>(pattern: &str, name: &'a str) -> Option<&'a str> {
let (prefix, suffix) = pattern.split_once('*')?;
let middle = name.strip_prefix(prefix)?.strip_suffix(suffix)?;
return Some(middle);
}
fn excludes(negative: &Refspec, name: &str) -> bool {
if negative.pattern {
return capture(&negative.source, name).is_some();
}
return negative.source == name;
}
#[derive(Debug, Eq, PartialEq)]
enum Judged {
Targets(Vec<String>),
Refused(String),
Unjudged(String),
}
fn judge(branch_ref: &str, remote: &str, refspecs: &[String], local_refs: &[String]) -> Judged {
let mut items: Vec<Refspec> = Vec::new();
for text in refspecs {
match parse_refspec(text) {
Some(item) => items.push(item),
None => return Judged::Unjudged(text.clone()),
}
}
let mut taken: BTreeMap<String, String> = BTreeMap::new();
for item in items.iter().filter(|item| { return !item.negative && !item.pattern; }) {
if !local_refs.contains(&item.source) {
return Judged::Refused(format!(
"`remote.{remote}.push` names `{}`, which is not a ref here, and Git refuses to push anything while it is not.",
item.source
));
}
let destination: String = item.destination.clone().unwrap_or_else(|| { return item.source.clone(); });
if let Some(earlier) = taken.insert(destination.clone(), item.source.clone()) {
return Judged::Refused(format!(
"`remote.{remote}.push` sends both `{earlier}` and `{}` to `{destination}`, and Git refuses that (\"receives from more than one src\").",
item.source
));
}
}
let mut sorted: Vec<&String> = local_refs.iter().collect();
sorted.sort();
for name in sorted {
let mapped = items.iter().filter(|item| { return !item.negative && item.pattern; }).find_map(|item| {
let middle = capture(&item.source, name)?;
let side: &str = item.destination.as_deref().unwrap_or(item.source.as_str());
return Some(side.replacen('*', middle, 1));
});
if let Some(destination) = mapped {
taken.entry(destination).or_insert_with(|| { return name.clone(); });
}
}
for negative in items.iter().filter(|item| { return item.negative; }) {
taken.retain(|destination, _| { return !excludes(negative, destination); });
}
return Judged::Targets(
taken
.into_iter()
.filter(|(_, source)| { return source == branch_ref; })
.map(|(destination, _)| { return destination; })
.collect(),
);
}
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> = settings
.refspecs
.iter()
.filter_map(|text| { return parse_refspec(text).filter(|item| { return !item.negative; }).map(|item| { return item.source; }); })
.collect();
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;