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