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