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 return run_bounded_query(
305 git_command(git, arguments),
306 describe_arguments(arguments).as_str(),
307 query_deadline(&git.environment),
308 STOP_GRACE,
309 git.environment.as_slice(),
310 );
311}
312
313/// Argument list from string literals.
314///
315/// In TS you'd write (pseudocode):
316/// ```ts
317/// ['rev-parse', '--verify', ...]
318/// ```
319fn argv(parts: &[&str]) -> Vec<OsString> {
320 return parts.iter().map(OsString::from).collect();
321}
322
323/// What:
324/// Resolve one branch's coordination file paths under the common Git directory and create that directory.
325/// Why:
326/// Linked worktrees share one common directory, so every worktree of a repository coordinates on one lock.
327///
328/// In TS you'd write (pseudocode):
329/// ```ts
330/// async function resolvePushCoordinationPaths({ gitPath, cwd, branchRef }): Promise<PushCoordinationPaths>;
331/// ```
332pub fn resolve_push_coordination_paths(
333 git: &PushGit,
334 branch_ref: &str,
335) -> Result<PushCoordinationPaths, SingleFlightError> {
336 let reported = git_output(
337 git,
338 &argv(&["rev-parse", "--path-format=absolute", "--git-common-dir"]),
339 )?;
340 let common = if Path::new(&reported).is_absolute() {
341 PathBuf::from(&reported)
342 } else {
343 git.cwd.join(&reported)
344 };
345 let directory = common.join("cli-git").join("push");
346 create_directory(&directory).map_err(SingleFlightError::Io)?;
347 let key = encode_branch_key(branch_ref);
348 return Ok(PushCoordinationPaths {
349 lock_directory: directory.join(format!("{key}.lock")),
350 record_path: directory.join(format!("{key}{LAST_PUSHED_SUFFIX}")),
351 });
352}
353
354/// What:
355/// Create a directory tree with mode `0700`, as the incumbent's `mkdir(..., { recursive, mode })`.
356/// Why:
357/// Coordination state is private to the account.
358///
359/// In TS you'd write (pseudocode):
360/// ```ts
361/// await mkdir(directory, { recursive: true, mode: 0o700 });
362/// ```
363fn create_directory(directory: &Path) -> std::io::Result<()> {
364 use std::os::unix::fs::DirBuilderExt;
365 return std::fs::DirBuilder::new()
366 .recursive(true)
367 .mode(PRIVATE_DIRECTORY_MODE)
368 .create(directory);
369}
370
371/// What:
372/// Whether a commit is the given tip or one of its ancestors.
373/// `Result<A, B>` is "answer or failure".
374/// Why:
375/// Only Git's "not an ancestor" status answers no; every other failure stops the caller.
376///
377/// In TS you'd write (pseudocode):
378/// ```ts
379/// async function tipContains({ gitPath, cwd, oid, tip }): Promise<boolean>; // throws
380/// ```
381pub fn tip_contains(git: &PushGit, oid: &str, tip: &str) -> Result<bool, SingleFlightError> {
382 let output = run_query(git, &argv(&["merge-base", "--is-ancestor", oid, tip]))?;
383 return match output.code {
384 Some(0) => Ok(true),
385 Some(NOT_ANCESTOR_EXIT_CODE) => Ok(false),
386 code => Err(SingleFlightError::Git {
387 arguments: format!("merge-base --is-ancestor {oid} {tip}"),
388 code,
389 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
390 }),
391 };
392}
393
394/// What:
395/// The joined outcome of a record another invocation wrote.
396/// Why:
397/// A joined commit is covered by the record's tip by construction; the output is the record's own.
398///
399/// In TS you'd write (pseudocode):
400/// ```ts
401/// function joinedOutcome(record: LastPushedRecord): SingleFlightOutcome;
402/// ```
403fn joined_outcome(record: LastPushedRecord) -> SingleFlightOutcome {
404 let kind = match record.outcome {
405 LastPushedOutcome::Pushed => SingleFlightKind::JoinedPushed,
406 LastPushedOutcome::Failed => SingleFlightKind::JoinedFailed,
407 };
408 return SingleFlightOutcome {
409 kind,
410 tip: record.tip,
411 exit_code: record.exit_code,
412 output: record.output,
413 covered: true,
414 };
415}
416
417/// What:
418/// Adapts a closure to the owner lock's wait notice.
419/// Why:
420/// The lock module stays silent; this coordinator reports its own wait through the closure.
421///
422/// In TS you'd write (pseudocode):
423/// ```ts
424/// onWait: () => void
425/// ```
426struct WaitObserver<'observer> {
427 /// Called with nothing when the first acquisition attempt finds the lock held,
428 /// and with how long the wait has lasted at every notice interval after it.
429 on_wait: &'observer mut dyn FnMut(Option<std::time::Duration>),
430}
431
432impl WaitNotice for WaitObserver<'_> {
433 /// Forwards the start of the wait to the observer.
434 fn waiting(&mut self) {
435 (self.on_wait)(None);
436 }
437
438 /// Forwards a repeat of the wait to the observer.
439 fn slow(&mut self, waited: std::time::Duration) {
440 (self.on_wait)(Some(waited));
441 }
442
443 /// The first line is printed by `waiting`, so the repeats start one interval later.
444 fn announces_at_start(&self) -> bool {
445 return true;
446 }
447}
448
449/// What:
450/// Finish auto-push of one landed commit: join a push that covers it, or push the branch tip itself.
451/// Why:
452/// Three joins are possible, and each keeps the commit from pushing twice:
453/// an early join on a recorded covering success, a join after waiting for the lock,
454/// and the rule that a failed record joins only when a push finished after the first read.
455/// The record is written before the lock is released, so the next waiter reads this attempt.
456///
457/// In TS you'd write (pseudocode):
458/// ```ts
459/// async function runSingleFlightPush({ gitPath, cwd, branchRef, landedOid, push, onWait }): Promise<SingleFlightOutcome>;
460/// ```
461pub fn run_single_flight_push(
462 git: &PushGit,
463 branch_ref: &str,
464 landed_oid: &str,
465 push: &mut dyn FnMut() -> Result<PushAttempt, SingleFlightError>,
466 on_wait: &mut dyn FnMut(),
467) -> Result<SingleFlightOutcome, SingleFlightError> {
468 return run_single_flight_push_repeating(git, branch_ref, landed_oid, push, &mut |waited| {
469 if waited.is_none() {
470 on_wait();
471 }
472 });
473}
474
475/// What:
476/// `run_single_flight_push` whose wait also reports each repeat of the notice:
477/// the observer gets nothing at the start of the wait and how long it has lasted at each repeat.
478/// Why:
479/// A commit that queues behind a long push keeps saying so at the notice interval,
480/// or the caller cannot tell it from a hung one.
481///
482/// In TS you'd write (pseudocode):
483/// ```ts
484/// await runSingleFlightPush({ gitPath, cwd, branchRef, landedOid, push, onWait, onStillWaiting });
485/// ```
486pub fn run_single_flight_push_repeating(
487 git: &PushGit,
488 branch_ref: &str,
489 landed_oid: &str,
490 push: &mut dyn FnMut() -> Result<PushAttempt, SingleFlightError>,
491 on_wait: &mut dyn FnMut(Option<std::time::Duration>),
492) -> Result<SingleFlightOutcome, SingleFlightError> {
493 let paths = resolve_push_coordination_paths(git, branch_ref)?;
494 let before = read_record(&paths.record_path)?;
495 let early = match &before {
496 LastPushedRead::Record(record)
497 if record.outcome == LastPushedOutcome::Pushed
498 && tip_contains(git, landed_oid, &record.tip)? =>
499 {
500 Some(record.clone())
501 }
502 LastPushedRead::Record(_) | LastPushedRead::Absent => None,
503 };
504 if let Some(record) = early {
505 debug(
506 TAG,
507 &format!(
508 "{landed_oid} already delivered by the push of {}",
509 record.tip
510 ),
511 );
512 return Ok(joined_outcome(record));
513 }
514 let mut observer = WaitObserver { on_wait };
515 // A live holder that outlives its own push deadline is stuck; waiting forever behind it would stall every commit.
516 // The invocation's own environment sets how often the wait repeats its notice, as it sets the wait's limit.
517 let cadence: Cadence = Cadence::from_value(environment_value(&git.environment, NOTICE_INTERVAL_VARIABLE).as_deref());
518 let lock: OwnerLock = acquire_within(
519 &paths.lock_directory,
520 DEFAULT_POLL_DELAY,
521 &mut observer,
522 lock_wait_limit(&git.environment),
523 cadence,
524 )
525 .map_err(SingleFlightError::Lock)?;
526 // Everything the holder does from here on shares one time budget, so the waiters' limit can be derived from it.
527 let budget = super::holder_budget::enter(super::bounded_push::holder_budget(&git.environment));
528 let after = read_record(&paths.record_path)?;
529 let late = match &after {
530 LastPushedRead::Record(record)
531 if (record.outcome == LastPushedOutcome::Pushed
532 || finished_while_waiting(&before, record))
533 && tip_contains(git, landed_oid, &record.tip)? =>
534 {
535 Some(record.clone())
536 }
537 LastPushedRead::Record(_) | LastPushedRead::Absent => None,
538 };
539 if let Some(record) = late {
540 debug(
541 TAG,
542 &format!(
543 "{landed_oid} joined the {} push of {}",
544 record.outcome.as_str(),
545 record.tip
546 ),
547 );
548 return Ok(joined_outcome(record));
549 }
550 let tip = git_output(
551 git,
552 &argv(&["rev-parse", "--verify", &format!("{branch_ref}^{{commit}}")]),
553 )?;
554 debug(
555 TAG,
556 &format!("pushing {branch_ref} at {tip} for {landed_oid}"),
557 );
558 let attempt = push()?;
559 let pushed = attempt.exit_code == 0;
560 let record = LastPushedRecord {
561 tip: tip.clone(),
562 outcome: if pushed {
563 LastPushedOutcome::Pushed
564 } else {
565 LastPushedOutcome::Failed
566 },
567 owner_token: lock.token().to_owned(),
568 owner_pid: i64::from(std::process::id()),
569 exit_code: attempt.exit_code,
570 output: if pushed {
571 String::new()
572 } else {
573 attempt.output.clone()
574 },
575 };
576 // The budget ends with the push: recording the outcome and the one query that follows are bounded on their own, and
577 // a push that succeeded in the last moments must not be reported as a failure for want of time.
578 drop(budget);
579 write_last_pushed_record(&paths.record_path, &record).map_err(SingleFlightError::Io)?;
580 let covered = tip_contains(git, landed_oid, &tip)?;
581 drop(lock);
582 return Ok(SingleFlightOutcome {
583 kind: if pushed {
584 SingleFlightKind::Pushed
585 } else {
586 SingleFlightKind::Failed
587 },
588 tip,
589 exit_code: attempt.exit_code,
590 output: attempt.output,
591 covered,
592 });
593}
594
595/// Whether a push finished after the commit first read the record: the record's owner changed,
596/// or the first read found nothing.
597///
598/// In TS you'd write (pseudocode):
599/// ```ts
600/// (before === LAST_PUSHED_ABSENT) || (before.ownerToken !== after.ownerToken)
601/// ```
602fn finished_while_waiting(before: &LastPushedRead, after: &LastPushedRecord) -> bool {
603 return match before {
604 LastPushedRead::Absent => true,
605 LastPushedRead::Record(earlier) => earlier.owner_token != after.owner_token,
606 };
607}
608
609/// Read a record; an I/O failure other than absence stops the coordinator.
610///
611/// In TS you'd write (pseudocode):
612/// ```ts
613/// await readLastPushedRecord(recordPath)
614/// ```
615fn read_record(record_path: &Path) -> Result<LastPushedRead, SingleFlightError> {
616 return read_last_pushed_record(record_path).map_err(SingleFlightError::Io);
617}
618
619/// Coordination behavior is controlled in the test module.
620#[cfg(test)]
621#[path = "auto_push_single_flight_tests.rs"]
622mod tests;