1use super::auto_push_reconcile::{
18 RECONCILE_FAILED_PREFIX, RECONCILE_LINE_PREFIX, destination_push, named_destination_push, reconcile_divergent_push,
19 tracked_push,
20};
21use super::auto_push_single_flight::{
23 PushAttempt, PushGit, SingleFlightError, SingleFlightKind, SingleFlightOutcome,
24 resolve_push_coordination_paths, run_query, run_single_flight_push_repeating,
25};
26use super::bounded_push::{LOCK_WAIT_VARIABLE, lock_wait_limit, push_deadline};
28use super::lock_holder_facts::holder_facts;
30use super::lfs_bounded_push::run_push;
32use super::diagnostic_log::debug;
34use super::owner_lock_record::{PublishedOwner, read_owner_lock_record};
36use std::ffi::OsString;
38use std::io::Write;
40
41const TAG: &str = "cli-git";
43
44const REMOTE_LINE_PREFIX: &str = "remote: ";
46
47pub const DETACHED_HEAD_NOTE: &str = "cli-git: HEAD is detached (mid-rebase/cherry-pick or a detached checkout); skipping auto-push.";
49
50pub const PUSH_FAILED_NOTE: &str =
52 "cli-git: commit saved locally, but auto-push to origin failed; run `git push` when ready.";
53
54const REFERENCE_LOCK_REFUSAL: &str = "cannot lock ref";
56
57pub const PUSH_LOCK_RETRIES: u32 = 3;
59
60const PUSH_LOCK_PAUSE: std::time::Duration = std::time::Duration::from_millis(250);
62
63const FIRST_PUSH_ARGUMENTS: &[&str] = &["push", "--set-upstream", "origin", "HEAD"];
65
66const UPSTREAM_PUSH_ARGUMENTS: &[&str] = &["push"];
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum AutoPushOutcome {
80 Skipped,
82 Pushed,
84 Failed,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct AutoPushResult {
99 pub outcome: AutoPushOutcome,
101 pub exit_code: i64,
103 pub shown: String,
105 pub joined: bool,
107}
108
109pub fn filter_push_output(output: &str, exit_code: i64) -> String {
119 if exit_code != 0 {
120 return output.to_owned();
121 }
122 return output
123 .split('\n')
124 .filter(|line| {
125 return line.starts_with(REMOTE_LINE_PREFIX) || line.starts_with(RECONCILE_LINE_PREFIX);
126 })
127 .collect::<Vec<&str>>()
128 .join("\n");
129}
130
131fn query_succeeds(git: &PushGit, arguments: &[&str]) -> Result<bool, SingleFlightError> {
141 let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
142 return Ok(run_query(git, &argv)?.code == Some(0));
143}
144
145fn read_symbolic_head(git: &PushGit) -> Result<Option<String>, SingleFlightError> {
155 let argv: Vec<OsString> = ["symbolic-ref", "--quiet", "HEAD"]
156 .iter()
157 .map(OsString::from)
158 .collect();
159 let output = run_query(git, &argv)?;
160 if output.code != Some(0) {
161 return Ok(None);
162 }
163 return Ok(Some(
164 super::js_text::trim_javascript(&String::from_utf8_lossy(&output.stdout)).to_owned(),
165 ));
166}
167
168fn run_push_command(git: &PushGit, arguments: &[&str]) -> Result<PushAttempt, SingleFlightError> {
181 let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
182 return run_push(git, &argv);
183}
184
185fn waiting_line(git: &PushGit, branch_ref: &str, waited: Option<std::time::Duration>) -> String {
196 let (pid, facts): (Option<i64>, String) = match holder(git, branch_ref) {
197 Some((pid, lock_directory)) => (
198 Some(pid),
199 holder_facts(pid, lock_directory.as_path(), push_deadline(&git.environment)),
200 ),
201 None => (None, String::new()),
202 };
203 let holder: String = match pid {
204 Some(number) => format!("process {number}"),
205 None => String::from("another cli-git process"),
206 };
207 let progress: String = match waited {
209 Some(elapsed) => format!(" ({} seconds so far)", elapsed.as_secs()),
210 None => String::new(),
211 };
212 return format!(
213 "cli-git: waiting for {holder} to finish pushing {branch_ref}{progress}; giving up after {} seconds \
214 ({LOCK_WAIT_VARIABLE} changes this). The commit is already saved.{facts}",
215 lock_wait_limit(&git.environment).as_secs()
216 );
217}
218
219fn holder(git: &PushGit, branch_ref: &str) -> Option<(i64, std::path::PathBuf)> {
226 let paths = resolve_push_coordination_paths(git, branch_ref).ok()?;
227 return match read_owner_lock_record(&paths.lock_directory) {
228 Ok(PublishedOwner::Owner(record)) => Some((record.owner_pid, paths.lock_directory)),
229 Ok(PublishedOwner::Busy) | Err(_) => None,
230 };
231}
232
233fn report_outcome(
244 outcome: &SingleFlightOutcome,
245 landed_oid: &str,
246 branch_ref: &str,
247 err: &mut dyn Write,
248) -> std::io::Result<AutoPushResult> {
249 let joined = matches!(
250 outcome.kind,
251 SingleFlightKind::JoinedPushed | SingleFlightKind::JoinedFailed
252 );
253 let failed = matches!(
254 outcome.kind,
255 SingleFlightKind::Failed | SingleFlightKind::JoinedFailed
256 );
257 let shown = if outcome.kind == SingleFlightKind::JoinedPushed {
258 String::new()
259 } else {
260 filter_push_output(&outcome.output, outcome.exit_code)
261 };
262 if !shown.is_empty() {
263 writeln!(err, "{shown}")?;
264 }
265 if !outcome.covered {
266 writeln!(
267 err,
268 "cli-git: landed commit {landed_oid} is no longer on {branch_ref}; auto-push pushed {} without it.",
269 outcome.tip
270 )?;
271 }
272 if failed && !outcome.output.contains(RECONCILE_FAILED_PREFIX) {
274 writeln!(err, "{PUSH_FAILED_NOTE}")?;
275 }
276 return Ok(AutoPushResult {
277 outcome: if failed {
278 AutoPushOutcome::Failed
279 } else {
280 AutoPushOutcome::Pushed
281 },
282 exit_code: outcome.exit_code,
283 shown,
284 joined,
285 });
286}
287
288enum Planned {
290 Skip(AutoPushResult),
292 Push {
294 branch_ref: String,
296 has_upstream: bool,
298 arguments: Vec<String>,
300 },
301}
302
303fn plan_push(git: &PushGit, err: &mut dyn Write) -> Result<Planned, SingleFlightError> {
315 let has_upstream = query_succeeds(
316 git,
317 &[
318 "rev-parse",
319 "--abbrev-ref",
320 "--symbolic-full-name",
321 "@{upstream}",
322 ],
323 )?;
324 if !has_upstream && !query_succeeds(git, &["remote", "get-url", "origin"])? {
325 debug(TAG, "no upstream and no origin remote; skipping auto-push");
326 return Ok(Planned::Skip(AutoPushResult {
327 outcome: AutoPushOutcome::Skipped,
328 exit_code: 0,
329 shown: String::new(),
330 joined: false,
331 }));
332 }
333 let Some(branch_ref) = read_symbolic_head(git)? else {
334 let _written = writeln!(err, "{DETACHED_HEAD_NOTE}");
336 return Ok(Planned::Skip(AutoPushResult {
337 outcome: AutoPushOutcome::Skipped,
338 exit_code: 0,
339 shown: DETACHED_HEAD_NOTE.to_owned(),
340 joined: false,
341 }));
342 };
343 let named: Option<Vec<String>> = if has_upstream {
344 named_destination_push(git, &branch_ref)?
345 } else {
346 None
347 };
348 let named_arguments: Vec<String> = named.into_iter().flatten().collect();
349 let arguments: Vec<String> = if !named_arguments.is_empty() {
350 named_arguments
351 } else if has_upstream {
352 UPSTREAM_PUSH_ARGUMENTS.iter().map(|argument| { return (*argument).to_owned(); }).collect()
353 } else {
354 FIRST_PUSH_ARGUMENTS.iter().map(|argument| { return (*argument).to_owned(); }).collect()
355 };
356 return Ok(Planned::Push { branch_ref, has_upstream, arguments });
357}
358
359fn plan_named_push(git: &PushGit, branch_ref: &str, err: &mut dyn Write) -> Result<Planned, SingleFlightError> {
372 let skipped = |shown: String| -> Planned {
373 return Planned::Skip(AutoPushResult { outcome: AutoPushOutcome::Skipped, exit_code: 0, shown, joined: false });
374 };
375 let upstream_name: String = format!("{branch_ref}@{{upstream}}");
376 let has_upstream = query_succeeds(git, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", upstream_name.as_str()])?;
377 if !has_upstream && !query_succeeds(git, &["remote", "get-url", "origin"])? {
378 debug(TAG, "no upstream and no origin remote; skipping auto-push");
379 return Ok(skipped(String::new()));
380 }
381 let arguments: Vec<String> = if has_upstream {
382 match destination_push(git, branch_ref)? {
383 Some(named) => named,
384 None => match tracked_push(git, branch_ref)? {
385 Some(tracked) => tracked,
386 None => {
387 let note: String = format!(
388 "cli-git: the branch {branch_ref} tracks no remote branch that can be named; skipping auto-push. Run `git push` on that branch."
389 );
390 let _written = writeln!(err, "{note}");
392 return Ok(skipped(note));
393 }
394 },
395 }
396 } else {
397 vec![String::from("push"), String::from("--set-upstream"), String::from("origin"), format!("{branch_ref}:{branch_ref}")]
398 };
399 return Ok(Planned::Push { branch_ref: branch_ref.to_owned(), has_upstream, arguments });
400}
401
402pub fn push_once(git: &PushGit, branch_ref: &str, landed_oid: &str, err: &mut dyn Write) -> Result<AutoPushResult, SingleFlightError> {
413 let planned: Planned = plan_named_push(git, branch_ref, err)?;
416 let (pushed_ref, has_upstream, arguments): (String, bool, Vec<String>) = match planned {
417 Planned::Skip(result) => return Ok(result),
418 Planned::Push { branch_ref: planned_ref, has_upstream, arguments } => (planned_ref, has_upstream, arguments),
419 };
420 let argument_views: Vec<&str> = arguments.iter().map(String::as_str).collect();
421 debug(TAG, &format!("pushing {landed_oid} with {argument_views:?} on {pushed_ref}"));
422 let mut attempt: PushAttempt = run_push_command(git, argument_views.as_slice())?;
423 let mut pause: u32 = 0;
427 while attempt.output.contains(REFERENCE_LOCK_REFUSAL) && pause < PUSH_LOCK_RETRIES {
428 pause += 1;
429 std::thread::sleep(PUSH_LOCK_PAUSE * pause);
430 attempt = run_push_command(git, argument_views.as_slice())?;
431 }
432 if has_upstream && attempt.exit_code != 0 {
433 attempt = reconcile_divergent_push(git, &pushed_ref, attempt)?;
434 }
435 let failed: bool = attempt.exit_code != 0;
436 let shown: String = filter_push_output(&attempt.output, attempt.exit_code);
437 if !shown.is_empty() {
438 writeln!(err, "{shown}").map_err(SingleFlightError::Io)?;
439 }
440 if failed && !attempt.output.contains(RECONCILE_FAILED_PREFIX) {
441 writeln!(err, "{PUSH_FAILED_NOTE}").map_err(SingleFlightError::Io)?;
442 }
443 return Ok(AutoPushResult {
444 outcome: if failed { AutoPushOutcome::Failed } else { AutoPushOutcome::Pushed },
445 exit_code: attempt.exit_code,
446 shown,
447 joined: false,
448 });
449}
450
451pub fn auto_push(
463 git: &PushGit,
464 landed_oid: &str,
465 err: &mut dyn Write,
466) -> Result<AutoPushResult, SingleFlightError> {
467 let (branch_ref, has_upstream, owned_arguments): (String, bool, Vec<String>) = match plan_push(git, err)? {
468 Planned::Skip(result) => return Ok(result),
469 Planned::Push { branch_ref, has_upstream, arguments } => (branch_ref, has_upstream, arguments),
470 };
471 let push_arguments: &[&str] = &owned_arguments.iter().map(String::as_str).collect::<Vec<&str>>();
472 debug(
473 TAG,
474 &format!("auto-pushing {landed_oid} with {push_arguments:?} on {branch_ref}"),
475 );
476 let coordinated = {
477 let mut push = || {
478 let attempt = run_push_command(git, push_arguments)?;
479 if has_upstream && attempt.exit_code != 0 {
481 return reconcile_divergent_push(git, &branch_ref, attempt);
482 }
483 return Ok(attempt);
484 };
485 let mut on_wait = |waited: Option<std::time::Duration>| {
486 let line = waiting_line(git, &branch_ref, waited);
487 debug(TAG, &line);
488 let _written = writeln!(err, "{line}");
490 };
491 run_single_flight_push_repeating(git, &branch_ref, landed_oid, &mut push, &mut on_wait)
492 };
493 return match coordinated {
494 Ok(outcome) => {
495 report_outcome(&outcome, landed_oid, &branch_ref, err).map_err(SingleFlightError::Io)
496 }
497 Err(error) => {
498 debug(TAG, &format!("auto-push coordination failed: {error}"));
499 let _written = writeln!(
500 err,
501 "cli-git: auto-push of {landed_oid} could not run: {error}"
502 );
503 let _noted = writeln!(err, "{PUSH_FAILED_NOTE}");
504 Ok(AutoPushResult {
505 outcome: AutoPushOutcome::Failed,
506 exit_code: 1,
507 shown: error.to_string(),
508 joined: false,
509 })
510 }
511 };
512}
513
514#[cfg(test)]
516#[path = "auto_push_tests.rs"]
517mod tests;
518
519#[cfg(test)]
521#[path = "auto_push_concurrency_tests.rs"]
522mod concurrency_tests;