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