Skip to main content

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