git_policy_cli/push_stacking.rs
1//! What:
2//! An explicit `git push` that lost a race: the commits that landed on the remote since the local base are merged in
3//! the object store, and the merge is published by the wrapper pushing it again through its own gates.
4//! Why:
5//! Several sessions push to one branch all day; a push rejected because the remote moved cannot succeed again,
6//! and every caller would fetch, merge or rebase and retry by hand while racing the others.
7//! The wrapper runs an eligible push as a child with the caller's streams, so Git's own output is unchanged.
8//! Only a failed push is looked at again, and only a divergence of the destination is acted on;
9//! an authentication, offline, hook or rule refusal keeps the exit status and the text Git gave.
10//! The merge is pushed through the same push planning, run inside this process (`stacked_merge_push`) with
11//! `STACKED_PUSH_MARKER` set in the planned environment, so the policies that gate any push read the integrated result
12//! and the marker stops that push from stacking again. The wrapper never starts itself for this.
13//! A push that carries the Git LFS upload also runs here, as a child, so this parent can explain a failed upload.
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! const code = await runPush(args); if (code !== 0) code = (await stackFailedPush({ args })) ?? code;
18//! ```
19
20/// The reconcile loop that auto-push shares, and what it needs.
21use super::auto_push_reconcile::{Destination, Integrated, Reconcile, integrate_and_push};
22/// The coordinator's Git, its read-only query runner and its error type.
23use super::auto_push_single_flight::{PushAttempt, PushGit, SingleFlightError, run_query};
24/// The action data of a stacked push.
25use super::action::PushForward;
26/// The environment of a Git child.
27use super::child_environment::environment_with_overlay;
28/// The table-driven read of a push's options.
29use super::command_push::stackable_option_tokens;
30/// The diagnostic log of the wrapper.
31use super::diagnostic_log::debug;
32/// The check that the upload can start, what the upload sends, and its run.
33use super::lfs_interactive::{InteractiveUpload, upload_first};
34/// The merge push, planned and run inside this process.
35use super::stacked_merge_push::gated_merge_push;
36/// The destination and object names the push would send.
37use super::manual_push_probe::probe_manual_push_updates;
38/// The settled or plain run of the forwarded command.
39use super::worktree_copy_forward::{ForwardedRun, run_forwarded_git};
40/// Owned OS strings for Git arguments.
41use std::ffi::OsString;
42/// Standard error as a writable stream.
43use std::io::Write;
44
45/// Tag used for diagnostics from this module.
46const TAG: &str = "cli-git";
47
48/// The environment variable the stacked push carries so that it is never stacked again.
49pub const STACKED_PUSH_MARKER: &str = "CLI_GIT_STACKED_PUSH";
50
51/// How the merge of an integrated push is published, returning the push's exit status.
52pub type MergePush<'a> =
53 &'a dyn Fn(&PushGit, &[OsString], &[OsString], &[String]) -> Result<i32, SingleFlightError>;
54
55/// What a failed explicit push needs to be integrated.
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// type StackRequest = { git: PushGit; ownExecutable: string; globalPrefix: string[]; region: string[] };
60/// ```
61pub struct StackRequest<'a> {
62 /// The repository's Git with the invocation's environment.
63 pub git: &'a PushGit,
64 /// The global options before `push`.
65 pub global_prefix: &'a [OsString],
66 /// Pushes the merge: the coordinator's Git, the global options, the option tokens, then the remote and refspec.
67 /// Production passes the in-process planner (`gated_merge_push`); controls pass plain Git.
68 pub merge_push: MergePush<'a>,
69 /// The arguments after `push`.
70 pub region: &'a [OsString],
71}
72
73/// What:
74/// The branch HEAD names, or nothing for a detached HEAD.
75/// Why:
76/// The branch follows the published merge when it can; a detached HEAD stays where it is.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// (await git(['symbolic-ref', '--quiet', 'HEAD'])).stdout.trim() || undefined
81/// ```
82fn checked_out_branch(git: &PushGit) -> Option<String> {
83 let arguments: Vec<OsString> = ["symbolic-ref", "--quiet", "HEAD"]
84 .iter()
85 .map(OsString::from)
86 .collect();
87 let output = run_query(git, &arguments).ok()?;
88 if output.code != Some(0) {
89 return None;
90 }
91 let name = String::from_utf8_lossy(&output.stdout).trim().to_owned();
92 if name.is_empty() {
93 return None;
94 }
95 return Some(name);
96}
97
98/// What:
99/// Integrate the remote's new commits after an explicit push failed, or say nothing when the failure was no divergence.
100/// `Option<i32>` is "the new exit status, or nothing to change".
101/// Why:
102/// The status is `0` when the merge was published, and the failing status when the run says why it was not
103/// (a conflict, a gate that refused the merge, a remote that kept moving); every other failure returns nothing
104/// and prints nothing, so the person sees only Git's own report.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// async function stackFailedPush(request, failedCode, err): Promise<number | undefined>;
109/// ```
110pub fn stack_failed_push(
111 request: &StackRequest<'_>,
112 failed_code: i64,
113 err: &mut dyn Write,
114) -> Option<i32> {
115 let tokens: Vec<OsString> = stackable_option_tokens(request.region)?;
116 let git = request.git;
117 let updates = match probe_manual_push_updates(git, request.global_prefix, request.region) {
118 Ok(found) => found,
119 Err(error) => {
120 debug(
121 TAG,
122 &format!("the failed push's updates could not be read: {}", error.message),
123 );
124 return None;
125 }
126 };
127 let [update] = updates.as_slice() else {
128 return None;
129 };
130 let local = update.local_oid.clone()?;
131 update.remote_oid.as_ref()?;
132 if !update.remote_ref.starts_with("refs/heads/") {
133 return None;
134 }
135 let branch_ref: Option<String> = checked_out_branch(git);
136 let plan = Reconcile {
137 destination: Destination {
138 remote: update.remote_name.clone(),
139 merge_ref: update.remote_ref.clone(),
140 },
141 local,
142 branch_ref: branch_ref.as_deref(),
143 label: "push",
144 explicit: true,
145 };
146 let mut push_merge = |destination: &Destination, merge_oid: &str| -> Result<PushAttempt, SingleFlightError> {
147 let code: i32 = (request.merge_push)(
148 git,
149 request.global_prefix,
150 &tokens,
151 &[destination.remote.clone(), format!("{merge_oid}:{}", destination.merge_ref)],
152 )?;
153 return Ok(PushAttempt {
154 exit_code: i64::from(code),
155 output: String::new(),
156 });
157 };
158 let rejected = PushAttempt {
159 exit_code: failed_code,
160 output: String::new(),
161 };
162 match integrate_and_push(git, &plan, rejected, &mut push_merge) {
163 Ok(Integrated::NotDivergent(_)) => return None,
164 Ok(Integrated::Finished(attempt)) => {
165 let text = attempt.output.trim();
166 if !text.is_empty() {
167 // Terminal output error: the report is advisory; the exit status already carries the result.
168 let _written = writeln!(err, "{text}");
169 }
170 return Some(i32::try_from(attempt.exit_code).unwrap_or(1));
171 }
172 Err(error) => {
173 debug(TAG, &format!("stacking the failed push stopped: {error}"));
174 return None;
175 }
176 }
177}
178
179/// What:
180/// Run an eligible explicit push as a child with the caller's streams, then integrate it when it failed on a divergence.
181/// Why:
182/// The wrapper must outlive Git to look at its status; nothing is buffered, so Git's own output is what the person sees.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// process.exitCode = await runStackedPush(forward);
187/// ```
188pub fn run_stacked_push(forward: &PushForward) -> i32 {
189 let (Some(prefix), Some(region)) = (
190 forward.arguments.get(..forward.subcommand_index),
191 forward.arguments.get(forward.subcommand_index + 1..),
192 ) else {
193 return 1;
194 };
195 // The parent uploads the LFS objects first, as one child of its own; the push, the probe and the merge push below
196 // carry no hook and start from the unchanged arguments.
197 if forward.lfs
198 && let Some(code) = upload_first(&InteractiveUpload {
199 prefix,
200 region,
201 real_git: forward.real_git.as_path(),
202 environment: forward.environment.as_slice(),
203 overlay: forward.overlay.as_slice(),
204 settings: forward.lfs_settings.as_deref(),
205 })
206 {
207 return code;
208 }
209 let code: i32 = match run_forwarded_git(
210 forward.real_git.as_path(),
211 forward.arguments.as_slice(),
212 forward.overlay.as_slice(),
213 forward.copy.as_deref(),
214 ) {
215 ForwardedRun::Ran(0) => return 0,
216 ForwardedRun::Ran(code) => code,
217 ForwardedRun::Failed { stderr, code } => {
218 // Terminal output error: there is nowhere left to report a failure to write a diagnostic.
219 let _written = std::io::stderr().write_all(stderr.as_bytes());
220 return code;
221 }
222 };
223 if !forward.stack {
224 return code;
225 }
226 let Ok(cwd) = std::env::current_dir() else {
227 return code;
228 };
229 let git = PushGit {
230 real_git: forward.real_git.clone(),
231 cwd,
232 environment: environment_with_overlay(forward.environment.as_slice(), forward.overlay.as_slice()),
233 lfs: false,
234 };
235 let request = StackRequest {
236 git: &git,
237 global_prefix: prefix,
238 merge_push: &gated_merge_push,
239 region,
240 };
241 return stack_failed_push(&request, i64::from(code), &mut std::io::stderr()).unwrap_or(code);
242}
243
244/// Stacking is controlled in the test module.
245#[cfg(test)]
246#[path = "push_stacking_tests.rs"]
247mod tests;