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