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