Skip to main content

git_policy_cli/
auto_push_destination.rs

1//! What:
2//!  Where a plain `git push` of one named branch would send it, decided from the configuration and the local refs,
3//!  or why Git would send it nowhere.
4//! Why:
5//!  The service pushes the branch a record names, which may not be the branch checked out, so it must name both sides of
6//!  the push itself; naming them from a partial reading of Git's rules pushed to places a plain `git push` refuses
7//!  or never goes (a `matching` push recreating a branch the remote deleted, one of several upstream branches chosen at
8//!  random, a destination that another configured refspec had already taken).
9//!  Everything here follows `setup_default_push_refspecs` in Git's `builtin/push.c` and `match_push_refs` in `remote.c`;
10//!  a setting Git refuses or a form this module does not interpret becomes a skip with a note that names the setting,
11//!  never a push.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const named: NamedPush = await namedPush({ git, branchRef }); // push | remote-must-have | skip
16//! ```
17
18use 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
24/// Start of every note this module words, so the terminal filter keeps it and a person sees which tool spoke.
25const NOTE_PREFIX: &str = "cli-git: auto-push: ";
26
27/// Where the plain push goes: the branch's push remote and the ref it updates there.
28///
29/// In TS you'd write (pseudocode):
30/// ```ts
31/// type Destination = { remote: string; mergeRef: string };
32/// ```
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct Destination {
35    /// Remote name the push goes to.
36    pub remote: String,
37    /// Full ref the push updates there, for example `refs/heads/main`.
38    pub merge_ref: String,
39}
40
41/// What the service does for one named branch whose upstream resolves.
42///
43/// In TS you'd write (pseudocode):
44/// ```ts
45/// type NamedPush = { kind: 'push'; remote: string; targets: string[] } | { kind: 'remote-must-have'; remote: string; target: string }
46///   | { kind: 'skip'; note: string };
47/// ```
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum NamedPush {
50    /// Push the branch to each of these refs on the remote.
51    Push {
52        /// The remote the push goes to.
53        remote: String,
54        /// The full refs that receive the branch.
55        targets: Vec<String>,
56    },
57    /// Git pushes the branch only if the remote already has `target`; the caller asks the remote.
58    RemoteMustHave {
59        /// The remote the push goes to.
60        remote: String,
61        /// The full ref that must exist there.
62        target: String,
63    },
64    /// Git pushes the branch nowhere or refuses; the note says why and what to do.
65    Skip(String),
66}
67
68/// The settings that decide where one branch is pushed, read once.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// type PushSettings = { branch: string; pushRemote: string; trackedRemote: string; merges: string[]; mode: string; refspecs: string[]; mirror: boolean };
73/// ```
74struct PushSettings {
75    /// Short name of the branch.
76    branch: String,
77    /// The remote the push goes to, in Git's order (`branch.<name>.pushRemote`, `remote.pushDefault`, `branch.<name>.remote`).
78    push_remote: String,
79    /// `branch.<name>.remote`, empty when unset.
80    tracked_remote: String,
81    /// Every `branch.<name>.merge` value, in order.
82    merges: Vec<String>,
83    /// The value of `push.default`, empty when unset.
84    mode: String,
85    /// Every `remote.<push remote>.push` value, in order.
86    refspecs: Vec<String>,
87    /// Whether `remote.<push remote>.mirror` is true.
88    mirror: bool,
89}
90
91/// Argument list from string literals and owned text.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// ['config', '--list', ...]
96/// ```
97fn argv(parts: &[&str]) -> Vec<OsString> {
98    return parts.iter().map(OsString::from).collect();
99}
100
101/// Every value of `key` in the snapshot, in listing order; a key written without `=` reads as an empty value.
102///
103/// In TS you'd write (pseudocode):
104/// ```ts
105/// snapshot.entries.filter((entry) => entry.key === key).map((entry) => entry.value ?? '')
106/// ```
107fn 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
120/// The last value of `key`, empty when it is not set.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// valuesOf(snapshot, key).at(-1) ?? ''
125/// ```
126fn last_of(snapshot: &ConfigSnapshot, key: &str) -> String {
127    return values_of(snapshot, key).pop().unwrap_or_default();
128}
129
130/// What:
131///  The settings that decide where the plain push of a branch goes, or nothing for a branch outside `refs/heads`
132///  and for the remote `.` or no remote.
133/// Why:
134///   One listing of the configuration (`config --list -z --show-scope`, answered without a Git process where the
135///  in-process reader can) gives every value of the multi-valued keys, which a `config --get` (the last value only)
136///  cannot: `branch.<name>.merge` and `remote.<name>.push` hold several.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// async function readPushSettings({ git, branchRef }): Promise<PushSettings | undefined>;
141/// ```
142fn 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
179/// A note for a branch that is not pushed: what was seen, and what to do about it.
180///
181/// In TS you'd write (pseudocode):
182/// ```ts
183/// `cli-git: auto-push: ${branch} was not pushed: ${reason} Nothing was pushed or changed.`
184/// ```
185fn 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
189/// The skip of a branch, with its note.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// ({ kind: 'skip', note: note(branch, reason) })
194/// ```
195fn skip(branch: &str, reason: &str) -> NamedPush {
196    return NamedPush::Skip(note(branch, reason));
197}
198
199/// How `push.default` sends the branch when no `remote.<name>.push` refspec does.
200enum ByMode {
201    /// The ref the branch updates on the push remote.
202    Target(String),
203    /// `matching`: the branch's own name, and only where the remote already has it.
204    Matching,
205    /// Git pushes nothing or refuses; the note says why.
206    Refused(NamedPush),
207}
208
209/// What:
210///  The ref `push.default` sends the branch to, or why Git sends it nowhere.
211/// Why:
212///   `current` and a push remote other than the tracked one (a triangular `simple`) use the branch's own name.
213///  `upstream` and `simple` on the tracked remote use the upstream branch, and Git dies when the branch has none or
214///  several (`get_upstream_ref` in `builtin/push.c`); a differently named single upstream, which `simple` also refuses,
215///  is where the person tracked the branch (a linked worktree branch created from `origin/main`), so it is pushed.
216///   `nothing`, `matching` and a triangular `upstream` name no single ref.
217///
218/// In TS you'd write (pseudocode):
219/// ```ts
220/// function byMode(branchRef: string, settings: PushSettings): ByMode;
221/// ```
222fn 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
267/// What:
268///  The remote and the branch ref that the plain `git push` of this branch updates, when the settings name one.
269/// Why:
270///   The reconcile of a rejected push merges the tip of the branch the rejected push targeted, which with a push remote,
271///  a triangular setup or `push.default=current` is not the tracked branch.
272///   The remote `.`, a `remote.<name>.push` refspec, `matching`, `nothing`, several upstream branches and a target
273///  outside `refs/heads` name none, because the rejection then came from a destination this module cannot name.
274///
275/// In TS you'd write (pseudocode):
276/// ```ts
277/// async function readDestination({ git, branchRef }): Promise<Destination | undefined>;
278/// ```
279pub 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
292/// What:
293///  What the service does for a named branch whose upstream resolves: the remote and refs it pushes to, the one remote
294///  question that decides, or the note that explains why it pushes nothing.
295/// Why:
296///   The plain `git push` pushes the checked-out branch, so a push of a branch that is not checked out names both sides;
297///  the destination follows Git's rules for the branch as if it were checked out, and a setting under which Git
298///  refuses or pushes nothing for it is a skip.
299///
300/// In TS you'd write (pseudocode):
301/// ```ts
302/// async function namedPush({ git, branchRef }): Promise<NamedPush>;
303/// ```
304pub 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
326/// The note for a `matching` push of a branch the remote does not have.
327///
328/// In TS you'd write (pseudocode):
329/// ```ts
330/// `cli-git: auto-push: ${branch} was not pushed: push.default is matching and ${remote} has no ${ref}.`
331/// ```
332pub 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/// What the remote answered to "do you advertise this ref".
341///
342/// In TS you'd write (pseudocode):
343/// ```ts
344/// type Advertised = 'yes' | 'no' | { unreachable: PushAttempt } | { severalUrls: number };
345/// ```
346#[derive(Debug, Eq, PartialEq)]
347pub enum Advertised {
348    /// The push URL lists the ref.
349    Yes,
350    /// The push URL does not list it.
351    No,
352    /// The push URL could not be asked; the attempt holds Git's words and exit code.
353    Unreachable(PushAttempt),
354    /// The remote has this many push URLs; a push by name goes to all of them, and Git decides each one separately.
355    SeveralUrls(usize),
356}
357
358/// What:
359///  Ask the remote's push URL whether it advertises `reference`, the question `push.default=matching` is decided by.
360/// Why:
361///   Plain `git push` under `matching` pairs local branches with the refs the remote advertises, which no local query can
362///  know: the remote-tracking ref outlives a branch deleted on the remote, and pushing by name would recreate it.
363///   The push goes to `remote.<name>.pushurl` (or the rewritten fetch URL), which can be another repository than the one
364///  the remote fetches from, so the question goes there: `git remote get-url --push --all` names it.
365///   The output is read for the exact ref, because `ls-remote` matches a pattern at the tail of any ref name.
366///
367/// In TS you'd write (pseudocode):
368/// ```ts
369/// async function remoteAdvertises({ git, remote, reference }): Promise<Advertised>;
370/// ```
371pub 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
398/// The note for a `matching` push of a branch to a remote with several push URLs.
399///
400/// In TS you'd write (pseudocode):
401/// ```ts
402/// `cli-git: auto-push: ${branch} was not pushed: ${remote} has ${count} push URLs ...`
403/// ```
404pub 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
412/// One `remote.<name>.push` refspec this module interprets.
413///
414/// In TS you'd write (pseudocode):
415/// ```ts
416/// type Refspec = { negative: boolean; source: string; destination?: string; pattern: boolean };
417/// ```
418struct Refspec {
419    /// A `^` refspec: it removes matching destinations.
420    negative: bool,
421    /// The local side.
422    source: String,
423    /// The remote side; the source when absent.
424    destination: Option<String>,
425    /// Whether the sides hold one `*`.
426    pattern: bool,
427}
428
429/// What:
430///  Read a configured push refspec, or nothing when it is a form this module does not interpret.
431/// Why:
432///   Only full ref names and one `*` per side are interpreted; `HEAD`, short names, object names, deletions and the
433///  `:` of matching depend on more than the local ref names, so they are never guessed at.
434///
435/// In TS you'd write (pseudocode):
436/// ```ts
437/// function parseRefspec(text: string): Refspec | undefined;
438/// ```
439fn 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
461/// The text a `*` pattern captured in `name`, or nothing when `name` does not match.
462///
463/// In TS you'd write (pseudocode):
464/// ```ts
465/// function capture(pattern: string, name: string): string | undefined;
466/// ```
467fn 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
473/// Whether a negative refspec removes the remote ref `name`.
474///
475/// In TS you'd write (pseudocode):
476/// ```ts
477/// function excludes(negative: Refspec, name: string): boolean;
478/// ```
479fn 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/// What the configured push refspecs make of one branch.
487#[derive(Debug, Eq, PartialEq)]
488enum Judged {
489    /// The refs on the remote that receive the branch, possibly none.
490    Targets(Vec<String>),
491    /// Git refuses the whole push; the sentence says why.
492    Refused(String),
493    /// A refspec of a form not interpreted; its text.
494    Unjudged(String),
495}
496
497/// What:
498///  Apply the configured push refspecs to the local refs the way Git's `match_push_refs` does, and report where the branch goes.
499/// Why:
500///   Git matches every explicit refspec first (a source that is not a local ref, or two sources for one destination, fail
501///  the whole push), then lets each local ref take the first matching pattern unless an earlier pairing already took its
502///  destination, then drops the destinations a negative refspec names.
503///  The `%(push:remoteref)` atom of `git for-each-ref` maps one branch alone, so it misses all three and cannot be used.
504///
505/// In TS you'd write (pseudocode):
506/// ```ts
507/// function judge({ branchRef, refspecs, localRefs }): Judged;
508/// ```
509fn 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
557/// What:
558///  The destinations of the configured push refspecs for the branch, or the note for why there are none.
559/// Why:
560///   The local refs the refspecs can name are asked for in one `for-each-ref`; everything else is decided here.
561///
562/// In TS you'd write (pseudocode):
563/// ```ts
564/// async function namedByRefspecs({ git, branchRef, settings }): Promise<NamedPush>;
565/// ```
566fn 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/// The destination logic is controlled in the test module.
602#[cfg(test)]
603#[path = "auto_push_destination_tests.rs"]
604mod tests;