1use super::auto_push_single_flight::{PushAttempt, PushGit, SingleFlightError, git_command, run_query};
19use super::bounded_push::{STOP_GRACE, apply_low_speed_defaults, push_deadline, run_bounded_push};
20use super::config_snapshot::{ConfigSnapshot, Lookup};
21use std::collections::BTreeMap;
22use std::ffi::OsString;
23
24const NOTE_PREFIX: &str = "cli-git: auto-push: ";
26
27#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct Destination {
35 pub remote: String,
37 pub merge_ref: String,
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum NamedPush {
50 Push {
52 remote: String,
54 targets: Vec<String>,
56 },
57 RemoteMustHave {
59 remote: String,
61 target: String,
63 },
64 Skip(String),
66}
67
68struct PushSettings {
75 branch: String,
77 push_remote: String,
79 tracked_remote: String,
81 merges: Vec<String>,
83 mode: String,
85 refspecs: Vec<String>,
87 mirror: bool,
89}
90
91fn argv(parts: &[&str]) -> Vec<OsString> {
98 return parts.iter().map(OsString::from).collect();
99}
100
101fn values_of(snapshot: &ConfigSnapshot, key: &str) -> Vec<String> {
108 return snapshot
109 .entries()
110 .iter()
111 .filter(|entry| {
112 return entry.key == key;
113 })
114 .map(|entry| {
115 return entry.value.as_deref().map(|bytes| { return String::from_utf8_lossy(bytes).into_owned(); }).unwrap_or_default();
116 })
117 .collect();
118}
119
120fn last_of(snapshot: &ConfigSnapshot, key: &str) -> String {
127 return values_of(snapshot, key).pop().unwrap_or_default();
128}
129
130fn read_push_settings(git: &PushGit, branch_ref: &str) -> Result<Option<PushSettings>, SingleFlightError> {
143 let Some(name) = branch_ref.strip_prefix("refs/heads/") else {
144 return Ok(None);
145 };
146 let arguments = argv(&["config", "--list", "-z", "--show-scope"]);
147 let listing = run_query(git, &arguments)?;
148 let unreadable = |stderr: String| -> SingleFlightError {
149 return SingleFlightError::Git { arguments: String::from("config --list -z --show-scope"), code: listing.code, stderr };
150 };
151 if listing.code != Some(0) {
152 return Err(unreadable(String::from_utf8_lossy(&listing.stderr).into_owned()));
153 }
154 let Some(snapshot) = ConfigSnapshot::parse(&listing.stdout, true) else {
155 return Err(unreadable(String::from("the listing is not in the shape `scope NUL key LF value NUL`")));
156 };
157 let tracked_remote = last_of(&snapshot, &format!("branch.{name}.remote"));
158 let mut push_remote = last_of(&snapshot, &format!("branch.{name}.pushremote"));
159 if push_remote.is_empty() {
160 push_remote = last_of(&snapshot, "remote.pushdefault");
161 }
162 if push_remote.is_empty() {
163 push_remote.clone_from(&tracked_remote);
164 }
165 if push_remote.is_empty() || push_remote == "." {
166 return Ok(None);
167 }
168 return Ok(Some(PushSettings {
169 branch: name.to_owned(),
170 refspecs: values_of(&snapshot, &format!("remote.{push_remote}.push")),
171 mirror: matches!(snapshot.bool_value(&format!("remote.{push_remote}.mirror")), Lookup::Found(true)),
172 merges: values_of(&snapshot, &format!("branch.{name}.merge")),
173 mode: last_of(&snapshot, "push.default"),
174 push_remote,
175 tracked_remote,
176 }));
177}
178
179fn note(branch: &str, reason: &str) -> String {
186 return format!("{NOTE_PREFIX}{branch} was not pushed: {reason} Nothing was pushed or changed; the commit is saved locally.");
187}
188
189fn skip(branch: &str, reason: &str) -> NamedPush {
196 return NamedPush::Skip(note(branch, reason));
197}
198
199enum ByMode {
201 Target(String),
203 Matching,
205 Refused(NamedPush),
207}
208
209fn by_mode(branch_ref: &str, settings: &PushSettings) -> ByMode {
223 let branch = settings.branch.as_str();
224 let mode = settings.mode.as_str();
225 let triangular = settings.push_remote != settings.tracked_remote;
226 let upstream = |mode_name: &str| -> ByMode {
227 if triangular {
228 return ByMode::Refused(skip(branch, &format!(
229 "`push.default` is `{mode_name}` but its push remote `{}` is not the remote it tracks (`{}`), so Git refuses to say which \
230 branch to update. Set `push.default` to `simple` or `current`, or push it with `git push {} {branch}`.",
231 settings.push_remote, settings.tracked_remote, settings.push_remote
232 )));
233 }
234 let [merge] = settings.merges.as_slice() else {
235 return ByMode::Refused(skip(branch, &format!(
236 "`branch.{branch}.merge` has {} values and `push.default` is `{}`, so Git refuses to pick one upstream branch (\"multiple upstream \
237 branches, refusing to push\"). Keep one `branch.{branch}.merge` value, or set `push.default` to `current`.",
238 settings.merges.len(),
239 if mode_name.is_empty() { "simple" } else { mode_name }
240 )));
241 };
242 if !merge.starts_with("refs/heads/") || merge == "refs/heads/" {
243 return ByMode::Refused(skip(branch, &format!(
244 "it tracks `{merge}`, which is not a branch under `refs/heads`. Push it yourself with `git push {} {branch}`.",
245 settings.push_remote
246 )));
247 }
248 return ByMode::Target(merge.clone());
249 };
250 return match mode {
251 "current" => ByMode::Target(branch_ref.to_owned()),
252 "" | "simple" if triangular => ByMode::Target(branch_ref.to_owned()),
253 "" | "simple" => upstream(""),
254 "upstream" | "tracking" => upstream(mode),
255 "matching" => ByMode::Matching,
256 "nothing" => ByMode::Refused(skip(branch, &format!(
257 "`push.default` is `nothing`, so Git pushes no branch without a refspec. Set `push.default` to `simple` or `current`, or push it \
258 with `git push {} {branch}`.",
259 settings.push_remote
260 ))),
261 other => ByMode::Refused(skip(branch, &format!(
262 "`push.default` is `{other}`, a value Git does not accept. Set it to `simple`, `current`, `upstream` or `matching`."
263 ))),
264 };
265}
266
267pub fn read_destination(git: &PushGit, branch_ref: &str) -> Result<Option<Destination>, SingleFlightError> {
280 let Some(settings) = read_push_settings(git, branch_ref)? else {
281 return Ok(None);
282 };
283 if settings.mirror || !settings.refspecs.is_empty() {
284 return Ok(None);
285 }
286 return Ok(match by_mode(branch_ref, &settings) {
287 ByMode::Target(merge_ref) => Some(Destination { remote: settings.push_remote, merge_ref }),
288 ByMode::Matching | ByMode::Refused(_) => None,
289 });
290}
291
292pub fn named_push(git: &PushGit, branch_ref: &str) -> Result<NamedPush, SingleFlightError> {
305 let Some(settings) = read_push_settings(git, branch_ref)? else {
306 let branch = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
307 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`."));
308 };
309 if settings.mirror {
310 return Ok(skip(&settings.branch, &format!(
311 "`remote.{0}.mirror` is true: Git mirrors every ref to `{0}` (and deletes the ones it lacks) and refuses a branch pushed by name, \
312 which is all this service pushes. Run `git push` yourself to mirror, or unset `remote.{0}.mirror`.",
313 settings.push_remote
314 )));
315 }
316 if !settings.refspecs.is_empty() {
317 return named_by_refspecs(git, branch_ref, &settings);
318 }
319 return Ok(match by_mode(branch_ref, &settings) {
320 ByMode::Target(target) => NamedPush::Push { remote: settings.push_remote.clone(), targets: vec![target] },
321 ByMode::Matching => NamedPush::RemoteMustHave { remote: settings.push_remote.clone(), target: branch_ref.to_owned() },
322 ByMode::Refused(skipped) => skipped,
323 });
324}
325
326pub fn absent_on_remote(branch_ref: &str, remote: &str) -> String {
333 let branch = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
334 return note(branch, &format!(
335 "`push.default` is `matching`, which pushes only branches the remote already has, and `{remote}` has no `{branch_ref}`. \
336 Publish it once with `git push --set-upstream {remote} {branch}`, or set `push.default` to `simple`."
337 ));
338}
339
340#[derive(Debug, Eq, PartialEq)]
347pub enum Advertised {
348 Yes,
350 No,
352 Unreachable(PushAttempt),
354 SeveralUrls(usize),
356}
357
358pub fn remote_has_ref(git: &PushGit, remote: &str, reference: &str) -> Result<Advertised, SingleFlightError> {
372 let urls_query = argv(&["remote", "get-url", "--push", "--all", remote]);
373 let named = run_query(git, &urls_query)?;
374 if named.code != Some(0) {
375 return Err(SingleFlightError::Git {
376 arguments: String::from("remote get-url --push --all"),
377 code: named.code,
378 stderr: String::from_utf8_lossy(&named.stderr).into_owned(),
379 });
380 }
381 let urls: Vec<String> = String::from_utf8_lossy(&named.stdout).lines().filter(|line| { return !line.is_empty(); }).map(str::to_owned).collect();
382 let [url] = urls.as_slice() else {
383 return Ok(Advertised::SeveralUrls(urls.len()));
384 };
385 let arguments = argv(&["ls-remote", url, reference]);
386 let mut command = git_command(git, &arguments);
387 apply_low_speed_defaults(&mut command, &git.environment);
388 let asked = run_bounded_push(command, push_deadline(&git.environment), STOP_GRACE, git.environment.as_slice())?;
389 if asked.exit_code != 0 {
390 return Ok(Advertised::Unreachable(asked));
391 }
392 let advertised: bool = asked.output.lines().any(|line| {
393 return line.split_once('\t').is_some_and(|(_, name)| { return name == reference; });
394 });
395 return Ok(if advertised { Advertised::Yes } else { Advertised::No });
396}
397
398pub fn several_push_urls(branch_ref: &str, remote: &str, count: usize) -> String {
405 let branch = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
406 return note(branch, &format!(
407 "`push.default` is `matching` and `{remote}` has {count} push URLs (`remote.{remote}.pushurl`); Git decides each URL by what it \
408 advertises, and a push by name goes to all of them. Push it yourself with `git push {remote}`, or keep one push URL."
409 ));
410}
411
412struct Refspec {
419 negative: bool,
421 source: String,
423 destination: Option<String>,
425 pattern: bool,
427}
428
429fn parse_refspec(text: &str) -> Option<Refspec> {
440 let stars = |side: &str| -> usize { return side.matches('*').count(); };
441 if let Some(source) = text.strip_prefix('^') {
442 let negative_ok = source.starts_with("refs/") && !source.contains(':') && stars(source) <= 1;
443 return negative_ok.then(|| { return Refspec { negative: true, source: source.to_owned(), destination: None, pattern: stars(source) == 1 }; });
444 }
445 let spec = text.strip_prefix('+').unwrap_or(text);
446 let (source, destination) = match spec.split_once(':') {
447 Some((source, destination)) => (source, Some(destination)),
448 None => (spec, None),
449 };
450 if !source.starts_with("refs/") || destination.is_some_and(|side| { return !side.starts_with("refs/"); }) {
451 return None;
452 }
453 let pattern = stars(source) == 1;
454 let sides_agree = stars(source) == destination.map_or(stars(source), stars);
455 if stars(source) > 1 || !sides_agree {
456 return None;
457 }
458 return Some(Refspec { negative: false, source: source.to_owned(), destination: destination.map(str::to_owned), pattern });
459}
460
461fn capture<'a>(pattern: &str, name: &'a str) -> Option<&'a str> {
468 let (prefix, suffix) = pattern.split_once('*')?;
469 let middle = name.strip_prefix(prefix)?.strip_suffix(suffix)?;
470 return Some(middle);
471}
472
473fn excludes(negative: &Refspec, name: &str) -> bool {
480 if negative.pattern {
481 return capture(&negative.source, name).is_some();
482 }
483 return negative.source == name;
484}
485
486#[derive(Debug, Eq, PartialEq)]
488enum Judged {
489 Targets(Vec<String>),
491 Refused(String),
493 Unjudged(String),
495}
496
497fn judge(branch_ref: &str, remote: &str, refspecs: &[String], local_refs: &[String]) -> Judged {
510 let mut items: Vec<Refspec> = Vec::new();
511 for text in refspecs {
512 match parse_refspec(text) {
513 Some(item) => items.push(item),
514 None => return Judged::Unjudged(text.clone()),
515 }
516 }
517 let mut taken: BTreeMap<String, String> = BTreeMap::new();
518 for item in items.iter().filter(|item| { return !item.negative && !item.pattern; }) {
519 if !local_refs.contains(&item.source) {
520 return Judged::Refused(format!(
521 "`remote.{remote}.push` names `{}`, which is not a ref here, and Git refuses to push anything while it is not.",
522 item.source
523 ));
524 }
525 let destination: String = item.destination.clone().unwrap_or_else(|| { return item.source.clone(); });
526 if let Some(earlier) = taken.insert(destination.clone(), item.source.clone()) {
527 return Judged::Refused(format!(
528 "`remote.{remote}.push` sends both `{earlier}` and `{}` to `{destination}`, and Git refuses that (\"receives from more than one src\").",
529 item.source
530 ));
531 }
532 }
533 let mut sorted: Vec<&String> = local_refs.iter().collect();
534 sorted.sort();
535 for name in sorted {
536 let mapped = items.iter().filter(|item| { return !item.negative && item.pattern; }).find_map(|item| {
537 let middle = capture(&item.source, name)?;
538 let side: &str = item.destination.as_deref().unwrap_or(item.source.as_str());
539 return Some(side.replacen('*', middle, 1));
540 });
541 if let Some(destination) = mapped {
542 taken.entry(destination).or_insert_with(|| { return name.clone(); });
543 }
544 }
545 for negative in items.iter().filter(|item| { return item.negative; }) {
546 taken.retain(|destination, _| { return !excludes(negative, destination); });
547 }
548 return Judged::Targets(
549 taken
550 .into_iter()
551 .filter(|(_, source)| { return source == branch_ref; })
552 .map(|(destination, _)| { return destination; })
553 .collect(),
554 );
555}
556
557fn named_by_refspecs(git: &PushGit, branch_ref: &str, settings: &PushSettings) -> Result<NamedPush, SingleFlightError> {
567 let branch = settings.branch.as_str();
568 let remote = settings.push_remote.as_str();
569 let mut patterns: Vec<String> = settings
570 .refspecs
571 .iter()
572 .filter_map(|text| { return parse_refspec(text).filter(|item| { return !item.negative; }).map(|item| { return item.source; }); })
573 .collect();
574 patterns.sort();
575 patterns.dedup();
576 let mut query: Vec<&str> = vec!["for-each-ref", "--format=%(refname)"];
577 query.extend(patterns.iter().map(String::as_str));
578 let asked = run_query(git, &argv(&query))?;
579 if asked.code != Some(0) {
580 return Err(SingleFlightError::Git {
581 arguments: query.join(" "),
582 code: asked.code,
583 stderr: String::from_utf8_lossy(&asked.stderr).into_owned(),
584 });
585 }
586 let local_refs: Vec<String> = String::from_utf8_lossy(&asked.stdout).lines().map(str::to_owned).collect();
587 return Ok(match judge(branch_ref, remote, &settings.refspecs, &local_refs) {
588 Judged::Targets(targets) if !targets.is_empty() => NamedPush::Push { remote: remote.to_owned(), targets },
589 Judged::Targets(_) => skip(branch, &format!(
590 "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 \
591 with `git push {remote} {branch}`."
592 )),
593 Judged::Refused(reason) => skip(branch, &format!("{reason} Fix `remote.{remote}.push`, or push it with `git push {remote} {branch}`.")),
594 Judged::Unjudged(text) => skip(branch, &format!(
595 "`remote.{remote}.push` has `{text}`, a form this service does not interpret (it reads full ref names and one `*` per side). \
596 Push it yourself with `git push`."
597 )),
598 });
599}
600
601#[cfg(test)]
603#[path = "auto_push_destination_tests.rs"]
604mod tests;