Skip to main content

git_policy_cli/
auto_push_single_flight.rs

1//! What:
2//!  Single-flight auto-push per branch: one owner lock serializes the pushes of a branch,
3//!  and the `last-pushed` record names the most recent attempt.
4//! Why:
5//!  A commit that a recorded successful push already covers finishes without pushing,
6//!  and a commit that waits behind a live pusher joins the push it waited for,
7//!  so concurrent landings on one branch cost fewer pushes than commits.
8//!  The incumbent's `src/auto-push-single-flight.ts` decides these joins,
9//!  and the lock and record paths must match its paths byte for byte.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await runSingleFlightPush({ gitPath, cwd, branchRef, landedOid, push, onWait });
14//! ```
15
16/// The branch key, the record and the owner lock this module coordinates through.
17use super::auto_push_branch_key::encode_branch_key;
18use super::auto_push_record::{
19    LAST_PUSHED_SUFFIX, LastPushedOutcome, LastPushedRead, LastPushedRecord,
20    read_last_pushed_record, write_last_pushed_record,
21};
22use super::bounded_push::{PUSH_DEADLINE_VARIABLE, STOP_GRACE, lock_wait_limit};
23use super::bounded_query::{QueryOutput, query_deadline, run_bounded_query};
24use super::diagnostic_log::debug;
25use super::js_text::trim_javascript;
26use super::owner_lock::{DEFAULT_POLL_DELAY, OwnerLock, OwnerLockError, WaitNotice, acquire_within};
27/// The invocation's environment values and the cadence of a wait's notices.
28use super::child_environment::environment_value;
29use super::wait_progress::{Cadence, NOTICE_INTERVAL_VARIABLE};
30/// Owned OS strings for Git arguments and the child environment.
31use std::ffi::OsString;
32/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
33use std::path::{Path, PathBuf};
34/// `Command` starts real Git.
35use std::process::Command;
36
37/// Tag used for diagnostics from this module.
38const TAG: &str = "cli-git";
39
40/// Exit code `git merge-base --is-ancestor` uses for "not an ancestor".
41const NOT_ANCESTOR_EXIT_CODE: i32 = 1;
42
43/// Mode of the coordination directory, shared with the incumbent.
44const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
45
46/// What:
47///  Why a single-flight push could not finish.
48/// Why:
49///   Every variant is a coordination failure, which the caller reports and keeps the commit for.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// SubprocessError | TypeError | OwnerLockError | NodeJS.ErrnoException
54/// ```
55#[derive(Debug)]
56pub enum SingleFlightError {
57    /// Git could not be started, or failed in a way the incumbent does not model as an exit status.
58    Start(std::io::Error),
59    /// A Git query exited with a status the caller did not expect.
60    Git {
61        /// Arguments of the query, for the diagnostic.
62        arguments: String,
63        /// Exit code, or nothing when a signal ended the process.
64        code: Option<i32>,
65        /// Complete standard error text of the query.
66        stderr: String,
67    },
68    /// A coordination file could not be read or published.
69    Io(std::io::Error),
70    /// The owner lock could not be acquired.
71    Lock(OwnerLockError),
72    /// A Git query was still running at its deadline and was stopped.
73    TimedOut {
74        /// Arguments of the query, for the diagnostic.
75        arguments: String,
76        /// The deadline it exceeded, in whole seconds.
77        seconds: u64,
78    },
79    /// A Git query was not started because the time the lock holder may spend was already spent.
80    BudgetSpent {
81        /// Arguments of the query, for the diagnostic.
82        arguments: String,
83    },
84}
85
86impl std::fmt::Display for SingleFlightError {
87    /// Writes the diagnostic text of the failure.
88    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        return match self {
90            Self::Start(error) => write!(formatter, "git could not be started: {error}"),
91            Self::Git {
92                arguments,
93                code,
94                stderr,
95            } => write!(
96                formatter,
97                "git {arguments} exited with {code:?}: {}",
98                stderr.trim_end()
99            ),
100            Self::Io(error) => write!(formatter, "{error}"),
101            Self::Lock(error) => write!(formatter, "{error}"),
102            Self::TimedOut { arguments, seconds } => write!(
103                formatter,
104                "git {arguments} did not finish within {seconds} seconds and was stopped; \
105                 {PUSH_DEADLINE_VARIABLE} sets the limit"
106            ),
107            Self::BudgetSpent { arguments } => write!(
108                formatter,
109                "git {arguments} was not started: the time this process may hold the push lock is spent; \
110                 {PUSH_DEADLINE_VARIABLE} sets the limit"
111            ),
112        };
113    }
114}
115
116impl std::error::Error for SingleFlightError {}
117
118/// What:
119///  One push this invocation ran itself.
120/// Why:
121///   `output` is Git's interleaved output with one trailing line break removed, as the incumbent's
122///  `nano-spawn` `output` is; a successful push's output is never recorded.
123///
124/// In TS you'd write (pseudocode):
125/// ```ts
126/// type PushAttempt = { exitCode: number; output: string };
127/// ```
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct PushAttempt {
130    /// Push exit code, `0` on success.
131    pub exit_code: i64,
132    /// Complete interleaved push output.
133    pub output: String,
134}
135
136/// What:
137///  How a landed commit's auto-push finished.
138/// Why:
139///   The four kinds keep "this invocation pushed" apart from "another invocation's push decided",
140///  because only the owner of a failed push prints its full output.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// type SingleFlightKind = 'pushed' | 'failed' | 'joined-pushed' | 'joined-failed';
145/// ```
146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
147pub enum SingleFlightKind {
148    /// This invocation's push succeeded.
149    Pushed,
150    /// This invocation's push failed.
151    Failed,
152    /// Another invocation's successful push covers this commit.
153    JoinedPushed,
154    /// Another invocation's failed push covers this commit.
155    JoinedFailed,
156}
157
158/// What:
159///  How a landed commit's auto-push finished, with the deciding push's facts.
160/// Why:
161///   The caller prints the output and the uncovered note from these fields alone.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// type SingleFlightOutcome = { kind; tip; exitCode; output; covered };
166/// ```
167#[derive(Clone, Debug, Eq, PartialEq)]
168pub struct SingleFlightOutcome {
169    /// Which of the four outcomes this is.
170    pub kind: SingleFlightKind,
171    /// Branch tip the deciding push resolved.
172    pub tip: String,
173    /// Deciding push exit code.
174    pub exit_code: i64,
175    /// Complete output of the deciding push; empty for a joined success.
176    pub output: String,
177    /// Whether the deciding push's tip contains the landed commit.
178    pub covered: bool,
179}
180
181/// What:
182///  Paths of one branch's coordination files.
183/// Why:
184///   The lock and the record live beside each other under the common Git directory.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// type PushCoordinationPaths = { lockDirectory: string; recordPath: string };
189/// ```
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct PushCoordinationPaths {
192    /// Owner-lock directory.
193    pub lock_directory: PathBuf,
194    /// `last-pushed` record file.
195    pub record_path: PathBuf,
196}
197
198/// What:
199///  The real Git a coordinator runs, where, and with which environment.
200/// Why:
201///   The incumbent's coordination inherits the wrapper's whole environment plus the child-environment overlay,
202///  so the caller passes that complete environment in (`environment_with_overlay`)
203///  and nothing is read from the process environment here.
204///
205/// In TS you'd write (pseudocode):
206/// ```ts
207/// type PushGit = { gitPath: string; cwd: string; environment: NodeJS.ProcessEnv };
208/// ```
209#[derive(Clone, Debug, Eq, PartialEq)]
210pub struct PushGit {
211    /// Absolute real Git executable.
212    pub real_git: PathBuf,
213    /// Directory the commit landed in.
214    pub cwd: PathBuf,
215    /// Complete environment every Git child receives: the invocation's variables with the overlay applied.
216    pub environment: Vec<(OsString, OsString)>,
217    /// Whether the pushes of this coordinator must upload LFS objects first (`lfs_bounded_push`); queries and fetches never do.
218    pub lfs: bool,
219}
220
221/// What:
222///  Build a real Git command with the given arguments, directory and environment.
223/// Why:
224///   Every coordination query and push starts Git this way, so none re-enters the wrapper.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// nanoSpawn(gitPath, args, { cwd })
229/// ```
230pub fn git_command(git: &PushGit, arguments: &[OsString]) -> Command {
231    let mut command = Command::new(&git.real_git);
232    super::hooks_off::add_arguments(&mut command, arguments);
233    return in_push_environment(command, git);
234}
235
236/// What:
237///  Give `command` the push's working directory and its exact environment.
238/// Why:
239///   Both builders of push Git commands run in the same place with the same environment.
240///
241/// In TS you'd write (pseudocode):
242/// ```ts
243/// { ...command, cwd: git.cwd, env: git.environment }
244/// ```
245fn in_push_environment(mut command: Command, git: &PushGit) -> Command {
246    command
247        .current_dir(&git.cwd)
248        .env_clear()
249        .envs(git.environment.iter().map(|pair| {
250            return (&pair.0, &pair.1);
251        }));
252    return command;
253}
254
255/// The arguments as one display string, for diagnostics.
256///
257/// In TS you'd write (pseudocode):
258/// ```ts
259/// args.join(' ')
260/// ```
261fn describe_arguments(arguments: &[OsString]) -> String {
262    return arguments
263        .iter()
264        .map(|argument| {
265            return argument.to_string_lossy().into_owned();
266        })
267        .collect::<Vec<String>>()
268        .join(" ");
269}
270
271/// What:
272///  Run a read-only Git query and return its trimmed standard output.
273///       `Result<A, B>` is either the output or a failure.
274/// Why:
275///   Any failed query is a coordination failure; it is never read as an answer.
276///
277/// In TS you'd write (pseudocode):
278/// ```ts
279/// (await nanoSpawn(gitPath, args, { cwd })).stdout.trim();
280/// ```
281pub fn git_output(git: &PushGit, arguments: &[OsString]) -> Result<String, SingleFlightError> {
282    let output = run_query(git, arguments)?;
283    if output.code != Some(0) {
284        return Err(SingleFlightError::Git {
285            arguments: describe_arguments(arguments),
286            code: output.code,
287            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
288        });
289    }
290    let text = String::from_utf8_lossy(&output.stdout);
291    return Ok(trim_javascript(&text).to_owned());
292}
293
294/// What:
295///  Run a read-only Git query with its own deadline and capture its streams separately.
296/// Why:
297///   A query that never returns would hold the caller, and the push lock behind it, for good.
298///
299/// In TS you'd write (pseudocode):
300/// ```ts
301/// await nanoSpawn(gitPath, args, { cwd, signal: AbortSignal.timeout(deadlineMs) })
302/// ```
303pub fn run_query(git: &PushGit, arguments: &[OsString]) -> Result<QueryOutput, SingleFlightError> {
304    // An exact read-only command line the in-process readers know costs no Git process.
305    if let Some((code, stdout, stderr)) = super::repo_read_hook::try_complete(arguments, git.cwd.as_path(), super::git_system_config::with_real_git(git.environment.as_slice(), git.real_git.as_path()).as_slice()) {
306        return Ok(QueryOutput { code, stdout, stderr });
307    }
308    return run_bounded_query(
309        git_command(git, arguments),
310        describe_arguments(arguments).as_str(),
311        query_deadline(&git.environment),
312        STOP_GRACE,
313        git.environment.as_slice(),
314    );
315}
316
317/// Argument list from string literals.
318///
319/// In TS you'd write (pseudocode):
320/// ```ts
321/// ['rev-parse', '--verify', ...]
322/// ```
323fn argv(parts: &[&str]) -> Vec<OsString> {
324    return parts.iter().map(OsString::from).collect();
325}
326
327/// What:
328///  Resolve one branch's coordination file paths under the common Git directory and create that directory.
329/// Why:
330///   Linked worktrees share one common directory, so every worktree of a repository coordinates on one lock.
331///
332/// In TS you'd write (pseudocode):
333/// ```ts
334/// async function resolvePushCoordinationPaths({ gitPath, cwd, branchRef }): Promise<PushCoordinationPaths>;
335/// ```
336pub fn resolve_push_coordination_paths(
337    git: &PushGit,
338    branch_ref: &str,
339) -> Result<PushCoordinationPaths, SingleFlightError> {
340    let reported = git_output(
341        git,
342        &argv(&["rev-parse", "--path-format=absolute", "--git-common-dir"]),
343    )?;
344    let common = if Path::new(&reported).is_absolute() {
345        PathBuf::from(&reported)
346    } else {
347        git.cwd.join(&reported)
348    };
349    let directory = common.join("cli-git").join("push");
350    create_directory(&directory).map_err(SingleFlightError::Io)?;
351    let key = encode_branch_key(branch_ref);
352    return Ok(PushCoordinationPaths {
353        lock_directory: directory.join(format!("{key}.lock")),
354        record_path: directory.join(format!("{key}{LAST_PUSHED_SUFFIX}")),
355    });
356}
357
358/// What:
359///  Create a directory tree with mode `0700`, as the incumbent's `mkdir(..., { recursive, mode })`.
360/// Why:
361///   Coordination state is private to the account.
362///
363/// In TS you'd write (pseudocode):
364/// ```ts
365/// await mkdir(directory, { recursive: true, mode: 0o700 });
366/// ```
367fn create_directory(directory: &Path) -> std::io::Result<()> {
368    use std::os::unix::fs::DirBuilderExt;
369    return std::fs::DirBuilder::new()
370        .recursive(true)
371        .mode(PRIVATE_DIRECTORY_MODE)
372        .create(directory);
373}
374
375/// What:
376///  Whether a commit is the given tip or one of its ancestors.
377///       `Result<A, B>` is "answer or failure".
378/// Why:
379///   Only Git's "not an ancestor" status answers no; every other failure stops the caller.
380///
381/// In TS you'd write (pseudocode):
382/// ```ts
383/// async function tipContains({ gitPath, cwd, oid, tip }): Promise<boolean>; // throws
384/// ```
385pub fn tip_contains(git: &PushGit, oid: &str, tip: &str) -> Result<bool, SingleFlightError> {
386    let output = run_query(git, &argv(&["merge-base", "--is-ancestor", oid, tip]))?;
387    return match output.code {
388        Some(0) => Ok(true),
389        Some(NOT_ANCESTOR_EXIT_CODE) => Ok(false),
390        code => Err(SingleFlightError::Git {
391            arguments: format!("merge-base --is-ancestor {oid} {tip}"),
392            code,
393            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
394        }),
395    };
396}
397
398/// What:
399///  The joined outcome of a record another invocation wrote.
400/// Why:
401///   A joined commit is covered by the record's tip by construction; the output is the record's own.
402///
403/// In TS you'd write (pseudocode):
404/// ```ts
405/// function joinedOutcome(record: LastPushedRecord): SingleFlightOutcome;
406/// ```
407fn joined_outcome(record: LastPushedRecord) -> SingleFlightOutcome {
408    let kind = match record.outcome {
409        LastPushedOutcome::Pushed => SingleFlightKind::JoinedPushed,
410        LastPushedOutcome::Failed => SingleFlightKind::JoinedFailed,
411    };
412    return SingleFlightOutcome {
413        kind,
414        tip: record.tip,
415        exit_code: record.exit_code,
416        output: record.output,
417        covered: true,
418    };
419}
420
421/// What:
422///  Adapts a closure to the owner lock's wait notice.
423/// Why:
424///   The lock module stays silent; this coordinator reports its own wait through the closure.
425///
426/// In TS you'd write (pseudocode):
427/// ```ts
428/// onWait: () => void
429/// ```
430struct WaitObserver<'observer> {
431    /// Called with nothing when the first acquisition attempt finds the lock held,
432    ///  and with how long the wait has lasted at every notice interval after it.
433    on_wait: &'observer mut dyn FnMut(Option<std::time::Duration>),
434}
435
436impl WaitNotice for WaitObserver<'_> {
437    /// Forwards the start of the wait to the observer.
438    fn waiting(&mut self) {
439        (self.on_wait)(None);
440    }
441
442    /// Forwards a repeat of the wait to the observer.
443    fn slow(&mut self, waited: std::time::Duration) {
444        (self.on_wait)(Some(waited));
445    }
446
447    /// The first line is printed by `waiting`, so the repeats start one interval later.
448    fn announces_at_start(&self) -> bool {
449        return true;
450    }
451}
452
453/// What:
454///  Finish auto-push of one landed commit: join a push that covers it, or push the branch tip itself.
455/// Why:
456///   Three joins are possible, and each keeps the commit from pushing twice:
457///  an early join on a recorded covering success, a join after waiting for the lock,
458///  and the rule that a failed record joins only when a push finished after the first read.
459///   The record is written before the lock is released, so the next waiter reads this attempt.
460///
461/// In TS you'd write (pseudocode):
462/// ```ts
463/// async function runSingleFlightPush({ gitPath, cwd, branchRef, landedOid, push, onWait }): Promise<SingleFlightOutcome>;
464/// ```
465pub fn run_single_flight_push(
466    git: &PushGit,
467    branch_ref: &str,
468    landed_oid: &str,
469    push: &mut dyn FnMut() -> Result<PushAttempt, SingleFlightError>,
470    on_wait: &mut dyn FnMut(),
471) -> Result<SingleFlightOutcome, SingleFlightError> {
472    return run_single_flight_push_repeating(git, branch_ref, landed_oid, push, &mut |waited| {
473        if waited.is_none() {
474            on_wait();
475        }
476    });
477}
478
479/// What:
480///  `run_single_flight_push` whose wait also reports each repeat of the notice:
481///  the observer gets nothing at the start of the wait and how long it has lasted at each repeat.
482/// Why:
483///   A commit that queues behind a long push keeps saying so at the notice interval,
484///  or the caller cannot tell it from a hung one.
485///
486/// In TS you'd write (pseudocode):
487/// ```ts
488/// await runSingleFlightPush({ gitPath, cwd, branchRef, landedOid, push, onWait, onStillWaiting });
489/// ```
490pub fn run_single_flight_push_repeating(
491    git: &PushGit,
492    branch_ref: &str,
493    landed_oid: &str,
494    push: &mut dyn FnMut() -> Result<PushAttempt, SingleFlightError>,
495    on_wait: &mut dyn FnMut(Option<std::time::Duration>),
496) -> Result<SingleFlightOutcome, SingleFlightError> {
497    let paths = resolve_push_coordination_paths(git, branch_ref)?;
498    let before = read_record(&paths.record_path)?;
499    let early = match &before {
500        LastPushedRead::Record(record)
501            if record.outcome == LastPushedOutcome::Pushed
502                && tip_contains(git, landed_oid, &record.tip)? =>
503        {
504            Some(record.clone())
505        }
506        LastPushedRead::Record(_) | LastPushedRead::Absent => None,
507    };
508    if let Some(record) = early {
509        debug(
510            TAG,
511            &format!(
512                "{landed_oid} already delivered by the push of {}",
513                record.tip
514            ),
515        );
516        return Ok(joined_outcome(record));
517    }
518    let mut observer = WaitObserver { on_wait };
519    // A live holder that outlives its own push deadline is stuck; waiting forever behind it would stall every commit.
520    // The invocation's own environment sets how often the wait repeats its notice, as it sets the wait's limit.
521    let cadence: Cadence = Cadence::from_value(environment_value(&git.environment, NOTICE_INTERVAL_VARIABLE).as_deref());
522    let lock: OwnerLock = acquire_within(
523        &paths.lock_directory,
524        DEFAULT_POLL_DELAY,
525        &mut observer,
526        lock_wait_limit(&git.environment),
527        cadence,
528    )
529    .map_err(SingleFlightError::Lock)?;
530    // Everything the holder does from here on shares one time budget, so the waiters' limit can be derived from it.
531    let budget = super::holder_budget::enter(super::bounded_push::holder_budget(&git.environment));
532    let after = read_record(&paths.record_path)?;
533    let late = match &after {
534        LastPushedRead::Record(record)
535            if (record.outcome == LastPushedOutcome::Pushed
536                || finished_while_waiting(&before, record))
537                && tip_contains(git, landed_oid, &record.tip)? =>
538        {
539            Some(record.clone())
540        }
541        LastPushedRead::Record(_) | LastPushedRead::Absent => None,
542    };
543    if let Some(record) = late {
544        debug(
545            TAG,
546            &format!(
547                "{landed_oid} joined the {} push of {}",
548                record.outcome.as_str(),
549                record.tip
550            ),
551        );
552        return Ok(joined_outcome(record));
553    }
554    let tip = git_output(
555        git,
556        &argv(&["rev-parse", "--verify", &format!("{branch_ref}^{{commit}}")]),
557    )?;
558    debug(
559        TAG,
560        &format!("pushing {branch_ref} at {tip} for {landed_oid}"),
561    );
562    let attempt = push()?;
563    let pushed = attempt.exit_code == 0;
564    let record = LastPushedRecord {
565        tip: tip.clone(),
566        outcome: if pushed {
567            LastPushedOutcome::Pushed
568        } else {
569            LastPushedOutcome::Failed
570        },
571        owner_token: lock.token().to_owned(),
572        owner_pid: i64::from(std::process::id()),
573        exit_code: attempt.exit_code,
574        output: if pushed {
575            String::new()
576        } else {
577            attempt.output.clone()
578        },
579    };
580    // The budget ends with the push: recording the outcome and the one query that follows are bounded on their own, and
581    //  a push that succeeded in the last moments must not be reported as a failure for want of time.
582    drop(budget);
583    write_last_pushed_record(&paths.record_path, &record).map_err(SingleFlightError::Io)?;
584    let covered = tip_contains(git, landed_oid, &tip)?;
585    drop(lock);
586    return Ok(SingleFlightOutcome {
587        kind: if pushed {
588            SingleFlightKind::Pushed
589        } else {
590            SingleFlightKind::Failed
591        },
592        tip,
593        exit_code: attempt.exit_code,
594        output: attempt.output,
595        covered,
596    });
597}
598
599/// Whether a push finished after the commit first read the record: the record's owner changed,
600/// or the first read found nothing.
601///
602/// In TS you'd write (pseudocode):
603/// ```ts
604/// (before === LAST_PUSHED_ABSENT) || (before.ownerToken !== after.ownerToken)
605/// ```
606fn finished_while_waiting(before: &LastPushedRead, after: &LastPushedRecord) -> bool {
607    return match before {
608        LastPushedRead::Absent => true,
609        LastPushedRead::Record(earlier) => earlier.owner_token != after.owner_token,
610    };
611}
612
613/// Read a record; an I/O failure other than absence stops the coordinator.
614///
615/// In TS you'd write (pseudocode):
616/// ```ts
617/// await readLastPushedRecord(recordPath)
618/// ```
619fn read_record(record_path: &Path) -> Result<LastPushedRead, SingleFlightError> {
620    return read_last_pushed_record(record_path).map_err(SingleFlightError::Io);
621}
622
623/// Coordination behavior is controlled in the test module.
624#[cfg(test)]
625#[path = "auto_push_single_flight_tests.rs"]
626mod tests;