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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
//! What:
//! Post-commit auto-push of a landed commit: which push runs, what its output shows,
//! and how a coordination failure is reported without failing the commit.
//! Why:
//! Every commit in this repository is backed up (`AGENTS.md` rule APG),
//! and the incumbent's `src/auto-push.ts` decides the push argv from the upstream state,
//! prints only GitHub `remote:` lines on success and the full output on failure,
//! and keeps the commit's exit status when the push fails.
//! The landed commit is an input, so the integrator can call this after any landing.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await autoPush({ gitPath, cwd, landedOid });
//! ```
/// The single-flight coordinator, its Git command builder and its error type.
use super::auto_push_single_flight::{
PushAttempt, PushGit, SingleFlightError, SingleFlightKind, SingleFlightOutcome, git_command,
run_single_flight_push,
};
/// The diagnostic log of the wrapper.
use super::diagnostic_log::debug;
/// Owned OS strings for Git arguments.
use std::ffi::OsString;
/// `Read` drains a pipe; `Write` is the stderr sink the terminal lines go to.
use std::io::{Read, Write};
/// `Stdio` connects the child's standard input.
use std::process::Stdio;
/// Tag used for diagnostics from this module.
const TAG: &str = "cli-git";
/// Prefix of the GitHub server-side lines a clean push surfaces.
const REMOTE_LINE_PREFIX: &str = "remote: ";
/// Note printed when HEAD is detached: pushing `HEAD` needs a branch.
pub const DETACHED_HEAD_NOTE: &str = "cli-git: HEAD is detached (mid-rebase/cherry-pick or a detached checkout); skipping auto-push.";
/// Note printed after every failed push, owned or joined: the commit stays local.
pub const PUSH_FAILED_NOTE: &str =
"cli-git: commit saved locally, but auto-push to origin failed; run `git push` when ready.";
/// Push argv for a branch without an upstream: create it on origin and record the upstream.
const FIRST_PUSH_ARGUMENTS: &[&str] = &["push", "--set-upstream", "origin", "HEAD"];
/// Push argv for a branch with an upstream: follow the configured upstream, wherever it lives.
const UPSTREAM_PUSH_ARGUMENTS: &[&str] = &["push"];
/// What:
/// Which branch the auto-push took.
/// Why:
/// `Skipped` covers both "nowhere to back up to" and "detached HEAD"; both exit 0 silently or with a note.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type AutoPushOutcome = 'skipped' | 'pushed' | 'failed';
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AutoPushOutcome {
/// Nothing was pushed.
Skipped,
/// A successful push delivered the commit.
Pushed,
/// The push that would deliver the commit failed or could not run.
Failed,
}
/// What:
/// The result of one auto-push attempt.
/// Why:
/// `shown` is what the terminal received, so tests compare the surfaced text directly.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type AutoPushResult = { outcome; exitCode; shown; joined };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AutoPushResult {
/// Which branch the auto-push took.
pub outcome: AutoPushOutcome,
/// Push exit code; `0` when skipped or pushed, Git's code (or `1`) on failure.
pub exit_code: i64,
/// Text surfaced to stderr; empty on a skip, a quiet clean push, or a joined success.
pub shown: String,
/// Whether another invocation's push decided the outcome.
pub joined: bool,
}
/// What:
/// Select the output a push shows: only `remote:` lines after a clean push, everything after a failure.
/// Why:
/// A rejection, a hook refusal or an offline error stays diagnosable only when the full output shows.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function filterPushOutput({ output, exitCode }): string;
/// ```
pub fn filter_push_output(output: &str, exit_code: i64) -> String {
if exit_code != 0 {
return output.to_owned();
}
return output
.split('\n')
.filter(|line| {
return line.starts_with(REMOTE_LINE_PREFIX);
})
.collect::<Vec<&str>>()
.join("\n");
}
/// What:
/// Whether a read-only Git query exits zero.
/// Why:
/// A non-zero exit is an answer ("no upstream", "no origin"); a failure to start Git is not an answer.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await gitQuerySucceeds({ gitPath, cwd, args }); // false on SubprocessError only
/// ```
fn query_succeeds(git: &PushGit, arguments: &[&str]) -> Result<bool, SingleFlightError> {
let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
let status = git_command(git, &argv)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(SingleFlightError::Start)?;
return Ok(status.success());
}
/// What:
/// The branch HEAD names, or nothing when HEAD is detached.
/// Why:
/// `symbolic-ref --quiet HEAD` exits non-zero exactly when HEAD is not a branch.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// readSymbolicHead({ gitPath, cwd }): Promise<string | typeof HEAD_DETACHED>;
/// ```
fn read_symbolic_head(git: &PushGit) -> Result<Option<String>, SingleFlightError> {
let argv: Vec<OsString> = ["symbolic-ref", "--quiet", "HEAD"]
.iter()
.map(OsString::from)
.collect();
let output = git_command(git, &argv)
.stdin(Stdio::null())
.output()
.map_err(SingleFlightError::Start)?;
if !output.status.success() {
return Ok(None);
}
return Ok(Some(
super::js_text::trim_javascript(&String::from_utf8_lossy(&output.stdout)).to_owned(),
));
}
/// What:
/// Run one real `git push` and capture its interleaved output, with one trailing line break removed.
/// Why:
/// Git's output of stdout and stderr shares one pipe so the lines interleave in the order they were written,
/// and the trailing line break is removed as the incumbent's captured output is.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const { exitCode, output } = await nanoSpawn(gitPath, pushArgs, { cwd }) // SubprocessError -> { exitCode ?? 1, output }
/// ```
fn run_push_command(git: &PushGit, arguments: &[&str]) -> Result<PushAttempt, SingleFlightError> {
let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
let (mut reader, writer) = std::io::pipe().map_err(SingleFlightError::Start)?;
let writer_for_stderr = writer.try_clone().map_err(SingleFlightError::Start)?;
let mut command = git_command(git, &argv);
command
.stdin(Stdio::null())
.stdout(writer)
.stderr(writer_for_stderr);
let mut child = command.spawn().map_err(SingleFlightError::Start)?;
drop(command);
let mut bytes: Vec<u8> = Vec::new();
reader
.read_to_end(&mut bytes)
.map_err(SingleFlightError::Start)?;
let status = child.wait().map_err(SingleFlightError::Start)?;
let text = String::from_utf8_lossy(&bytes);
return Ok(PushAttempt {
exit_code: i64::from(status.code().unwrap_or(1)),
output: strip_one_line_break(&text).to_owned(),
});
}
/// Remove exactly one trailing `\n` or `\r\n`, as the captured output of the incumbent does.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// output.endsWith('\n') ? output.slice(0, output.endsWith('\r\n') ? -2 : -1) : output
/// ```
fn strip_one_line_break(text: &str) -> &str {
let without_lf = match text.strip_suffix('\n') {
Some(stripped) => stripped,
None => return text,
};
return without_lf.strip_suffix('\r').unwrap_or(without_lf);
}
/// What:
/// Surface a settled single-flight outcome and convert it into the auto-push result.
/// Why:
/// Lines go to `err` exactly as Git printed them, followed by the uncovered note and the failure note,
/// so the commit's terminal output matches the incumbent's.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function reportSingleFlightOutcome({ outcome, landedOid, branchRef }): AutoPushResult;
/// ```
fn report_outcome(
outcome: &SingleFlightOutcome,
landed_oid: &str,
branch_ref: &str,
err: &mut dyn Write,
) -> std::io::Result<AutoPushResult> {
let joined = matches!(
outcome.kind,
SingleFlightKind::JoinedPushed | SingleFlightKind::JoinedFailed
);
let failed = matches!(
outcome.kind,
SingleFlightKind::Failed | SingleFlightKind::JoinedFailed
);
let shown = if outcome.kind == SingleFlightKind::JoinedPushed {
String::new()
} else {
filter_push_output(&outcome.output, outcome.exit_code)
};
if !shown.is_empty() {
writeln!(err, "{shown}")?;
}
if !outcome.covered {
writeln!(
err,
"cli-git: landed commit {landed_oid} is no longer on {branch_ref}; auto-push pushed {} without it.",
outcome.tip
)?;
}
if failed {
writeln!(err, "{PUSH_FAILED_NOTE}")?;
}
return Ok(AutoPushResult {
outcome: if failed {
AutoPushOutcome::Failed
} else {
AutoPushOutcome::Pushed
},
exit_code: outcome.exit_code,
shown,
joined,
});
}
/// What:
/// Back up a landed commit by pushing its branch, single-flight per branch.
/// Why:
/// The commit has happened by the time this runs and Git ignores a post-commit hook's status,
/// so a failed push or a failed coordination is printed and returned as `Failed`, and never fails the commit.
/// Failures before the push decision (an unreadable repository, a Git that cannot start) are returned as errors.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function autoPush({ gitPath, cwd, landedOid }): Promise<AutoPushResult>;
/// ```
pub fn auto_push(
git: &PushGit,
landed_oid: &str,
err: &mut dyn Write,
) -> Result<AutoPushResult, SingleFlightError> {
let has_upstream = query_succeeds(
git,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
)?;
if !has_upstream && !query_succeeds(git, &["remote", "get-url", "origin"])? {
debug(TAG, "no upstream and no origin remote; skipping auto-push");
return Ok(AutoPushResult {
outcome: AutoPushOutcome::Skipped,
exit_code: 0,
shown: String::new(),
joined: false,
});
}
let Some(branch_ref) = read_symbolic_head(git)? else {
// Terminal output error: the note itself is the only diagnostic; a failed write to stderr is not a push failure.
let _written = writeln!(err, "{DETACHED_HEAD_NOTE}");
return Ok(AutoPushResult {
outcome: AutoPushOutcome::Skipped,
exit_code: 0,
shown: DETACHED_HEAD_NOTE.to_owned(),
joined: false,
});
};
let push_arguments = if has_upstream {
UPSTREAM_PUSH_ARGUMENTS
} else {
FIRST_PUSH_ARGUMENTS
};
debug(
TAG,
&format!("auto-pushing {landed_oid} with {push_arguments:?} on {branch_ref}"),
);
let coordinated = {
let mut push = || {
return run_push_command(git, push_arguments);
};
let mut on_wait = || {
return debug(
TAG,
&format!("waiting for the in-flight push of {branch_ref}"),
);
};
run_single_flight_push(git, &branch_ref, landed_oid, &mut push, &mut on_wait)
};
return match coordinated {
Ok(outcome) => {
report_outcome(&outcome, landed_oid, &branch_ref, err).map_err(SingleFlightError::Io)
}
Err(error) => {
debug(TAG, &format!("auto-push coordination failed: {error}"));
let _written = writeln!(
err,
"cli-git: auto-push of {landed_oid} could not run: {error}"
);
let _noted = writeln!(err, "{PUSH_FAILED_NOTE}");
Ok(AutoPushResult {
outcome: AutoPushOutcome::Failed,
exit_code: 1,
shown: error.to_string(),
joined: false,
})
}
};
}
/// Auto-push behavior is controlled in the test module.
#[cfg(test)]
#[path = "auto_push_tests.rs"]
mod tests;
/// Racing auto-push processes on one branch, killed pushers and joins.
#[cfg(test)]
#[path = "auto_push_concurrency_tests.rs"]
mod concurrency_tests;