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