git_policy_cli/auto_push.rs
1//! What:
2//! The commit service's push of a landed commit's branch: which push runs, what its output shows,
3//! and how a failure is reported without failing the commit.
4//! Why:
5//! Every commit in this repository is backed up (`AGENTS.md` rule APG),
6//! the service pushes the branch its record names and never reads `HEAD`,
7//! prints only GitHub `remote:` lines on success and the full output on failure,
8//! and leaves a notice for the next command when the push fails.
9//! The service is the only pusher of the repositories it serves and coalesces pending pushes of one branch
10//! into one call, so the push needs no lock.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! await pushOnce({ gitPath, cwd }, branchRef, landedOid);
15//! ```
16
17/// Where a plain push of a named branch goes, and the remote question `push.default=matching` asks.
18use super::auto_push_destination::{Advertised, NamedPush, absent_on_remote, named_push, remote_has_ref, several_push_urls};
19/// The reconcile of a push that a moved remote branch rejected, and the lines it adds to the output.
20use super::auto_push_reconcile::{RECONCILE_FAILED_PREFIX, RECONCILE_LINE_PREFIX, reconcile_divergent_push};
21/// The service's Git, the failed attempt it reports and its error type.
22use super::auto_push_single_flight::{PushAttempt, PushGit, SingleFlightError, run_query};
23/// The auto-push's bounded `git push` with the Git LFS upload.
24use super::lfs_bounded_push::run_push;
25/// The diagnostic log of the wrapper.
26use super::diagnostic_log::debug;
27/// Owned OS strings for Git arguments.
28use std::ffi::OsString;
29/// `Write` is the stderr sink the terminal lines go to.
30use std::io::Write;
31
32/// Tag used for diagnostics from this module.
33const TAG: &str = "cli-git";
34
35/// Prefix of the GitHub server-side lines a clean push surfaces.
36const REMOTE_LINE_PREFIX: &str = "remote: ";
37
38/// Note printed after every failed push: the commit stays local.
39pub const PUSH_FAILED_NOTE: &str =
40 "cli-git: commit saved locally, but auto-push to origin failed; run `git push` when ready.";
41
42/// What Git prints when another process holds the lock of a reference the push updates.
43const REFERENCE_LOCK_REFUSAL: &str = "cannot lock ref";
44
45/// How many times the service pushes again when a held reference lock refused the update of a remote-tracking reference.
46pub const PUSH_LOCK_RETRIES: u32 = 3;
47
48/// The pause before the first of those pushes; each later one waits a multiple of it.
49const PUSH_LOCK_PAUSE: std::time::Duration = std::time::Duration::from_millis(250);
50
51/// What:
52/// Which branch the auto-push took.
53/// Why:
54/// `Skipped` covers both "nowhere to back up to" and a setting under which Git pushes the branch nowhere; the first
55/// exits 0 silently, the second with a note that the person must see.
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// type AutoPushOutcome = 'skipped' | 'pushed' | 'failed';
60/// ```
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum AutoPushOutcome {
63 /// Nothing was pushed.
64 Skipped,
65 /// A successful push delivered the commit.
66 Pushed,
67 /// The push that would deliver the commit failed or could not run.
68 Failed,
69}
70
71/// What:
72/// The result of one auto-push attempt.
73/// Why:
74/// `shown` is what the terminal received, so tests compare the surfaced text directly.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// type AutoPushResult = { outcome; exitCode; shown };
79/// ```
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct AutoPushResult {
82 /// Which branch the auto-push took.
83 pub outcome: AutoPushOutcome,
84 /// Push exit code; `0` when skipped or pushed, Git's code (or `1`) on failure.
85 pub exit_code: i64,
86 /// Text surfaced to stderr; empty on a silent skip or a quiet clean push, the note on a skip with one.
87 pub shown: String,
88}
89
90/// What:
91/// Select the output a push shows: only `remote:` lines after a clean push, everything after a failure.
92/// Why:
93/// A rejection, a hook refusal or an offline error stays diagnosable only when the full output shows.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// function filterPushOutput({ output, exitCode }): string;
98/// ```
99pub fn filter_push_output(output: &str, exit_code: i64) -> String {
100 if exit_code != 0 {
101 return output.to_owned();
102 }
103 return output
104 .split('\n')
105 .filter(|line| {
106 return line.starts_with(REMOTE_LINE_PREFIX) || line.starts_with(RECONCILE_LINE_PREFIX);
107 })
108 .collect::<Vec<&str>>()
109 .join("\n");
110}
111
112/// What:
113/// Whether a read-only Git query exits zero.
114/// Why:
115/// A non-zero exit is an answer ("no upstream", "no origin"); a failure to start Git is not an answer.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// await gitQuerySucceeds({ gitPath, cwd, args }); // false on SubprocessError only
120/// ```
121fn query_succeeds(git: &PushGit, arguments: &[&str]) -> Result<bool, SingleFlightError> {
122 let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
123 return Ok(run_query(git, &argv)?.code == Some(0));
124}
125
126/// What:
127/// Run one real `git push` and capture its interleaved output, with one trailing line break removed,
128/// stopping it when its deadline passes.
129/// Why:
130/// Git's output of stdout and stderr shares one file so the lines interleave in the order they were written,
131/// the trailing line break is removed as the incumbent's captured output is,
132/// and a push that stalls must not hold the service's push of the branch forever.
133///
134/// In TS you'd write (pseudocode):
135/// ```ts
136/// const { exitCode, output } = await nanoSpawn(gitPath, pushArgs, { cwd }) // SubprocessError -> { exitCode ?? 1, output }
137/// ```
138fn run_push_command(git: &PushGit, arguments: &[&str]) -> Result<PushAttempt, SingleFlightError> {
139 let argv: Vec<OsString> = arguments.iter().map(OsString::from).collect();
140 return run_push(git, &argv);
141}
142
143/// What a push plan decided before anything was pushed.
144enum Planned {
145 /// Nothing to push; the result says why.
146 Skip(AutoPushResult),
147 /// Push the branch with these arguments.
148 Push {
149 /// The branch HEAD names.
150 branch_ref: String,
151 /// Whether the branch has an upstream, which makes a moved remote branch reconcilable.
152 has_upstream: bool,
153 /// The `git push` arguments.
154 arguments: Vec<String>,
155 },
156 /// The remote could not be asked whether it has the branch, so nothing was pushed; the attempt says why.
157 Unreachable(PushAttempt),
158}
159
160/// What:
161/// The plan that skips a branch with a note, after writing the note to the terminal sink.
162/// Why:
163/// The note is the only diagnostic of a push that never ran, so the result carries it as well, for the caller that
164/// reports it elsewhere than the sink.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// function skippedWith(note: string, err: Writable): Planned;
169/// ```
170fn skipped_with(note: String, err: &mut dyn Write) -> Planned {
171 // Terminal output error: the note itself is the only diagnostic; a failed write to stderr is not a push failure.
172 let _written = writeln!(err, "{note}");
173 return Planned::Skip(AutoPushResult { outcome: AutoPushOutcome::Skipped, exit_code: 0, shown: note });
174}
175
176/// What:
177/// Decide how to push a recorded branch whatever the worktree has checked out: no upstream and no origin skips, otherwise
178/// the push names the branch on both sides and never reads `HEAD`.
179/// Why:
180/// The service pushes after the command returned, so the person may switch branches or detach the head at any instant,
181/// including between a check and the push; the record names the branch that received the commit, and the plain
182/// `git push` would publish the checked-out one instead.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// const plan = await planNamedPush({ gitPath, cwd }, branchRef);
187/// ```
188fn plan_named_push(git: &PushGit, branch_ref: &str, err: &mut dyn Write) -> Result<Planned, SingleFlightError> {
189 // Git reads `refs/heads/x@{upstream}` as a branch named `refs/heads/x` ("no such branch"), so the query takes the short name;
190 // a ref outside `refs/heads` has no upstream.
191 let has_upstream: bool = match branch_ref.strip_prefix("refs/heads/") {
192 Some(name) => {
193 let upstream_name: String = format!("{name}@{{upstream}}");
194 query_succeeds(git, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", upstream_name.as_str()])?
195 }
196 None => false,
197 };
198 if !has_upstream && !query_succeeds(git, &["remote", "get-url", "origin"])? {
199 debug(TAG, "no upstream and no origin remote; skipping auto-push");
200 return Ok(Planned::Skip(AutoPushResult { outcome: AutoPushOutcome::Skipped, exit_code: 0, shown: String::new() }));
201 }
202 let arguments: Vec<String> = if has_upstream {
203 let (remote, targets): (String, Vec<String>) = match named_push(git, branch_ref)? {
204 NamedPush::Push { remote, targets } => (remote, targets),
205 NamedPush::RemoteMustHave { remote, target } => match remote_has_ref(git, &remote, &target)? {
206 Advertised::Yes => (remote, vec![target]),
207 Advertised::No => return Ok(skipped_with(absent_on_remote(branch_ref, &remote), err)),
208 Advertised::SeveralUrls(count) => return Ok(skipped_with(several_push_urls(branch_ref, &remote, count), err)),
209 Advertised::Unreachable(failed) => return Ok(Planned::Unreachable(failed)),
210 },
211 NamedPush::Skip(note) => return Ok(skipped_with(note, err)),
212 };
213 let mut arguments: Vec<String> = vec![String::from("push"), remote];
214 arguments.extend(targets.iter().map(|target| { return format!("{branch_ref}:{target}"); }));
215 arguments
216 } else {
217 vec![String::from("push"), String::from("--set-upstream"), String::from("origin"), format!("{branch_ref}:{branch_ref}")]
218 };
219 return Ok(Planned::Push { branch_ref: branch_ref.to_owned(), has_upstream, arguments });
220}
221
222/// What:
223/// Push a landed commit's branch once, with no coordination with other processes.
224/// Why:
225/// The service's push consumer is the only pusher of the repositories it serves and coalesces pending pushes of one
226/// branch into this single call, so it needs no lock.
227///
228/// In TS you'd write (pseudocode):
229/// ```ts
230/// const result = await pushOnce({ gitPath, cwd }, landedOid);
231/// ```
232pub fn push_once(git: &PushGit, branch_ref: &str, landed_oid: &str, err: &mut dyn Write) -> Result<AutoPushResult, SingleFlightError> {
233 // The record names the branch, and the worktree may switch away from it at any instant, including between a check of
234 // `HEAD` and the push itself: the plan never reads `HEAD`, so what is pushed is always the recorded branch.
235 let planned: Planned = plan_named_push(git, branch_ref, err)?;
236 let (pushed_ref, has_upstream, mut attempt): (String, bool, PushAttempt) = match planned {
237 Planned::Skip(result) => return Ok(result),
238 Planned::Unreachable(failed) => (branch_ref.to_owned(), false, failed),
239 Planned::Push { branch_ref: planned_ref, has_upstream, arguments } => {
240 let argument_views: Vec<&str> = arguments.iter().map(String::as_str).collect();
241 debug(TAG, &format!("pushing {landed_oid} with {argument_views:?} on {planned_ref}"));
242 let mut attempt: PushAttempt = run_push_command(git, argument_views.as_slice())?;
243 // The instances of two worktrees share the repository's references. While another holds the lock of the remote-tracking
244 // reference this push updates, Git still delivers the push, prints the refusal to update the reference, and exits 0, which
245 // leaves the reference stale; the same push again finds nothing to deliver and brings the reference up to date.
246 let mut pause: u32 = 0;
247 while attempt.output.contains(REFERENCE_LOCK_REFUSAL) && pause < PUSH_LOCK_RETRIES {
248 pause += 1;
249 std::thread::sleep(PUSH_LOCK_PAUSE * pause);
250 attempt = run_push_command(git, argument_views.as_slice())?;
251 }
252 (planned_ref, has_upstream, attempt)
253 }
254 };
255 if has_upstream && attempt.exit_code != 0 {
256 attempt = reconcile_divergent_push(git, &pushed_ref, attempt)?;
257 }
258 let failed: bool = attempt.exit_code != 0;
259 let shown: String = filter_push_output(&attempt.output, attempt.exit_code);
260 if !shown.is_empty() {
261 writeln!(err, "{shown}").map_err(SingleFlightError::Io)?;
262 }
263 if failed && !attempt.output.contains(RECONCILE_FAILED_PREFIX) {
264 writeln!(err, "{PUSH_FAILED_NOTE}").map_err(SingleFlightError::Io)?;
265 }
266 return Ok(AutoPushResult {
267 outcome: if failed { AutoPushOutcome::Failed } else { AutoPushOutcome::Pushed },
268 exit_code: attempt.exit_code,
269 shown,
270 });
271}
272
273/// Auto-push behavior is controlled in the test module.
274#[cfg(test)]
275#[path = "auto_push_tests.rs"]
276mod tests;
277
278/// Controls that the service never pushes where a plain `git push` of the branch would not.
279#[cfg(test)]
280#[path = "auto_push_semantics_tests.rs"]
281mod semantics_tests;