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