git_policy_cli/auto_push.rs
1//! What:
2//! Post-commit auto-push of a landed commit: which push runs, what its output shows,
3//! and how a coordination failure is reported without failing the commit.
4//! Why:
5//! Every commit in this repository is backed up (`AGENTS.md` rule APG),
6//! and the incumbent's `src/auto-push.ts` decides the push argv from the upstream state,
7//! prints only GitHub `remote:` lines on success and the full output on failure,
8//! and keeps the commit's exit status when the push fails.
9//! The landed commit is an input, so the integrator can call this after any landing.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await autoPush({ gitPath, cwd, landedOid });
14//! ```
15
16/// The single-flight coordinator, its Git command builder and its error type.
17use super::auto_push_single_flight::{
18 PushAttempt, PushGit, SingleFlightError, SingleFlightKind, SingleFlightOutcome, git_command,
19 run_single_flight_push,
20};
21/// The diagnostic log of the wrapper.
22use super::diagnostic_log::debug;
23/// Owned OS strings for Git arguments.
24use std::ffi::OsString;
25/// `Read` drains a pipe; `Write` is the stderr sink the terminal lines go to.
26use std::io::{Read, Write};
27/// `Stdio` connects the child's standard input.
28use std::process::Stdio;
29
30/// Tag used for diagnostics from this module.
31const TAG: &str = "cli-git";
32
33/// Prefix of the GitHub server-side lines a clean push surfaces.
34const REMOTE_LINE_PREFIX: &str = "remote: ";
35
36/// Note printed when HEAD is detached: pushing `HEAD` needs a branch.
37pub const DETACHED_HEAD_NOTE: &str = "cli-git: HEAD is detached (mid-rebase/cherry-pick or a detached checkout); skipping auto-push.";
38
39/// Note printed after every failed push, owned or joined: the commit stays local.
40pub const PUSH_FAILED_NOTE: &str =
41 "cli-git: commit saved locally, but auto-push to origin failed; run `git push` when ready.";
42
43/// Push argv for a branch without an upstream: create it on origin and record the upstream.
44const FIRST_PUSH_ARGUMENTS: &[&str] = &["push", "--set-upstream", "origin", "HEAD"];
45
46/// Push argv for a branch with an upstream: follow the configured upstream, wherever it lives.
47const UPSTREAM_PUSH_ARGUMENTS: &[&str] = &["push"];
48
49/// What:
50/// Which branch the auto-push took.
51/// Why:
52/// `Skipped` covers both "nowhere to back up to" and "detached HEAD"; both exit 0 silently or with a note.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// type AutoPushOutcome = 'skipped' | 'pushed' | 'failed';
57/// ```
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub enum AutoPushOutcome {
60 /// Nothing was pushed.
61 Skipped,
62 /// A successful push delivered the commit.
63 Pushed,
64 /// The push that would deliver the commit failed or could not run.
65 Failed,
66}
67
68/// What:
69/// The result of one auto-push attempt.
70/// Why:
71/// `shown` is what the terminal received, so tests compare the surfaced text directly.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// type AutoPushResult = { outcome; exitCode; shown; joined };
76/// ```
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct AutoPushResult {
79 /// Which branch the auto-push took.
80 pub outcome: AutoPushOutcome,
81 /// Push exit code; `0` when skipped or pushed, Git's code (or `1`) on failure.
82 pub exit_code: i64,
83 /// Text surfaced to stderr; empty on a skip, a quiet clean push, or a joined success.
84 pub shown: String,
85 /// Whether another invocation's push decided the outcome.
86 pub joined: bool,
87}
88
89/// What:
90/// Select the output a push shows: only `remote:` lines after a clean push, everything after a failure.
91/// Why:
92/// A rejection, a hook refusal or an offline error stays diagnosable only when the full output shows.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// function filterPushOutput({ output, exitCode }): string;
97/// ```
98pub fn filter_push_output(output: &str, exit_code: i64) -> String {
99 if exit_code != 0 {
100 return output.to_owned();
101 }
102 return output
103 .split('\n')
104 .filter(|line| {
105 return line.starts_with(REMOTE_LINE_PREFIX);
106 })
107 .collect::<Vec<&str>>()
108 .join("\n");
109}
110
111/// What:
112/// Whether a read-only Git query exits zero.
113/// Why:
114/// A non-zero exit is an answer ("no upstream", "no origin"); a failure to start Git is not an answer.
115///
116/// In TS you'd write (pseudocode):
117/// ```ts
118/// await gitQuerySucceeds({ gitPath, cwd, args }); // false on SubprocessError only
119/// ```
120fn query_succeeds(git: &PushGit, arguments: &[&str]) -> Result<bool, SingleFlightError> {
121 let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
122 let status = git_command(git, &argv)
123 .stdin(Stdio::null())
124 .stdout(Stdio::null())
125 .stderr(Stdio::null())
126 .status()
127 .map_err(SingleFlightError::Start)?;
128 return Ok(status.success());
129}
130
131/// What:
132/// The branch HEAD names, or nothing when HEAD is detached.
133/// Why:
134/// `symbolic-ref --quiet HEAD` exits non-zero exactly when HEAD is not a branch.
135///
136/// In TS you'd write (pseudocode):
137/// ```ts
138/// readSymbolicHead({ gitPath, cwd }): Promise<string | typeof HEAD_DETACHED>;
139/// ```
140fn read_symbolic_head(git: &PushGit) -> Result<Option<String>, SingleFlightError> {
141 let argv: Vec<OsString> = ["symbolic-ref", "--quiet", "HEAD"]
142 .iter()
143 .map(OsString::from)
144 .collect();
145 let output = git_command(git, &argv)
146 .stdin(Stdio::null())
147 .output()
148 .map_err(SingleFlightError::Start)?;
149 if !output.status.success() {
150 return Ok(None);
151 }
152 return Ok(Some(
153 super::js_text::trim_javascript(&String::from_utf8_lossy(&output.stdout)).to_owned(),
154 ));
155}
156
157/// What:
158/// Run one real `git push` and capture its interleaved output, with one trailing line break removed.
159/// Why:
160/// Git's output of stdout and stderr shares one pipe so the lines interleave in the order they were written,
161/// and the trailing line break is removed as the incumbent's captured output is.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// const { exitCode, output } = await nanoSpawn(gitPath, pushArgs, { cwd }) // SubprocessError -> { exitCode ?? 1, output }
166/// ```
167fn run_push_command(git: &PushGit, arguments: &[&str]) -> Result<PushAttempt, SingleFlightError> {
168 let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
169 let (mut reader, writer) = std::io::pipe().map_err(SingleFlightError::Start)?;
170 let writer_for_stderr = writer.try_clone().map_err(SingleFlightError::Start)?;
171 let mut command = git_command(git, &argv);
172 command
173 .stdin(Stdio::null())
174 .stdout(writer)
175 .stderr(writer_for_stderr);
176 let mut child = command.spawn().map_err(SingleFlightError::Start)?;
177 drop(command);
178 let mut bytes: Vec<u8> = Vec::new();
179 reader
180 .read_to_end(&mut bytes)
181 .map_err(SingleFlightError::Start)?;
182 let status = child.wait().map_err(SingleFlightError::Start)?;
183 let text = String::from_utf8_lossy(&bytes);
184 return Ok(PushAttempt {
185 exit_code: i64::from(status.code().unwrap_or(1)),
186 output: strip_one_line_break(&text).to_owned(),
187 });
188}
189
190/// Remove exactly one trailing `\n` or `\r\n`, as the captured output of the incumbent does.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// output.endsWith('\n') ? output.slice(0, output.endsWith('\r\n') ? -2 : -1) : output
195/// ```
196fn strip_one_line_break(text: &str) -> &str {
197 let without_lf = match text.strip_suffix('\n') {
198 Some(stripped) => stripped,
199 None => return text,
200 };
201 return without_lf.strip_suffix('\r').unwrap_or(without_lf);
202}
203
204/// What:
205/// Surface a settled single-flight outcome and convert it into the auto-push result.
206/// Why:
207/// Lines go to `err` exactly as Git printed them, followed by the uncovered note and the failure note,
208/// so the commit's terminal output matches the incumbent's.
209///
210/// In TS you'd write (pseudocode):
211/// ```ts
212/// function reportSingleFlightOutcome({ outcome, landedOid, branchRef }): AutoPushResult;
213/// ```
214fn report_outcome(
215 outcome: &SingleFlightOutcome,
216 landed_oid: &str,
217 branch_ref: &str,
218 err: &mut dyn Write,
219) -> std::io::Result<AutoPushResult> {
220 let joined = matches!(
221 outcome.kind,
222 SingleFlightKind::JoinedPushed | SingleFlightKind::JoinedFailed
223 );
224 let failed = matches!(
225 outcome.kind,
226 SingleFlightKind::Failed | SingleFlightKind::JoinedFailed
227 );
228 let shown = if outcome.kind == SingleFlightKind::JoinedPushed {
229 String::new()
230 } else {
231 filter_push_output(&outcome.output, outcome.exit_code)
232 };
233 if !shown.is_empty() {
234 writeln!(err, "{shown}")?;
235 }
236 if !outcome.covered {
237 writeln!(
238 err,
239 "cli-git: landed commit {landed_oid} is no longer on {branch_ref}; auto-push pushed {} without it.",
240 outcome.tip
241 )?;
242 }
243 if failed {
244 writeln!(err, "{PUSH_FAILED_NOTE}")?;
245 }
246 return Ok(AutoPushResult {
247 outcome: if failed {
248 AutoPushOutcome::Failed
249 } else {
250 AutoPushOutcome::Pushed
251 },
252 exit_code: outcome.exit_code,
253 shown,
254 joined,
255 });
256}
257
258/// What:
259/// Back up a landed commit by pushing its branch, single-flight per branch.
260/// Why:
261/// The commit has happened by the time this runs and Git ignores a post-commit hook's status,
262/// so a failed push or a failed coordination is printed and returned as `Failed`, and never fails the commit.
263/// Failures before the push decision (an unreadable repository, a Git that cannot start) are returned as errors.
264///
265/// In TS you'd write (pseudocode):
266/// ```ts
267/// async function autoPush({ gitPath, cwd, landedOid }): Promise<AutoPushResult>;
268/// ```
269pub fn auto_push(
270 git: &PushGit,
271 landed_oid: &str,
272 err: &mut dyn Write,
273) -> Result<AutoPushResult, SingleFlightError> {
274 let has_upstream = query_succeeds(
275 git,
276 &[
277 "rev-parse",
278 "--abbrev-ref",
279 "--symbolic-full-name",
280 "@{upstream}",
281 ],
282 )?;
283 if !has_upstream && !query_succeeds(git, &["remote", "get-url", "origin"])? {
284 debug(TAG, "no upstream and no origin remote; skipping auto-push");
285 return Ok(AutoPushResult {
286 outcome: AutoPushOutcome::Skipped,
287 exit_code: 0,
288 shown: String::new(),
289 joined: false,
290 });
291 }
292 let Some(branch_ref) = read_symbolic_head(git)? else {
293 // Terminal output error: the note itself is the only diagnostic; a failed write to stderr is not a push failure.
294 let _written = writeln!(err, "{DETACHED_HEAD_NOTE}");
295 return Ok(AutoPushResult {
296 outcome: AutoPushOutcome::Skipped,
297 exit_code: 0,
298 shown: DETACHED_HEAD_NOTE.to_owned(),
299 joined: false,
300 });
301 };
302 let push_arguments = if has_upstream {
303 UPSTREAM_PUSH_ARGUMENTS
304 } else {
305 FIRST_PUSH_ARGUMENTS
306 };
307 debug(
308 TAG,
309 &format!("auto-pushing {landed_oid} with {push_arguments:?} on {branch_ref}"),
310 );
311 let coordinated = {
312 let mut push = || {
313 return run_push_command(git, push_arguments);
314 };
315 let mut on_wait = || {
316 return debug(
317 TAG,
318 &format!("waiting for the in-flight push of {branch_ref}"),
319 );
320 };
321 run_single_flight_push(git, &branch_ref, landed_oid, &mut push, &mut on_wait)
322 };
323 return match coordinated {
324 Ok(outcome) => {
325 report_outcome(&outcome, landed_oid, &branch_ref, err).map_err(SingleFlightError::Io)
326 }
327 Err(error) => {
328 debug(TAG, &format!("auto-push coordination failed: {error}"));
329 let _written = writeln!(
330 err,
331 "cli-git: auto-push of {landed_oid} could not run: {error}"
332 );
333 let _noted = writeln!(err, "{PUSH_FAILED_NOTE}");
334 Ok(AutoPushResult {
335 outcome: AutoPushOutcome::Failed,
336 exit_code: 1,
337 shown: error.to_string(),
338 joined: false,
339 })
340 }
341 };
342}
343
344/// Auto-push behavior is controlled in the test module.
345#[cfg(test)]
346#[path = "auto_push_tests.rs"]
347mod tests;
348
349/// Racing auto-push processes on one branch, killed pushers and joins.
350#[cfg(test)]
351#[path = "auto_push_concurrency_tests.rs"]
352mod concurrency_tests;