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