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