Skip to main content

git_policy_cli/
lfs_upload_plan.rs

1//! What:
2//!  What the Git LFS upload before a push sends: the remote `git lfs push` takes and the refs whose objects it uploads.
3//! Why:
4//!  `git lfs push` takes refs and no ref-update lines, so the plan is read from the push's own arguments with the same
5//!  option table as the rest of the wrapper, and from the configuration Git itself would read for the parts the
6//!  arguments leave out (the default remote, `remote.<name>.push`, `push.default`). Refs that Git expands from a
7//!  pattern are expanded with `git for-each-ref`, so the upload covers what the push sends and no more; only a
8//!  mirror push and `push.default=matching` (which needs the remote's own refs to narrow) upload everything.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const plan = planUpload(globalPrefix, region, env, directory, query); // { remote, refs, everything }
13//! ```
14
15/// The destination and the named refs of a push.
16use super::command_push::{parse_push_region, push_shape};
17/// The switch and the skip variable.
18use super::lfs_pre_push::{hook_switched_off, skip_requested};
19/// Owned OS text for arguments.
20use std::ffi::{OsStr, OsString};
21/// Locations.
22use std::path::Path;
23
24/// A read-only Git query: its trimmed standard output when it exits with status zero.
25pub type Query<'a> = &'a mut dyn FnMut(&[&str]) -> Option<String>;
26
27/// What an upload sends.
28///
29/// In TS you'd write (pseudocode):
30/// ```ts
31/// type UploadPlan = { remote: string; refs: string[]; everything: boolean; submodules: boolean };
32/// ```
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct UploadPlan {
35    /// The remote name or URL `git lfs push` takes first.
36    pub remote: OsString,
37    /// The refs whose objects are uploaded; empty with `everything`.
38    pub refs: Vec<OsString>,
39    /// Upload everything reachable from every local ref (`--all`).
40    pub everything: bool,
41    /// The push also pushes submodules, whose objects this upload does not cover.
42    pub submodules: bool,
43}
44
45impl UploadPlan {
46    /// The arguments of `git-lfs`: `push`, `--all` when everything is sent, the remote, then the refs.
47    pub fn arguments(&self) -> Vec<OsString> {
48        let mut arguments: Vec<OsString> = vec![OsString::from("push")];
49        if self.everything {
50            arguments.push(OsString::from("--all"));
51        }
52        arguments.push(self.remote.clone());
53        if !self.everything {
54            arguments.extend(self.refs.iter().cloned());
55        }
56        return arguments;
57    }
58}
59
60/// The source side of a refspec, or nothing for a deletion (`:dst`); a leading `+` is dropped.
61fn refspec_source(spec: &OsStr) -> Option<OsString> {
62    let bytes: &[u8] = spec.as_encoded_bytes();
63    let unforced: &[u8] = bytes.strip_prefix(b"+").unwrap_or(bytes);
64    let source: &[u8] = unforced
65        .split(|byte| {
66            return *byte == b':';
67        })
68        .next()
69        .unwrap_or_default();
70    if source.is_empty() {
71        return None;
72    }
73    return Some(<OsString as std::os::unix::ffi::OsStringExt>::from_vec(source.to_vec()));
74}
75
76/// Settings Git reads to pick a push remote and a bare push's refs: `(key, value)` pairs from one configuration read.
77fn settings(query: Query<'_>) -> Vec<(String, String)> {
78    let listing: String = query(&[
79        "config",
80        "--get-regexp",
81        "^(branch\\..*\\.(pushremote|remote)|remote\\.pushdefault|remote\\..*\\.push|push\\.default)$",
82    ])
83    .unwrap_or_default();
84    return listing
85        .lines()
86        .filter_map(|line| {
87            return line.split_once(' ').map(|(key, value)| {
88                return (key.to_owned(), value.trim().to_owned());
89            });
90        })
91        .collect();
92}
93
94/// The last value of a setting, the last one written winning as in Git.
95fn setting<'a>(all: &'a [(String, String)], key: &str) -> Option<&'a str> {
96    return all
97        .iter()
98        .rev()
99        .find(|(name, _)| {
100            return name == key;
101        })
102        .map(|(_, value)| {
103            return value.as_str();
104        });
105}
106
107/// The branch `HEAD` names, from the full ref so that a tag with the same name cannot change the answer.
108fn current_branch(query: Query<'_>) -> Option<String> {
109    let full: String = query(&["symbolic-ref", "--quiet", "HEAD"])?;
110    return full.strip_prefix("refs/heads/").map(str::to_owned);
111}
112
113/// The remote Git pushes to when the caller names none: the branch's push remote, the default push remote, the
114///  branch's remote, then `origin`.
115fn default_remote(branch: Option<&str>, all: &[(String, String)]) -> String {
116    let named = |variable: &str| -> Option<String> {
117        return branch.and_then(|name| {
118            return setting(all, &format!("branch.{name}.{variable}")).map(str::to_owned);
119        });
120    };
121    return named("pushremote")
122        .or_else(|| {
123            return setting(all, "remote.pushdefault").map(str::to_owned);
124        })
125        .or_else(|| {
126            return named("remote");
127        })
128        .unwrap_or_else(|| {
129            return String::from("origin");
130        });
131}
132
133/// What:
134///  The remote a push goes to: the one the caller named, or the one Git picks for a bare push.
135/// Why:
136///   The manual-push probe and the upload plan must agree on the destination, so both read it here.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// named ?? defaultRemote(branch, settings)
141/// ```
142pub fn resolve_push_remote(named: Option<OsString>, query: Query<'_>) -> OsString {
143    if let Some(found) = named {
144        return found;
145    }
146    let branch: Option<String> = current_branch(query);
147    let all: Vec<(String, String)> = settings(query);
148    return OsString::from(default_remote(branch.as_deref(), all.as_slice()));
149}
150
151/// The refs a pattern or a prefix expands to in the local repository, one `for-each-ref` start.
152fn expand(query: Query<'_>, patterns: &[&str]) -> Vec<OsString> {
153    let mut arguments: Vec<&str> = vec!["for-each-ref", "--format=%(refname)"];
154    arguments.extend(patterns);
155    return query(arguments.as_slice())
156        .unwrap_or_default()
157        .lines()
158        .map(OsString::from)
159        .collect();
160}
161
162/// What:
163///  Turn the destination a caller typed into what `git lfs push` accepts: a path (it starts with `/`, `./` or `../`)
164///  becomes a `file://` URL, which `git-lfs` accepts where it refuses a bare path.
165/// Why:
166///   Git pushes to a path directly, and the old hook flow handed `git-lfs` the URL Git had made of it.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// /^\.{0,2}\//.test(remote) ? pathToFileURL(resolve(directory, remote)) : remote
171/// ```
172fn lfs_destination(remote: OsString, directory: &Path) -> OsString {
173    let bytes: &[u8] = remote.as_encoded_bytes();
174    let path_like: bool = bytes.starts_with(b"/") || bytes.starts_with(b"./") || bytes.starts_with(b"../");
175    if !path_like {
176        return remote;
177    }
178    let Ok(absolute) = std::path::absolute(directory.join(Path::new(&remote))) else {
179        return remote;
180    };
181    let mut url: OsString = OsString::from("file://");
182    url.push(absolute.as_os_str());
183    return url;
184}
185
186/// What:
187///  What to upload for a push, or nothing when the push uploads nothing.
188/// Why:
189///   A push that bypasses the upload (`--no-verify`, `GIT_LFS_SKIP_PUSH`, the caller's switch), sends nothing (a dry
190///  run, only deletions) uploads nothing. A push that names refs uploads those; a pattern, `--all` and `--tags` are
191///  expanded to the refs they stand for; a bare push uploads what `remote.<name>.push` names, else the branch Git
192///  would send (`HEAD` when the branch has no name). `directory` is where relative paths are resolved.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// function planUpload(globalPrefix, region, environment, directory, query): UploadPlan | undefined;
197/// ```
198pub fn plan_upload(
199    global_prefix: &[OsString],
200    region: &[OsString],
201    environment: &[(OsString, OsString)],
202    directory: &Path,
203    query: Query<'_>,
204) -> Option<UploadPlan> {
205    if hook_switched_off(global_prefix, environment) || skip_requested(environment) {
206        return None;
207    }
208    let region_facts = parse_push_region(region, &[]).ok()?;
209    if region_facts.no_verify || region_facts.dry_run {
210        return None;
211    }
212    let shape = push_shape(region)?;
213    if shape.deletes {
214        return None;
215    }
216    let submodules: bool = shape.recurse_submodules.as_ref().is_some_and(|value| {
217        return matches!(value.to_str(), Some("on-demand" | "only"));
218    });
219    let mut everything: bool = shape.mirror;
220    let mut specs: Vec<OsString> = shape.refspecs.clone();
221    let mut patterns: Vec<String> = Vec::new();
222    if shape.branches {
223        patterns.push(String::from("refs/heads"));
224    }
225    if shape.tags {
226        patterns.push(String::from("refs/tags"));
227    }
228    let bare: bool = specs.is_empty() && patterns.is_empty() && !everything;
229    let mut remote: Option<OsString> = shape.repository.clone();
230    if remote.is_none() || bare {
231        let branch: Option<String> = current_branch(query);
232        let all: Vec<(String, String)> = settings(query);
233        if remote.is_none() {
234            remote = Some(OsString::from(default_remote(branch.as_deref(), all.as_slice())));
235        }
236        if bare {
237            let key: String = format!("remote.{}.push", remote.as_ref()?.to_string_lossy());
238            let configured: Vec<&str> = all
239                .iter()
240                .filter(|(name, _)| {
241                    return *name == key;
242                })
243                .map(|(_, value)| {
244                    return value.as_str();
245                })
246                .collect();
247            if !configured.is_empty() {
248                specs.extend(configured.into_iter().map(OsString::from));
249            } else if setting(all.as_slice(), "push.default") == Some("matching") {
250                everything = true;
251            } else {
252                let head: String = match branch.as_deref() {
253                    Some(name) => format!("refs/heads/{name}"),
254                    None => String::from("HEAD"),
255                };
256                specs.push(OsString::from(head));
257            }
258        }
259    }
260    let mut refs: Vec<OsString> = Vec::new();
261    for spec in &specs {
262        let Some(source) = refspec_source(spec.as_os_str()) else {
263            continue;
264        };
265        match source.to_str() {
266            Some(text) if text.contains('*') => refs.extend(expand(query, &[text])),
267            _ => refs.push(source),
268        }
269    }
270    if !patterns.is_empty() {
271        let borrowed: Vec<&str> = patterns.iter().map(String::as_str).collect();
272        refs.extend(expand(query, borrowed.as_slice()));
273    }
274    if refs.is_empty() && !everything {
275        return None;
276    }
277    let destination: OsString = lfs_destination(remote?, directory);
278    return Some(UploadPlan { remote: destination, refs, everything, submodules });
279}
280
281/// The plan is controlled in the test module.
282#[cfg(test)]
283#[path = "lfs_upload_plan_tests.rs"]
284mod tests;