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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
//! What:
//! What the Git LFS upload before a push sends: the destinations `git lfs push` takes and the refs whose objects it
//! uploads, always with `--all` so that no remote-tracking ref of this clone can hide an object a destination lacks.
//! Why:
//! `git lfs push` takes refs and no ref-update lines, so the plan is read from the push's own arguments with the same
//! option table as the rest of the wrapper, and from the configuration Git itself would read for the parts the
//! arguments leave out (the default remote, `remote.<name>.push`, `remote.<name>.mirror`, the push URLs,
//! `push.default`). The configuration comes from the snapshot the caller already holds, or from one query.
//! Refs that Git expands from a pattern are listed with `git for-each-ref` and matched here with a refspec's own rule
//! (`*` crosses `/`). The plan fails closed: a query that fails is an error the caller turns into a refusal, never
//! into "nothing to upload". Only a mirror push uploads every ref.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const plan = planUpload(globalPrefix, region, env, directory, settings, query); // { remotes, refs, everything }
//! ```
/// The destination and the named refs of a push.
use super::command_push::{parse_push_region, push_shape};
/// The configuration the plan reads.
use super::lfs_upload_settings::{current_branch, default_remote, reads_as_true, setting, values};
pub use super::lfs_upload_settings::{PlanError, Query, Settings, read_settings, resolve_push_remote, settings_from_snapshot};
/// The switch and the skip variable.
use super::lfs_pre_push::{hook_switched_off, skip_requested};
/// Owned OS text for arguments.
use std::ffi::{OsStr, OsString};
/// Locations.
use std::path::Path;
/// What an upload sends.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type UploadPlan = { remotes: string[]; refs: string[]; everything: boolean; submodules: boolean };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UploadPlan {
/// The destinations `git lfs push` is run for, one run each: the remote name, or one URL per push URL when the
/// remote has several (each may keep its objects in its own place).
pub remotes: Vec<OsString>,
/// The refs whose objects are uploaded; empty with `everything`.
pub refs: Vec<OsString>,
/// Upload everything reachable from every local ref.
pub everything: bool,
/// The push also pushes submodules, whose objects this upload does not cover.
pub submodules: bool,
}
impl UploadPlan {
/// The arguments of one `git-lfs` run: `push --all`, the destination, then the refs. `--all` with refs uploads
/// every object reachable from them, so objects that remote-tracking refs of this clone mention are not skipped.
pub fn arguments(&self, remote: &OsStr) -> Vec<OsString> {
let mut arguments: Vec<OsString> = vec![OsString::from("push"), OsString::from("--all"), remote.to_owned()];
if !self.everything {
arguments.extend(self.refs.iter().cloned());
}
return arguments;
}
}
/// What the source side of a refspec is: nothing for a deletion (`:dst`), the matching marker for `:`, else the part
/// before the last colon (Git splits at the last one, so a source such as `:/message` survives); a leading `+` is dropped.
enum Source {
/// The refspec only deletes.
Deletion,
/// The refspec `:` (or `+:`): every branch that exists on both sides.
Matching,
/// A ref, a revision expression or a pattern.
Named(OsString),
}
/// Read the source of one refspec.
fn refspec_source(spec: &OsStr) -> Source {
let bytes: &[u8] = spec.as_encoded_bytes();
let unforced: &[u8] = bytes.strip_prefix(b"+").unwrap_or(bytes);
if unforced == b":" {
return Source::Matching;
}
let source: &[u8] = match unforced.iter().rposition(|byte| {
return *byte == b':';
}) {
Some(position) => &unforced[..position],
None => unforced,
};
if source.is_empty() {
return Source::Deletion;
}
return Source::Named(<OsString as std::os::unix::ffi::OsStringExt>::from_vec(source.to_vec()));
}
/// Keeps every ref name.
fn keep_all(_name: &str) -> bool {
return true;
}
/// The refs under a prefix, listed by one `for-each-ref`, kept when `keep` accepts the full name.
fn list(query: Query<'_>, prefix: &str, keep: &dyn Fn(&str) -> bool) -> Result<Vec<OsString>, PlanError> {
let listing: String = query(&["for-each-ref", "--format=%(refname)", prefix]).map_err(|why| {
return PlanError(format!("git for-each-ref {prefix}: {why}"));
})?;
return Ok(listing
.lines()
.filter(|name| {
return keep(name);
})
.map(OsString::from)
.collect());
}
/// The refs a source with a `*` stands for, by a refspec's own rule: the text before the star, the text after it, and
/// anything between, `/` included (which a `for-each-ref` pattern does not do).
fn expand_pattern(query: Query<'_>, pattern: &str) -> Result<Vec<OsString>, PlanError> {
let (front, back) = pattern.split_once('*').unwrap_or((pattern, ""));
let directory: &str = front.rsplit_once('/').map_or("refs", |(head, _)| {
return head;
});
return list(query, directory, &|name| {
return name.len() >= front.len() + back.len() && name.starts_with(front) && name.ends_with(back);
});
}
/// `file://` and percent-encoding for a path, where `git-lfs` accepts only a remote name or an URL.
fn file_url(absolute: &Path) -> OsString {
let mut url: Vec<u8> = b"file://".to_vec();
for byte in absolute.as_os_str().as_encoded_bytes() {
if byte.is_ascii_alphanumeric() || b"/-._~".contains(byte) {
url.push(*byte);
} else {
url.extend_from_slice(format!("%{byte:02X}").as_bytes());
}
}
return <OsString as std::os::unix::ffi::OsStringExt>::from_vec(url);
}
/// What:
/// Turn a destination typed in place of a remote name into what `git lfs push` accepts. A remote name and an URL stay as
/// they are; a path (it starts with `/`, `./` or `../`, or names an existing file or directory relative to `directory`
/// and is not a configured remote) becomes a percent-encoded `file://` URL.
/// Why:
/// Git pushes to a path directly, and the old hook flow handed `git-lfs` the URL Git had made of it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// isPath(remote) ? pathToFileURL(resolve(directory, remote)) : remote
/// ```
fn lfs_destination(remote: &OsStr, directory: &Path, configured: bool) -> OsString {
let bytes: &[u8] = remote.as_encoded_bytes();
let has_scheme: bool = bytes.windows(3).any(|window| {
return window == b"://";
});
let scp_like: bool = bytes.iter().position(|byte| {
return *byte == b':';
}).is_some_and(|colon| {
return !bytes[..colon].contains(&b'/');
});
let explicit_path: bool = bytes.starts_with(b"/") || bytes.starts_with(b"./") || bytes.starts_with(b"../");
if configured || has_scheme || (scp_like && !explicit_path) {
return remote.to_owned();
}
let candidate = directory.join(Path::new(remote));
if !explicit_path && !candidate.exists() {
return remote.to_owned();
}
return match std::path::absolute(&candidate) {
Ok(absolute) => file_url(absolute.as_path()),
Err(_) => remote.to_owned(),
};
}
/// The remotes the configuration defines (a `url` or a `pushurl`).
fn configured_remotes(all: &[(String, String)]) -> Vec<&str> {
return all
.iter()
.filter_map(|(key, _)| {
let rest: &str = key.strip_prefix("remote.")?;
let (name, variable) = rest.rsplit_once('.')?;
return (variable == "url" || variable == "pushurl").then_some(name);
})
.collect();
}
/// What:
/// What to upload for a push, or nothing when the push uploads nothing, or why the plan cannot be worked out.
/// Why:
/// A push that bypasses the upload (`--no-verify`, `GIT_LFS_SKIP_PUSH`, the caller's switch), sends nothing (a dry run,
/// only deletions) uploads nothing. A push that names refs uploads those, with patterns, `--all`, `--tags`, the matching
/// refspec `:` and the configured `remote.<name>.push` expanded to the refs they stand for; a bare push uploads what the
/// remote's configuration names, else the branch Git would send (`HEAD` when it is not a branch); a mirror push uploads
/// everything. A remote with several push URLs is uploaded to once per URL. `directory` is where relative paths are
/// resolved; `settings` is the configuration the plan reads.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function planUpload(globalPrefix, region, environment, directory, settings, query): UploadPlan | undefined; // throws PlanError
/// ```
pub fn plan_upload(
global_prefix: &[OsString],
region: &[OsString],
environment: &[(OsString, OsString)],
directory: &Path,
all: &[(String, String)],
query: Query<'_>,
) -> Result<Option<UploadPlan>, PlanError> {
if hook_switched_off(global_prefix, environment) || skip_requested(environment) {
return Ok(None);
}
let Some((region_facts, shape)) = parse_push_region(region, &[]).ok().zip(push_shape(region)) else {
return Ok(None);
};
if region_facts.no_verify || region_facts.dry_run || shape.deletes {
return Ok(None);
}
let submodules: bool = shape.recurse_submodules.as_ref().is_some_and(|value| {
return matches!(value.to_str(), Some("on-demand" | "only"));
});
let branch_needed: bool = shape.repository.is_none() || shape.refspecs.is_empty();
let branch: Option<String> = if branch_needed { current_branch(query)? } else { None };
let remote_name: OsString = match shape.repository.clone() {
Some(typed) => typed,
None => OsString::from(default_remote(branch.as_deref(), all)),
};
let remote_text: String = remote_name.to_string_lossy().into_owned();
let mut everything: bool = shape.mirror;
let mut specs: Vec<OsString> = shape.refspecs.clone();
let bare: bool = specs.is_empty() && !shape.branches && !shape.tags && !everything;
if bare {
let configured: Vec<&str> = values(all, &format!("remote.{remote_text}.push"));
if setting(all, &format!("remote.{remote_text}.mirror")).is_some_and(reads_as_true) {
everything = true;
} else if !configured.is_empty() {
specs.extend(configured.into_iter().map(OsString::from));
} else if setting(all, "push.default") == Some("matching") {
specs.push(OsString::from(":"));
} else {
specs.push(OsString::from(match branch.as_deref() {
Some(name) => format!("refs/heads/{name}"),
None => String::from("HEAD"),
}));
}
}
let mut refs: Vec<OsString> = Vec::new();
for spec in &specs {
match refspec_source(spec.as_os_str()) {
Source::Deletion => {}
Source::Matching => refs.extend(list(query, "refs/heads", &keep_all)?),
Source::Named(source) => match source.to_str() {
Some(text) if text.contains('*') => refs.extend(expand_pattern(query, text)?),
_ => refs.push(source),
},
}
}
if shape.branches {
refs.extend(list(query, "refs/heads", &keep_all)?);
}
if shape.tags {
refs.extend(list(query, "refs/tags", &keep_all)?);
}
let mut unique: Vec<OsString> = Vec::new();
for found in refs {
if !unique.contains(&found) {
unique.push(found);
}
}
if unique.is_empty() && !everything {
return Ok(None);
}
let configured: bool = configured_remotes(all).contains(&remote_text.as_str());
let push_urls: Vec<&str> = if configured { values(all, &format!("remote.{remote_text}.pushurl")) } else { Vec::new() };
let remotes: Vec<OsString> = if push_urls.len() > 1 {
push_urls
.into_iter()
.map(|url| {
return lfs_destination(OsStr::new(url), directory, false);
})
.collect()
} else {
vec![lfs_destination(remote_name.as_os_str(), directory, configured)]
};
return Ok(Some(UploadPlan { remotes, refs: unique, everything, submodules }));
}
/// The plan is controlled in the test module.
#[cfg(test)]
#[path = "lfs_upload_plan_tests.rs"]
mod tests;