git_policy_cli/lfs_pre_push.rs
1//! What:
2//! The Git LFS upload before a push, run as a supported `pre-push` configuration hook the wrapper puts in effect
3//! for its own pushes, and the `git cli-git lfs-pre-push` entry that hook starts.
4//! Why:
5//! Traditional hook files are unsupported, so the `pre-push` shim `git lfs install` writes cannot be relied on.
6//! Without an upload step a push publishes pointer files whose objects the LFS server never received.
7//! The wrapper therefore adds `hook.cli-git-lfs-pre-push.event=pre-push` and a command naming itself to every real
8//! push it starts, only when the Git configuration holds LFS keys.
9//! Git runs that hook with its own arguments (`<remote> <url>`) and ref-update lines on standard input, before it
10//! sends anything, and a non-zero status stops the push; this module adds no copy of that computation.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const args = usesLfs(snapshot) ? [...lfsHookArguments(executable), ...pushArguments] : pushArguments;
15//! ```
16
17/// Whether the configuration snapshot holds LFS keys.
18use super::config_snapshot::ConfigSnapshot;
19/// How the `git-lfs` child ended, as a status the hook returns.
20use super::forwarding::{ChildOutcome, exit_code, outcome_of};
21/// The retried start of the `git-lfs` child.
22use super::start_retry::RetryStart;
23/// Owned operating-system text and its byte view for the shell quoting of the hook command.
24use std::ffi::{OsStr, OsString};
25/// Where the hook's diagnostics go.
26use std::io::Write;
27/// Byte access to operating-system text.
28use std::os::unix::ffi::{OsStrExt, OsStringExt};
29/// The executable path the hook command names.
30use std::path::Path;
31/// The `git-lfs` child.
32use std::process::Command;
33
34/// The friendly name of the injected hook; `hook.<name>.enabled=false` on the command line turns it off.
35pub const HOOK_NAME: &str = "cli-git-lfs-pre-push";
36
37/// The management words the injected hook command starts the wrapper with.
38pub const HOOK_ENTRY: [&str; 2] = ["cli-git", "lfs-pre-push"];
39
40/// The program the hook runs, found on `PATH` as the shim `git lfs install` wrote found it.
41const LFS_PROGRAM: &str = "git-lfs";
42
43/// Key prefixes whose presence means the repository is set up for Git LFS.
44const LFS_KEY_PREFIXES: [&str; 2] = ["filter.lfs.", "lfs."];
45
46/// Scopes that describe the machine or the account, not this repository.
47const MACHINE_SCOPES: [&str; 2] = ["system", "global"];
48
49/// What:
50/// Whether a configuration entry of this repository, its worktree or the command line belongs to Git LFS.
51/// Why:
52/// `git lfs install` in an account writes `filter.lfs.*` to the global file, which every repository then sees, so a
53/// global key says nothing about this repository.
54/// The filter's first run in a repository writes `lfs.repositoryformatversion` to its local file, and a server is
55/// named by `lfs.url`; one such key in the local, worktree or command scope is the sign that pushed pointers need
56/// their objects uploaded first, without making every repository of the machine an LFS repository.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// entries.some(({ scope, key }) => !['system', 'global'].includes(scope) && (key.startsWith('filter.lfs.') || key.startsWith('lfs.')));
61/// ```
62pub fn uses_lfs(snapshot: &ConfigSnapshot) -> bool {
63 return snapshot.entries().iter().any(|entry| {
64 return !MACHINE_SCOPES.contains(&entry.scope.as_str())
65 && LFS_KEY_PREFIXES.iter().any(|prefix| {
66 return entry.key.starts_with(prefix);
67 });
68 });
69}
70
71/// What:
72/// Quote operating-system text as one word of a POSIX shell command.
73/// Why:
74/// Git runs a `hook.<name>.command` that holds a space through `sh -c`, so a path with spaces or quotes must stay
75/// one word; single quotes keep every byte literal, and an embedded single quote is closed, escaped and reopened.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// `'${text.replaceAll("'", "'\\''")}'`
80/// ```
81pub fn shell_quote(text: &OsStr) -> OsString {
82 let mut quoted: Vec<u8> = vec![b'\''];
83 for byte in text.as_bytes() {
84 if *byte == b'\'' {
85 quoted.extend_from_slice(b"'\\''");
86 } else {
87 quoted.push(*byte);
88 }
89 }
90 quoted.push(b'\'');
91 return OsString::from_vec(quoted);
92}
93
94/// What:
95/// The four global-option tokens that put the LFS hook in effect for one Git process.
96/// Why:
97/// The hook command names the running executable, so an upgraded or moved wrapper never leaves a stale path in
98/// the repository configuration; nothing is written to any configuration file.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// ['-c', `hook.${name}.event=pre-push`, '-c', `hook.${name}.command='${exe}' cli-git lfs-pre-push`]
103/// ```
104pub fn hook_arguments(executable: &Path) -> Vec<OsString> {
105 let mut command: OsString = OsString::from(format!("hook.{HOOK_NAME}.command="));
106 command.push(shell_quote(executable.as_os_str()));
107 for word in HOOK_ENTRY {
108 command.push(" ");
109 command.push(word);
110 }
111 return vec![
112 OsString::from("-c"),
113 OsString::from(format!("hook.{HOOK_NAME}.event=pre-push")),
114 OsString::from("-c"),
115 command,
116 ];
117}
118
119/// What:
120/// Put the LFS hook in front of a Git command line.
121/// Why:
122/// Global options may precede the subcommand, so the hook definition goes first and a `-c` the caller wrote after
123/// it (for example `-c hook.cli-git-lfs-pre-push.enabled=false`) still wins.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// [...hookArguments(executable), ...arguments]
128/// ```
129pub fn with_lfs_hook(arguments: &[OsString], executable: &Path) -> Vec<OsString> {
130 let mut combined: Vec<OsString> = hook_arguments(executable);
131 combined.extend(arguments.iter().cloned());
132 return combined;
133}
134
135/// What:
136/// The arguments after `cli-git lfs-pre-push`, when the invocation is the hook entry.
137/// Why:
138/// The entry is started by Git with exactly the management words and then `<remote> <url>`; anything else, such as
139/// global options in front, is an ordinary invocation.
140///
141/// In TS you'd write (pseudocode):
142/// ```ts
143/// args[0] === 'cli-git' && args[1] === 'lfs-pre-push' ? args.slice(2) : undefined
144/// ```
145pub fn hook_entry_arguments(arguments: &[OsString]) -> Option<&[OsString]> {
146 let [first, second, rest @ ..] = arguments else {
147 return None;
148 };
149 if first.as_os_str() == HOOK_ENTRY[0] && second.as_os_str() == HOOK_ENTRY[1] {
150 return Some(rest);
151 }
152 return None;
153}
154
155/// What:
156/// The refusal printed when `git-lfs` cannot be started.
157/// Why:
158/// Without the program the objects cannot be uploaded, and pushing anyway would publish pointers the server cannot
159/// serve; every valid way forward is named, including leaving LFS if the repository does not need it.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// `cli-git: this push was stopped before any ref changed: ...`
164/// ```
165pub fn missing_program_message(error: &std::io::Error) -> String {
166 return format!(
167 "cli-git: this push was stopped before it sent anything. The Git configuration of this repository has Git LFS \
168 keys (`filter.lfs.*` or `lfs.*`), so the wrapper runs `{LFS_PROGRAM} pre-push` to upload the large files the \
169 pushed commits point to before it publishes the pointer files, but `{LFS_PROGRAM}` could not be started: \
170 {error}. Pushing without that upload would publish pointers whose content the LFS server does not have. \
171 Ways forward: install Git LFS so that `{LFS_PROGRAM}` is on PATH and push again; or, if this repository does not \
172 use Git LFS, remove its configuration (`git lfs uninstall`, or delete the `filter.lfs.*` and `lfs.*` keys); \
173 or accept pointers without objects for this push with `git push --no-verify` and upload the objects later with \
174 `git lfs push --all <remote>` once Git LFS is installed.\n"
175 );
176}
177
178/// What:
179/// The explanation printed after `git-lfs pre-push` failed.
180/// Why:
181/// `git-lfs` prints its own error first (a refused credential, an unreachable server, a missing local object);
182/// this text says what that means for the push and every way forward.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// `cli-git: git-lfs pre-push exited with status ${code}; ...`
187/// ```
188pub fn failed_message(status: &str) -> String {
189 return format!(
190 "cli-git: `{LFS_PROGRAM} pre-push` {status}, so this push was stopped before any ref changed and nothing was \
191 published. The lines above come from `{LFS_PROGRAM}`; they usually name the cause: the LFS server \
192 (`lfs.url`) is unreachable or refused the credentials, or a large file the pushed commits point to is not \
193 in this clone's `.git/lfs`. Ways forward: fix that cause and push again, because objects the server already \
194 has are not sent twice; upload by hand with `git lfs push --all <remote>` and push again; or publish the \
195 pointer files without their objects, which nobody can download until the objects are uploaded, with \
196 `git push --no-verify` (or `GIT_LFS_SKIP_PUSH=1`).\n"
197 );
198}
199
200/// What:
201/// Run `program pre-push <arguments>` with this process's standard streams and report Git's verdict for the hook.
202/// Why:
203/// Standard input carries the ref-update lines Git computed, so the child inherits it untouched; only a failure to
204/// start or a non-zero status adds this wrapper's explanation, and the child's status is the hook's status.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// const { status } = spawnSync(program, ['pre-push', ...args], { stdio: 'inherit' });
209/// ```
210pub fn run_with_program(program: &OsStr, arguments: &[OsString], diagnostics: &mut dyn Write) -> i32 {
211 let started = Command::new(program)
212 .arg("pre-push")
213 .args(arguments)
214 .status_retrying();
215 let status = match started {
216 Ok(finished) => finished,
217 Err(error) => {
218 // Terminal output error: the exit status already blocks the push; a closed stderr cannot change that.
219 let _written = diagnostics.write_all(missing_program_message(&error).as_bytes());
220 return 1;
221 }
222 };
223 if status.success() {
224 return 0;
225 }
226 let outcome: ChildOutcome = outcome_of(status);
227 let description: String = match outcome {
228 ChildOutcome::Exited(code) => format!("exited with status {code}"),
229 ChildOutcome::Signaled(signal) => format!("was ended by signal {signal}"),
230 };
231 let code: i32 = exit_code(outcome);
232 // Terminal output error: see above.
233 let _written = diagnostics.write_all(failed_message(description.as_str()).as_bytes());
234 return if code == 0 { 1 } else { code };
235}
236
237/// What:
238/// The hook entry: run `git-lfs pre-push` and print diagnostics on standard error.
239/// Why:
240/// Git starts this through the injected `hook.cli-git-lfs-pre-push.command` with `<remote> <url>` and the update
241/// lines on standard input.
242///
243/// In TS you'd write (pseudocode):
244/// ```ts
245/// process.exitCode = runWithProgram('git-lfs', args, process.stderr);
246/// ```
247pub fn run_hook_entry(arguments: &[OsString]) -> i32 {
248 return run_with_program(OsStr::new(LFS_PROGRAM), arguments, &mut std::io::stderr());
249}
250
251/// Pure parts and the hook entry are controlled in the test module.
252#[cfg(test)]
253#[path = "lfs_pre_push_tests.rs"]
254mod tests;