1use crate::auto_push_single_flight::{PushGit, SingleFlightError, run_query};
19use crate::lfs_upload_plan::resolve_push_remote;
21use crate::lfs_output_mask::mask_userinfo_text;
23use crate::manual_push_cause::ManualPushCause;
24use crate::manual_push_porcelain::{PorcelainBlock, PorcelainUpdate, RemoteBefore, parse_porcelain};
25use crate::parallel_budget::{BudgetRequest, HostResources, worker_budget};
26use crate::parallel_runner::{Event, OrderedRun, run_ordered};
27use crate::command_options::{Boundary, DEFAULT_MODE, parse_options};
29use crate::command_push::{PUSH_TABLE, push_shape};
30use std::ffi::OsString;
32
33#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct GitRefusal {
46 pub code: i32,
48 pub stderr: String,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct ManualPushProbeError {
64 pub message: String,
66 pub refusal: Option<GitRefusal>,
69 pub cause: ManualPushCause,
71}
72
73impl std::fmt::Display for ManualPushProbeError {
74 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 return formatter.write_str(&self.message);
77 }
78}
79
80impl std::error::Error for ManualPushProbeError {}
81
82fn probe_error(cause: ManualPushCause, message: String) -> ManualPushProbeError {
89 return ManualPushProbeError { message, refusal: None, cause };
90}
91
92fn cause_of_query(error: &SingleFlightError) -> ManualPushCause {
103 return match error {
104 SingleFlightError::Start(_) => ManualPushCause::GitNotStarted,
105 SingleFlightError::TimedOut { .. } => ManualPushCause::SelfExplained,
106 SingleFlightError::Git { .. } | SingleFlightError::Io(_) => ManualPushCause::GitSilent,
107 };
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct PushUpdate {
121 pub local_oid: Option<String>,
123 pub remote_oid: Option<String>,
125 pub remote_name: String,
127 pub remote_ref: String,
129}
130
131pub const MOST_CONCURRENT_REMOTE_QUERIES: usize = 4;
136
137pub const MOST_CONCURRENT_LOCAL_QUERIES: usize = LOCAL_QUERY_GAIN_STOPS_AT;
143
144pub const LOCAL_QUERY_GAIN_STOPS_AT: usize = 8;
146
147pub const MEASURED_WHOLE_HISTORY_QUERY: u64 = 29_863_936 + 1_413_721;
152
153pub const QUERY_MEMORY: u64 = MEASURED_WHOLE_HISTORY_QUERY.next_power_of_two();
157
158pub fn probe_arguments(global_prefix: &[OsString], post_subcommand: &[OsString]) -> Vec<OsString> {
171 let separator = match parse_options(post_subcommand, PUSH_TABLE, DEFAULT_MODE, &[]) {
174 Ok(parsed) => match parsed.boundary {
175 Boundary::DashDash(at) | Boundary::EndOfOptions(at) => Some(at),
176 Boundary::End | Boundary::NonOption(_) => None,
177 },
178 Err(_) => post_subcommand.iter().position(|argument| {
179 return argument.as_os_str() == "--";
180 }),
181 };
182 let insertion = separator.unwrap_or(post_subcommand.len());
183 let mut arguments: Vec<OsString> = global_prefix.to_vec();
184 for setting in [
185 format!("core.hooksPath={}", crate::hooks_off::NO_HOOKS_DIRECTORY),
186 String::from("hook.pre-push.enabled=false"),
187 String::from("core.abbrev=no"),
188 ] {
189 arguments.push(OsString::from("-c"));
190 arguments.push(OsString::from(setting));
191 }
192 arguments.push(OsString::from("push"));
193 arguments.extend(post_subcommand[..insertion].iter().cloned());
194 arguments.push(OsString::from("--no-quiet"));
197 arguments.push(OsString::from("--dry-run"));
198 arguments.push(OsString::from("--porcelain"));
199 arguments.extend(post_subcommand[insertion..].iter().cloned());
200 return arguments;
201}
202
203fn looks_like_location(typed: &str) -> bool {
205 return typed.contains("://")
206 || typed.starts_with('/')
207 || typed.starts_with("./")
208 || typed.starts_with("../")
209 || typed.split_once(':').is_some_and(|(front, _)| {
210 return !front.contains('/');
211 });
212}
213
214fn answer(git: &PushGit, arguments: &[OsString]) -> Option<String> {
216 let output = run_query(git, arguments).ok()?;
217 if output.code != Some(0) {
218 return None;
219 }
220 return Some(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_owned());
221}
222
223fn locations_of(git: &PushGit, global_prefix: &[OsString], remote: &str) -> Vec<String> {
226 let mut arguments: Vec<OsString> = global_prefix.to_vec();
227 arguments.extend(["remote", "get-url", "--push", "--all", remote].map(OsString::from));
228 return match answer(git, arguments.as_slice()) {
229 Some(listing) if !listing.is_empty() => listing.lines().map(str::to_owned).collect(),
230 Some(_) | None => vec![remote.to_owned()],
231 };
232}
233
234fn local_objects(git: &PushGit, global_prefix: &[OsString], names: &[&str]) -> Result<Vec<String>, ManualPushProbeError> {
244 if names.is_empty() {
245 return Ok(Vec::new());
246 }
247 let mut arguments: Vec<OsString> = global_prefix.to_vec();
248 arguments.push(OsString::from("rev-parse"));
249 arguments.extend(names.iter().map(OsString::from));
250 let listing: String = answer(git, arguments.as_slice())
251 .ok_or_else(|| {
252 return probe_error(ManualPushCause::ContentMissing, format!("the local objects of {names:?} could not be read"));
253 })?;
254 let objects: Vec<String> = listing.lines().map(str::to_owned).collect();
255 if objects.len() != names.len() {
256 return Err(probe_error(ManualPushCause::UnexpectedOutput, format!("rev-parse answered {} names for {}", objects.len(), names.len())));
257 }
258 return Ok(objects);
259}
260
261pub fn confirm_group(
273 git: &PushGit,
274 remote_location: &str,
275 remote_name: &str,
276 updates: &[(PorcelainUpdate, Option<String>)],
277) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
278 let mut arguments: Vec<OsString> = vec![
279 OsString::from("ls-remote"),
280 OsString::from("--refs"),
281 OsString::from(remote_location),
282 ];
283 arguments.extend(updates.iter().map(|(update, _local)| {
284 return OsString::from(&update.to);
285 }));
286 let output = run_query(git, arguments.as_slice())
287 .map_err(|error| {
288 return probe_error(cause_of_query(&error), mask_userinfo_text(format!("ls-remote of {remote_location} failed: {error}").as_str()));
289 })?;
290 if output.code != Some(0) {
291 return Err(probe_error(
292 ManualPushCause::RemoteUnreachable,
293 mask_userinfo_text(
294 format!("ls-remote of {remote_location} failed: {}", String::from_utf8_lossy(&output.stderr).trim_end()).as_str(),
295 ),
296 ));
297 }
298 let listing: String = String::from_utf8_lossy(&output.stdout).into_owned();
299 let mut confirmed: Vec<PushUpdate> = Vec::new();
300 for (update, local) in updates {
301 let authoritative: Option<String> = listing.lines().find_map(|line| {
302 return line.split_once('\t').filter(|(_oid, name)| {
303 return *name == update.to;
304 }).map(|(oid, _name)| {
305 return oid.to_owned();
306 });
307 });
308 let changed: bool = match &update.before {
309 RemoteBefore::Absent => authoritative.is_some(),
310 RemoteBefore::Known(reported) => authoritative.as_ref() != Some(reported),
311 RemoteBefore::Unknown => false,
312 };
313 if changed {
314 return Err(probe_error(ManualPushCause::RemoteMoved, format!("Remote ref changed during manual-push discovery: {}", update.to)));
315 }
316 confirmed.push(PushUpdate {
317 local_oid: local.clone(),
318 remote_oid: authoritative,
319 remote_name: remote_name.to_owned(),
320 remote_ref: update.to.clone(),
321 });
322 }
323 return Ok(confirmed);
324}
325
326pub fn probe_manual_push_updates(
336 git: &PushGit,
337 global_prefix: &[OsString],
338 post_subcommand: &[OsString],
339) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
340 return probe_manual_push_updates_on(&HostResources::read(), git, global_prefix, post_subcommand);
341}
342
343pub fn probe_manual_push_updates_on(
345 host: &HostResources,
346 git: &PushGit,
347 global_prefix: &[OsString],
348 post_subcommand: &[OsString],
349) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
350 let arguments: Vec<OsString> = probe_arguments(global_prefix, post_subcommand);
351 let output = run_query(git, arguments.as_slice()).map_err(|error| {
352 return probe_error(cause_of_query(&error), format!("probe push could not run: {error}"));
353 })?;
354 let report: String = String::from_utf8_lossy(output.stdout.as_slice()).into_owned();
355 let blocks = parse_porcelain(report.as_str()).map_err(|message| {
356 return probe_error(ManualPushCause::UnexpectedOutput, message);
357 })?;
358 if blocks.is_empty() {
359 if output.code == Some(0) {
360 return Ok(Vec::new());
361 }
362 let said: String = String::from_utf8_lossy(&output.stderr).into_owned();
363 let message: String = mask_userinfo_text(format!("Git could not determine push updates: {}", said.trim_end()).as_str());
364 return Err(match output.code {
368 Some(code) if !said.trim().is_empty() => {
369 ManualPushProbeError { message, refusal: Some(GitRefusal { code, stderr: said }), cause: ManualPushCause::SelfExplained }
370 }
371 Some(_) | None => probe_error(ManualPushCause::GitSilent, message),
372 });
373 }
374 let named = push_shape(post_subcommand).and_then(|shape| {
375 return shape.repository;
376 });
377 let typed_location: Option<String> = named.as_ref().map(|typed| {
378 return typed.to_string_lossy().into_owned();
379 });
380 let mut asking = |query: &[&str]| -> Result<String, String> {
381 let mut owned: Vec<OsString> = global_prefix.to_vec();
382 owned.extend(query.iter().map(OsString::from));
383 let answered = run_query(git, owned.as_slice()).map_err(|error| {
384 return error.to_string();
385 })?;
386 let text: String = String::from_utf8_lossy(answered.stdout.as_slice()).trim().to_owned();
387 return match answered.code {
388 Some(0) => Ok(text),
389 Some(1) if text.is_empty() => Ok(text),
390 code => Err(format!("exited with {code:?}")),
391 };
392 };
393 let remote_name: String = resolve_push_remote(named, &mut asking)
394 .map_err(|error| {
395 return probe_error(ManualPushCause::SelfExplained, format!("Git could not tell which remote this push goes to: {}", error.0));
396 })?
397 .to_string_lossy()
398 .into_owned();
399 let locations: Vec<String> = match typed_location {
402 Some(typed) if looks_like_location(typed.as_str()) => vec![typed],
403 _ => locations_of(git, global_prefix, remote_name.as_str()),
404 };
405 if locations.len() != blocks.len() {
406 return Err(probe_error(
407 ManualPushCause::SelfExplained,
408 mask_userinfo_text(
409 format!("Git reported {} destinations but {remote_name} has {} push locations", blocks.len(), locations.len()).as_str(),
410 ),
411 ));
412 }
413 let destinations: Vec<(&PorcelainBlock, &String)> = blocks.iter().zip(locations.iter()).collect();
417 let lanes: Vec<Vec<usize>> = (0..destinations.len()).map(|index: usize| { return vec![index]; }).collect();
418 let workers: usize = worker_budget(&BudgetRequest { items: destinations.len(), per_item_memory: QUERY_MEMORY, ceiling: Some(MOST_CONCURRENT_REMOTE_QUERIES), cap: None, host });
419 let mut confirmed: Vec<PushUpdate> = Vec::new();
420 let mut failure: Option<ManualPushProbeError> = None;
421 run_ordered(OrderedRun {
422 items: destinations.as_slice(),
423 lanes: lanes.as_slice(),
424 workers,
425 work: &|_index: usize, destination: &(&PorcelainBlock, &String), _say: &mut dyn FnMut(&str)| { return confirm_block(git, global_prefix, remote_name.as_str(), destination.0, destination.1.as_str()); },
426 on_event: &mut |event: Event<'_, Result<Vec<PushUpdate>, ManualPushProbeError>>| {
427 if let Event::End(_index, outcome) = event {
428 match outcome {
429 Ok(updates) if failure.is_none() => confirmed.extend(updates),
430 Err(error) if failure.is_none() => failure = Some(error),
431 Ok(_) | Err(_) => {}
432 }
433 }
434 },
435 });
436 return match failure {
437 Some(error) => Err(error),
438 None => Ok(confirmed),
439 };
440}
441
442fn confirm_block(git: &PushGit, global_prefix: &[OsString], remote_name: &str, block: &PorcelainBlock, location: &str) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
452 let unnamed: Vec<&str> = block
453 .updates
454 .iter()
455 .filter(|update| {
456 return !update.from.is_empty() && update.after.is_none();
457 })
458 .map(|update| {
459 return update.from.as_str();
460 })
461 .collect();
462 let mut resolved = local_objects(git, global_prefix, unnamed.as_slice())?.into_iter();
463 let group: Vec<(PorcelainUpdate, Option<String>)> = block
464 .updates
465 .iter()
466 .map(|update| {
467 let local: Option<String> = if update.from.is_empty() {
468 None
469 } else {
470 update.after.clone().or_else(|| {
471 return resolved.next();
472 })
473 };
474 return (update.clone(), local);
475 })
476 .collect();
477 return confirm_group(git, location, remote_name, group.as_slice());
478}
479
480#[cfg(test)]
482#[path = "manual_push_probe_tests.rs"]
483mod tests;