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, named_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/// What Git prints when another process holds the lock of a reference the push updates.
54const REFERENCE_LOCK_REFUSAL: &str = "cannot lock ref";
55
56/// How many times the service pushes again when a held reference lock refused the update of a remote-tracking reference.
57pub const PUSH_LOCK_RETRIES: u32 = 3;
58
59/// The pause before the first of those pushes; each later one waits a multiple of it.
60const PUSH_LOCK_PAUSE: std::time::Duration = std::time::Duration::from_millis(250);
61
62/// Push argv for a branch without an upstream: create it on origin and record the upstream.
63const FIRST_PUSH_ARGUMENTS: &[&str] = &["push", "--set-upstream", "origin", "HEAD"];
64
65/// Push argv for a branch with an upstream: follow the configured upstream, wherever it lives.
66const UPSTREAM_PUSH_ARGUMENTS: &[&str] = &["push"];
67
68/// What:
69///  Which branch the auto-push took.
70/// Why:
71///   `Skipped` covers both "nowhere to back up to" and "detached HEAD"; both exit 0 silently or with a note.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// type AutoPushOutcome = 'skipped' | 'pushed' | 'failed';
76/// ```
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum AutoPushOutcome {
79    /// Nothing was pushed.
80    Skipped,
81    /// A successful push delivered the commit.
82    Pushed,
83    /// The push that would deliver the commit failed or could not run.
84    Failed,
85}
86
87/// What:
88///  The result of one auto-push attempt.
89/// Why:
90///   `shown` is what the terminal received, so tests compare the surfaced text directly.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// type AutoPushResult = { outcome; exitCode; shown; joined };
95/// ```
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct AutoPushResult {
98    /// Which branch the auto-push took.
99    pub outcome: AutoPushOutcome,
100    /// Push exit code; `0` when skipped or pushed, Git's code (or `1`) on failure.
101    pub exit_code: i64,
102    /// Text surfaced to stderr; empty on a skip, a quiet clean push, or a joined success.
103    pub shown: String,
104    /// Whether another invocation's push decided the outcome.
105    pub joined: bool,
106}
107
108/// What:
109///  Select the output a push shows: only `remote:` lines after a clean push, everything after a failure.
110/// Why:
111///   A rejection, a hook refusal or an offline error stays diagnosable only when the full output shows.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// function filterPushOutput({ output, exitCode }): string;
116/// ```
117pub fn filter_push_output(output: &str, exit_code: i64) -> String {
118    if exit_code != 0 {
119        return output.to_owned();
120    }
121    return output
122        .split('\n')
123        .filter(|line| {
124            return line.starts_with(REMOTE_LINE_PREFIX) || line.starts_with(RECONCILE_LINE_PREFIX);
125        })
126        .collect::<Vec<&str>>()
127        .join("\n");
128}
129
130/// What:
131///  Whether a read-only Git query exits zero.
132/// Why:
133///   A non-zero exit is an answer ("no upstream", "no origin"); a failure to start Git is not an answer.
134///
135/// In TS you'd write (pseudocode):
136/// ```ts
137/// await gitQuerySucceeds({ gitPath, cwd, args }); // false on SubprocessError only
138/// ```
139fn query_succeeds(git: &PushGit, arguments: &[&str]) -> Result<bool, SingleFlightError> {
140    let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
141    return Ok(run_query(git, &argv)?.code == Some(0));
142}
143
144/// What:
145///  The branch HEAD names, or nothing when HEAD is detached.
146/// Why:
147///   `symbolic-ref --quiet HEAD` exits non-zero exactly when HEAD is not a branch.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// readSymbolicHead({ gitPath, cwd }): Promise<string | typeof HEAD_DETACHED>;
152/// ```
153fn read_symbolic_head(git: &PushGit) -> Result<Option<String>, SingleFlightError> {
154    let argv: Vec<OsString> = ["symbolic-ref", "--quiet", "HEAD"]
155        .iter()
156        .map(OsString::from)
157        .collect();
158    let output = run_query(git, &argv)?;
159    if output.code != Some(0) {
160        return Ok(None);
161    }
162    return Ok(Some(
163        super::js_text::trim_javascript(&String::from_utf8_lossy(&output.stdout)).to_owned(),
164    ));
165}
166
167/// What:
168///  Run one real `git push` and capture its interleaved output, with one trailing line break removed,
169///  stopping it when its deadline passes.
170/// Why:
171///   Git's output of stdout and stderr shares one file so the lines interleave in the order they were written,
172///  the trailing line break is removed as the incumbent's captured output is,
173///  and a push that stalls must not hold the branch's push lock forever.
174///
175/// In TS you'd write (pseudocode):
176/// ```ts
177/// const { exitCode, output } = await nanoSpawn(gitPath, pushArgs, { cwd }) // SubprocessError -> { exitCode ?? 1, output }
178/// ```
179fn run_push_command(git: &PushGit, arguments: &[&str]) -> Result<PushAttempt, SingleFlightError> {
180    let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
181    return run_push(git, &argv);
182}
183
184/// What:
185///  The stderr line shown when this commit has to wait behind another process's push.
186/// Why:
187///   A silent wait looks like a hang; the line names the process to look at and how long the wait lasts,
188///  and says the commit is already saved.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// `cli-git: waiting for process ${pid} to finish pushing ${branchRef}; giving up after ${seconds} seconds`
193/// ```
194fn waiting_line(git: &PushGit, branch_ref: &str, waited: Option<std::time::Duration>) -> String {
195    let (pid, facts): (Option<i64>, String) = match holder(git, branch_ref) {
196        Some((pid, lock_directory)) => (
197            Some(pid),
198            holder_facts(pid, lock_directory.as_path(), push_deadline(&git.environment)),
199        ),
200        None => (None, String::new()),
201    };
202    let holder: String = match pid {
203        Some(number) => format!("process {number}"),
204        None => String::from("another cli-git process"),
205    };
206    // A repeat names how long the wait has lasted; the first line has just begun.
207    let progress: String = match waited {
208        Some(elapsed) => format!(" ({} seconds so far)", elapsed.as_secs()),
209        None => String::new(),
210    };
211    return format!(
212        "cli-git: waiting for {holder} to finish pushing {branch_ref}{progress}; giving up after {} seconds \
213         ({LOCK_WAIT_VARIABLE} changes this). The commit is already saved.{facts}",
214        lock_wait_limit(&git.environment).as_secs()
215    );
216}
217
218/// The process that holds the branch's push lock and the lock's directory, when the record can be read.
219///
220/// In TS you'd write (pseudocode):
221/// ```ts
222/// (await readOwnerLockRecord(lockDirectory))?.ownerPid
223/// ```
224fn holder(git: &PushGit, branch_ref: &str) -> Option<(i64, std::path::PathBuf)> {
225    let paths = resolve_push_coordination_paths(git, branch_ref).ok()?;
226    return match read_owner_lock_record(&paths.lock_directory) {
227        Ok(PublishedOwner::Owner(record)) => Some((record.owner_pid, paths.lock_directory)),
228        Ok(PublishedOwner::Busy) | Err(_) => None,
229    };
230}
231
232/// What:
233///  Surface a settled single-flight outcome and convert it into the auto-push result.
234/// Why:
235///   Lines go to `err` exactly as Git printed them, followed by the uncovered note and the failure note,
236///  so the commit's terminal output matches the incumbent's.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// function reportSingleFlightOutcome({ outcome, landedOid, branchRef }): AutoPushResult;
241/// ```
242fn report_outcome(
243    outcome: &SingleFlightOutcome,
244    landed_oid: &str,
245    branch_ref: &str,
246    err: &mut dyn Write,
247) -> std::io::Result<AutoPushResult> {
248    let joined = matches!(
249        outcome.kind,
250        SingleFlightKind::JoinedPushed | SingleFlightKind::JoinedFailed
251    );
252    let failed = matches!(
253        outcome.kind,
254        SingleFlightKind::Failed | SingleFlightKind::JoinedFailed
255    );
256    let shown = if outcome.kind == SingleFlightKind::JoinedPushed {
257        String::new()
258    } else {
259        filter_push_output(&outcome.output, outcome.exit_code)
260    };
261    if !shown.is_empty() {
262        writeln!(err, "{shown}")?;
263    }
264    if !outcome.covered {
265        writeln!(
266            err,
267            "cli-git: landed commit {landed_oid} is no longer on {branch_ref}; auto-push pushed {} without it.",
268            outcome.tip
269        )?;
270    }
271    // A reconcile failure already says why the commit is unpublished; "run `git push`" would repeat a push that cannot succeed.
272    if failed && !outcome.output.contains(RECONCILE_FAILED_PREFIX) {
273        writeln!(err, "{PUSH_FAILED_NOTE}")?;
274    }
275    return Ok(AutoPushResult {
276        outcome: if failed {
277            AutoPushOutcome::Failed
278        } else {
279            AutoPushOutcome::Pushed
280        },
281        exit_code: outcome.exit_code,
282        shown,
283        joined,
284    });
285}
286
287/// What a push plan decided before anything was pushed.
288enum Planned {
289    /// Nothing to push; the result says why.
290    Skip(AutoPushResult),
291    /// Push the branch with these arguments.
292    Push {
293        /// The branch HEAD names.
294        branch_ref: String,
295        /// Whether the branch has an upstream, which makes a moved remote branch reconcilable.
296        has_upstream: bool,
297        /// The `git push` arguments.
298        arguments: Vec<String>,
299    },
300}
301
302/// What:
303///  Decide whether and how to push: no upstream and no origin skips, a detached HEAD skips with a note, otherwise the
304///  push arguments follow the upstream state.
305/// Why:
306///   The wrapper's own auto-push and the service's push consumer decide identically; only how the push is coordinated
307///  differs.
308///
309/// In TS you'd write (pseudocode):
310/// ```ts
311/// const plan = await planPush({ gitPath, cwd });
312/// ```
313fn plan_push(git: &PushGit, err: &mut dyn Write) -> Result<Planned, SingleFlightError> {
314    let has_upstream = query_succeeds(
315        git,
316        &[
317            "rev-parse",
318            "--abbrev-ref",
319            "--symbolic-full-name",
320            "@{upstream}",
321        ],
322    )?;
323    if !has_upstream && !query_succeeds(git, &["remote", "get-url", "origin"])? {
324        debug(TAG, "no upstream and no origin remote; skipping auto-push");
325        return Ok(Planned::Skip(AutoPushResult {
326            outcome: AutoPushOutcome::Skipped,
327            exit_code: 0,
328            shown: String::new(),
329            joined: false,
330        }));
331    }
332    let Some(branch_ref) = read_symbolic_head(git)? else {
333        // Terminal output error: the note itself is the only diagnostic; a failed write to stderr is not a push failure.
334        let _written = writeln!(err, "{DETACHED_HEAD_NOTE}");
335        return Ok(Planned::Skip(AutoPushResult {
336            outcome: AutoPushOutcome::Skipped,
337            exit_code: 0,
338            shown: DETACHED_HEAD_NOTE.to_owned(),
339            joined: false,
340        }));
341    };
342    let named: Option<Vec<String>> = if has_upstream {
343        named_destination_push(git, &branch_ref)?
344    } else {
345        None
346    };
347    let named_arguments: Vec<String> = named.into_iter().flatten().collect();
348    let arguments: Vec<String> = if !named_arguments.is_empty() {
349        named_arguments
350    } else if has_upstream {
351        UPSTREAM_PUSH_ARGUMENTS.iter().map(|argument| { return (*argument).to_owned(); }).collect()
352    } else {
353        FIRST_PUSH_ARGUMENTS.iter().map(|argument| { return (*argument).to_owned(); }).collect()
354    };
355    return Ok(Planned::Push { branch_ref, has_upstream, arguments });
356}
357
358/// What:
359///  Decide how to push a recorded branch whatever the worktree has checked out: no upstream and no origin skips, otherwise
360///  the push names the branch on both sides and never reads `HEAD`.
361/// Why:
362///   The service pushes after the command returned, so the person may switch branches or detach the head at any instant,
363///  including between a check and the push; the record names the branch that received the commit, and the plain
364///  `git push` would publish the checked-out one instead.
365///
366/// In TS you'd write (pseudocode):
367/// ```ts
368/// const plan = await planNamedPush({ gitPath, cwd }, branchRef);
369/// ```
370fn plan_named_push(git: &PushGit, branch_ref: &str, err: &mut dyn Write) -> Result<Planned, SingleFlightError> {
371    let skipped = |shown: String| -> Planned {
372        return Planned::Skip(AutoPushResult { outcome: AutoPushOutcome::Skipped, exit_code: 0, shown, joined: false });
373    };
374    // Git reads `refs/heads/x@{upstream}` as a branch named `refs/heads/x` ("no such branch"), so the query takes the short name;
375    //  a ref outside `refs/heads` has no upstream.
376    let has_upstream: bool = match branch_ref.strip_prefix("refs/heads/") {
377        Some(name) => {
378            let upstream_name: String = format!("{name}@{{upstream}}");
379            query_succeeds(git, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", upstream_name.as_str()])?
380        }
381        None => false,
382    };
383    if !has_upstream && !query_succeeds(git, &["remote", "get-url", "origin"])? {
384        debug(TAG, "no upstream and no origin remote; skipping auto-push");
385        return Ok(skipped(String::new()));
386    }
387    let arguments: Vec<String> = if has_upstream {
388        match named_push(git, branch_ref)? {
389            Some(named) => named,
390            None => {
391                let note: String = format!(
392                    "cli-git: the branch {branch_ref} tracks no remote branch that can be named; skipping auto-push. Run `git push` on that branch."
393                );
394                // Terminal output error: the note itself is the only diagnostic; a failed write to stderr is not a push failure.
395                let _written = writeln!(err, "{note}");
396                return Ok(skipped(note));
397            }
398        }
399    } else {
400        vec![String::from("push"), String::from("--set-upstream"), String::from("origin"), format!("{branch_ref}:{branch_ref}")]
401    };
402    return Ok(Planned::Push { branch_ref: branch_ref.to_owned(), has_upstream, arguments });
403}
404
405/// What:
406///  Push a landed commit's branch once, with no coordination with other processes.
407/// Why:
408///   The service's push consumer is the only pusher of the repositories it serves and coalesces pending pushes of one
409///  branch into this single call, so it needs no lock; the output and the failure note are those of `auto_push`.
410///
411/// In TS you'd write (pseudocode):
412/// ```ts
413/// const result = await pushOnce({ gitPath, cwd }, landedOid);
414/// ```
415pub fn push_once(git: &PushGit, branch_ref: &str, landed_oid: &str, err: &mut dyn Write) -> Result<AutoPushResult, SingleFlightError> {
416    // The record names the branch, and the worktree may switch away from it at any instant, including between a check of
417    //  `HEAD` and the push itself: the plan never reads `HEAD`, so what is pushed is always the recorded branch.
418    let planned: Planned = plan_named_push(git, branch_ref, err)?;
419    let (pushed_ref, has_upstream, arguments): (String, bool, Vec<String>) = match planned {
420        Planned::Skip(result) => return Ok(result),
421        Planned::Push { branch_ref: planned_ref, has_upstream, arguments } => (planned_ref, has_upstream, arguments),
422    };
423    let argument_views: Vec<&str> = arguments.iter().map(String::as_str).collect();
424    debug(TAG, &format!("pushing {landed_oid} with {argument_views:?} on {pushed_ref}"));
425    let mut attempt: PushAttempt = run_push_command(git, argument_views.as_slice())?;
426    // The instances of two worktrees share the repository's references. While another holds the lock of the remote-tracking
427    //  reference this push updates, Git still delivers the push, prints the refusal to update the reference, and exits 0, which
428    //  leaves the reference stale; the same push again finds nothing to deliver and brings the reference up to date.
429    let mut pause: u32 = 0;
430    while attempt.output.contains(REFERENCE_LOCK_REFUSAL) && pause < PUSH_LOCK_RETRIES {
431        pause += 1;
432        std::thread::sleep(PUSH_LOCK_PAUSE * pause);
433        attempt = run_push_command(git, argument_views.as_slice())?;
434    }
435    if has_upstream && attempt.exit_code != 0 {
436        attempt = reconcile_divergent_push(git, &pushed_ref, attempt)?;
437    }
438    let failed: bool = attempt.exit_code != 0;
439    let shown: String = filter_push_output(&attempt.output, attempt.exit_code);
440    if !shown.is_empty() {
441        writeln!(err, "{shown}").map_err(SingleFlightError::Io)?;
442    }
443    if failed && !attempt.output.contains(RECONCILE_FAILED_PREFIX) {
444        writeln!(err, "{PUSH_FAILED_NOTE}").map_err(SingleFlightError::Io)?;
445    }
446    return Ok(AutoPushResult {
447        outcome: if failed { AutoPushOutcome::Failed } else { AutoPushOutcome::Pushed },
448        exit_code: attempt.exit_code,
449        shown,
450        joined: false,
451    });
452}
453
454/// What:
455///  Back up a landed commit by pushing its branch, single-flight per branch.
456/// Why:
457///   The commit has happened by the time this runs and Git ignores a post-commit hook's status,
458///  so a failed push or a failed coordination is printed and returned as `Failed`, and never fails the commit.
459///   Failures before the push decision (an unreadable repository, a Git that cannot start) are returned as errors.
460///
461/// In TS you'd write (pseudocode):
462/// ```ts
463/// async function autoPush({ gitPath, cwd, landedOid }): Promise<AutoPushResult>;
464/// ```
465pub fn auto_push(
466    git: &PushGit,
467    landed_oid: &str,
468    err: &mut dyn Write,
469) -> Result<AutoPushResult, SingleFlightError> {
470    let (branch_ref, has_upstream, owned_arguments): (String, bool, Vec<String>) = match plan_push(git, err)? {
471        Planned::Skip(result) => return Ok(result),
472        Planned::Push { branch_ref, has_upstream, arguments } => (branch_ref, has_upstream, arguments),
473    };
474    let push_arguments: &[&str] = &owned_arguments.iter().map(String::as_str).collect::<Vec<&str>>();
475    debug(
476        TAG,
477        &format!("auto-pushing {landed_oid} with {push_arguments:?} on {branch_ref}"),
478    );
479    let coordinated = {
480        let mut push = || {
481            let attempt = run_push_command(git, push_arguments)?;
482            // Only a branch with an upstream can be rejected for a moved remote branch that a merge cures.
483            if has_upstream && attempt.exit_code != 0 {
484                return reconcile_divergent_push(git, &branch_ref, attempt);
485            }
486            return Ok(attempt);
487        };
488        let mut on_wait = |waited: Option<std::time::Duration>| {
489            let line = waiting_line(git, &branch_ref, waited);
490            debug(TAG, &line);
491            // Terminal output error: the notice is advisory; a failed write to stderr must not stop the wait.
492            let _written = writeln!(err, "{line}");
493        };
494        run_single_flight_push_repeating(git, &branch_ref, landed_oid, &mut push, &mut on_wait)
495    };
496    return match coordinated {
497        Ok(outcome) => {
498            report_outcome(&outcome, landed_oid, &branch_ref, err).map_err(SingleFlightError::Io)
499        }
500        Err(error) => {
501            debug(TAG, &format!("auto-push coordination failed: {error}"));
502            let _written = writeln!(
503                err,
504                "cli-git: auto-push of {landed_oid} could not run: {error}"
505            );
506            let _noted = writeln!(err, "{PUSH_FAILED_NOTE}");
507            Ok(AutoPushResult {
508                outcome: AutoPushOutcome::Failed,
509                exit_code: 1,
510                shown: error.to_string(),
511                joined: false,
512            })
513        }
514    };
515}
516
517/// Auto-push behavior is controlled in the test module.
518#[cfg(test)]
519#[path = "auto_push_tests.rs"]
520mod tests;
521
522/// Racing auto-push processes on one branch, killed pushers and joins.
523#[cfg(test)]
524#[path = "auto_push_concurrency_tests.rs"]
525mod concurrency_tests;