Skip to main content

git_policy_cli/push/
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 crate::auto_push_reconcile::{Destination, Integrated, Reconcile, integrate_and_push};
22/// The coordinator's Git, its read-only query runner and its error type.
23use crate::auto_push_single_flight::{PushAttempt, PushGit, SingleFlightError, run_query};
24/// The action data of a stacked push.
25use crate::action::PushForward;
26/// The environment of a Git child.
27use crate::child_environment::environment_with_overlay;
28/// The table-driven read of a push's options.
29use crate::command_push::stackable_option_tokens;
30/// The diagnostic log of the wrapper.
31use crate::diagnostic_log::debug;
32/// The check that the upload can start, what the upload sends, and its run.
33use crate::lfs_interactive::{InteractiveUpload, upload_first};
34/// The merge push, planned and run inside this process.
35use crate::stacked_merge_push::gated_merge_push;
36/// The destination and object names the push would send.
37use crate::manual_push_probe::probe_manual_push_updates;
38/// The settled or plain run of the forwarded command.
39use crate::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;