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 value of the switch reads as false the way Git reads a boolean (an empty value counts as false).
59fn reads_as_false(value: &str) -> bool {
60    return ["false", "no", "off", "0", ""].iter().any(|spelling| {
61        return value.eq_ignore_ascii_case(spelling);
62    });
63}
64
65/// What:
66///  Whether the caller switched the upload off for this push, with `-c hook.cli-git-lfs-pre-push.enabled=false` among
67///  the global options or the same pair in `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`.
68/// Why:
69///   It is a documented way to push without the upload, so a missing `git-lfs` must not stop such a push. The last
70///  setting decides, as Git reads them, and `-c` options come after the environment's pairs. A repository or account
71///  configuration file is not read: that would cost a Git start for every push; use `--no-verify` or
72///  `GIT_LFS_SKIP_PUSH` for a standing choice in the environment.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// lastEnabledSetting([...envPairs, ...cPairs], `hook.${name}.enabled`) is false
77/// ```
78pub fn hook_switched_off(global_prefix: &[OsString], environment: &[(OsString, OsString)]) -> bool {
79    let key: String = format!("hook.{SWITCH_NAME}.enabled");
80    let mut off: bool = false;
81    let count: usize = super::child_environment::environment_value(environment, "GIT_CONFIG_COUNT")
82        .and_then(|value| {
83            return value.to_str().and_then(|text| {
84                return text.parse::<usize>().ok();
85            });
86        })
87        .unwrap_or(0);
88    for index in 0..count {
89        let name_of = |kind: &str| -> Option<OsString> {
90            return super::child_environment::environment_value(environment, &format!("GIT_CONFIG_{kind}_{index}"));
91        };
92        if name_of("KEY").is_some_and(|name| {
93            return name.eq_ignore_ascii_case(key.as_str());
94        }) {
95            off = name_of("VALUE").is_some_and(|value| {
96                return reads_as_false(&value.to_string_lossy());
97            });
98        }
99    }
100    let mut tokens = global_prefix.iter();
101    while let Some(token) = tokens.next() {
102        if token.as_os_str() != "-c" {
103            continue;
104        }
105        let Some(setting) = tokens.next().and_then(|value| {
106            return value.to_str();
107        }) else {
108            continue;
109        };
110        if let Some(value) = setting.strip_prefix(format!("{key}=").as_str()) {
111            off = reads_as_false(value);
112        }
113    }
114    return off;
115}
116
117/// What:
118///  The first executable file named `git-lfs` in the directories of a `PATH` value, in order.
119/// Why:
120///   A stat per directory answers it without starting any process; an empty entry means the current directory, as
121///  `PATH` lookup treats it. The upload starts exactly this file, so the pre-check and the start agree about which
122///  `git-lfs` runs even though the child's `PATH` leads with the real Git's directory.
123///
124/// In TS you'd write (pseudocode):
125/// ```ts
126/// path.split(':').map((dir) => join(dir || '.', 'git-lfs')).find(isExecutableFile)
127/// ```
128pub fn find_program(path: &OsStr) -> Option<std::path::PathBuf> {
129    use std::os::unix::fs::PermissionsExt;
130    return std::env::split_paths(path)
131        .map(|directory| {
132            let base: &Path = if directory.as_os_str().is_empty() { Path::new(".") } else { directory.as_path() };
133            return base.join(LFS_PROGRAM);
134        })
135        .find(|candidate| {
136            return std::fs::metadata(candidate).is_ok_and(|found| {
137                return found.is_file() && found.permissions().mode() & 0o111 != 0;
138            });
139        });
140}
141
142/// Whether `git-lfs` is an executable file in one of the directories of a `PATH` value.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// findProgram(path) !== undefined
147/// ```
148pub fn program_on_path(path: &OsStr) -> bool {
149    return find_program(path).is_some();
150}
151
152/// What:
153///  Whether the environment asks to skip the upload (`GIT_LFS_SKIP_PUSH` set to a true value).
154/// Why:
155///   `git lfs push` does not read that variable (only the hook form did), so the parent honours it, as the explanation
156///  texts promise.
157///
158/// In TS you'd write (pseudocode):
159/// ```ts
160/// ['1', 't', 'true', 'y', 'yes', 'on'].includes(env.GIT_LFS_SKIP_PUSH?.toLowerCase())
161/// ```
162pub fn skip_requested(environment: &[(std::ffi::OsString, std::ffi::OsString)]) -> bool {
163    return super::child_environment::environment_value(environment, "GIT_LFS_SKIP_PUSH").is_some_and(|value| {
164        let text: String = value.to_string_lossy().to_ascii_lowercase();
165        return ["1", "t", "true", "y", "yes", "on"].contains(&text.as_str());
166    });
167}
168
169/// What:
170///  The refusal printed when `git-lfs` is not on `PATH`.
171/// Why:
172///   Without the program the objects cannot be uploaded, and pushing anyway would publish pointers the server cannot
173///  serve; every valid way forward is named, including leaving LFS if the repository does not need it.
174///
175/// In TS you'd write (pseudocode):
176/// ```ts
177/// `cli-git: this push was stopped before it sent anything: ...`
178/// ```
179pub fn missing_program_message() -> String {
180    return format!(
181        "cli-git: this push was stopped before it sent anything. The Git configuration of this repository has Git LFS \
182         keys (`filter.lfs.*` or `lfs.*`), so the wrapper runs `{LFS_PROGRAM} push` to upload the large \
183         files the pushed commits point to before it publishes the pointer files, but `{LFS_PROGRAM}` was not found in \
184         any directory of PATH. Pushing without that upload would publish pointers whose content the LFS server does \
185         not have. Ways forward: install Git LFS so that `{LFS_PROGRAM}` is on PATH and push again; or, if this \
186         repository does not use Git LFS, remove its configuration (`git lfs uninstall`, or delete the `filter.lfs.*` \
187         and `lfs.*` keys); or accept pointers without objects for this push with `git push --no-verify` and upload \
188         the objects later with `git lfs push --all <remote>` once Git LFS is installed.\n"
189    );
190}
191
192/// What:
193///  The explanation printed after `git-lfs push` failed.
194/// Why:
195///   `git-lfs` prints its own error first (a refused credential, an unreachable server, a missing local object);
196///  this text says what that means for the push and every way forward.
197///
198/// In TS you'd write (pseudocode):
199/// ```ts
200/// `cli-git: git-lfs push exited with status ${code}; ...`
201/// ```
202pub fn failed_message(status: &str) -> String {
203    return format!(
204        "cli-git: `{LFS_PROGRAM} push` {status}, so this push was not started and nothing was published. \
205         The lines above come from `{LFS_PROGRAM}`; they usually name the cause: the LFS server \
206         (`lfs.url`) is unreachable or refused the credentials, or a large file the pushed commits point to is not \
207         in this clone's `.git/lfs`. Ways forward: fix that cause and push again, because objects the server already \
208         has are not sent twice; upload by hand with `git lfs push --all <remote>` and push again; or publish the \
209         pointer files without their objects, which nobody can download until the objects are uploaded, with \
210         `git push --no-verify` (or `GIT_LFS_SKIP_PUSH=1`).\n"
211    );
212}
213
214/// Pure parts are controlled in the test module.
215#[cfg(test)]
216#[path = "lfs_pre_push_tests.rs"]
217mod tests;