Skip to main content

git_policy_cli/
lfs_pre_push.rs

1//! What:
2//!  When a push needs the Git LFS upload, and the texts the parent prints around it.
3//! Why:
4//!  Traditional hook files are unsupported, so the `pre-push` shim `git lfs install` writes cannot be relied on.
5//!  Without an upload step a push publishes pointer files whose objects the LFS server never received.
6//!  The wrapper therefore runs `git-lfs push <remote> <refs>` itself, as a child it owns, before it starts the push
7//!  (`lfs_upload.rs`), only when the Git configuration holds LFS keys; the push carries no hook and no injected
8//!  configuration, the wrapper never starts itself and writes no shell text
9//!  (`doc/planning/cli-git-no-self-invocation.md`).
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! if (usesLfs(snapshot) && !skipRequested(env)) { const code = await uploadObjects(plan); if (code) return code; }
14//! ```
15
16/// Whether the configuration snapshot holds LFS keys.
17use super::config_snapshot::ConfigSnapshot;
18/// Owned operating-system text for the arguments.
19use std::ffi::{OsStr, OsString};
20/// Where `PATH` entries are looked at.
21use std::path::Path;
22
23/// The name the caller's switch uses: `-c hook.cli-git-lfs-pre-push.enabled=false` skips the upload for one command,
24///  as it turned off the hook an earlier version put in effect.
25pub const SWITCH_NAME: &str = "cli-git-lfs-pre-push";
26
27/// The program the upload runs, found on `PATH` as the shim `git lfs install` wrote found it.
28pub const LFS_PROGRAM: &str = "git-lfs";
29
30/// Key prefixes whose presence means the repository is set up for Git LFS.
31const LFS_KEY_PREFIXES: [&str; 2] = ["filter.lfs.", "lfs."];
32
33/// Scopes that describe the machine or the account, not this repository.
34const MACHINE_SCOPES: [&str; 2] = ["system", "global"];
35
36/// What:
37///  Whether a configuration entry of this repository, its worktree or the command line belongs to Git LFS.
38/// Why:
39///   `git lfs install` in an account writes `filter.lfs.*` to the global file, which every repository then sees, so a
40///  global key says nothing about this repository.
41///  The filter's first run in a repository writes `lfs.repositoryformatversion` to its local file, and a server is
42///  named by `lfs.url`; one such key in the local, worktree or command scope is the sign that pushed pointers need
43///  their objects uploaded first, without making every repository of the machine an LFS repository.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// entries.some(({ scope, key }) => !['system', 'global'].includes(scope) && (key.startsWith('filter.lfs.') || key.startsWith('lfs.')));
48/// ```
49pub fn uses_lfs(snapshot: &ConfigSnapshot) -> bool {
50    return snapshot.entries().iter().any(|entry| {
51        return !MACHINE_SCOPES.contains(&entry.scope.as_str())
52            && LFS_KEY_PREFIXES.iter().any(|prefix| {
53                return entry.key.starts_with(prefix);
54            });
55    });
56}
57
58/// Whether a configuration name is the switch: Git reads the section and the variable case-insensitively and the
59///  subsection exactly.
60fn is_switch_key(name: &str) -> bool {
61    const VARIABLE: &str = ".enabled";
62    let Some(rest) = name.get(..5).filter(|section| {
63        return section.eq_ignore_ascii_case("hook.");
64    }).map(|_| {
65        return &name[5..];
66    }) else {
67        return false;
68    };
69    if rest.len() <= VARIABLE.len() {
70        return false;
71    }
72    if !rest.is_char_boundary(rest.len() - VARIABLE.len()) {
73        return false;
74    }
75    let (subsection, variable) = rest.split_at(rest.len() - VARIABLE.len());
76    return variable.eq_ignore_ascii_case(VARIABLE) && subsection == SWITCH_NAME;
77}
78
79/// Whether a value of the switch reads as false the way Git reads a boolean (an empty value counts as false).
80fn reads_as_false(value: &str) -> bool {
81    return ["false", "no", "off", "0", ""].iter().any(|spelling| {
82        return value.eq_ignore_ascii_case(spelling);
83    });
84}
85
86/// What:
87///  Whether the caller switched the upload off for this push, with `-c hook.cli-git-lfs-pre-push.enabled=false` among
88///  the global options or the same pair in `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`.
89/// Why:
90///   It is a documented way to push without the upload, so a missing `git-lfs` must not stop such a push. The last
91///  setting decides, as Git reads them, and `-c` options come after the environment's pairs. A repository or account
92///  configuration file is not read: that would cost a Git start for every push; use `--no-verify` or
93///  `GIT_LFS_SKIP_PUSH` for a standing choice in the environment.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// lastEnabledSetting([...envPairs, ...cPairs], `hook.${name}.enabled`) is false
98/// ```
99pub fn hook_switched_off(global_prefix: &[OsString], environment: &[(OsString, OsString)]) -> bool {
100    let mut off: bool = false;
101    let count: usize = super::child_environment::environment_value(environment, "GIT_CONFIG_COUNT")
102        .and_then(|value| {
103            return value.to_str().and_then(|text| {
104                return text.parse::<usize>().ok();
105            });
106        })
107        .unwrap_or(0);
108    for index in 0..count {
109        let name_of = |kind: &str| -> Option<OsString> {
110            return super::child_environment::environment_value(environment, &format!("GIT_CONFIG_{kind}_{index}"));
111        };
112        if name_of("KEY").is_some_and(|name| {
113            return is_switch_key(&name.to_string_lossy());
114        }) {
115            off = name_of("VALUE").is_some_and(|value| {
116                return reads_as_false(&value.to_string_lossy());
117            });
118        }
119    }
120    let mut tokens = global_prefix.iter();
121    while let Some(token) = tokens.next() {
122        if token.as_os_str() != "-c" {
123            continue;
124        }
125        let Some(setting) = tokens.next().and_then(|value| {
126            return value.to_str();
127        }) else {
128            continue;
129        };
130        // Git reads the name case-insensitively and a name without `=` as the boolean true.
131        let (name, value) = setting.split_once('=').map_or((setting, None), |(name, value)| {
132            return (name, Some(value));
133        });
134        if is_switch_key(name) {
135            off = value.is_some_and(reads_as_false);
136        }
137    }
138    return off;
139}
140
141/// What:
142///  The first executable file named `git-lfs` in the directories of a `PATH` value, in order.
143/// Why:
144///   A stat per directory answers it without starting any process; an empty entry means the current directory, as
145///  `PATH` lookup treats it. The upload starts exactly this file, so the pre-check and the start agree about which
146///  `git-lfs` runs even though the child's `PATH` leads with the real Git's directory.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// path.split(':').map((dir) => join(dir || '.', 'git-lfs')).find(isExecutableFile)
151/// ```
152pub fn find_program(path: &OsStr) -> Option<std::path::PathBuf> {
153    use std::os::unix::fs::PermissionsExt;
154    return std::env::split_paths(path)
155        .map(|directory| {
156            let base: &Path = if directory.as_os_str().is_empty() { Path::new(".") } else { directory.as_path() };
157            return base.join(LFS_PROGRAM);
158        })
159        .find(|candidate| {
160            return std::fs::metadata(candidate).is_ok_and(|found| {
161                return found.is_file() && found.permissions().mode() & 0o111 != 0;
162            });
163        })
164        // A relative `PATH` entry is relative to where the lookup happens; the child may start elsewhere (`-C`).
165        .and_then(|found| {
166            return std::path::absolute(found).ok();
167        });
168}
169
170/// Whether `git-lfs` is an executable file in one of the directories of a `PATH` value.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// findProgram(path) !== undefined
175/// ```
176pub fn program_on_path(path: &OsStr) -> bool {
177    return find_program(path).is_some();
178}
179
180/// What:
181///  Whether the environment asks to skip the upload (`GIT_LFS_SKIP_PUSH` set to a true value).
182/// Why:
183///   `git lfs push` does not read that variable (only the hook form did), so the parent honours it, as the explanation
184///  texts promise.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// ['1', 't', 'true', 'y', 'yes', 'on'].includes(env.GIT_LFS_SKIP_PUSH?.toLowerCase())
189/// ```
190pub fn skip_requested(environment: &[(std::ffi::OsString, std::ffi::OsString)]) -> bool {
191    return super::child_environment::environment_value(environment, "GIT_LFS_SKIP_PUSH").is_some_and(|value| {
192        let text: String = value.to_string_lossy().to_ascii_lowercase();
193        return ["1", "t", "true", "y", "yes", "on"].contains(&text.as_str());
194    });
195}
196
197/// What:
198///  The refusal printed when `git-lfs` is not on `PATH`.
199/// Why:
200///   Without the program the objects cannot be uploaded, and pushing anyway would publish pointers the server cannot
201///  serve; every valid way forward is named, including leaving LFS if the repository does not need it.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// `cli-git: this push was stopped before it sent anything: ...`
206/// ```
207pub fn missing_program_message() -> String {
208    return format!(
209        "cli-git: this push was stopped before it sent anything. The Git configuration of this repository has Git LFS \
210         keys (`filter.lfs.*` or `lfs.*`), so the wrapper runs `{LFS_PROGRAM} push` to upload the large \
211         files the pushed commits point to before it publishes the pointer files, but `{LFS_PROGRAM}` was not found in \
212         any directory of PATH. Pushing without that upload would publish pointers whose content the LFS server does \
213         not have. Ways forward: install Git LFS so that `{LFS_PROGRAM}` is on PATH and push again; or, if this \
214         repository does not use Git LFS, remove its configuration (`git lfs uninstall`, or delete the `filter.lfs.*` \
215         and `lfs.*` keys); or accept pointers without objects for this push with `git push --no-verify` and upload \
216         the objects later with `git lfs push --all <remote>` once Git LFS is installed.\n"
217    );
218}
219
220/// What:
221///  The refusal printed when the wrapper cannot work out which objects a push needs uploaded.
222/// Why:
223///   Running the push without an upload could publish pointers whose objects the server lacks, so the push is not started;
224///  every way forward is named.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// `cli-git: this push was stopped before it sent anything: ${why}...`
229/// ```
230pub fn plan_failed_message(why: &str) -> String {
231    return format!(
232        "cli-git: this push was stopped before it sent anything. The Git configuration of this repository has Git LFS \
233         keys, so the wrapper uploads the large files the pushed commits point to before it publishes the pointer \
234         files, but it could not work out which refs this push sends ({why}). Pushing without that upload could \
235         publish pointers whose content the LFS server does not have. Ways forward: push again, because the cause may \
236         be transient; or upload by hand with `git lfs push --all <remote>` and push with `git push --no-verify`.\n"
237    );
238}
239
240/// What:
241///  The explanation printed after `git-lfs push` failed.
242/// Why:
243///   `git-lfs` prints its own error first (a refused credential, an unreachable server, a missing local object);
244///  this text says what that means for the push and every way forward.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// `cli-git: git-lfs push exited with status ${code}; ...`
249/// ```
250pub fn failed_message(status: &str) -> String {
251    return format!(
252        "cli-git: `{LFS_PROGRAM} push` {status}, so this push was not started and nothing was published. \
253         The lines above come from `{LFS_PROGRAM}`; they usually name the cause: the LFS server \
254         (`lfs.url`) is unreachable or refused the credentials, or a large file the pushed commits point to is not \
255         in this clone's `.git/lfs`. Ways forward: fix that cause and push again, because objects the server already \
256         has are not sent twice; upload by hand with `git lfs push --all <remote>` and push again; or publish the \
257         pointer files without their objects, which nobody can download until the objects are uploaded, with \
258         `git push --no-verify` (or `GIT_LFS_SKIP_PUSH=1`).\n"
259    );
260}
261
262/// Pure parts are controlled in the test module.
263#[cfg(test)]
264#[path = "lfs_pre_push_tests.rs"]
265mod tests;