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
255
256
257
258
259
260
261
262
263
264
265
//! What:
//! When a push needs the Git LFS upload, and the texts the parent prints around it.
//! 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 runs `git-lfs push <remote> <refs>` itself, as a child it owns, before it starts the push
//! (`lfs_upload.rs`), only when the Git configuration holds LFS keys; the push carries no hook and no injected
//! configuration, the wrapper never starts itself and writes no shell text
//! (`doc/planning/cli-git-no-self-invocation.md`).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! if (usesLfs(snapshot) && !skipRequested(env)) { const code = await uploadObjects(plan); if (code) return code; }
//! ```
/// Whether the configuration snapshot holds LFS keys.
use super::config_snapshot::ConfigSnapshot;
/// Owned operating-system text for the arguments.
use std::ffi::{OsStr, OsString};
/// Where `PATH` entries are looked at.
use std::path::Path;
/// The name the caller's switch uses: `-c hook.cli-git-lfs-pre-push.enabled=false` skips the upload for one command,
/// as it turned off the hook an earlier version put in effect.
pub const SWITCH_NAME: &str = "cli-git-lfs-pre-push";
/// The program the upload runs, found on `PATH` as the shim `git lfs install` wrote found it.
pub 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);
});
});
}
/// Whether a configuration name is the switch: Git reads the section and the variable case-insensitively and the
/// subsection exactly.
fn is_switch_key(name: &str) -> bool {
const VARIABLE: &str = ".enabled";
let Some(rest) = name.get(..5).filter(|section| {
return section.eq_ignore_ascii_case("hook.");
}).map(|_| {
return &name[5..];
}) else {
return false;
};
if rest.len() <= VARIABLE.len() {
return false;
}
if !rest.is_char_boundary(rest.len() - VARIABLE.len()) {
return false;
}
let (subsection, variable) = rest.split_at(rest.len() - VARIABLE.len());
return variable.eq_ignore_ascii_case(VARIABLE) && subsection == SWITCH_NAME;
}
/// Whether a value of the switch reads as false the way Git reads a boolean (an empty value counts as false).
fn reads_as_false(value: &str) -> bool {
return ["false", "no", "off", "0", ""].iter().any(|spelling| {
return value.eq_ignore_ascii_case(spelling);
});
}
/// What:
/// Whether the caller switched the upload off for this push, with `-c hook.cli-git-lfs-pre-push.enabled=false` among
/// the global options or the same pair in `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`.
/// Why:
/// It is a documented way to push without the upload, so a missing `git-lfs` must not stop such a push. The last
/// setting decides, as Git reads them, and `-c` options come after the environment's pairs. A repository or account
/// configuration file is not read: that would cost a Git start for every push; use `--no-verify` or
/// `GIT_LFS_SKIP_PUSH` for a standing choice in the environment.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// lastEnabledSetting([...envPairs, ...cPairs], `hook.${name}.enabled`) is false
/// ```
pub fn hook_switched_off(global_prefix: &[OsString], environment: &[(OsString, OsString)]) -> bool {
let mut off: bool = false;
let count: usize = super::child_environment::environment_value(environment, "GIT_CONFIG_COUNT")
.and_then(|value| {
return value.to_str().and_then(|text| {
return text.parse::<usize>().ok();
});
})
.unwrap_or(0);
for index in 0..count {
let name_of = |kind: &str| -> Option<OsString> {
return super::child_environment::environment_value(environment, &format!("GIT_CONFIG_{kind}_{index}"));
};
if name_of("KEY").is_some_and(|name| {
return is_switch_key(&name.to_string_lossy());
}) {
off = name_of("VALUE").is_some_and(|value| {
return reads_as_false(&value.to_string_lossy());
});
}
}
let mut tokens = global_prefix.iter();
while let Some(token) = tokens.next() {
if token.as_os_str() != "-c" {
continue;
}
let Some(setting) = tokens.next().and_then(|value| {
return value.to_str();
}) else {
continue;
};
// Git reads the name case-insensitively and a name without `=` as the boolean true.
let (name, value) = setting.split_once('=').map_or((setting, None), |(name, value)| {
return (name, Some(value));
});
if is_switch_key(name) {
off = value.is_some_and(reads_as_false);
}
}
return off;
}
/// What:
/// The first executable file named `git-lfs` in the directories of a `PATH` value, in order.
/// Why:
/// A stat per directory answers it without starting any process; an empty entry means the current directory, as
/// `PATH` lookup treats it. The upload starts exactly this file, so the pre-check and the start agree about which
/// `git-lfs` runs even though the child's `PATH` leads with the real Git's directory.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// path.split(':').map((dir) => join(dir || '.', 'git-lfs')).find(isExecutableFile)
/// ```
pub fn find_program(path: &OsStr) -> Option<std::path::PathBuf> {
use std::os::unix::fs::PermissionsExt;
return std::env::split_paths(path)
.map(|directory| {
let base: &Path = if directory.as_os_str().is_empty() { Path::new(".") } else { directory.as_path() };
return base.join(LFS_PROGRAM);
})
.find(|candidate| {
return std::fs::metadata(candidate).is_ok_and(|found| {
return found.is_file() && found.permissions().mode() & 0o111 != 0;
});
})
// A relative `PATH` entry is relative to where the lookup happens; the child may start elsewhere (`-C`).
.and_then(|found| {
return std::path::absolute(found).ok();
});
}
/// Whether `git-lfs` is an executable file in one of the directories of a `PATH` value.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// findProgram(path) !== undefined
/// ```
pub fn program_on_path(path: &OsStr) -> bool {
return find_program(path).is_some();
}
/// What:
/// Whether the environment asks to skip the upload (`GIT_LFS_SKIP_PUSH` set to a true value).
/// Why:
/// `git lfs push` does not read that variable (only the hook form did), so the parent honours it, as the explanation
/// texts promise.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// ['1', 't', 'true', 'y', 'yes', 'on'].includes(env.GIT_LFS_SKIP_PUSH?.toLowerCase())
/// ```
pub fn skip_requested(environment: &[(std::ffi::OsString, std::ffi::OsString)]) -> bool {
return super::child_environment::environment_value(environment, "GIT_LFS_SKIP_PUSH").is_some_and(|value| {
let text: String = value.to_string_lossy().to_ascii_lowercase();
return ["1", "t", "true", "y", "yes", "on"].contains(&text.as_str());
});
}
/// What:
/// The refusal printed when `git-lfs` is not on `PATH`.
/// 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 it sent anything: ...`
/// ```
pub fn missing_program_message() -> 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} push` to upload the large \
files the pushed commits point to before it publishes the pointer files, but `{LFS_PROGRAM}` was not found in \
any directory of PATH. 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 refusal printed when the wrapper cannot work out which objects a push needs uploaded.
/// Why:
/// Running the push without an upload could publish pointers whose objects the server lacks, so the push is not started;
/// every way forward is named.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// `cli-git: this push was stopped before it sent anything: ${why}...`
/// ```
pub fn plan_failed_message(why: &str) -> String {
return format!(
"cli-git: this push was stopped before it sent anything. The Git configuration of this repository has Git LFS \
keys, so the wrapper uploads the large files the pushed commits point to before it publishes the pointer \
files, but it could not work out which refs this push sends ({why}). Pushing without that upload could \
publish pointers whose content the LFS server does not have. Ways forward: push again, because the cause may \
be transient; or upload by hand with `git lfs push --all <remote>` and push with `git push --no-verify`.\n"
);
}
/// What:
/// The explanation printed after `git-lfs 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 push exited with status ${code}; ...`
/// ```
pub fn failed_message(status: &str) -> String {
return format!(
"cli-git: `{LFS_PROGRAM} push` {status}, so this push was not started 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"
);
}
/// Pure parts are controlled in the test module.
#[cfg(test)]
#[path = "lfs_pre_push_tests.rs"]
mod tests;