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