Skip to main content

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;