1use super::auto_push_single_flight::{PushGit, git_command};
16use super::random_id::random_uuid;
18use super::scratch_location::prepare_user_scratch;
20use super::start_retry::RetryStart;
22use std::ffi::OsString;
24use std::fs;
26use std::path::{Path, PathBuf};
28use std::process::Stdio;
30
31pub const CAPTURE_DIRECTORY_ENV: &str = "CLI_GIT_PUSH_FACTS_DIRECTORY";
33
34pub const PRE_PUSH_FIELD_COUNT: usize = 4;
36
37pub const MAX_HOOK_INPUT_BYTES: u64 = 16 * 1024 * 1024;
39
40const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
42
43const HOOK_NAME: &str = "pre-push";
45
46const CAPTURE_SUFFIX: &str = ".capture";
48
49#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ManualPushProbeError {
60 pub message: String,
62}
63
64impl std::fmt::Display for ManualPushProbeError {
65 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 return formatter.write_str(&self.message);
68 }
69}
70
71impl std::error::Error for ManualPushProbeError {}
72
73fn probe_error(message: String) -> ManualPushProbeError {
80 return ManualPushProbeError { message };
81}
82
83#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct ProbedUpdate {
94 pub local_ref: String,
96 pub local_oid: String,
98 pub remote_name: String,
100 pub remote_location: String,
102 pub remote_ref: String,
104 pub advertised_remote_oid: String,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct PushUpdate {
119 pub local_oid: Option<String>,
121 pub remote_oid: Option<String>,
123 pub remote_name: String,
125 pub remote_ref: String,
127}
128
129pub fn is_zero_oid(oid: &str) -> bool {
137 return !oid.is_empty()
138 && oid.bytes().all(|byte| {
139 return byte == b'0';
140 });
141}
142
143pub fn parse_update_records(
153 input: &str,
154 remote_name: &str,
155 remote_location: &str,
156) -> Result<Vec<ProbedUpdate>, ManualPushProbeError> {
157 let mut updates: Vec<ProbedUpdate> = Vec::new();
158 for line in input.split('\n').filter(|line| {
159 return !line.is_empty();
160 }) {
161 let parts: Vec<&str> = line.split(' ').collect();
162 if parts.len() != PRE_PUSH_FIELD_COUNT {
163 return Err(probe_error(format!(
164 "Malformed pre-push update record: {line}"
165 )));
166 }
167 updates.push(ProbedUpdate {
168 local_ref: parts[0].to_owned(),
169 local_oid: parts[1].to_owned(),
170 remote_name: remote_name.to_owned(),
171 remote_location: remote_location.to_owned(),
172 remote_ref: parts[2].to_owned(),
173 advertised_remote_oid: parts[3].to_owned(),
174 });
175 }
176 return Ok(updates);
177}
178
179pub fn probe_arguments(
190 global_prefix: &[OsString],
191 post_subcommand: &[OsString],
192 hooks_directory: &Path,
193) -> Vec<OsString> {
194 let separator = post_subcommand.iter().position(|argument| {
195 return argument.as_os_str() == "--";
196 });
197 let insertion = separator.unwrap_or(post_subcommand.len());
198 let mut arguments: Vec<OsString> = global_prefix.to_vec();
199 arguments.push(OsString::from("-c"));
200 arguments.push(OsString::from(format!(
201 "core.hooksPath={}",
202 hooks_directory.display()
203 )));
204 arguments.push(OsString::from("-c"));
208 arguments.push(OsString::from("hook.pre-push.enabled=false"));
209 arguments.push(OsString::from("push"));
210 arguments.extend(post_subcommand[..insertion].iter().cloned());
211 arguments.push(OsString::from("--dry-run"));
212 arguments.push(OsString::from("--verify"));
213 arguments.extend(post_subcommand[insertion..].iter().cloned());
214 return arguments;
215}
216
217pub fn hook_source() -> String {
228 return format!(
229 "#!/bin/sh\nset -C\n{{ printf '%s\\n%s\\n' \"$1\" \"$2\"; head -c {}; }} > \"${CAPTURE_DIRECTORY_ENV}/$$.capture\" || exit 1\n",
230 MAX_HOOK_INPUT_BYTES + 1
231 );
232}
233
234fn create_private_directory(path: &Path) -> std::io::Result<()> {
242 use std::os::unix::fs::DirBuilderExt;
243 return fs::DirBuilder::new()
244 .mode(PRIVATE_DIRECTORY_MODE)
245 .create(path);
246}
247
248pub fn exceeds_capture_limit(length: usize) -> bool {
259 return length as u64 > MAX_HOOK_INPUT_BYTES;
260}
261
262pub fn capture_probed_updates(
273 git: &PushGit,
274 global_prefix: &[OsString],
275 post_subcommand: &[OsString],
276 scratch: &Path,
277) -> Result<Vec<ProbedUpdate>, ManualPushProbeError> {
278 let hooks = scratch.join("hooks");
279 let captures = scratch.join("captures");
280 let created = |directory: &Path| -> Result<(), ManualPushProbeError> {
281 return create_private_directory(directory).map_err(|error| {
282 return probe_error(format!(
283 "private probe directory {} could not be created: {error}",
284 directory.display()
285 ));
286 });
287 };
288 created(scratch)?;
291 let _scratch_guard = ScratchGuard(scratch.to_path_buf());
292 created(hooks.as_path())?;
293 created(captures.as_path())?;
294 let hook_path = hooks.join(HOOK_NAME);
295 fs::write(&hook_path, hook_source()).map_err(|error| {
296 return probe_error(format!("hook write failed: {error}"));
297 })?;
298 set_executable(&hook_path)?;
299 let arguments = probe_arguments(global_prefix, post_subcommand, &hooks);
300 let mut command = git_command(git, &arguments);
301 command
302 .stdin(Stdio::null())
303 .env(CAPTURE_DIRECTORY_ENV, &captures);
304 let probe = command.output_retrying().map_err(|error| {
305 return probe_error(format!("probe push could not start: {error}"));
306 })?;
307 let mut names: Vec<PathBuf> = fs::read_dir(&captures)
308 .map_err(|error| {
309 return probe_error(format!("captures unreadable: {error}"));
310 })?
311 .map(|entry| {
312 return entry.map(|found| {
313 return found.path();
314 });
315 })
316 .collect::<Result<Vec<PathBuf>, std::io::Error>>()
317 .map_err(|error| {
318 return probe_error(format!("captures unreadable: {error}"));
319 })?;
320 names.retain(|path| {
321 return path.to_string_lossy().ends_with(CAPTURE_SUFFIX);
322 });
323 names.sort();
324 if names.is_empty() {
325 return Err(probe_error(format!(
326 "Git could not determine push updates: {}",
327 String::from_utf8_lossy(&probe.stderr).trim_end()
328 )));
329 }
330 let mut updates: Vec<ProbedUpdate> = Vec::new();
331 for path in names {
332 let bytes = fs::read(&path).map_err(|error| {
333 return probe_error(format!("capture unreadable: {error}"));
334 })?;
335 let text = String::from_utf8_lossy(&bytes);
336 let mut lines = text.splitn(3, '\n');
337 let remote_name = lines.next().unwrap_or_default();
338 let remote_location = lines.next().unwrap_or_default();
339 let input = lines.next().unwrap_or_default();
340 if exceeds_capture_limit(input.len()) {
341 return Err(probe_error(
342 "Pre-push input exceeded capture limit.".to_owned(),
343 ));
344 }
345 updates.extend(parse_update_records(input, remote_name, remote_location)?);
346 }
347 return Ok(updates);
348}
349
350struct ScratchGuard(PathBuf);
360
361impl Drop for ScratchGuard {
362 fn drop(&mut self) {
364 let _removed = fs::remove_dir_all(&self.0);
365 }
366}
367
368fn set_executable(path: &Path) -> Result<(), ManualPushProbeError> {
375 use std::os::unix::fs::PermissionsExt;
376 return fs::set_permissions(path, fs::Permissions::from_mode(PRIVATE_DIRECTORY_MODE)).map_err(
377 |error| {
378 return probe_error(format!("hook mode failed: {error}"));
379 },
380 );
381}
382
383pub fn parse_remote_refs(output: &str) -> Result<Vec<(String, String)>, ManualPushProbeError> {
390 let mut refs: Vec<(String, String)> = Vec::new();
391 for line in output.split('\n').filter(|line| {
392 return !line.is_empty();
393 }) {
394 let Some((oid, name)) = line.split_once('\t') else {
395 return Err(probe_error(format!("Malformed ls-remote record: {line}")));
396 };
397 refs.push((name.to_owned(), oid.to_owned()));
398 }
399 return Ok(refs);
400}
401
402pub fn confirm_group(
413 git: &PushGit,
414 remote_location: &str,
415 updates: &[ProbedUpdate],
416) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
417 let mut arguments: Vec<OsString> = vec![
418 OsString::from("ls-remote"),
419 OsString::from("--refs"),
420 OsString::from(remote_location),
421 ];
422 arguments.extend(updates.iter().map(|update| {
423 return OsString::from(&update.remote_ref);
424 }));
425 let output = git_command(git, &arguments)
426 .stdin(Stdio::null())
427 .output_retrying()
428 .map_err(|error| {
429 return probe_error(format!("ls-remote could not start: {error}"));
430 })?;
431 if !output.status.success() {
432 return Err(probe_error(format!(
433 "ls-remote of {remote_location} failed: {}",
434 String::from_utf8_lossy(&output.stderr).trim_end()
435 )));
436 }
437 let remote_refs = parse_remote_refs(&String::from_utf8_lossy(&output.stdout))?;
438 let mut confirmed: Vec<PushUpdate> = Vec::new();
439 for update in updates {
440 let authoritative: Option<&String> = remote_refs
441 .iter()
442 .find(|(name, _oid)| {
443 return name == &update.remote_ref;
444 })
445 .map(|(_name, oid)| {
446 return oid;
447 });
448 let advertised_absent = is_zero_oid(&update.advertised_remote_oid);
449 if authoritative.is_none() != advertised_absent {
450 return Err(probe_error(format!(
451 "Remote ref changed during manual-push discovery: {}",
452 update.remote_ref
453 )));
454 }
455 if let Some(oid) = authoritative
456 && *oid != update.advertised_remote_oid
457 {
458 return Err(probe_error(format!(
459 "Remote ref changed during manual-push discovery: {}",
460 update.remote_ref
461 )));
462 }
463 confirmed.push(PushUpdate {
464 local_oid: if is_zero_oid(&update.local_oid) {
465 None
466 } else {
467 Some(update.local_oid.clone())
468 },
469 remote_oid: authoritative.cloned(),
470 remote_name: update.remote_name.clone(),
471 remote_ref: update.remote_ref.clone(),
472 });
473 }
474 return Ok(confirmed);
475}
476
477pub fn probe_manual_push_updates(
487 git: &PushGit,
488 global_prefix: &[OsString],
489 post_subcommand: &[OsString],
490) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
491 let parent = prepare_user_scratch(git.environment.as_slice()).map_err(probe_error)?;
494 let scratch = parent.join(format!(
495 "cli-git-push-facts-{}",
496 random_uuid().map_err(|error| {
497 return probe_error(format!("random source failed: {}", error.0));
498 })?
499 ));
500 let probed = capture_probed_updates(git, global_prefix, post_subcommand, &scratch)?;
501 let mut locations: Vec<String> = Vec::new();
502 for update in &probed {
503 if !locations.contains(&update.remote_location) {
504 locations.push(update.remote_location.clone());
505 }
506 }
507 let mut confirmed: Vec<PushUpdate> = Vec::new();
508 for location in &locations {
509 let group: Vec<ProbedUpdate> = probed
510 .iter()
511 .filter(|update| {
512 return &update.remote_location == location;
513 })
514 .cloned()
515 .collect();
516 confirmed.extend(confirm_group(git, location, &group)?);
517 }
518 return Ok(confirmed);
519}
520
521#[cfg(test)]
523#[path = "manual_push_probe_tests.rs"]
524mod tests;