Skip to main content

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 by this executable with `STACKED_PUSH_MARKER` set, so the policies that gate any push read the
11//!  integrated result, and the marker stops that push from stacking again.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const code = await runPush(args); if (code !== 0) code = (await stackFailedPush({ args })) ?? code;
16//! ```
17
18/// The reconcile loop that auto-push shares, and what it needs.
19use super::auto_push_reconcile::{Destination, Integrated, Reconcile, integrate_and_push};
20/// The coordinator's Git, its read-only query runner and its error type.
21use super::auto_push_single_flight::{PushAttempt, PushGit, SingleFlightError, run_query};
22/// Starts that ride out a short shortage of processes.
23use super::start_retry::RetryStart;
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 destination and object names the push would send.
33use super::manual_push_probe::probe_manual_push_updates;
34/// The settled or plain run of the forwarded command.
35use super::worktree_copy_forward::{ForwardedRun, run_forwarded_git};
36/// Owned OS strings for Git arguments.
37use std::ffi::OsString;
38/// Standard error as a writable stream.
39use std::io::Write;
40/// File locations.
41use std::path::Path;
42
43/// Tag used for diagnostics from this module.
44const TAG: &str = "cli-git";
45
46/// The environment variable the stacked push carries so that it is never stacked again.
47pub const STACKED_PUSH_MARKER: &str = "CLI_GIT_STACKED_PUSH";
48
49/// What a failed explicit push needs to be integrated.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// type StackRequest = { git: PushGit; ownExecutable: string; globalPrefix: string[]; region: string[] };
54/// ```
55pub struct StackRequest<'a> {
56    /// The repository's Git with the invocation's environment.
57    pub git: &'a PushGit,
58    /// This executable, which publishes the merge so every push gate reads it.
59    pub own_executable: &'a Path,
60    /// The global options before `push`.
61    pub global_prefix: &'a [OsString],
62    /// The arguments after `push`.
63    pub region: &'a [OsString],
64}
65
66/// What:
67///  The branch HEAD names, or nothing for a detached HEAD.
68/// Why:
69///   The branch follows the published merge when it can; a detached HEAD stays where it is.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// (await git(['symbolic-ref', '--quiet', 'HEAD'])).stdout.trim() || undefined
74/// ```
75fn checked_out_branch(git: &PushGit) -> Option<String> {
76    let arguments: Vec<OsString> = ["symbolic-ref", "--quiet", "HEAD"]
77        .iter()
78        .map(OsString::from)
79        .collect();
80    let output = run_query(git, &arguments).ok()?;
81    if output.code != Some(0) {
82        return None;
83    }
84    let name = String::from_utf8_lossy(&output.stdout).trim().to_owned();
85    if name.is_empty() {
86        return None;
87    }
88    return Some(name);
89}
90
91/// What:
92///  Integrate the remote's new commits after an explicit push failed, or say nothing when the failure was no divergence.
93///  `Option<i32>` is "the new exit status, or nothing to change".
94/// Why:
95///   The status is `0` when the merge was published, and the failing status when the run says why it was not
96///  (a conflict, a gate that refused the merge, a remote that kept moving); every other failure returns nothing
97///  and prints nothing, so the person sees only Git's own report.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// async function stackFailedPush(request, failedCode, err): Promise<number | undefined>;
102/// ```
103pub fn stack_failed_push(
104    request: &StackRequest<'_>,
105    failed_code: i64,
106    err: &mut dyn Write,
107) -> Option<i32> {
108    let tokens: Vec<OsString> = stackable_option_tokens(request.region)?;
109    let git = request.git;
110    let updates = match probe_manual_push_updates(git, request.global_prefix, request.region) {
111        Ok(found) => found,
112        Err(error) => {
113            debug(
114                TAG,
115                &format!("the failed push's updates could not be read: {}", error.message),
116            );
117            return None;
118        }
119    };
120    let [update] = updates.as_slice() else {
121        return None;
122    };
123    let local = update.local_oid.clone()?;
124    update.remote_oid.as_ref()?;
125    if !update.remote_ref.starts_with("refs/heads/") {
126        return None;
127    }
128    let branch_ref: Option<String> = checked_out_branch(git);
129    let plan = Reconcile {
130        destination: Destination {
131            remote: update.remote_name.clone(),
132            merge_ref: update.remote_ref.clone(),
133        },
134        local,
135        branch_ref: branch_ref.as_deref(),
136        label: "push",
137        explicit: true,
138    };
139    let mut push_merge = |destination: &Destination, merge_oid: &str| -> Result<PushAttempt, SingleFlightError> {
140        let status = std::process::Command::new(request.own_executable)
141            .current_dir(&git.cwd)
142            .args(request.global_prefix)
143            .arg("push")
144            .args(&tokens)
145            .arg(&destination.remote)
146            .arg(format!("{merge_oid}:{}", destination.merge_ref))
147            .env_clear()
148            .envs(git.environment.iter().map(|(name, value)| {
149                return (name, value);
150            }))
151            .env(STACKED_PUSH_MARKER, "1")
152            .status_retrying()
153            .map_err(SingleFlightError::Io)?;
154        return Ok(PushAttempt {
155            exit_code: i64::from(status.code().unwrap_or(1)),
156            output: String::new(),
157        });
158    };
159    let rejected = PushAttempt {
160        exit_code: failed_code,
161        output: String::new(),
162    };
163    match integrate_and_push(git, &plan, rejected, &mut push_merge) {
164        Ok(Integrated::NotDivergent(_)) => return None,
165        Ok(Integrated::Finished(attempt)) => {
166            let text = attempt.output.trim();
167            if !text.is_empty() {
168                // Terminal output error: the report is advisory; the exit status already carries the result.
169                let _written = writeln!(err, "{text}");
170            }
171            return Some(i32::try_from(attempt.exit_code).unwrap_or(1));
172        }
173        Err(error) => {
174            debug(TAG, &format!("stacking the failed push stopped: {error}"));
175            return None;
176        }
177    }
178}
179
180/// What:
181///  Run an eligible explicit push as a child with the caller's streams, then integrate it when it failed on a divergence.
182/// Why:
183///   The wrapper must outlive Git to look at its status; nothing is buffered, so Git's own output is what the person sees.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// process.exitCode = await runStackedPush(forward);
188/// ```
189pub fn run_stacked_push(forward: &PushForward) -> i32 {
190    let code: i32 = match run_forwarded_git(
191        forward.real_git.as_path(),
192        forward.arguments.as_slice(),
193        forward.overlay.as_slice(),
194        forward.copy.as_deref(),
195    ) {
196        ForwardedRun::Ran(0) => return 0,
197        ForwardedRun::Ran(code) => code,
198        ForwardedRun::Failed { stderr, code } => {
199            // Terminal output error: there is nowhere left to report a failure to write a diagnostic.
200            let _written = std::io::stderr().write_all(stderr.as_bytes());
201            return code;
202        }
203    };
204    let (Ok(cwd), Ok(own_executable)) = (std::env::current_dir(), std::env::current_exe()) else {
205        return code;
206    };
207    let git = PushGit {
208        real_git: forward.real_git.clone(),
209        cwd,
210        environment: environment_with_overlay(forward.environment.as_slice(), forward.overlay.as_slice()),
211    };
212    let Some(prefix) = forward.arguments.get(..forward.subcommand_index) else {
213        return code;
214    };
215    let Some(region) = forward.arguments.get(forward.subcommand_index + 1..) else {
216        return code;
217    };
218    let request = StackRequest {
219        git: &git,
220        own_executable: own_executable.as_path(),
221        global_prefix: prefix,
222        region,
223    };
224    return stack_failed_push(&request, i64::from(code), &mut std::io::stderr()).unwrap_or(code);
225}
226
227/// Stacking is controlled in the test module.
228#[cfg(test)]
229#[path = "push_stacking_tests.rs"]
230mod tests;