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;