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