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::{PushGit, SingleFlightError, run_query};
19use super::config_snapshot::{ConfigSnapshot, Lookup};
20use std::collections::BTreeMap;
21use std::ffi::OsString;
22
23/// Start of every note this module words, so the terminal filter keeps it and a person sees which tool spoke.
24const NOTE_PREFIX: &str = "cli-git: auto-push: ";
25
26/// Where the plain push goes: the branch's push remote and the ref it updates there.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// type Destination = { remote: string; mergeRef: string };
31/// ```
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct Destination {
34    /// Remote name the push goes to.
35    pub remote: String,
36    /// Full ref the push updates there, for example `refs/heads/main`.
37    pub merge_ref: String,
38}
39
40/// What the service does for one named branch whose upstream resolves.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type NamedPush = { kind: 'push'; remote: string; targets: string[] } | { kind: 'matching'; remote: string }
45///   | { kind: 'skip'; note: string };
46/// ```
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub enum NamedPush {
49    /// Push the branch to each of these refs on the remote.
50    Push {
51        /// The remote the push goes to.
52        remote: String,
53        /// The full refs that receive the branch.
54        targets: Vec<String>,
55    },
56    /// `push.default=matching`: the plain `git push <remote>` with no refspec, so Git matches the branches itself.
57    Matching {
58        /// The remote the push goes to.
59        remote: String,
60    },
61    /// Git pushes the branch nowhere or refuses; the note says why and what to do.
62    Skip(String),
63}
64
65/// The settings that decide where one branch is pushed, read once.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// type PushSettings = { branch: string; pushRemote: string; trackedRemote: string; merges: string[]; mode: string; refspecs: string[]; mirror: boolean };
70/// ```
71struct PushSettings {
72    /// Short name of the branch.
73    branch: String,
74    /// The remote the push goes to, in Git's order (`branch.<name>.pushRemote`, `remote.pushDefault`, `branch.<name>.remote`).
75    push_remote: String,
76    /// `branch.<name>.remote`, empty when unset.
77    tracked_remote: String,
78    /// Every `branch.<name>.merge` value, in order.
79    merges: Vec<String>,
80    /// The value of `push.default`, empty when unset.
81    mode: String,
82    /// Every `remote.<push remote>.push` value, in order.
83    refspecs: Vec<String>,
84    /// Whether `remote.<push remote>.mirror` is true.
85    mirror: bool,
86}
87
88/// Argument list from string literals and owned text.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// ['config', '--list', ...]
93/// ```
94fn argv(parts: &[&str]) -> Vec<OsString> {
95    return parts.iter().map(OsString::from).collect();
96}
97
98/// Every value of `key` in the snapshot, in listing order; a key written without `=` reads as an empty value.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// snapshot.entries.filter((entry) => entry.key === key).map((entry) => entry.value ?? '')
103/// ```
104fn 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
117/// The last value of `key`, empty when it is not set.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// valuesOf(snapshot, key).at(-1) ?? ''
122/// ```
123fn last_of(snapshot: &ConfigSnapshot, key: &str) -> String {
124    return values_of(snapshot, key).pop().unwrap_or_default();
125}
126
127/// What:
128///  The settings that decide where the plain push of a branch goes, or nothing for a branch outside `refs/heads`
129///  and for the remote `.` or no remote.
130/// Why:
131///   One listing of the configuration (`config --list -z --show-scope`, answered without a Git process where the
132///  in-process reader can) gives every value of the multi-valued keys, which a `config --get` (the last value only)
133///  cannot: `branch.<name>.merge` and `remote.<name>.push` hold several.
134///
135/// In TS you'd write (pseudocode):
136/// ```ts
137/// async function readPushSettings({ git, branchRef }): Promise<PushSettings | undefined>;
138/// ```
139fn 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
176/// A note for a branch that is not pushed: what was seen, and what to do about it.
177///
178/// In TS you'd write (pseudocode):
179/// ```ts
180/// `cli-git: auto-push: ${branch} was not pushed: ${reason} Nothing was pushed or changed.`
181/// ```
182fn 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
186/// The skip of a branch, with its note.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// ({ kind: 'skip', note: note(branch, reason) })
191/// ```
192fn skip(branch: &str, reason: &str) -> NamedPush {
193    return NamedPush::Skip(note(branch, reason));
194}
195
196/// How `push.default` sends the branch when no `remote.<name>.push` refspec does.
197enum ByMode {
198    /// The ref the branch updates on the push remote.
199    Target(String),
200    /// `matching`: Git pairs the branches itself, so the plain push is run.
201    Matching,
202    /// Git pushes nothing or refuses; the note says why.
203    Refused(NamedPush),
204}
205
206/// What:
207///  The ref `push.default` sends the branch to, or why Git sends it nowhere.
208/// Why:
209///   `current` and a push remote other than the tracked one (a triangular `simple`) use the branch's own name.
210///  `upstream` and `simple` on the tracked remote use the upstream branch, and Git dies when the branch has none or
211///  several (`get_upstream_ref` in `builtin/push.c`); a differently named single upstream, which `simple` also refuses,
212///  is where the person tracked the branch (a linked worktree branch created from `origin/main`), so it is pushed.
213///   `nothing` and a triangular `upstream` name no single ref; `matching` names none either, and Git is left to match.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// function byMode(branchRef: string, settings: PushSettings): ByMode;
218/// ```
219fn 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
264/// What:
265///  The remote and the branch ref that the plain `git push` of this branch updates, when the settings name one.
266/// Why:
267///   The reconcile of a rejected push merges the tip of the branch the rejected push targeted, which with a push remote,
268///  a triangular setup or `push.default=current` is not the tracked branch.
269///   The remote `.`, a `remote.<name>.push` refspec, `matching`, `nothing`, several upstream branches and a target
270///  outside `refs/heads` name none, because the rejection then came from a destination this module cannot name.
271///
272/// In TS you'd write (pseudocode):
273/// ```ts
274/// async function readDestination({ git, branchRef }): Promise<Destination | undefined>;
275/// ```
276pub 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
289/// What:
290///  What the service does for a named branch whose upstream resolves: the remote and refs it pushes to, the one remote
291///  question that decides, or the note that explains why it pushes nothing.
292/// Why:
293///   The plain `git push` pushes the checked-out branch, so a push of a branch that is not checked out names both sides;
294///  the destination follows Git's rules for the branch as if it were checked out, and a setting under which Git
295///  refuses or pushes nothing for it is a skip.
296///
297/// In TS you'd write (pseudocode):
298/// ```ts
299/// async function namedPush({ git, branchRef }): Promise<NamedPush>;
300/// ```
301pub 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
323/// One `remote.<name>.push` refspec this module interprets.
324///
325/// In TS you'd write (pseudocode):
326/// ```ts
327/// type Refspec = { negative: boolean; source: string; destination?: string; pattern: boolean };
328/// ```
329struct Refspec {
330    /// A `^` refspec: it removes matching destinations.
331    negative: bool,
332    /// The local side.
333    source: String,
334    /// The remote side; the source when absent.
335    destination: Option<String>,
336    /// Whether the sides hold one `*`.
337    pattern: bool,
338}
339
340/// What:
341///  Read a configured push refspec, or nothing when it is a form this module does not interpret.
342/// Why:
343///   Only full ref names and one `*` per side are interpreted; `HEAD`, short names, object names, deletions and the
344///  `:` of matching depend on more than the local ref names, so they are never guessed at.
345///
346/// In TS you'd write (pseudocode):
347/// ```ts
348/// function parseRefspec(text: string): Refspec | undefined;
349/// ```
350fn 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
372/// The text a `*` pattern captured in `name`, or nothing when `name` does not match.
373///
374/// In TS you'd write (pseudocode):
375/// ```ts
376/// function capture(pattern: string, name: string): string | undefined;
377/// ```
378fn 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
384/// Whether a negative refspec removes the remote ref `name`.
385///
386/// In TS you'd write (pseudocode):
387/// ```ts
388/// function excludes(negative: Refspec, name: string): boolean;
389/// ```
390fn 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/// What the configured push refspecs make of one branch.
398#[derive(Debug, Eq, PartialEq)]
399enum Judged {
400    /// The refs on the remote that receive the branch, possibly none.
401    Targets(Vec<String>),
402    /// Git refuses the whole push; the sentence says why.
403    Refused(String),
404    /// A refspec of a form not interpreted; its text.
405    Unjudged(String),
406}
407
408/// What:
409///  Apply the configured push refspecs to the local refs the way Git's `match_push_refs` does, and report where the branch goes.
410/// Why:
411///   Git matches every explicit refspec first (a source that is not a local ref, or two sources for one destination, fail
412///  the whole push), then lets each local ref take the first matching pattern unless an earlier pairing already took its
413///  destination, then drops the destinations a negative refspec names.
414///  The `%(push:remoteref)` atom of `git for-each-ref` maps one branch alone, so it misses all three and cannot be used.
415///
416/// In TS you'd write (pseudocode):
417/// ```ts
418/// function judge({ branchRef, refspecs, localRefs }): Judged;
419/// ```
420fn 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
468/// What:
469///  The destinations of the configured push refspecs for the branch, or the note for why there are none.
470/// Why:
471///   The local refs the refspecs can name are asked for in one `for-each-ref`; everything else is decided here.
472///
473/// In TS you'd write (pseudocode):
474/// ```ts
475/// async function namedByRefspecs({ git, branchRef, settings }): Promise<NamedPush>;
476/// ```
477fn 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/// The destination logic is controlled in the test module.
513#[cfg(test)]
514#[path = "auto_push_destination_tests.rs"]
515mod tests;