1use super::auto_push_single_flight::{PushGit, git_command};
16use super::random_id::random_uuid;
18use super::start_retry::RetryStart;
20use std::ffi::OsString;
22use std::fs;
24use std::path::{Path, PathBuf};
26use std::process::Stdio;
28
29pub const CAPTURE_DIRECTORY_ENV: &str = "CLI_GIT_PUSH_FACTS_DIRECTORY";
31
32pub const PRE_PUSH_FIELD_COUNT: usize = 4;
34
35pub const MAX_HOOK_INPUT_BYTES: u64 = 16 * 1024 * 1024;
37
38const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
40
41const HOOK_NAME: &str = "pre-push";
43
44const CAPTURE_SUFFIX: &str = ".capture";
46
47#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct ManualPushProbeError {
58 pub message: String,
60}
61
62impl std::fmt::Display for ManualPushProbeError {
63 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 return formatter.write_str(&self.message);
66 }
67}
68
69impl std::error::Error for ManualPushProbeError {}
70
71fn probe_error(message: String) -> ManualPushProbeError {
78 return ManualPushProbeError { message };
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct ProbedUpdate {
92 pub local_ref: String,
94 pub local_oid: String,
96 pub remote_name: String,
98 pub remote_location: String,
100 pub remote_ref: String,
102 pub advertised_remote_oid: String,
104}
105
106#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct PushUpdate {
117 pub local_oid: Option<String>,
119 pub remote_oid: Option<String>,
121 pub remote_name: String,
123 pub remote_ref: String,
125}
126
127pub fn is_zero_oid(oid: &str) -> bool {
135 return !oid.is_empty()
136 && oid.bytes().all(|byte| {
137 return byte == b'0';
138 });
139}
140
141pub fn parse_update_records(
151 input: &str,
152 remote_name: &str,
153 remote_location: &str,
154) -> Result<Vec<ProbedUpdate>, ManualPushProbeError> {
155 let mut updates: Vec<ProbedUpdate> = Vec::new();
156 for line in input.split('\n').filter(|line| {
157 return !line.is_empty();
158 }) {
159 let parts: Vec<&str> = line.split(' ').collect();
160 if parts.len() != PRE_PUSH_FIELD_COUNT {
161 return Err(probe_error(format!(
162 "Malformed pre-push update record: {line}"
163 )));
164 }
165 updates.push(ProbedUpdate {
166 local_ref: parts[0].to_owned(),
167 local_oid: parts[1].to_owned(),
168 remote_name: remote_name.to_owned(),
169 remote_location: remote_location.to_owned(),
170 remote_ref: parts[2].to_owned(),
171 advertised_remote_oid: parts[3].to_owned(),
172 });
173 }
174 return Ok(updates);
175}
176
177pub fn probe_arguments(
188 global_prefix: &[OsString],
189 post_subcommand: &[OsString],
190 hooks_directory: &Path,
191) -> Vec<OsString> {
192 let separator = post_subcommand.iter().position(|argument| {
193 return argument.as_os_str() == "--";
194 });
195 let insertion = separator.unwrap_or(post_subcommand.len());
196 let mut arguments: Vec<OsString> = global_prefix.to_vec();
197 arguments.push(OsString::from("-c"));
198 arguments.push(OsString::from(format!(
199 "core.hooksPath={}",
200 hooks_directory.display()
201 )));
202 arguments.push(OsString::from("push"));
203 arguments.extend(post_subcommand[..insertion].iter().cloned());
204 arguments.push(OsString::from("--dry-run"));
205 arguments.push(OsString::from("--verify"));
206 arguments.extend(post_subcommand[insertion..].iter().cloned());
207 return arguments;
208}
209
210pub fn hook_source() -> String {
221 return format!(
222 "#!/bin/sh\nset -C\n{{ printf '%s\\n%s\\n' \"$1\" \"$2\"; head -c {}; }} > \"${CAPTURE_DIRECTORY_ENV}/$$.capture\" || exit 1\n",
223 MAX_HOOK_INPUT_BYTES + 1
224 );
225}
226
227fn create_private_directory(path: &Path) -> std::io::Result<()> {
235 use std::os::unix::fs::DirBuilderExt;
236 return fs::DirBuilder::new()
237 .mode(PRIVATE_DIRECTORY_MODE)
238 .create(path);
239}
240
241pub fn exceeds_capture_limit(length: usize) -> bool {
252 return length as u64 > MAX_HOOK_INPUT_BYTES;
253}
254
255pub fn capture_probed_updates(
266 git: &PushGit,
267 global_prefix: &[OsString],
268 post_subcommand: &[OsString],
269 scratch: &Path,
270) -> Result<Vec<ProbedUpdate>, ManualPushProbeError> {
271 let hooks = scratch.join("hooks");
272 let captures = scratch.join("captures");
273 let created = |directory: &Path| -> Result<(), ManualPushProbeError> {
274 return create_private_directory(directory).map_err(|error| {
275 return probe_error(format!(
276 "private probe directory {} could not be created: {error}",
277 directory.display()
278 ));
279 });
280 };
281 created(scratch)?;
284 let _scratch_guard = ScratchGuard(scratch.to_path_buf());
285 created(hooks.as_path())?;
286 created(captures.as_path())?;
287 let hook_path = hooks.join(HOOK_NAME);
288 fs::write(&hook_path, hook_source()).map_err(|error| {
289 return probe_error(format!("hook write failed: {error}"));
290 })?;
291 set_executable(&hook_path)?;
292 let arguments = probe_arguments(global_prefix, post_subcommand, &hooks);
293 let mut command = git_command(git, &arguments);
294 command
295 .stdin(Stdio::null())
296 .env(CAPTURE_DIRECTORY_ENV, &captures);
297 let probe = command.output_retrying().map_err(|error| {
298 return probe_error(format!("probe push could not start: {error}"));
299 })?;
300 let mut names: Vec<PathBuf> = fs::read_dir(&captures)
301 .map_err(|error| {
302 return probe_error(format!("captures unreadable: {error}"));
303 })?
304 .map(|entry| {
305 return entry.map(|found| {
306 return found.path();
307 });
308 })
309 .collect::<Result<Vec<PathBuf>, std::io::Error>>()
310 .map_err(|error| {
311 return probe_error(format!("captures unreadable: {error}"));
312 })?;
313 names.retain(|path| {
314 return path.to_string_lossy().ends_with(CAPTURE_SUFFIX);
315 });
316 names.sort();
317 if names.is_empty() {
318 return Err(probe_error(format!(
319 "Git could not determine push updates: {}",
320 String::from_utf8_lossy(&probe.stderr).trim_end()
321 )));
322 }
323 let mut updates: Vec<ProbedUpdate> = Vec::new();
324 for path in names {
325 let bytes = fs::read(&path).map_err(|error| {
326 return probe_error(format!("capture unreadable: {error}"));
327 })?;
328 let text = String::from_utf8_lossy(&bytes);
329 let mut lines = text.splitn(3, '\n');
330 let remote_name = lines.next().unwrap_or_default();
331 let remote_location = lines.next().unwrap_or_default();
332 let input = lines.next().unwrap_or_default();
333 if exceeds_capture_limit(input.len()) {
334 return Err(probe_error(
335 "Pre-push input exceeded capture limit.".to_owned(),
336 ));
337 }
338 updates.extend(parse_update_records(input, remote_name, remote_location)?);
339 }
340 return Ok(updates);
341}
342
343struct ScratchGuard(PathBuf);
353
354impl Drop for ScratchGuard {
355 fn drop(&mut self) {
357 let _removed = fs::remove_dir_all(&self.0);
358 }
359}
360
361fn set_executable(path: &Path) -> Result<(), ManualPushProbeError> {
368 use std::os::unix::fs::PermissionsExt;
369 return fs::set_permissions(path, fs::Permissions::from_mode(PRIVATE_DIRECTORY_MODE)).map_err(
370 |error| {
371 return probe_error(format!("hook mode failed: {error}"));
372 },
373 );
374}
375
376pub fn parse_remote_refs(output: &str) -> Result<Vec<(String, String)>, ManualPushProbeError> {
383 let mut refs: Vec<(String, String)> = Vec::new();
384 for line in output.split('\n').filter(|line| {
385 return !line.is_empty();
386 }) {
387 let Some((oid, name)) = line.split_once('\t') else {
388 return Err(probe_error(format!("Malformed ls-remote record: {line}")));
389 };
390 refs.push((name.to_owned(), oid.to_owned()));
391 }
392 return Ok(refs);
393}
394
395pub fn confirm_group(
406 git: &PushGit,
407 remote_location: &str,
408 updates: &[ProbedUpdate],
409) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
410 let mut arguments: Vec<OsString> = vec![
411 OsString::from("ls-remote"),
412 OsString::from("--refs"),
413 OsString::from(remote_location),
414 ];
415 arguments.extend(updates.iter().map(|update| {
416 return OsString::from(&update.remote_ref);
417 }));
418 let output = git_command(git, &arguments)
419 .stdin(Stdio::null())
420 .output_retrying()
421 .map_err(|error| {
422 return probe_error(format!("ls-remote could not start: {error}"));
423 })?;
424 if !output.status.success() {
425 return Err(probe_error(format!(
426 "ls-remote of {remote_location} failed: {}",
427 String::from_utf8_lossy(&output.stderr).trim_end()
428 )));
429 }
430 let remote_refs = parse_remote_refs(&String::from_utf8_lossy(&output.stdout))?;
431 let mut confirmed: Vec<PushUpdate> = Vec::new();
432 for update in updates {
433 let authoritative: Option<&String> = remote_refs
434 .iter()
435 .find(|(name, _oid)| {
436 return name == &update.remote_ref;
437 })
438 .map(|(_name, oid)| {
439 return oid;
440 });
441 let advertised_absent = is_zero_oid(&update.advertised_remote_oid);
442 if authoritative.is_none() != advertised_absent {
443 return Err(probe_error(format!(
444 "Remote ref changed during manual-push discovery: {}",
445 update.remote_ref
446 )));
447 }
448 if let Some(oid) = authoritative
449 && *oid != update.advertised_remote_oid
450 {
451 return Err(probe_error(format!(
452 "Remote ref changed during manual-push discovery: {}",
453 update.remote_ref
454 )));
455 }
456 confirmed.push(PushUpdate {
457 local_oid: if is_zero_oid(&update.local_oid) {
458 None
459 } else {
460 Some(update.local_oid.clone())
461 },
462 remote_oid: authoritative.cloned(),
463 remote_name: update.remote_name.clone(),
464 remote_ref: update.remote_ref.clone(),
465 });
466 }
467 return Ok(confirmed);
468}
469
470pub fn probe_manual_push_updates(
480 git: &PushGit,
481 global_prefix: &[OsString],
482 post_subcommand: &[OsString],
483) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
484 let scratch = std::env::temp_dir().join(format!(
485 "cli-git-push-facts-{}",
486 random_uuid().map_err(|error| {
487 return probe_error(format!("random source failed: {}", error.0));
488 })?
489 ));
490 let probed = capture_probed_updates(git, global_prefix, post_subcommand, &scratch)?;
491 let mut locations: Vec<String> = Vec::new();
492 for update in &probed {
493 if !locations.contains(&update.remote_location) {
494 locations.push(update.remote_location.clone());
495 }
496 }
497 let mut confirmed: Vec<PushUpdate> = Vec::new();
498 for location in &locations {
499 let group: Vec<ProbedUpdate> = probed
500 .iter()
501 .filter(|update| {
502 return &update.remote_location == location;
503 })
504 .cloned()
505 .collect();
506 confirmed.extend(confirm_group(git, location, &group)?);
507 }
508 return Ok(confirmed);
509}
510
511#[cfg(test)]
513#[path = "manual_push_probe_tests.rs"]
514mod tests;