1use crate::auto_push_single_flight::{PushGit, SingleFlightError, run_query};
19use crate::config_snapshot::{ConfigSnapshot, Lookup};
20use std::collections::BTreeMap;
21use std::ffi::OsString;
22
23const NOTE_PREFIX: &str = "cli-git: auto-push: ";
25
26#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct Destination {
34 pub remote: String,
36 pub merge_ref: String,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
48pub enum NamedPush {
49 Push {
51 remote: String,
53 targets: Vec<String>,
55 },
56 Matching {
58 remote: String,
60 },
61 Skip(String),
63}
64
65struct PushSettings {
72 branch: String,
74 push_remote: String,
76 tracked_remote: String,
78 merges: Vec<String>,
80 mode: String,
82 refspecs: Vec<String>,
84 mirror: bool,
86}
87
88fn argv(parts: &[&str]) -> Vec<OsString> {
95 return parts.iter().map(OsString::from).collect();
96}
97
98fn values_of(snapshot: &ConfigSnapshot, key: &str) -> Vec<String> {
105 return snapshot
106 .entries()
107 .iter()
108 .filter(|entry| {
109 return entry.key == key;
110 })
111 .map(|entry| {
112 return entry.value.as_deref().map(|bytes| { return String::from_utf8_lossy(bytes).into_owned(); }).unwrap_or_default();
113 })
114 .collect();
115}
116
117fn last_of(snapshot: &ConfigSnapshot, key: &str) -> String {
124 return values_of(snapshot, key).pop().unwrap_or_default();
125}
126
127fn read_push_settings(git: &PushGit, branch_ref: &str) -> Result<Option<PushSettings>, SingleFlightError> {
140 let Some(name) = branch_ref.strip_prefix("refs/heads/") else {
141 return Ok(None);
142 };
143 let arguments = argv(&["config", "--list", "-z", "--show-scope"]);
144 let listing = run_query(git, &arguments)?;
145 let unreadable = |stderr: String| -> SingleFlightError {
146 return SingleFlightError::Git { arguments: String::from("config --list -z --show-scope"), code: listing.code, stderr };
147 };
148 if listing.code != Some(0) {
149 return Err(unreadable(String::from_utf8_lossy(&listing.stderr).into_owned()));
150 }
151 let Some(snapshot) = ConfigSnapshot::parse(&listing.stdout, true) else {
152 return Err(unreadable(String::from("the listing is not in the shape `scope NUL key LF value NUL`")));
153 };
154 let tracked_remote = last_of(&snapshot, &format!("branch.{name}.remote"));
155 let mut push_remote = last_of(&snapshot, &format!("branch.{name}.pushremote"));
156 if push_remote.is_empty() {
157 push_remote = last_of(&snapshot, "remote.pushdefault");
158 }
159 if push_remote.is_empty() {
160 push_remote.clone_from(&tracked_remote);
161 }
162 if push_remote.is_empty() || push_remote == "." {
163 return Ok(None);
164 }
165 return Ok(Some(PushSettings {
166 branch: name.to_owned(),
167 refspecs: values_of(&snapshot, &format!("remote.{push_remote}.push")),
168 mirror: matches!(snapshot.bool_value(&format!("remote.{push_remote}.mirror")), Lookup::Found(true)),
169 merges: values_of(&snapshot, &format!("branch.{name}.merge")),
170 mode: last_of(&snapshot, "push.default"),
171 push_remote,
172 tracked_remote,
173 }));
174}
175
176fn note(branch: &str, reason: &str) -> String {
183 return format!("{NOTE_PREFIX}{branch} was not pushed: {reason} Nothing was pushed or changed; the commit is saved locally.");
184}
185
186fn skip(branch: &str, reason: &str) -> NamedPush {
193 return NamedPush::Skip(note(branch, reason));
194}
195
196enum ByMode {
198 Target(String),
200 Matching,
202 Refused(NamedPush),
204}
205
206fn by_mode(branch_ref: &str, settings: &PushSettings) -> ByMode {
220 let branch = settings.branch.as_str();
221 let mode = settings.mode.as_str();
222 let triangular = settings.push_remote != settings.tracked_remote;
223 let upstream = |mode_name: &str| -> ByMode {
224 if triangular {
225 return ByMode::Refused(skip(branch, &format!(
226 "`push.default` is `{mode_name}` but its push remote `{}` is not the remote it tracks (`{}`), so Git refuses to say which \
227 branch to update. Set `push.default` to `simple` or `current`, or push it with `git push {} {branch}`.",
228 settings.push_remote, settings.tracked_remote, settings.push_remote
229 )));
230 }
231 let [merge] = settings.merges.as_slice() else {
232 return ByMode::Refused(skip(branch, &format!(
233 "`branch.{branch}.merge` has {} values and `push.default` is `{}`, so Git refuses to pick one upstream branch (\"multiple upstream \
234 branches, refusing to push\"). Keep one `branch.{branch}.merge` value, or set `push.default` to `current`.",
235 settings.merges.len(),
236 if mode_name.is_empty() { "simple" } else { mode_name }
237 )));
238 };
239 if !merge.starts_with("refs/heads/") || merge == "refs/heads/" {
240 return ByMode::Refused(skip(branch, &format!(
241 "it tracks `{merge}`, which is not a branch under `refs/heads`. Push it yourself with `git push {} {branch}`.",
242 settings.push_remote
243 )));
244 }
245 return ByMode::Target(merge.clone());
246 };
247 return match mode {
248 "current" => ByMode::Target(branch_ref.to_owned()),
249 "" | "simple" if triangular => ByMode::Target(branch_ref.to_owned()),
250 "" | "simple" => upstream(""),
251 "upstream" | "tracking" => upstream(mode),
252 "matching" => ByMode::Matching,
253 "nothing" => ByMode::Refused(skip(branch, &format!(
254 "`push.default` is `nothing`, so Git pushes no branch without a refspec. Set `push.default` to `simple` or `current`, or push it \
255 with `git push {} {branch}`.",
256 settings.push_remote
257 ))),
258 other => ByMode::Refused(skip(branch, &format!(
259 "`push.default` is `{other}`, a value Git does not accept. Set it to `simple`, `current`, `upstream` or `matching`."
260 ))),
261 };
262}
263
264pub fn read_destination(git: &PushGit, branch_ref: &str) -> Result<Option<Destination>, SingleFlightError> {
277 let Some(settings) = read_push_settings(git, branch_ref)? else {
278 return Ok(None);
279 };
280 if settings.mirror || !settings.refspecs.is_empty() {
281 return Ok(None);
282 }
283 return Ok(match by_mode(branch_ref, &settings) {
284 ByMode::Target(merge_ref) => Some(Destination { remote: settings.push_remote, merge_ref }),
285 ByMode::Matching | ByMode::Refused(_) => None,
286 });
287}
288
289pub fn named_push(git: &PushGit, branch_ref: &str) -> Result<NamedPush, SingleFlightError> {
302 let Some(settings) = read_push_settings(git, branch_ref)? else {
303 let branch = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
304 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`."));
305 };
306 if settings.mirror {
307 return Ok(skip(&settings.branch, &format!(
308 "`remote.{0}.mirror` is true: Git mirrors every ref to `{0}` (and deletes the ones it lacks) and refuses a branch pushed by name, \
309 which is all this service pushes. Run `git push` yourself to mirror, or unset `remote.{0}.mirror`.",
310 settings.push_remote
311 )));
312 }
313 if !settings.refspecs.is_empty() {
314 return named_by_refspecs(git, branch_ref, &settings);
315 }
316 return Ok(match by_mode(branch_ref, &settings) {
317 ByMode::Target(target) => NamedPush::Push { remote: settings.push_remote.clone(), targets: vec![target] },
318 ByMode::Matching => NamedPush::Matching { remote: settings.push_remote.clone() },
319 ByMode::Refused(skipped) => skipped,
320 });
321}
322
323struct Refspec {
330 negative: bool,
332 source: String,
334 destination: Option<String>,
336 pattern: bool,
338}
339
340fn parse_refspec(text: &str) -> Option<Refspec> {
351 let stars = |side: &str| -> usize { return side.matches('*').count(); };
352 if let Some(source) = text.strip_prefix('^') {
353 let negative_ok = source.starts_with("refs/") && !source.contains(':') && stars(source) <= 1;
354 return negative_ok.then(|| { return Refspec { negative: true, source: source.to_owned(), destination: None, pattern: stars(source) == 1 }; });
355 }
356 let spec = text.strip_prefix('+').unwrap_or(text);
357 let (source, destination) = match spec.split_once(':') {
358 Some((source, destination)) => (source, Some(destination)),
359 None => (spec, None),
360 };
361 if !source.starts_with("refs/") || destination.is_some_and(|side| { return !side.starts_with("refs/"); }) {
362 return None;
363 }
364 let pattern = stars(source) == 1;
365 let sides_agree = stars(source) == destination.map_or(stars(source), stars);
366 if stars(source) > 1 || !sides_agree {
367 return None;
368 }
369 return Some(Refspec { negative: false, source: source.to_owned(), destination: destination.map(str::to_owned), pattern });
370}
371
372fn capture<'a>(pattern: &str, name: &'a str) -> Option<&'a str> {
379 let (prefix, suffix) = pattern.split_once('*')?;
380 let middle = name.strip_prefix(prefix)?.strip_suffix(suffix)?;
381 return Some(middle);
382}
383
384fn excludes(negative: &Refspec, name: &str) -> bool {
391 if negative.pattern {
392 return capture(&negative.source, name).is_some();
393 }
394 return negative.source == name;
395}
396
397#[derive(Debug, Eq, PartialEq)]
399enum Judged {
400 Targets(Vec<String>),
402 Refused(String),
404 Unjudged(String),
406}
407
408fn judge(branch_ref: &str, remote: &str, refspecs: &[String], local_refs: &[String]) -> Judged {
421 let mut items: Vec<Refspec> = Vec::new();
422 for text in refspecs {
423 match parse_refspec(text) {
424 Some(item) => items.push(item),
425 None => return Judged::Unjudged(text.clone()),
426 }
427 }
428 let mut taken: BTreeMap<String, String> = BTreeMap::new();
429 for item in items.iter().filter(|item| { return !item.negative && !item.pattern; }) {
430 if !local_refs.contains(&item.source) {
431 return Judged::Refused(format!(
432 "`remote.{remote}.push` names `{}`, which is not a ref here, and Git refuses to push anything while it is not.",
433 item.source
434 ));
435 }
436 let destination: String = item.destination.clone().unwrap_or_else(|| { return item.source.clone(); });
437 if let Some(earlier) = taken.insert(destination.clone(), item.source.clone()) {
438 return Judged::Refused(format!(
439 "`remote.{remote}.push` sends both `{earlier}` and `{}` to `{destination}`, and Git refuses that (\"receives from more than one src\").",
440 item.source
441 ));
442 }
443 }
444 let mut sorted: Vec<&String> = local_refs.iter().collect();
445 sorted.sort();
446 for name in sorted {
447 let mapped = items.iter().filter(|item| { return !item.negative && item.pattern; }).find_map(|item| {
448 let middle = capture(&item.source, name)?;
449 let side: &str = item.destination.as_deref().unwrap_or(item.source.as_str());
450 return Some(side.replacen('*', middle, 1));
451 });
452 if let Some(destination) = mapped {
453 taken.entry(destination).or_insert_with(|| { return name.clone(); });
454 }
455 }
456 for negative in items.iter().filter(|item| { return item.negative; }) {
457 taken.retain(|destination, _| { return !excludes(negative, destination); });
458 }
459 return Judged::Targets(
460 taken
461 .into_iter()
462 .filter(|(_, source)| { return source == branch_ref; })
463 .map(|(destination, _)| { return destination; })
464 .collect(),
465 );
466}
467
468fn named_by_refspecs(git: &PushGit, branch_ref: &str, settings: &PushSettings) -> Result<NamedPush, SingleFlightError> {
478 let branch = settings.branch.as_str();
479 let remote = settings.push_remote.as_str();
480 let mut patterns: Vec<String> = settings
481 .refspecs
482 .iter()
483 .filter_map(|text| { return parse_refspec(text).filter(|item| { return !item.negative; }).map(|item| { return item.source; }); })
484 .collect();
485 patterns.sort();
486 patterns.dedup();
487 let mut query: Vec<&str> = vec!["for-each-ref", "--format=%(refname)"];
488 query.extend(patterns.iter().map(String::as_str));
489 let asked = run_query(git, &argv(&query))?;
490 if asked.code != Some(0) {
491 return Err(SingleFlightError::Git {
492 arguments: query.join(" "),
493 code: asked.code,
494 stderr: String::from_utf8_lossy(&asked.stderr).into_owned(),
495 });
496 }
497 let local_refs: Vec<String> = String::from_utf8_lossy(&asked.stdout).lines().map(str::to_owned).collect();
498 return Ok(match judge(branch_ref, remote, &settings.refspecs, &local_refs) {
499 Judged::Targets(targets) if !targets.is_empty() => NamedPush::Push { remote: remote.to_owned(), targets },
500 Judged::Targets(_) => skip(branch, &format!(
501 "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 \
502 with `git push {remote} {branch}`."
503 )),
504 Judged::Refused(reason) => skip(branch, &format!("{reason} Fix `remote.{remote}.push`, or push it with `git push {remote} {branch}`.")),
505 Judged::Unjudged(text) => skip(branch, &format!(
506 "`remote.{remote}.push` has `{text}`, a form this service does not interpret (it reads full ref names and one `*` per side). \
507 Push it yourself with `git push`."
508 )),
509 });
510}
511
512#[cfg(test)]
514#[path = "auto_push_destination_tests.rs"]
515mod tests;