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