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(name) = branch_ref.strip_prefix("refs/heads/") else {
165 return Ok(None);
166 };
167 let tracked_remote = config_value(git, &format!("branch.{name}.remote"))?;
168 let mut push_remote = config_value(git, &format!("branch.{name}.pushRemote"))?;
169 if push_remote.is_empty() {
170 push_remote = config_value(git, "remote.pushDefault")?;
171 }
172 if push_remote.is_empty() {
173 push_remote.clone_from(&tracked_remote);
174 }
175 if push_remote.is_empty() || push_remote == "." {
176 return Ok(None);
177 }
178 let refspecs = run_query(
179 git,
180 &argv(&["config", "--get-all", &format!("remote.{push_remote}.push")]),
181 )?;
182 if !refspecs.stdout.is_empty() {
183 return Ok(None);
184 }
185 let triangular = push_remote != tracked_remote;
186 let mode = config_value(git, "push.default")?;
187 let merge_ref = config_value(git, &format!("branch.{name}.merge"))?;
188 let target = match (mode.as_str(), triangular) {
189 ("current", _) | ("" | "simple", true) => format!("refs/heads/{name}"),
190 ("" | "simple" | "upstream" | "tracking", false) => merge_ref,
191 _ => return Ok(None),
192 };
193 if !target.starts_with("refs/heads/") || target == "refs/heads/" {
194 return Ok(None);
195 }
196 return Ok(Some(Destination {
197 remote: push_remote,
198 merge_ref: target,
199 }));
200}
201
202/// What:
203/// The arguments of a push that names its destination, when the plain `git push` of this branch cannot.
204/// Why:
205/// Under `push.default=simple` Git refuses to push a branch whose upstream has another name (a linked worktree
206/// branch created from `origin/main` is the usual case) before it contacts the remote, so no reconcile can start.
207/// The destination `read_destination` resolves is exactly where the person tracked the branch, so the push names it.
208/// A branch whose destination has its own name needs no such push, and nothing is returned for it.
209///
210/// In TS you'd write (pseudocode):
211/// ```ts
212/// async function namedDestinationPush({ branchRef }): Promise<string[] | undefined>; // ['push', remote, `${branchRef}:${mergeRef}`]
213/// ```
214pub fn named_destination_push(
215 git: &PushGit,
216 branch_ref: &str,
217) -> Result<Option<Vec<String>>, SingleFlightError> {
218 let Some(destination) = read_destination(git, branch_ref)? else {
219 return Ok(None);
220 };
221 if destination.merge_ref == branch_ref {
222 return Ok(None);
223 }
224 return Ok(Some(vec![
225 String::from("push"),
226 destination.remote,
227 format!("{branch_ref}:{}", destination.merge_ref),
228 ]));
229}
230
231/// What:
232/// The arguments of a push of one named branch to the remote branch it tracks, for a worktree that has another branch
233/// checked out.
234/// Why:
235/// The plain `git push` pushes the checked-out branch, so a push of a branch that is not checked out must name both sides.
236/// The destination is the one `read_destination` resolves, so the push goes where the plain push of that branch would.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// async function destinationPush({ branchRef }): Promise<string[] | undefined>; // ['push', remote, `${branchRef}:${mergeRef}`]
241/// ```
242pub fn destination_push(git: &PushGit, branch_ref: &str) -> Result<Option<Vec<String>>, SingleFlightError> {
243 return Ok(read_destination(git, branch_ref)?.map(|destination| {
244 return vec![String::from("push"), destination.remote, format!("{branch_ref}:{}", destination.merge_ref)];
245 }));
246}
247
248/// What:
249/// The arguments of a push of one named branch to its tracked branch when `read_destination` cannot name the destination,
250/// or nothing when the branch tracks no remote branch.
251/// Why:
252/// The service pushes a recorded branch whatever the worktree has checked out by then, and it never reads `HEAD` to
253/// decide: a plain `git push` would publish the checked-out branch, so the push names both sides even for a setting
254/// (`push.default=matching`, a `remote.<name>.push` refspec) whose destination `read_destination` leaves unnamed.
255/// The remote follows Git's order (`branch.<name>.pushRemote`, `remote.pushDefault`, `branch.<name>.remote`) and the
256/// remote branch is the tracked one, or the branch's own name when the tracked ref is outside `refs/heads`.
257///
258/// In TS you'd write (pseudocode):
259/// ```ts
260/// async function trackedPush({ branchRef }): Promise<string[] | undefined>; // ['push', remote, `${branchRef}:${mergeRef}`]
261/// ```
262pub fn tracked_push(git: &PushGit, branch_ref: &str) -> Result<Option<Vec<String>>, SingleFlightError> {
263 let Some(name) = branch_ref.strip_prefix("refs/heads/") else {
264 return Ok(None);
265 };
266 let tracked_remote = config_value(git, &format!("branch.{name}.remote"))?;
267 let mut remote = config_value(git, &format!("branch.{name}.pushRemote"))?;
268 if remote.is_empty() {
269 remote = config_value(git, "remote.pushDefault")?;
270 }
271 if remote.is_empty() {
272 remote = tracked_remote;
273 }
274 if remote.is_empty() || remote == "." {
275 return Ok(None);
276 }
277 let merge_ref = config_value(git, &format!("branch.{name}.merge"))?;
278 let target: &str = if merge_ref.starts_with("refs/heads/") && merge_ref != "refs/heads/" { merge_ref.as_str() } else { branch_ref };
279 return Ok(Some(vec![String::from("push"), remote, format!("{branch_ref}:{target}")]));
280}
281
282/// What:
283/// Fetch the remote branch into a private ref and return the commit it names.
284/// Why:
285/// `FETCH_HEAD` is shared by every fetch in the repository, so another process could replace it between the fetch
286/// and the read; a ref named for this process cannot be replaced, and it is deleted before this returns.
287///
288/// In TS you'd write (pseudocode):
289/// ```ts
290/// async function fetchRemoteTip({ destination }): Promise<string | PushAttempt>;
291/// ```
292fn fetch_remote_tip(
293 git: &PushGit,
294 destination: &Destination,
295) -> Result<Result<String, PushAttempt>, SingleFlightError> {
296 let private_ref = format!("refs/cli-git/auto-push/fetch-{}", std::process::id());
297 let refspec = format!("+{}:{private_ref}", destination.merge_ref);
298 let arguments = argv(&[
299 "fetch",
300 "--no-tags",
301 "--quiet",
302 &destination.remote,
303 &refspec,
304 ]);
305 let mut command = git_command(git, &arguments);
306 apply_low_speed_defaults(&mut command, &git.environment);
307 let fetched = run_bounded_push(command, push_deadline(&git.environment), STOP_GRACE, git.environment.as_slice())?;
308 if fetched.exit_code != 0 {
309 return Ok(Err(fetched));
310 }
311 let tip = git_output(
312 git,
313 &argv(&[
314 "rev-parse",
315 "--verify",
316 &format!("{private_ref}^{{commit}}"),
317 ]),
318 );
319 // The private ref is scaffolding; a failed deletion leaves one small ref that the next run overwrites.
320 let _deleted = run_query(git, &argv(&["update-ref", "-d", &private_ref]));
321 return Ok(Ok(tip?));
322}
323
324/// What:
325/// Commit the merge of two tips from objects alone and return its OID, or the paths that conflict.
326/// Why:
327/// `merge-tree --write-tree` exits `1` for a conflicted merge and lists the paths; nothing is written to a worktree.
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// async function commitMerge({ local, remote, message }): Promise<{ oid } | { conflicts }>;
332/// ```
333fn commit_merge(
334 git: &PushGit,
335 local: &str,
336 remote: &str,
337 message: &str,
338) -> Result<Result<String, Vec<String>>, SingleFlightError> {
339 let merged = run_query(
340 git,
341 &argv(&[
342 "merge-tree",
343 "--write-tree",
344 "--no-messages",
345 "--name-only",
346 "--allow-unrelated-histories",
347 local,
348 remote,
349 ]),
350 )?;
351 let text = String::from_utf8_lossy(&merged.stdout).into_owned();
352 let mut lines = text.lines();
353 let tree = lines.next().unwrap_or_default().trim().to_owned();
354 if merged.code == Some(1) {
355 let paths: Vec<String> = lines
356 .map(str::trim)
357 .filter(|line| {
358 return !line.is_empty();
359 })
360 .map(str::to_owned)
361 .collect();
362 return Ok(Err(paths));
363 }
364 if merged.code != Some(0) {
365 return Err(SingleFlightError::Git {
366 arguments: format!("merge-tree --write-tree {local} {remote}"),
367 code: merged.code,
368 stderr: String::from_utf8_lossy(&merged.stderr).into_owned(),
369 });
370 }
371 let mut commit_tree = vec!["commit-tree"];
372 if signs_commits(git)? {
373 commit_tree.push("-S");
374 }
375 commit_tree.extend(["-p", local, "-p", remote, "-m", message, &tree]);
376 let oid = git_output(git, &argv(&commit_tree))?;
377 return Ok(Ok(oid));
378}
379
380/// What:
381/// Whether `commit.gpgsign` asks for signed commits.
382/// Why:
383/// `git commit-tree` signs only when given `-S` and never reads `commit.gpgsign`, so the merge would be the one
384/// unsigned commit of a signed branch, which a remote that requires verified signatures rejects.
385/// An unset key is `git config` exit `1`; any other failure is reported like every failed command.
386///
387/// In TS you'd write (pseudocode):
388/// ```ts
389/// async function signsCommits(git): Promise<boolean>;
390/// ```
391fn signs_commits(git: &PushGit) -> Result<bool, SingleFlightError> {
392 let asked = run_query(
393 git,
394 &argv(&["config", "--type=bool", "--get", "commit.gpgsign"]),
395 )?;
396 if asked.code == Some(1) {
397 return Ok(false);
398 }
399 if asked.code != Some(0) {
400 return Err(SingleFlightError::Git {
401 arguments: String::from("config --type=bool --get commit.gpgsign"),
402 code: asked.code,
403 stderr: String::from_utf8_lossy(&asked.stderr).into_owned(),
404 });
405 }
406 return Ok(asked.stdout.trim_ascii() == b"true");
407}
408
409/// What:
410/// Whether the status read after a fast-forward differs from the one read before it.
411/// `&[u8]` borrows Git's output.
412/// Why:
413/// The uncommitted work must read exactly as it did; a difference is the only sign that the move touched it.
414///
415/// In TS you'd write (pseudocode):
416/// ```ts
417/// function statusDiffers(before: Uint8Array, after: Uint8Array): boolean;
418/// ```
419fn status_differs(before: &[u8], after: &[u8]) -> bool {
420 return before != after;
421}
422
423/// What:
424/// Fast-forward the pushed branch and this worktree to the published merge, or the reason that was not done.
425/// The success value says whether uncommitted work was left in place.
426/// Why:
427/// A branch left behind the remote makes the next commit rejected again, and the person at the keyboard would have
428/// to merge by hand; the merge contains the branch tip, so the move is a pure fast-forward.
429/// It is attempted only where nothing can be lost or surprised: the worktree has the branch checked out at the tip
430/// that was pushed, and no uncommitted change (tracked file, staged entry, or either name of a staged rename)
431/// lies in a file the move writes.
432/// Another session's work in progress therefore never holds the branch behind the remote, while a move that
433/// would write over such a file is left for the person.
434/// The status read takes no optional lock, and `git merge --ff-only` refuses, without changing anything, a move that
435/// would overwrite an untracked file or a change made after the status read.
436/// Every refusal is a reason for the report, never a failure: the merge is already published.
437///
438/// In TS you'd write (pseudocode):
439/// ```ts
440/// async function advanceLocalBranch({ git, branchRef, local, mergeOid }): Promise<{ reason?: string; keptChanges?: boolean }>;
441/// ```
442fn advance_local_branch(
443 git: &PushGit,
444 branch_ref: &str,
445 local: &str,
446 merge_oid: &str,
447) -> Result<Result<bool, String>, SingleFlightError> {
448 let checked_out = run_query(git, &argv(&["symbolic-ref", "--quiet", "HEAD"]))?;
449 if checked_out.code != Some(0) {
450 return Ok(Err(String::from("HEAD is detached")));
451 }
452 if String::from_utf8_lossy(&checked_out.stdout).trim() != branch_ref {
453 return Ok(Err(String::from(
454 "this worktree has another branch checked out",
455 )));
456 }
457 let head = run_query(git, &argv(&["rev-parse", "--verify", "HEAD^{commit}"]))?;
458 if String::from_utf8_lossy(&head.stdout).trim() != local {
459 return Ok(Err(String::from(
460 "the branch moved after the commit landed",
461 )));
462 }
463 let status_arguments = argv(&[
464 "--no-optional-locks",
465 "status",
466 "--porcelain=v1",
467 "-z",
468 "--untracked-files=no",
469 ]);
470 let status = run_query(git, &status_arguments)?;
471 if status.code != Some(0) {
472 return Ok(Err(String::from("the worktree status could not be read")));
473 }
474 let dirty = status_paths(&status.stdout);
475 let kept: bool = !dirty.is_empty();
476 if kept {
477 let incoming = run_query(
478 git,
479 &argv(&["diff", "--name-only", "-z", "--no-renames", local, merge_oid]),
480 )?;
481 if incoming.code != Some(0) {
482 return Ok(Err(String::from(
483 "the files the merge changes could not be read",
484 )));
485 }
486 let overlap = overlapping_paths(&dirty, &diff_paths(&incoming.stdout));
487 if !overlap.is_empty() {
488 return Ok(Err(format!(
489 "the worktree has uncommitted changes to files the merge changes: {}",
490 listed(&overlap)
491 )));
492 }
493 }
494 let moved = run_query(git, &argv(&["merge", "--ff-only", "--quiet", merge_oid]))?;
495 if moved.code != Some(0) {
496 let detail = String::from_utf8_lossy(&moved.stderr);
497 return Ok(Err(format!(
498 "Git refused the fast-forward: {}",
499 detail.lines().next().unwrap_or_default().trim()
500 )));
501 }
502 if kept {
503 // The uncommitted work must read exactly as it did; the move is already done, so a difference is reported.
504 let after = run_query(git, &status_arguments)?;
505 if status_differs(&status.stdout, &after.stdout) {
506 warn(
507 TAG,
508 "the worktree status differs after the fast-forward; compare it with the status before the commit",
509 );
510 }
511 }
512 return Ok(Ok(kept));
513}
514
515/// What:
516/// Merge the remote tip into the pushed branch's tip and publish the result, a bounded number of times.
517/// Why:
518/// Another worktree advancing the remote makes the plain push fail for good, while both histories are valid and
519/// must both be kept; the merge is published as a new commit above the local tip, so the landed commit stays
520/// reachable and nothing is forced.
521/// Once the merge is published, the branch and its worktree are fast-forwarded to it when that cannot lose or
522/// overwrite anything; otherwise they are left as they are and the report says why.
523/// A push destination Git cannot name, a conflict, an exhausted retry budget and every failure of a command are
524/// reported, and only the first leaves the original rejection unchanged.
525///
526/// In TS you'd write (pseudocode):
527/// ```ts
528/// async function reconcileDivergentPush({ git, branchRef, rejected }): Promise<PushAttempt>;
529/// ```
530pub fn reconcile_divergent_push(
531 git: &PushGit,
532 branch_ref: &str,
533 rejected: PushAttempt,
534) -> Result<PushAttempt, SingleFlightError> {
535 if !was_rejected_as_stale(&rejected.output) {
536 return Ok(rejected);
537 }
538 let Some(destination) = read_destination(git, branch_ref)? else {
539 debug(
540 TAG,
541 "the push destination cannot be resolved to a remote branch; no reconcile",
542 );
543 return Ok(rejected);
544 };
545 let local = git_output(
546 git,
547 &argv(&["rev-parse", "--verify", &format!("{branch_ref}^{{commit}}")]),
548 )?;
549 let plan = Reconcile {
550 destination,
551 local,
552 branch_ref: Some(branch_ref),
553 label: "auto-push",
554 explicit: false,
555 };
556 let mut push_merge = |target: &Destination, merge_oid: &str| -> Result<PushAttempt, SingleFlightError> {
557 let arguments = argv(&[
558 "push",
559 &target.remote,
560 &format!("{merge_oid}:{}", target.merge_ref),
561 ]);
562 return run_push(git, &arguments);
563 };
564 return Ok(match integrate_and_push(git, &plan, rejected, &mut push_merge)? {
565 Integrated::NotDivergent(attempt) | Integrated::Finished(attempt) => attempt,
566 });
567}
568
569/// What:
570/// What one reconcile run integrates, where, and how it words and limits itself.
571/// `Option<&str>` is "the branch, or nothing for a detached HEAD".
572/// Why:
573/// Auto-push and an explicit push share the loop; they differ in the line prefix they print, in whether the branch
574/// follows the published merge, and in how much of a failure they keep quiet about.
575/// An explicit push already printed Git's own refusal, so a remote that cannot be fetched, a remote that did not
576/// diverge, and a failure that is not a stale update are left exactly as the push reported them.
577///
578/// In TS you'd write (pseudocode):
579/// ```ts
580/// type Reconcile = { destination: Destination; local: string; branchRef?: string; label: string; explicit: boolean };
581/// ```
582pub struct Reconcile<'a> {
583 /// The remote and the ref the rejected push updated.
584 pub destination: Destination,
585 /// The commit the rejected push sent.
586 pub local: String,
587 /// The branch that follows the published merge when it can, or nothing for a detached HEAD.
588 pub branch_ref: Option<&'a str>,
589 /// The word after `cli-git:` in every line the run adds.
590 pub label: &'a str,
591 /// Whether the rejected push was typed by a person and has already shown its own output.
592 pub explicit: bool,
593}
594
595/// How a reconcile run ended.
596///
597/// In TS you'd write (pseudocode):
598/// ```ts
599/// type Integrated = { kind: 'not-divergent'; attempt } | { kind: 'finished'; attempt };
600/// ```
601pub enum Integrated {
602 /// The remote did not diverge, or the run could not tell; the rejected attempt stands as it was.
603 NotDivergent(PushAttempt),
604 /// The run published the merge, or says why it did not.
605 Finished(PushAttempt),
606}
607
608/// What:
609/// Merge the remote tip into the pushed commit and publish the result, a bounded number of times.
610/// Why:
611/// Another worktree advancing the remote makes the plain push fail for good, while both histories are valid and
612/// must both be kept; the merge is published as a new commit above the local tip, so the landed commit stays
613/// reachable and nothing is forced.
614/// `push_merge` publishes one merge, and is told the destination and the merge commit.
615///
616/// In TS you'd write (pseudocode):
617/// ```ts
618/// async function integrateAndPush({ git, plan, rejected, pushMerge }): Promise<Integrated>;
619/// ```
620pub fn integrate_and_push(
621 git: &PushGit,
622 plan: &Reconcile<'_>,
623 rejected: PushAttempt,
624 push_merge: &mut dyn FnMut(&Destination, &str) -> Result<PushAttempt, SingleFlightError>,
625) -> Result<Integrated, SingleFlightError> {
626 let destination = plan.destination.clone();
627 let local = plan.local.clone();
628 let label = plan.label;
629 let line_prefix = format!("cli-git: {label}: ");
630 let failed_prefix = format!("{line_prefix}not published: ");
631 let short = destination
632 .merge_ref
633 .strip_prefix("refs/heads/")
634 .unwrap_or(&destination.merge_ref)
635 .to_owned();
636 let message = format!(
637 "Merge {}/{short} into {short}\n\nCreated by cli-git {label} because {}/{short} advanced after the commit landed.",
638 destination.remote, destination.remote
639 );
640 let mut previous = rejected.output.clone();
641 // The remote tip a merge was pushed against, with the refusal it got: a remote that still shows that tip did not
642 // move, so the refusal was a rule and not a race.
643 let mut refused_against: Option<(String, PushAttempt)> = None;
644 for attempt in 1..=MAX_RECONCILE_ATTEMPTS {
645 let remote_tip = match fetch_remote_tip(git, &destination)? {
646 Ok(tip) => tip,
647 Err(failed) => {
648 if plan.explicit && refused_against.is_none() {
649 debug(
650 TAG,
651 "the remote could not be fetched after an explicit push failed; its own report stands",
652 );
653 return Ok(Integrated::NotDivergent(rejected));
654 }
655 return Ok(Integrated::Finished(unpublished(
656 &failed_prefix,
657 &format!("{previous}\n{}", failed.output),
658 failed.exit_code,
659 &format!(
660 "{}/{short} has commits this branch lacks, and fetching them failed; nothing was pushed and the worktree was not touched.",
661 destination.remote
662 ),
663 )));
664 }
665 };
666 if let Some((tip, refusal)) = refused_against.take()
667 && tip == remote_tip
668 {
669 debug(
670 TAG,
671 "the remote did not move after it refused the merge; the refusal is final",
672 );
673 return Ok(Integrated::Finished(refusal));
674 }
675 match (
676 is_ancestor(git, &remote_tip, &local)?,
677 is_ancestor(git, &local, &remote_tip)?,
678 ) {
679 (Some(true), _) => {
680 debug(
681 TAG,
682 "the remote tip is behind the branch; the rejection was not a divergence",
683 );
684 return Ok(Integrated::NotDivergent(rejected));
685 }
686 (_, Some(true)) if plan.explicit => {
687 debug(
688 TAG,
689 "the remote already contains the pushed commit; the explicit push failed for another reason",
690 );
691 return Ok(Integrated::NotDivergent(rejected));
692 }
693 (_, Some(true)) => {
694 return Ok(Integrated::Finished(PushAttempt {
695 exit_code: 0,
696 output: format!(
697 "{line_prefix}{}/{short} already contains {local}; nothing to push.",
698 destination.remote
699 ),
700 }));
701 }
702 (Some(false), Some(false)) => {}
703 _ => {
704 if plan.explicit {
705 return Ok(Integrated::NotDivergent(rejected));
706 }
707 return Ok(Integrated::Finished(unpublished(
708 &failed_prefix,
709 &previous,
710 rejected.exit_code,
711 "Git could not relate the branch to the remote tip; nothing was pushed and the worktree was not touched.",
712 )));
713 }
714 }
715 let merge_oid = match commit_merge(git, &local, &remote_tip, &message)? {
716 Ok(oid) => oid,
717 Err(paths) => {
718 return Ok(Integrated::Finished(unpublished(
719 &failed_prefix,
720 &previous,
721 rejected.exit_code,
722 &format!(
723 "merging {}/{short} into {short} conflicts in: {}. Nothing was pushed and the worktree was not touched; \
724 merge {}/{short} in a separate worktree, resolve the conflicts and push.",
725 destination.remote,
726 paths.join(", "),
727 destination.remote
728 ),
729 )));
730 }
731 };
732 debug(
733 TAG,
734 &format!(
735 "attempt {attempt}: pushing the merge {merge_oid} of {local} and {remote_tip}"
736 ),
737 );
738 let pushed = push_merge(&destination, &merge_oid)?;
739 if pushed.exit_code == 0 {
740 let branch = plan
741 .branch_ref
742 .map(|reference| {
743 return reference.strip_prefix("refs/heads/").unwrap_or(reference);
744 })
745 .unwrap_or("HEAD");
746 let advanced = match plan.branch_ref {
747 Some(branch_ref) => advance_local_branch(git, branch_ref, &local, &merge_oid)?,
748 None => Err(String::from("HEAD is detached")),
749 };
750 let followed = match advanced {
751 Ok(false) => format!("{branch} and the worktree were fast-forwarded to it."),
752 Ok(true) => format!(
753 "{branch} and the worktree were fast-forwarded to it; your uncommitted changes were left as they are."
754 ),
755 Err(reason) => {
756 debug(TAG, &format!("{branch} was not fast-forwarded: {reason}"));
757 format!(
758 "{branch} itself and the worktree are unchanged ({reason}); update {branch} with `git merge --ff-only {}/{short}` \
759 when the worktree allows.",
760 destination.remote
761 )
762 }
763 };
764 return Ok(Integrated::Finished(PushAttempt {
765 exit_code: 0,
766 output: format!(
767 "{}\n{line_prefix}{}/{short} had new commits, so the merge {merge_oid} of {branch} and {}/{short} \
768 was published. {followed}",
769 pushed.output, destination.remote, destination.remote
770 )
771 .trim_start_matches('\n')
772 .to_owned(),
773 }));
774 }
775 if !plan.explicit && !was_rejected_as_stale(&pushed.output) {
776 return Ok(Integrated::Finished(pushed));
777 }
778 previous = pushed.output.clone();
779 refused_against = Some((remote_tip, pushed));
780 }
781 return Ok(Integrated::Finished(unpublished(
782 &failed_prefix,
783 &previous,
784 rejected.exit_code,
785 &format!(
786 "{}/{short} advanced again during each of {MAX_RECONCILE_ATTEMPTS} attempts, so nothing was pushed and the worktree was not touched. \
787 Push again when the remote is quiet.",
788 destination.remote
789 ),
790 )));
791}
792
793/// Reconcile behavior is controlled in the test module.
794#[cfg(test)]
795#[path = "auto_push_reconcile_tests.rs"]
796mod tests;