git_policy_cli/auto_push_reconcile.rs
1//! What:
2//! Publishing a landed commit when the remote branch advanced after the commit landed:
3//! the merge of the remote tip and the local tip is computed in the object store,
4//! committed without a worktree or an index, and pushed with a plain push.
5//! Why:
6//! A rejected `push` of a branch that the remote moved cannot succeed again, and an ordinary merge in the active
7//! worktree can be blocked, or made unsafe, by another consumer's uncommitted files.
8//! `git merge-tree --write-tree` and `git commit-tree` read and write only objects, so the active worktree,
9//! its index and its untracked files are never touched while merging, and both histories stay reachable from the
10//! published tip.
11//! Only after the push succeeds is the branch fast-forwarded, and only where nothing can be lost: a dirty worktree
12//! follows too when the move changes none of the files its uncommitted work touches.
13//! An amended or rebased branch is merged like any other: nothing already published is lost, and the replaced
14//! commits stay in the remote history beside the new ones.
15
16/// The coordinator's Git, its command builder, queries and error type.
17use super::auto_push_single_flight::{
18 PushAttempt, PushGit, SingleFlightError, git_command, git_output, run_query,
19};
20/// The deadline-bound runner of network commands and the limits it applies.
21use super::auto_push_dirty_paths::{diff_paths, listed, overlapping_paths, status_paths};
22use super::bounded_push::{STOP_GRACE, apply_low_speed_defaults, push_deadline, run_bounded_push};
23/// The auto-push's bounded `git push` with the Git LFS upload.
24use super::lfs_bounded_push::run_push;
25/// The diagnostic log of the wrapper.
26use super::diagnostic_log::{debug, warn};
27/// Owned OS strings for Git arguments.
28use std::ffi::OsString;
29
30/// Tag used for diagnostics from this module.
31const TAG: &str = "cli-git";
32
33/// Prefix of every line this module adds to a push's output, so the terminal filter can keep it.
34pub const RECONCILE_LINE_PREFIX: &str = "cli-git: auto-push: ";
35
36/// Start of the line that explains why a rejected push stays unpublished; the generic "run `git push`" note is then
37/// wrong advice and is not printed.
38pub const RECONCILE_FAILED_PREFIX: &str = "cli-git: auto-push: not published: ";
39
40/// How many times the remote tip is fetched, merged and pushed before the push is reported as failed.
41pub const MAX_RECONCILE_ATTEMPTS: u32 = 10;
42
43/// The marker Git prints for a push the remote refused as stale; the word is not translated.
44const REJECTED_MARKER: &str = "[rejected]";
45
46/// The marker Git prints for an update that the remote's own rules refused, such as GitHub's protected branch (GH006).
47/// A remote that is updated by two pushes at once refuses the slower one this way, with no word about staleness.
48const REMOTE_REJECTED_MARKER: &str = "[remote rejected]";
49
50/// Where the plain push goes, as `auto_push_destination` decides it.
51pub use super::auto_push_destination::Destination;
52/// The destination of the plain push of a branch, from the settings alone.
53use super::auto_push_destination::read_destination;
54
55/// What:
56/// Whether a push's output shows a refusal that a moved remote branch can explain: Git's stale `[rejected]`, or the
57/// remote's own `[remote rejected]`.
58/// Why:
59/// Only a refusal of an update that raced another push can be cured by merging; an authentication failure or an offline
60/// error must be reported as they are.
61/// The message cannot tell a race from a rule that refuses every push, so the caller compares the remote tip with the
62/// branch and returns the refusal unchanged when the remote has not moved.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// const wasRejectedAsStale = (output: string) => output.includes('[rejected]') || output.includes('[remote rejected]');
67/// ```
68pub fn was_rejected_as_stale(output: &str) -> bool {
69 return output.contains(REJECTED_MARKER) || output.contains(REMOTE_REJECTED_MARKER);
70}
71
72/// A failed attempt that explains itself and keeps the push output that came before.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// { exitCode: 1, output: `${previous}\n${RECONCILE_FAILED_PREFIX}${reason}` }
77/// ```
78fn unpublished(failed_prefix: &str, previous: &str, exit_code: i64, reason: &str) -> PushAttempt {
79 return PushAttempt {
80 exit_code: if exit_code == 0 { 1 } else { exit_code },
81 output: format!("{previous}\n{failed_prefix}{reason}"),
82 };
83}
84
85/// Argument list from string literals and owned text.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// ['merge-tree', '--write-tree', ...]
90/// ```
91fn argv(parts: &[&str]) -> Vec<OsString> {
92 return parts.iter().map(OsString::from).collect();
93}
94
95/// What:
96/// Whether the first commit is the second or one of its ancestors, or nothing when Git cannot tell.
97/// Why:
98/// Exit `0` and `1` are the answers; any other exit means an object is missing, and that is not an answer.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// async function isAncestor({ ancestor, descendant }): Promise<boolean | undefined>;
103/// ```
104fn is_ancestor(
105 git: &PushGit,
106 ancestor: &str,
107 descendant: &str,
108) -> Result<Option<bool>, SingleFlightError> {
109 let output = run_query(
110 git,
111 &argv(&["merge-base", "--is-ancestor", ancestor, descendant]),
112 )?;
113 return Ok(match output.code {
114 Some(0) => Some(true),
115 Some(1) => Some(false),
116 _ => None,
117 });
118}
119
120/// What:
121/// Fetch the remote branch into a private ref and return the commit it names.
122/// Why:
123/// `FETCH_HEAD` is shared by every fetch in the repository, so another process could replace it between the fetch
124/// and the read; a ref named for this process cannot be replaced, and it is deleted before this returns.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// async function fetchRemoteTip({ destination }): Promise<string | PushAttempt>;
129/// ```
130fn fetch_remote_tip(
131 git: &PushGit,
132 destination: &Destination,
133) -> Result<Result<String, PushAttempt>, SingleFlightError> {
134 let private_ref = format!("refs/cli-git/auto-push/fetch-{}", std::process::id());
135 let refspec = format!("+{}:{private_ref}", destination.merge_ref);
136 let arguments = argv(&[
137 "fetch",
138 "--no-tags",
139 "--quiet",
140 &destination.remote,
141 &refspec,
142 ]);
143 let mut command = git_command(git, &arguments);
144 apply_low_speed_defaults(&mut command, &git.environment);
145 let fetched = run_bounded_push(command, push_deadline(&git.environment), STOP_GRACE, git.environment.as_slice())?;
146 if fetched.exit_code != 0 {
147 return Ok(Err(fetched));
148 }
149 let tip = git_output(
150 git,
151 &argv(&[
152 "rev-parse",
153 "--verify",
154 &format!("{private_ref}^{{commit}}"),
155 ]),
156 );
157 // The private ref is scaffolding; a failed deletion leaves one small ref that the next run overwrites.
158 let _deleted = run_query(git, &argv(&["update-ref", "-d", &private_ref]));
159 return Ok(Ok(tip?));
160}
161
162/// What:
163/// Commit the merge of two tips from objects alone and return its OID, or the paths that conflict.
164/// Why:
165/// `merge-tree --write-tree` exits `1` for a conflicted merge and lists the paths; nothing is written to a worktree.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// async function commitMerge({ local, remote, message }): Promise<{ oid } | { conflicts }>;
170/// ```
171fn commit_merge(
172 git: &PushGit,
173 local: &str,
174 remote: &str,
175 message: &str,
176) -> Result<Result<String, Vec<String>>, SingleFlightError> {
177 let merged = run_query(
178 git,
179 &argv(&[
180 "merge-tree",
181 "--write-tree",
182 "--no-messages",
183 "--name-only",
184 "--allow-unrelated-histories",
185 local,
186 remote,
187 ]),
188 )?;
189 let text = String::from_utf8_lossy(&merged.stdout).into_owned();
190 let mut lines = text.lines();
191 let tree = lines.next().unwrap_or_default().trim().to_owned();
192 if merged.code == Some(1) {
193 let paths: Vec<String> = lines
194 .map(str::trim)
195 .filter(|line| {
196 return !line.is_empty();
197 })
198 .map(str::to_owned)
199 .collect();
200 return Ok(Err(paths));
201 }
202 if merged.code != Some(0) {
203 return Err(SingleFlightError::Git {
204 arguments: format!("merge-tree --write-tree {local} {remote}"),
205 code: merged.code,
206 stderr: String::from_utf8_lossy(&merged.stderr).into_owned(),
207 });
208 }
209 let mut commit_tree = vec!["commit-tree"];
210 if signs_commits(git)? {
211 commit_tree.push("-S");
212 }
213 commit_tree.extend(["-p", local, "-p", remote, "-m", message, &tree]);
214 let oid = git_output(git, &argv(&commit_tree))?;
215 return Ok(Ok(oid));
216}
217
218/// What:
219/// Whether `commit.gpgsign` asks for signed commits.
220/// Why:
221/// `git commit-tree` signs only when given `-S` and never reads `commit.gpgsign`, so the merge would be the one
222/// unsigned commit of a signed branch, which a remote that requires verified signatures rejects.
223/// An unset key is `git config` exit `1`; any other failure is reported like every failed command.
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// async function signsCommits(git): Promise<boolean>;
228/// ```
229fn signs_commits(git: &PushGit) -> Result<bool, SingleFlightError> {
230 let asked = run_query(
231 git,
232 &argv(&["config", "--type=bool", "--get", "commit.gpgsign"]),
233 )?;
234 if asked.code == Some(1) {
235 return Ok(false);
236 }
237 if asked.code != Some(0) {
238 return Err(SingleFlightError::Git {
239 arguments: String::from("config --type=bool --get commit.gpgsign"),
240 code: asked.code,
241 stderr: String::from_utf8_lossy(&asked.stderr).into_owned(),
242 });
243 }
244 return Ok(asked.stdout.trim_ascii() == b"true");
245}
246
247/// What:
248/// Whether the status read after a fast-forward differs from the one read before it.
249/// `&[u8]` borrows Git's output.
250/// Why:
251/// The uncommitted work must read exactly as it did; a difference is the only sign that the move touched it.
252///
253/// In TS you'd write (pseudocode):
254/// ```ts
255/// function statusDiffers(before: Uint8Array, after: Uint8Array): boolean;
256/// ```
257fn status_differs(before: &[u8], after: &[u8]) -> bool {
258 return before != after;
259}
260
261/// What:
262/// Fast-forward the pushed branch and this worktree to the published merge, or the reason that was not done.
263/// The success value says whether uncommitted work was left in place.
264/// Why:
265/// A branch left behind the remote makes the next commit rejected again, and the person at the keyboard would have
266/// to merge by hand; the merge contains the branch tip, so the move is a pure fast-forward.
267/// It is attempted only where nothing can be lost or surprised: the worktree has the branch checked out at the tip
268/// that was pushed, and no uncommitted change (tracked file, staged entry, or either name of a staged rename)
269/// lies in a file the move writes.
270/// Another session's work in progress therefore never holds the branch behind the remote, while a move that
271/// would write over such a file is left for the person.
272/// The status read takes no optional lock, and `git merge --ff-only` refuses, without changing anything, a move that
273/// would overwrite an untracked file or a change made after the status read.
274/// Every refusal is a reason for the report, never a failure: the merge is already published.
275///
276/// In TS you'd write (pseudocode):
277/// ```ts
278/// async function advanceLocalBranch({ git, branchRef, local, mergeOid }): Promise<{ reason?: string; keptChanges?: boolean }>;
279/// ```
280fn advance_local_branch(
281 git: &PushGit,
282 branch_ref: &str,
283 local: &str,
284 merge_oid: &str,
285) -> Result<Result<bool, String>, SingleFlightError> {
286 let checked_out = run_query(git, &argv(&["symbolic-ref", "--quiet", "HEAD"]))?;
287 if checked_out.code != Some(0) {
288 return Ok(Err(String::from("HEAD is detached")));
289 }
290 if String::from_utf8_lossy(&checked_out.stdout).trim() != branch_ref {
291 return Ok(Err(String::from(
292 "this worktree has another branch checked out",
293 )));
294 }
295 let head = run_query(git, &argv(&["rev-parse", "--verify", "HEAD^{commit}"]))?;
296 if String::from_utf8_lossy(&head.stdout).trim() != local {
297 return Ok(Err(String::from(
298 "the branch moved after the commit landed",
299 )));
300 }
301 let status_arguments = argv(&[
302 "--no-optional-locks",
303 "status",
304 "--porcelain=v1",
305 "-z",
306 "--untracked-files=no",
307 ]);
308 let status = run_query(git, &status_arguments)?;
309 if status.code != Some(0) {
310 return Ok(Err(String::from("the worktree status could not be read")));
311 }
312 let dirty = status_paths(&status.stdout);
313 let kept: bool = !dirty.is_empty();
314 if kept {
315 let incoming = run_query(
316 git,
317 &argv(&["diff", "--name-only", "-z", "--no-renames", local, merge_oid]),
318 )?;
319 if incoming.code != Some(0) {
320 return Ok(Err(String::from(
321 "the files the merge changes could not be read",
322 )));
323 }
324 let overlap = overlapping_paths(&dirty, &diff_paths(&incoming.stdout));
325 if !overlap.is_empty() {
326 return Ok(Err(format!(
327 "the worktree has uncommitted changes to files the merge changes: {}",
328 listed(&overlap)
329 )));
330 }
331 }
332 let moved = run_query(git, &argv(&["merge", "--ff-only", "--quiet", merge_oid]))?;
333 if moved.code != Some(0) {
334 let detail = String::from_utf8_lossy(&moved.stderr);
335 return Ok(Err(format!(
336 "Git refused the fast-forward: {}",
337 detail.lines().next().unwrap_or_default().trim()
338 )));
339 }
340 if kept {
341 // The uncommitted work must read exactly as it did; the move is already done, so a difference is reported.
342 let after = run_query(git, &status_arguments)?;
343 if status_differs(&status.stdout, &after.stdout) {
344 warn(
345 TAG,
346 "the worktree status differs after the fast-forward; compare it with the status before the commit",
347 );
348 }
349 }
350 return Ok(Ok(kept));
351}
352
353/// What:
354/// Merge the remote tip into the pushed branch's tip and publish the result, a bounded number of times.
355/// Why:
356/// Another worktree advancing the remote makes the plain push fail for good, while both histories are valid and
357/// must both be kept; the merge is published as a new commit above the local tip, so the landed commit stays
358/// reachable and nothing is forced.
359/// Once the merge is published, the branch and its worktree are fast-forwarded to it when that cannot lose or
360/// overwrite anything; otherwise they are left as they are and the report says why.
361/// A push destination Git cannot name, a conflict, an exhausted retry budget and every failure of a command are
362/// reported, and only the first leaves the original rejection unchanged.
363///
364/// In TS you'd write (pseudocode):
365/// ```ts
366/// async function reconcileDivergentPush({ git, branchRef, rejected }): Promise<PushAttempt>;
367/// ```
368pub fn reconcile_divergent_push(
369 git: &PushGit,
370 branch_ref: &str,
371 rejected: PushAttempt,
372) -> Result<PushAttempt, SingleFlightError> {
373 if !was_rejected_as_stale(&rejected.output) {
374 return Ok(rejected);
375 }
376 let Some(destination) = read_destination(git, branch_ref)? else {
377 debug(
378 TAG,
379 "the push destination cannot be resolved to a remote branch; no reconcile",
380 );
381 return Ok(rejected);
382 };
383 let local = git_output(
384 git,
385 &argv(&["rev-parse", "--verify", &format!("{branch_ref}^{{commit}}")]),
386 )?;
387 let plan = Reconcile {
388 destination,
389 local,
390 branch_ref: Some(branch_ref),
391 label: "auto-push",
392 explicit: false,
393 };
394 let mut push_merge = |target: &Destination, merge_oid: &str| -> Result<PushAttempt, SingleFlightError> {
395 let arguments = argv(&[
396 "push",
397 &target.remote,
398 &format!("{merge_oid}:{}", target.merge_ref),
399 ]);
400 return run_push(git, &arguments);
401 };
402 return Ok(match integrate_and_push(git, &plan, rejected, &mut push_merge)? {
403 Integrated::NotDivergent(attempt) | Integrated::Finished(attempt) => attempt,
404 });
405}
406
407/// What:
408/// What one reconcile run integrates, where, and how it words and limits itself.
409/// `Option<&str>` is "the branch, or nothing for a detached HEAD".
410/// Why:
411/// Auto-push and an explicit push share the loop; they differ in the line prefix they print, in whether the branch
412/// follows the published merge, and in how much of a failure they keep quiet about.
413/// An explicit push already printed Git's own refusal, so a remote that cannot be fetched, a remote that did not
414/// diverge, and a failure that is not a stale update are left exactly as the push reported them.
415///
416/// In TS you'd write (pseudocode):
417/// ```ts
418/// type Reconcile = { destination: Destination; local: string; branchRef?: string; label: string; explicit: boolean };
419/// ```
420pub struct Reconcile<'a> {
421 /// The remote and the ref the rejected push updated.
422 pub destination: Destination,
423 /// The commit the rejected push sent.
424 pub local: String,
425 /// The branch that follows the published merge when it can, or nothing for a detached HEAD.
426 pub branch_ref: Option<&'a str>,
427 /// The word after `cli-git:` in every line the run adds.
428 pub label: &'a str,
429 /// Whether the rejected push was typed by a person and has already shown its own output.
430 pub explicit: bool,
431}
432
433/// How a reconcile run ended.
434///
435/// In TS you'd write (pseudocode):
436/// ```ts
437/// type Integrated = { kind: 'not-divergent'; attempt } | { kind: 'finished'; attempt };
438/// ```
439pub enum Integrated {
440 /// The remote did not diverge, or the run could not tell; the rejected attempt stands as it was.
441 NotDivergent(PushAttempt),
442 /// The run published the merge, or says why it did not.
443 Finished(PushAttempt),
444}
445
446/// What:
447/// Merge the remote tip into the pushed commit and publish the result, a bounded number of times.
448/// Why:
449/// Another worktree advancing the remote makes the plain push fail for good, while both histories are valid and
450/// must both be kept; the merge is published as a new commit above the local tip, so the landed commit stays
451/// reachable and nothing is forced.
452/// `push_merge` publishes one merge, and is told the destination and the merge commit.
453///
454/// In TS you'd write (pseudocode):
455/// ```ts
456/// async function integrateAndPush({ git, plan, rejected, pushMerge }): Promise<Integrated>;
457/// ```
458pub fn integrate_and_push(
459 git: &PushGit,
460 plan: &Reconcile<'_>,
461 rejected: PushAttempt,
462 push_merge: &mut dyn FnMut(&Destination, &str) -> Result<PushAttempt, SingleFlightError>,
463) -> Result<Integrated, SingleFlightError> {
464 let destination = plan.destination.clone();
465 let local = plan.local.clone();
466 let label = plan.label;
467 let line_prefix = format!("cli-git: {label}: ");
468 let failed_prefix = format!("{line_prefix}not published: ");
469 let short = destination
470 .merge_ref
471 .strip_prefix("refs/heads/")
472 .unwrap_or(&destination.merge_ref)
473 .to_owned();
474 let message = format!(
475 "Merge {}/{short} into {short}\n\nCreated by cli-git {label} because {}/{short} advanced after the commit landed.",
476 destination.remote, destination.remote
477 );
478 let mut previous = rejected.output.clone();
479 // The remote tip a merge was pushed against, with the refusal it got: a remote that still shows that tip did not
480 // move, so the refusal was a rule and not a race.
481 let mut refused_against: Option<(String, PushAttempt)> = None;
482 for attempt in 1..=MAX_RECONCILE_ATTEMPTS {
483 let remote_tip = match fetch_remote_tip(git, &destination)? {
484 Ok(tip) => tip,
485 Err(failed) => {
486 if plan.explicit && refused_against.is_none() {
487 debug(
488 TAG,
489 "the remote could not be fetched after an explicit push failed; its own report stands",
490 );
491 return Ok(Integrated::NotDivergent(rejected));
492 }
493 return Ok(Integrated::Finished(unpublished(
494 &failed_prefix,
495 &format!("{previous}\n{}", failed.output),
496 failed.exit_code,
497 &format!(
498 "{}/{short} has commits this branch lacks, and fetching them failed; nothing was pushed and the worktree was not touched.",
499 destination.remote
500 ),
501 )));
502 }
503 };
504 if let Some((tip, refusal)) = refused_against.take()
505 && tip == remote_tip
506 {
507 debug(
508 TAG,
509 "the remote did not move after it refused the merge; the refusal is final",
510 );
511 return Ok(Integrated::Finished(refusal));
512 }
513 match (
514 is_ancestor(git, &remote_tip, &local)?,
515 is_ancestor(git, &local, &remote_tip)?,
516 ) {
517 (Some(true), _) => {
518 debug(
519 TAG,
520 "the remote tip is behind the branch; the rejection was not a divergence",
521 );
522 return Ok(Integrated::NotDivergent(rejected));
523 }
524 (_, Some(true)) if plan.explicit => {
525 debug(
526 TAG,
527 "the remote already contains the pushed commit; the explicit push failed for another reason",
528 );
529 return Ok(Integrated::NotDivergent(rejected));
530 }
531 (_, Some(true)) => {
532 return Ok(Integrated::Finished(PushAttempt {
533 exit_code: 0,
534 output: format!(
535 "{line_prefix}{}/{short} already contains {local}; nothing to push.",
536 destination.remote
537 ),
538 }));
539 }
540 (Some(false), Some(false)) => {}
541 _ => {
542 if plan.explicit {
543 return Ok(Integrated::NotDivergent(rejected));
544 }
545 return Ok(Integrated::Finished(unpublished(
546 &failed_prefix,
547 &previous,
548 rejected.exit_code,
549 "Git could not relate the branch to the remote tip; nothing was pushed and the worktree was not touched.",
550 )));
551 }
552 }
553 let merge_oid = match commit_merge(git, &local, &remote_tip, &message)? {
554 Ok(oid) => oid,
555 Err(paths) => {
556 return Ok(Integrated::Finished(unpublished(
557 &failed_prefix,
558 &previous,
559 rejected.exit_code,
560 &format!(
561 "merging {}/{short} into {short} conflicts in: {}. Nothing was pushed and the worktree was not touched; \
562 merge {}/{short} in a separate worktree, resolve the conflicts and push.",
563 destination.remote,
564 paths.join(", "),
565 destination.remote
566 ),
567 )));
568 }
569 };
570 debug(
571 TAG,
572 &format!(
573 "attempt {attempt}: pushing the merge {merge_oid} of {local} and {remote_tip}"
574 ),
575 );
576 let pushed = push_merge(&destination, &merge_oid)?;
577 if pushed.exit_code == 0 {
578 let branch = plan
579 .branch_ref
580 .map(|reference| {
581 return reference.strip_prefix("refs/heads/").unwrap_or(reference);
582 })
583 .unwrap_or("HEAD");
584 let advanced = match plan.branch_ref {
585 Some(branch_ref) => advance_local_branch(git, branch_ref, &local, &merge_oid)?,
586 None => Err(String::from("HEAD is detached")),
587 };
588 let followed = match advanced {
589 Ok(false) => format!("{branch} and the worktree were fast-forwarded to it."),
590 Ok(true) => format!(
591 "{branch} and the worktree were fast-forwarded to it; your uncommitted changes were left as they are."
592 ),
593 Err(reason) => {
594 debug(TAG, &format!("{branch} was not fast-forwarded: {reason}"));
595 format!(
596 "{branch} itself and the worktree are unchanged ({reason}); update {branch} with `git merge --ff-only {}/{short}` \
597 when the worktree allows.",
598 destination.remote
599 )
600 }
601 };
602 return Ok(Integrated::Finished(PushAttempt {
603 exit_code: 0,
604 output: format!(
605 "{}\n{line_prefix}{}/{short} had new commits, so the merge {merge_oid} of {branch} and {}/{short} \
606 was published. {followed}",
607 pushed.output, destination.remote, destination.remote
608 )
609 .trim_start_matches('\n')
610 .to_owned(),
611 }));
612 }
613 if !plan.explicit && !was_rejected_as_stale(&pushed.output) {
614 return Ok(Integrated::Finished(pushed));
615 }
616 previous = pushed.output.clone();
617 refused_against = Some((remote_tip, pushed));
618 }
619 return Ok(Integrated::Finished(unpublished(
620 &failed_prefix,
621 &previous,
622 rejected.exit_code,
623 &format!(
624 "{}/{short} advanced again during each of {MAX_RECONCILE_ATTEMPTS} attempts, so nothing was pushed and the worktree was not touched. \
625 Push again when the remote is quiet.",
626 destination.remote
627 ),
628 )));
629}
630
631/// Reconcile behavior is controlled in the test module.
632#[cfg(test)]
633#[path = "auto_push_reconcile_tests.rs"]
634mod tests;