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