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;