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