Skip to main content

git_policy_cli/
lfs_upload_plan.rs

1//! What:
2//!  What the Git LFS upload before a push sends: the destinations `git lfs push` takes and the refs whose objects it
3//!  uploads, with `--all` wherever a remote-tracking ref of this clone may not describe the destination, so that it
4//!  cannot hide an object the destination lacks, and in git-lfs's bounded form (no `--all`) everywhere else.
5//! Why:
6//!  `git lfs push` takes refs and no ref-update lines, so the plan is read from the push's own arguments with the same
7//!  option table as the rest of the wrapper, and from the configuration Git itself would read for the parts the
8//!  arguments leave out (the default remote, `remote.<name>.push`, `remote.<name>.mirror`, the push URLs,
9//!  `push.default`). The configuration comes from the snapshot the caller already holds, or from one query.
10//!  Refs that Git expands from a pattern are listed with `git for-each-ref` and matched here with a refspec's own rule
11//!  (`*` crosses `/`). The plan fails closed: a query that fails is an error the caller turns into a refusal, never
12//!  into "nothing to upload". Only a mirror push uploads every ref.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const plan = planUpload(globalPrefix, region, env, directory, settings, query); // { remotes, refs, everything }
17//! ```
18
19/// The destination and the named refs of a push.
20use super::command_push::{parse_push_region, push_shape};
21/// The configuration the plan reads.
22use super::lfs_upload_settings::{current_branch, default_remote, reads_as_true, setting, values};
23pub use super::lfs_upload_settings::{PlanError, Query, Settings, read_settings, resolve_push_remote, settings_from_snapshot};
24/// The switch and the skip variable.
25use super::lfs_pre_push::{hook_switched_off, skip_requested};
26/// Owned OS text for arguments.
27use std::ffi::{OsStr, OsString};
28/// Locations.
29use std::path::Path;
30
31/// What an upload sends.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// type UploadPlan = { remotes: string[]; refs: string[]; everything: boolean; submodules: boolean };
36/// ```
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct UploadPlan {
39    /// The destinations `git lfs push` is run for, one run each: the remote name, or one URL per push URL when the
40    ///  remote has several (each may keep its objects in its own place).
41    pub remotes: Vec<OsString>,
42    /// The refs whose objects are uploaded; empty with `everything`.
43    pub refs: Vec<OsString>,
44    /// Upload everything reachable from every local ref.
45    pub everything: bool,
46    /// The push also pushes submodules, whose objects this upload does not cover.
47    pub submodules: bool,
48    /// Scan every object reachable from the refs (`--all`) instead of the ones the remote's tracking refs do not cover.
49    ///  Set for every destination whose tracking refs cannot be trusted to describe it, and for a mirror push.
50    pub full_scan: bool,
51    /// The exact objects to upload (`git-lfs push --object-id`), when the wrapper found them itself; `None` leaves the
52    ///  scan to `git-lfs` with the refs.
53    pub objects: Option<Vec<String>>,
54}
55
56/// How many object names one `git-lfs push --object-id` run takes, so the argument list stays far under the system limit.
57pub const OBJECTS_PER_RUN: usize = 500;
58
59impl UploadPlan {
60    /// The argument lists of the runs for one destination: one run that scans the refs, or, with named objects, one
61    ///  `push --object-id <destination> <oid>...` run per [`OBJECTS_PER_RUN`] objects.
62    pub fn runs(&self, remote: &OsStr) -> Vec<Vec<OsString>> {
63        let Some(objects) = self.objects.as_ref() else {
64            return vec![self.arguments(remote)];
65        };
66        return objects
67            .chunks(OBJECTS_PER_RUN)
68            .map(|chunk| {
69                let mut arguments: Vec<OsString> = vec![OsString::from("push"), OsString::from("--object-id"), remote.to_owned()];
70                arguments.extend(chunk.iter().map(OsString::from));
71                return arguments;
72            })
73            .collect();
74    }
75
76    /// The arguments of one `git-lfs` run: `push`, with `--all` for a full scan, the destination, then the refs.
77    ///  `--all` with refs uploads every object reachable from them, so objects that remote-tracking refs of this clone
78    ///  mention are not skipped; without it `git-lfs` skips them, and a push that adds no object sends no batch request.
79    pub fn arguments(&self, remote: &OsStr) -> Vec<OsString> {
80        let mut arguments: Vec<OsString> = vec![OsString::from("push")];
81        if self.full_scan {
82            arguments.push(OsString::from("--all"));
83        }
84        arguments.push(remote.to_owned());
85        if !self.everything {
86            arguments.extend(self.refs.iter().cloned());
87        }
88        return arguments;
89    }
90}
91
92/// What the source side of a refspec is: nothing for a deletion (`:dst`), the matching marker for `:`, else the part
93///  before the last colon (Git splits at the last one, so a source such as `:/message` survives); a leading `+` is dropped.
94enum Source {
95    /// The refspec only deletes.
96    Deletion,
97    /// The refspec `:` (or `+:`): every branch that exists on both sides.
98    Matching,
99    /// A ref, a revision expression or a pattern.
100    Named(OsString),
101}
102
103/// Read the source of one refspec.
104fn refspec_source(spec: &OsStr) -> Source {
105    let bytes: &[u8] = spec.as_encoded_bytes();
106    let unforced: &[u8] = bytes.strip_prefix(b"+").unwrap_or(bytes);
107    if unforced == b":" {
108        return Source::Matching;
109    }
110    let source: &[u8] = match unforced.iter().rposition(|byte| {
111        return *byte == b':';
112    }) {
113        Some(position) => &unforced[..position],
114        None => unforced,
115    };
116    if source.is_empty() {
117        return Source::Deletion;
118    }
119    return Source::Named(<OsString as std::os::unix::ffi::OsStringExt>::from_vec(source.to_vec()));
120}
121
122/// Keeps every ref name.
123fn keep_all(_name: &str) -> bool {
124    return true;
125}
126
127/// The refs under a prefix, listed by one `for-each-ref`, kept when `keep` accepts the full name.
128fn list(query: Query<'_>, prefix: &str, keep: &dyn Fn(&str) -> bool) -> Result<Vec<OsString>, PlanError> {
129    let listing: String = query(&["for-each-ref", "--format=%(refname)", prefix]).map_err(|why| {
130        return PlanError(format!("git for-each-ref {prefix}: {why}"));
131    })?;
132    return Ok(listing
133        .lines()
134        .filter(|name| {
135            return keep(name);
136        })
137        .map(OsString::from)
138        .collect());
139}
140
141/// The refs a source with a `*` stands for, by a refspec's own rule: the text before the star, the text after it, and
142///  anything between, `/` included (which a `for-each-ref` pattern does not do).
143fn expand_pattern(query: Query<'_>, pattern: &str) -> Result<Vec<OsString>, PlanError> {
144    let (front, back) = pattern.split_once('*').unwrap_or((pattern, ""));
145    let directory: &str = front.rsplit_once('/').map_or("refs", |(head, _)| {
146        return head;
147    });
148    return list(query, directory, &|name| {
149        return name.len() >= front.len() + back.len() && name.starts_with(front) && name.ends_with(back);
150    });
151}
152
153/// `file://` and percent-encoding for a path, where `git-lfs` accepts only a remote name or an URL.
154fn file_url(absolute: &Path) -> OsString {
155    let mut url: Vec<u8> = b"file://".to_vec();
156    for byte in absolute.as_os_str().as_encoded_bytes() {
157        if byte.is_ascii_alphanumeric() || b"/-._~".contains(byte) {
158            url.push(*byte);
159        } else {
160            url.extend_from_slice(format!("%{byte:02X}").as_bytes());
161        }
162    }
163    return <OsString as std::os::unix::ffi::OsStringExt>::from_vec(url);
164}
165
166/// What:
167///  Turn a destination typed in place of a remote name into what `git lfs push` accepts. A remote name and an URL stay as
168///  they are; a path (it starts with `/`, `./` or `../`, or names an existing file or directory relative to `directory`
169///  and is not a configured remote) becomes a percent-encoded `file://` URL.
170/// Why:
171///   Git pushes to a path directly, and the old hook flow handed `git-lfs` the URL Git had made of it.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// isPath(remote) ? pathToFileURL(resolve(directory, remote)) : remote
176/// ```
177fn lfs_destination(remote: &OsStr, directory: &Path, configured: bool) -> OsString {
178    let bytes: &[u8] = remote.as_encoded_bytes();
179    let has_scheme: bool = bytes.windows(3).any(|window| {
180        return window == b"://";
181    });
182    let scp_like: bool = bytes.iter().position(|byte| {
183        return *byte == b':';
184    }).is_some_and(|colon| {
185        return !bytes[..colon].contains(&b'/');
186    });
187    let explicit_path: bool = bytes.starts_with(b"/") || bytes.starts_with(b"./") || bytes.starts_with(b"../");
188    if configured || has_scheme || (scp_like && !explicit_path) {
189        return remote.to_owned();
190    }
191    let candidate = directory.join(Path::new(remote));
192    if !explicit_path && !candidate.exists() {
193        return remote.to_owned();
194    }
195    return match std::path::absolute(&candidate) {
196        Ok(absolute) => file_url(absolute.as_path()),
197        Err(_) => remote.to_owned(),
198    };
199}
200
201/// The remotes the configuration defines (a `url` or a `pushurl`).
202fn configured_remotes(all: &[(String, String)]) -> Vec<&str> {
203    return all
204        .iter()
205        .filter_map(|(key, _)| {
206            let rest: &str = key.strip_prefix("remote.")?;
207            let (name, variable) = rest.rsplit_once('.')?;
208            return (variable == "url" || variable == "pushurl").then_some(name);
209        })
210        .collect();
211}
212
213/// Whether any `url.<base>.pushInsteadOf` rewrite is configured, which can send the push elsewhere than the fetch.
214fn has_push_rewrite(all: &[(String, String)]) -> bool {
215    return all.iter().any(|(key, _)| {
216        return key.starts_with("url.") && key.ends_with(".pushinsteadof");
217    });
218}
219
220/// What:
221///  What to upload for a push, or nothing when the push uploads nothing, or why the plan cannot be worked out.
222/// Why:
223///   A push that bypasses the upload (`--no-verify`, `GIT_LFS_SKIP_PUSH`, the caller's switch), sends nothing (a dry run,
224///  only deletions) uploads nothing. A push that names refs uploads those, with patterns, `--all`, `--tags`, the matching
225///  refspec `:` and the configured `remote.<name>.push` expanded to the refs they stand for; a bare push uploads what the
226///  remote's configuration names, else the branch Git would send (`HEAD` when it is not a branch); a mirror push uploads
227///  everything. A remote with several push URLs is uploaded to once per URL. `directory` is where relative paths are
228///  resolved; `settings` is the configuration the plan reads.
229///
230/// In TS you'd write (pseudocode):
231/// ```ts
232/// function planUpload(globalPrefix, region, environment, directory, settings, query): UploadPlan | undefined; // throws PlanError
233/// ```
234pub fn plan_upload(
235    global_prefix: &[OsString],
236    region: &[OsString],
237    environment: &[(OsString, OsString)],
238    directory: &Path,
239    all: &[(String, String)],
240    query: Query<'_>,
241) -> Result<Option<UploadPlan>, PlanError> {
242    if hook_switched_off(global_prefix, environment) || skip_requested(environment) {
243        return Ok(None);
244    }
245    let Some((region_facts, shape)) = parse_push_region(region, &[]).ok().zip(push_shape(region)) else {
246        return Ok(None);
247    };
248    if region_facts.no_verify || region_facts.dry_run || shape.deletes {
249        return Ok(None);
250    }
251    let submodules: bool = shape.recurse_submodules.as_ref().is_some_and(|value| {
252        return matches!(value.to_str(), Some("on-demand" | "only"));
253    });
254    let branch_needed: bool = shape.repository.is_none() || shape.refspecs.is_empty();
255    let branch: Option<String> = if branch_needed { current_branch(query)? } else { None };
256    let remote_name: OsString = match shape.repository.clone() {
257        Some(typed) => typed,
258        None => OsString::from(default_remote(branch.as_deref(), all)),
259    };
260    let remote_text: String = remote_name.to_string_lossy().into_owned();
261    let mut everything: bool = shape.mirror;
262    let mut specs: Vec<OsString> = shape.refspecs.clone();
263    let bare: bool = specs.is_empty() && !shape.branches && !shape.tags && !everything;
264    if bare {
265        let configured: Vec<&str> = values(all, &format!("remote.{remote_text}.push"));
266        if setting(all, &format!("remote.{remote_text}.mirror")).is_some_and(reads_as_true) {
267            everything = true;
268        } else if !configured.is_empty() {
269            specs.extend(configured.into_iter().map(OsString::from));
270        } else if setting(all, "push.default") == Some("matching") {
271            specs.push(OsString::from(":"));
272        } else {
273            specs.push(OsString::from(match branch.as_deref() {
274                Some(name) => format!("refs/heads/{name}"),
275                None => String::from("HEAD"),
276            }));
277        }
278    }
279    let mut refs: Vec<OsString> = Vec::new();
280    for spec in &specs {
281        match refspec_source(spec.as_os_str()) {
282            Source::Deletion => {}
283            Source::Matching => refs.extend(list(query, "refs/heads", &keep_all)?),
284            Source::Named(source) => match source.to_str() {
285                Some(text) if text.contains('*') => refs.extend(expand_pattern(query, text)?),
286                _ => refs.push(source),
287            },
288        }
289    }
290    if shape.branches {
291        refs.extend(list(query, "refs/heads", &keep_all)?);
292    }
293    if shape.tags {
294        refs.extend(list(query, "refs/tags", &keep_all)?);
295    }
296    let mut unique: Vec<OsString> = Vec::new();
297    for found in refs {
298        if !unique.contains(&found) {
299            unique.push(found);
300        }
301    }
302    if unique.is_empty() && !everything {
303        return Ok(None);
304    }
305    let configured: bool = configured_remotes(all).contains(&remote_text.as_str());
306    let push_urls: Vec<&str> = if configured { values(all, &format!("remote.{remote_text}.pushurl")) } else { Vec::new() };
307    let push_url_count: usize = push_urls.len();
308    let remotes: Vec<OsString> = if push_url_count > 1 {
309        push_urls
310            .iter()
311            .copied()
312            .map(|url| {
313                return lfs_destination(OsStr::new(url), directory, false);
314            })
315            .collect()
316    } else {
317        vec![lfs_destination(remote_name.as_os_str(), directory, configured)]
318    };
319    // The tracking refs describe the destination only for a configured remote that is fetched from and pushed to the
320    //  same place: a push URL, a `pushInsteadOf` rewrite, a URL or path destination or a mirror breaks that.
321    let tracking_trusted: bool = configured && push_urls.is_empty() && !has_push_rewrite(all);
322    let full_scan: bool = everything || !tracking_trusted;
323    return Ok(Some(UploadPlan { remotes, refs: unique, everything, submodules, full_scan, objects: None }));
324}
325
326/// The plan is controlled in the test module.
327#[cfg(test)]
328#[path = "lfs_upload_plan_tests.rs"]
329mod tests;