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