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