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::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 whole environment plus the child-environment overlay,
177///  so the caller passes that complete environment in (`environment_with_overlay`)
178///  and nothing is read from the process environment here.
179///
180/// In TS you'd write (pseudocode):
181/// ```ts
182/// type PushGit = { gitPath: string; cwd: string; environment: NodeJS.ProcessEnv };
183/// ```
184#[derive(Clone, Debug, Eq, PartialEq)]
185pub struct PushGit {
186    /// Absolute real Git executable.
187    pub real_git: PathBuf,
188    /// Directory the commit landed in.
189    pub cwd: PathBuf,
190    /// Complete environment every Git child receives: the invocation's variables with the overlay applied.
191    pub environment: Vec<(OsString, OsString)>,
192}
193
194/// What:
195///  Build a real Git command with the given arguments, directory and environment.
196/// Why:
197///   Every coordination query and push starts Git this way, so none re-enters the wrapper.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// nanoSpawn(gitPath, args, { cwd })
202/// ```
203pub fn git_command(git: &PushGit, arguments: &[OsString]) -> Command {
204    let mut command = Command::new(&git.real_git);
205    command
206        .current_dir(&git.cwd)
207        .env_clear()
208        .envs(git.environment.iter().map(|pair| {
209            return (&pair.0, &pair.1);
210        }))
211        .args(arguments);
212    return command;
213}
214
215/// The arguments as one display string, for diagnostics.
216///
217/// In TS you'd write (pseudocode):
218/// ```ts
219/// args.join(' ')
220/// ```
221fn describe_arguments(arguments: &[OsString]) -> String {
222    return arguments
223        .iter()
224        .map(|argument| {
225            return argument.to_string_lossy().into_owned();
226        })
227        .collect::<Vec<String>>()
228        .join(" ");
229}
230
231/// What:
232///  Run a read-only Git query and return its trimmed standard output.
233///       `Result<A, B>` is either the output or a failure.
234/// Why:
235///   Any failed query is a coordination failure; it is never read as an answer.
236///
237/// In TS you'd write (pseudocode):
238/// ```ts
239/// (await nanoSpawn(gitPath, args, { cwd })).stdout.trim();
240/// ```
241pub fn git_output(git: &PushGit, arguments: &[OsString]) -> Result<String, SingleFlightError> {
242    let output = git_command(git, arguments)
243        .stdin(Stdio::null())
244        .output()
245        .map_err(SingleFlightError::Start)?;
246    if !output.status.success() {
247        return Err(SingleFlightError::Git {
248            arguments: describe_arguments(arguments),
249            code: output.status.code(),
250            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
251        });
252    }
253    let text = String::from_utf8_lossy(&output.stdout);
254    return Ok(trim_javascript(&text).to_owned());
255}
256
257/// Argument list from string literals.
258///
259/// In TS you'd write (pseudocode):
260/// ```ts
261/// ['rev-parse', '--verify', ...]
262/// ```
263fn argv(parts: &[&str]) -> Vec<OsString> {
264    return parts.iter().map(OsString::from).collect();
265}
266
267/// What:
268///  Resolve one branch's coordination file paths under the common Git directory and create that directory.
269/// Why:
270///   Linked worktrees share one common directory, so every worktree of a repository coordinates on one lock.
271///
272/// In TS you'd write (pseudocode):
273/// ```ts
274/// async function resolvePushCoordinationPaths({ gitPath, cwd, branchRef }): Promise<PushCoordinationPaths>;
275/// ```
276pub fn resolve_push_coordination_paths(
277    git: &PushGit,
278    branch_ref: &str,
279) -> Result<PushCoordinationPaths, SingleFlightError> {
280    let reported = git_output(
281        git,
282        &argv(&["rev-parse", "--path-format=absolute", "--git-common-dir"]),
283    )?;
284    let common = if Path::new(&reported).is_absolute() {
285        PathBuf::from(&reported)
286    } else {
287        git.cwd.join(&reported)
288    };
289    let directory = common.join("cli-git").join("push");
290    create_directory(&directory).map_err(SingleFlightError::Io)?;
291    let key = encode_branch_key(branch_ref);
292    return Ok(PushCoordinationPaths {
293        lock_directory: directory.join(format!("{key}.lock")),
294        record_path: directory.join(format!("{key}{LAST_PUSHED_SUFFIX}")),
295    });
296}
297
298/// What:
299///  Create a directory tree with mode `0700`, as the incumbent's `mkdir(..., { recursive, mode })`.
300/// Why:
301///   Coordination state is private to the account.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// await mkdir(directory, { recursive: true, mode: 0o700 });
306/// ```
307fn create_directory(directory: &Path) -> std::io::Result<()> {
308    use std::os::unix::fs::DirBuilderExt;
309    return std::fs::DirBuilder::new()
310        .recursive(true)
311        .mode(PRIVATE_DIRECTORY_MODE)
312        .create(directory);
313}
314
315/// What:
316///  Whether a commit is the given tip or one of its ancestors.
317///       `Result<A, B>` is "answer or failure".
318/// Why:
319///   Only Git's "not an ancestor" status answers no; every other failure stops the caller.
320///
321/// In TS you'd write (pseudocode):
322/// ```ts
323/// async function tipContains({ gitPath, cwd, oid, tip }): Promise<boolean>; // throws
324/// ```
325pub fn tip_contains(git: &PushGit, oid: &str, tip: &str) -> Result<bool, SingleFlightError> {
326    let output = git_command(git, &argv(&["merge-base", "--is-ancestor", oid, tip]))
327        .stdin(Stdio::null())
328        .output()
329        .map_err(SingleFlightError::Start)?;
330    return match output.status.code() {
331        Some(0) => Ok(true),
332        Some(NOT_ANCESTOR_EXIT_CODE) => Ok(false),
333        code => Err(SingleFlightError::Git {
334            arguments: format!("merge-base --is-ancestor {oid} {tip}"),
335            code,
336            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
337        }),
338    };
339}
340
341/// What:
342///  The joined outcome of a record another invocation wrote.
343/// Why:
344///   A joined commit is covered by the record's tip by construction; the output is the record's own.
345///
346/// In TS you'd write (pseudocode):
347/// ```ts
348/// function joinedOutcome(record: LastPushedRecord): SingleFlightOutcome;
349/// ```
350fn joined_outcome(record: LastPushedRecord) -> SingleFlightOutcome {
351    let kind = match record.outcome {
352        LastPushedOutcome::Pushed => SingleFlightKind::JoinedPushed,
353        LastPushedOutcome::Failed => SingleFlightKind::JoinedFailed,
354    };
355    return SingleFlightOutcome {
356        kind,
357        tip: record.tip,
358        exit_code: record.exit_code,
359        output: record.output,
360        covered: true,
361    };
362}
363
364/// What:
365///  Adapts a closure to the owner lock's wait notice.
366/// Why:
367///   The lock module stays silent; this coordinator reports its own wait through the closure.
368///
369/// In TS you'd write (pseudocode):
370/// ```ts
371/// onWait: () => void
372/// ```
373struct WaitObserver<'observer> {
374    /// Called once when the first acquisition attempt finds the lock held.
375    on_wait: &'observer mut dyn FnMut(),
376}
377
378impl WaitNotice for WaitObserver<'_> {
379    /// Forwards the wait to the observer.
380    fn waiting(&mut self) {
381        (self.on_wait)();
382    }
383}
384
385/// What:
386///  Finish auto-push of one landed commit: join a push that covers it, or push the branch tip itself.
387/// Why:
388///   Three joins are possible, and each keeps the commit from pushing twice:
389///  an early join on a recorded covering success, a join after waiting for the lock,
390///  and the rule that a failed record joins only when a push finished after the first read.
391///   The record is written before the lock is released, so the next waiter reads this attempt.
392///
393/// In TS you'd write (pseudocode):
394/// ```ts
395/// async function runSingleFlightPush({ gitPath, cwd, branchRef, landedOid, push, onWait }): Promise<SingleFlightOutcome>;
396/// ```
397pub fn run_single_flight_push(
398    git: &PushGit,
399    branch_ref: &str,
400    landed_oid: &str,
401    push: &mut dyn FnMut() -> Result<PushAttempt, SingleFlightError>,
402    on_wait: &mut dyn FnMut(),
403) -> Result<SingleFlightOutcome, SingleFlightError> {
404    let paths = resolve_push_coordination_paths(git, branch_ref)?;
405    let before = read_record(&paths.record_path)?;
406    let early = match &before {
407        LastPushedRead::Record(record)
408            if record.outcome == LastPushedOutcome::Pushed
409                && tip_contains(git, landed_oid, &record.tip)? =>
410        {
411            Some(record.clone())
412        }
413        LastPushedRead::Record(_) | LastPushedRead::Absent => None,
414    };
415    if let Some(record) = early {
416        debug(
417            TAG,
418            &format!(
419                "{landed_oid} already delivered by the push of {}",
420                record.tip
421            ),
422        );
423        return Ok(joined_outcome(record));
424    }
425    let mut observer = WaitObserver { on_wait };
426    let lock: OwnerLock =
427        acquire_owner_lock(&paths.lock_directory, DEFAULT_POLL_DELAY, &mut observer)
428            .map_err(SingleFlightError::Lock)?;
429    let after = read_record(&paths.record_path)?;
430    let late = match &after {
431        LastPushedRead::Record(record)
432            if (record.outcome == LastPushedOutcome::Pushed
433                || finished_while_waiting(&before, record))
434                && tip_contains(git, landed_oid, &record.tip)? =>
435        {
436            Some(record.clone())
437        }
438        LastPushedRead::Record(_) | LastPushedRead::Absent => None,
439    };
440    if let Some(record) = late {
441        debug(
442            TAG,
443            &format!(
444                "{landed_oid} joined the {} push of {}",
445                record.outcome.as_str(),
446                record.tip
447            ),
448        );
449        return Ok(joined_outcome(record));
450    }
451    let tip = git_output(
452        git,
453        &argv(&["rev-parse", "--verify", &format!("{branch_ref}^{{commit}}")]),
454    )?;
455    debug(
456        TAG,
457        &format!("pushing {branch_ref} at {tip} for {landed_oid}"),
458    );
459    let attempt = push()?;
460    let pushed = attempt.exit_code == 0;
461    let record = LastPushedRecord {
462        tip: tip.clone(),
463        outcome: if pushed {
464            LastPushedOutcome::Pushed
465        } else {
466            LastPushedOutcome::Failed
467        },
468        owner_token: lock.token().to_owned(),
469        owner_pid: i64::from(std::process::id()),
470        exit_code: attempt.exit_code,
471        output: if pushed {
472            String::new()
473        } else {
474            attempt.output.clone()
475        },
476    };
477    write_last_pushed_record(&paths.record_path, &record).map_err(SingleFlightError::Io)?;
478    let covered = tip_contains(git, landed_oid, &tip)?;
479    drop(lock);
480    return Ok(SingleFlightOutcome {
481        kind: if pushed {
482            SingleFlightKind::Pushed
483        } else {
484            SingleFlightKind::Failed
485        },
486        tip,
487        exit_code: attempt.exit_code,
488        output: attempt.output,
489        covered,
490    });
491}
492
493/// Whether a push finished after the commit first read the record: the record's owner changed,
494/// or the first read found nothing.
495///
496/// In TS you'd write (pseudocode):
497/// ```ts
498/// (before === LAST_PUSHED_ABSENT) || (before.ownerToken !== after.ownerToken)
499/// ```
500fn finished_while_waiting(before: &LastPushedRead, after: &LastPushedRecord) -> bool {
501    return match before {
502        LastPushedRead::Absent => true,
503        LastPushedRead::Record(earlier) => earlier.owner_token != after.owner_token,
504    };
505}
506
507/// Read a record; an I/O failure other than absence stops the coordinator.
508///
509/// In TS you'd write (pseudocode):
510/// ```ts
511/// await readLastPushedRecord(recordPath)
512/// ```
513fn read_record(record_path: &Path) -> Result<LastPushedRead, SingleFlightError> {
514    return read_last_pushed_record(record_path).map_err(SingleFlightError::Io);
515}
516
517/// Coordination behavior is controlled in the test module.
518#[cfg(test)]
519#[path = "auto_push_single_flight_tests.rs"]
520mod tests;