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;