Skip to main content

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;