Skip to main content

git_policy_cli/push/
push_retry.rs

1//! What:
2//!  The service's own retry of a push it stopped or that failed: the pause before each new try, the list of branches waiting
3//!  for one, and the test for the one failure no retry can mend.
4//! Why:
5//!  The owner's rule is that auto-push must land (`SPEC.md`, "Merges and commits never fail"). A push stopped by the stall
6//!  window, by the network, by an LFS server error or by a reference lock passes by itself, so the service tries again on its own,
7//!  with pauses that grow, until the push lands or a conflict needs the person. Nothing waits for the next commit: the work
8//!  loop is woken when the pause is over. The list is owned by the service's work loop and kept on disk beside the instance's
9//!  journal (`push_retry_store.rs`), so a restarted service rebuilds it; the person's failure record is not the plan, since the
10//!  person's next command shows it and removes it. A conflict, a login the service does not have and a hook on the server that
11//!  declined the push stop the retry until the next commit (owner decision, 2026-11-03).
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const pause = retries.schedule(item, attempt); /* later */ for (const due of retries.takeDue(gitDir)) await push(due);
16//! ```
17
18/// The positive-seconds reading shared by the wait limits.
19use crate::bounded_push::positive_seconds;
20/// The plan on disk.
21use crate::push_retry_store::{StoredPlan, load, remove, save};
22/// What the retry pushes again.
23use crate::work_item::WorkItem;
24use std::path::{Path, PathBuf};
25use std::time::{Duration, Instant};
26
27/// Environment variable that sets, in whole seconds, the pause before the first new try of a stopped push.
28pub const RETRY_VARIABLE: &str = "CLI_GIT_PUSH_RETRY_SECONDS";
29
30/// Directory, under the service instance's journal, that holds the plan of the pushes waiting for a new try, so that a restarted
31/// service keeps trying them.
32pub const PLAN_DIRECTORY: &str = "push-retries";
33
34/// The pause before the first new try when nothing configures it.
35pub const FIRST_PAUSE: Duration = Duration::from_secs(10);
36
37/// The longest pause between two tries: a push that keeps failing is tried about this often for as long as it fails.
38pub const LONGEST_PAUSE: Duration = Duration::from_secs(10 * 60);
39
40/// The words of the reconcile's report of a conflict, the one failure that retrying cannot mend.
41pub const CONFLICT_PHRASE: &str = " conflicts in: ";
42
43/// What:
44///  The first pause for a value of the variable.
45/// Why:
46///   A missing, zero or malformed value falls back to the default, never to a tight loop.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// positiveSeconds(value) ?? FIRST_PAUSE_MS
51/// ```
52pub fn first_pause_from(value: Option<&std::ffi::OsStr>) -> Duration {
53    let Some(text) = value else { return FIRST_PAUSE };
54    return positive_seconds(RETRY_VARIABLE, text).unwrap_or(FIRST_PAUSE).min(LONGEST_PAUSE);
55}
56
57/// What:
58///  The pause before try number `attempt` (the first new try is number one): the first pause, doubled each time, never past
59///  the longest.
60/// Why:
61///   Short pauses mend a stall quickly; longer ones keep a host that is offline for an hour from being asked every few seconds.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// Math.min(first * 2 ** (attempt - 1), LONGEST)
66/// ```
67pub fn pause_before(attempt: u32, first: Duration) -> Duration {
68    let doublings: u32 = attempt.saturating_sub(1).min(30);
69    return first.saturating_mul(1_u32 << doublings).min(LONGEST_PAUSE);
70}
71
72/// Why a failed push cannot pass by itself.
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum FinalFailure {
75    /// The reconcile reported a conflict.
76    Conflict,
77    /// The remote asked for a login the service does not have.
78    Login,
79    /// A hook on the server declined the push.
80    Declined,
81}
82
83/// The mark of a line in which the server rejected one reference; Git prints it for every refused reference
84/// (`transport.c`, `print_ref_status('!', "[remote rejected]", ...)`, Git 2.56.0).
85pub const REMOTE_REJECTED: &str = "[remote rejected]";
86
87/// The words every hook decline of Git's own server side carries in the rejection's reason: `"pre-receive hook declined"` and
88/// `"push-to-checkout hook declined"` and `"hook declined"` (the update hook) in `builtin/receive-pack.c`, Git 2.56.0; a hosting
89/// service's own reason that names a hook, such as `protected branch hook declined`, ends the same way.
90pub const HOOK_DECLINED: &str = "hook declined";
91
92/// The words with which Git and the programs it runs say that the remote wanted a login the push did not have, each from a
93/// fixture run of `git push` from Git 2.56.0 on 2026-11-03:
94/// - `remote-curl.c` `die(_("Authentication failed for '%s'"))`: a 401 and no usable password;
95/// - `prompt.c` `die("could not read %s%s", prompt, "terminal prompts disabled")`: a 401 with no user name and no prompt allowed;
96/// - `http.c` `"The requested URL returned error: %ld"` with 401 or 403: the server refused the login or the permission;
97/// - OpenSSH's `Permission denied (publickey).`, which Git relays when the key is refused.
98pub const LOGIN_PHRASES: [&str; 5] = ["Authentication failed for", "terminal prompts disabled", "returned error: 401", "returned error: 403", "Permission denied ("];
99
100/// The lines of an output in which the server rejected a reference.
101fn rejection_lines(output: &str) -> impl Iterator<Item = &str> {
102    return output.lines().map(str::trim).filter(|line| { return line.contains(REMOTE_REJECTED); });
103}
104
105/// The failure of a push that no retry can mend, from Git's own words; nothing for a failure that can pass (a stall, the
106/// network, a held lock) and for a rejection whose reason this release does not know.
107pub fn final_failure(output: &str) -> Option<FinalFailure> {
108    if output.contains(CONFLICT_PHRASE) {
109        return Some(FinalFailure::Conflict);
110    }
111    if rejection_lines(output).any(|line| { return line.contains(HOOK_DECLINED); }) {
112        return Some(FinalFailure::Declined);
113    }
114    if LOGIN_PHRASES.iter().any(|phrase| { return output.contains(phrase); }) {
115        return Some(FinalFailure::Login);
116    }
117    return None;
118}
119
120/// The first line in which the server rejected the push for a reason this release does not know, or nothing: a push with such a
121/// line stays retried, and the journal names the line so that the reason can be learned.
122pub fn unrecognised_rejection(output: &str) -> Option<String> {
123    if final_failure(output).is_some() {
124        return None;
125    }
126    return rejection_lines(output).next().map(str::to_string);
127}
128
129/// What:
130///  The plain words the person's failure record starts with for a failure that stops the retry, or nothing for a conflict, whose
131///  report already says what to do.
132/// Why:
133///   The owner's decision is that a login failure and a server hook's decline stop until the next commit
134///  ("Offline is retried, but auth and server hook declines stop until your next commit."); the person is told which one it was,
135///  in words, above Git's own.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// plainWords(cause) // 'cli-git: the remote wants a login ...'
140/// ```
141pub fn plain_words(cause: FinalFailure) -> Option<&'static str> {
142    return match cause {
143        FinalFailure::Conflict => None,
144        FinalFailure::Login => Some(
145            "cli-git: the remote refused this push because it wants a login that the commit service does not have, or because the login it has lacks \
146             permission, so the commit service stops pushing this branch until your next commit. Log in in a way the service can use (a credential \
147             helper, or an SSH key without a passphrase that its environment reaches) and make sure that login may push, then push by hand or commit again.",
148        ),
149        FinalFailure::Declined => Some(
150            "cli-git: the server refused this push: a hook on the server declined it, with the message shown below. Trying again would be refused the same way, \
151             so the commit service stops pushing this branch until your next commit. Fix what the message names, then push by hand or commit again.",
152        ),
153    };
154}
155
156/// Whether a failed push's output says something stopped it that only the person can resolve.
157pub fn needs_the_person(output: &str) -> bool {
158    return final_failure(output).is_some();
159}
160
161/// One branch waiting for a new try.
162#[derive(Clone, Debug)]
163struct Waiting {
164    /// The Git directory of the worktree whose service pushes.
165    git_dir: PathBuf,
166    /// The newest push item of the branch; its branch is what is pushed again.
167    item: WorkItem,
168    /// The number of the try that is due.
169    attempt: u32,
170    /// When it is due.
171    due: Instant,
172}
173
174/// A push that is due again.
175#[derive(Clone, Debug)]
176pub struct Due {
177    /// The Git directory whose service pushes; the plan is keyed by it.
178    pub git_dir: PathBuf,
179    /// The item to push again.
180    pub item: WorkItem,
181    /// The number of this try.
182    pub attempt: u32,
183}
184
185/// What:
186///  What the worker of one directory is handed about retries: the first pause (nothing when its handlers do not retry) and the
187///  pushes of that directory that are due again.
188/// Why:
189///   The plan belongs to the service loop alone. A worker never reads it: it gets its own copy of what is due for its directory,
190///  as an owned value inside its request, so no worker shares the plan with another and nothing waits for it.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// type Retrying = { first?: number; due: Due[] };
195/// ```
196#[derive(Clone, Debug, Default)]
197pub struct Retrying {
198    /// The pause before the first new try; nothing when a retryable failure is to be reported as a failed push.
199    pub first: Option<Duration>,
200    /// The pushes due again, each naming the directory it belongs to.
201    pub due: Vec<Due>,
202}
203
204/// Methods over a request.
205impl Retrying {
206    /// A request for a drain that keeps no plan: a push that could pass is reported as failed, as it is for handlers that do not retry.
207    pub fn none() -> Self {
208        return Self { first: None, due: Vec::new() };
209    }
210}
211
212/// What:
213///  One change a worker asks of the plan: plan a try, or drop the plan of a branch.
214/// Why:
215///   A worker cannot change the plan (it does not hold it), so it returns what it did and the service loop applies the changes
216///  on its own thread, in the order they were made, which is how the last plan of a branch wins.
217///
218/// In TS you'd write (pseudocode):
219/// ```ts
220/// type RetryChange = { plan: { gitDir; item; attempt; due } } | { forget: { gitDir; branchRef } };
221/// ```
222#[derive(Clone, Debug)]
223pub enum RetryChange {
224    /// Try the branch of `item` again as try number `attempt`, at `due`, replacing any plan of the same branch.
225    Plan {
226        /// The Git directory whose service pushes.
227        git_dir: PathBuf,
228        /// The newest push item of the branch.
229        item: WorkItem,
230        /// The number of the try that is planned.
231        attempt: u32,
232        /// When it is due.
233        due: Instant,
234    },
235    /// The branch needs no new try: its push landed, or the person must act.
236    Forget {
237        /// The Git directory whose service pushes.
238        git_dir: PathBuf,
239        /// The branch reference.
240        branch_ref: String,
241    },
242}
243
244/// What:
245///  The branches whose push is waiting for a new try, with the first pause.
246/// Why:
247///   The service loop alone owns it and changes it through `&mut self`, so it needs no cell and no lock; workers get what is due
248///  as owned values (`Retrying`) and hand their changes back (`RetryChange`).
249///
250/// In TS you'd write (pseudocode):
251/// ```ts
252/// class PushRetries { schedule(); forget(); takeDue(); nextWait(); apply(changes); }
253/// ```
254#[derive(Debug)]
255pub struct PushRetries {
256    /// The pause before the first new try.
257    first: Duration,
258    /// The waiting pushes, one per branch of a Git directory.
259    waiting: Vec<Waiting>,
260    /// The directory the plan is kept in, so that a restarted service rebuilds it; nothing for a list kept in memory only.
261    store: Option<PathBuf>,
262}
263
264/// Methods over the list.
265impl PushRetries {
266    /// A list whose first pause is the variable's value or the default.
267    pub fn from_environment() -> Self {
268        return Self::with_first_pause(first_pause_from(std::env::var_os(RETRY_VARIABLE).as_deref()));
269    }
270
271    /// A list with this first pause.
272    pub fn with_first_pause(first: Duration) -> Self {
273        return Self { first, waiting: Vec::new(), store: None };
274    }
275
276    /// What:
277    ///  A list for a service that starts now, whose plan is kept in `directory`: the plan the service that stopped before this one
278    ///  left there is rebuilt, each branch due after its pause counted from now.
279    /// Why:
280    ///   A pause that was running when the service stopped starts again, so a service restarted in a loop does not push every few
281    ///  seconds; the try number is kept, so the pauses go on growing.
282    ///
283    /// In TS you'd write (pseudocode):
284    /// ```ts
285    /// const retries = new PushRetries(first, dir); for (const saved of await load(dir)) retries.plan(saved, now + pauseBefore(saved.attempt, first));
286    /// ```
287    pub fn restored(first: Duration, directory: &Path) -> Self {
288        let mut list: Self = Self { first, waiting: Vec::new(), store: Some(directory.to_path_buf()) };
289        let now: Instant = Instant::now();
290        for saved in load(directory) {
291            list.waiting.push(Waiting { git_dir: saved.git_dir, item: saved.item, attempt: saved.attempt, due: now + pause_before(saved.attempt, first) });
292        }
293        return list;
294    }
295
296    /// A list for a service that starts now, with the first pause the variable gives or the default.
297    pub fn restored_from_environment(directory: &Path) -> Self {
298        return Self::restored(first_pause_from(std::env::var_os(RETRY_VARIABLE).as_deref()), directory);
299    }
300
301    /// The pause before the first new try.
302    pub fn first(&self) -> Duration {
303        return self.first;
304    }
305
306    /// Plan try number `attempt` of the branch of `item`, replacing any plan for the same branch; gives the pause.
307    pub fn schedule(&mut self, git_dir: &Path, item: &WorkItem, attempt: u32, now: Instant) -> Duration {
308        let pause: Duration = pause_before(attempt, self.first);
309        self.plan(git_dir, item, attempt, now + pause);
310        return pause;
311    }
312
313    /// Plan try number `attempt` of the branch of `item` for `due`, replacing any plan for the same branch.
314    fn plan(&mut self, git_dir: &Path, item: &WorkItem, attempt: u32, due: Instant) {
315        self.waiting.retain(|entry| { return !(entry.git_dir == git_dir && entry.item.branch_ref == item.branch_ref); });
316        self.waiting.push(Waiting { git_dir: git_dir.to_path_buf(), item: item.clone(), attempt, due });
317        if let Some(directory) = self.store.as_deref()
318            && let Err(reason) = save(directory, &StoredPlan { git_dir: git_dir.to_path_buf(), item: item.clone(), attempt })
319        {
320            crate::service_warn!("the plan to push {:?} again could not be kept for a restart: {reason}", item.branch_ref);
321        }
322    }
323
324    /// Drop the plan of a branch: its push landed, or the person must act.
325    pub fn forget(&mut self, git_dir: &Path, branch_ref: &str) {
326        self.waiting.retain(|entry| { return !(entry.git_dir == git_dir && entry.item.branch_ref == branch_ref); });
327        if let Some(directory) = self.store.as_deref() {
328            remove(directory, git_dir, branch_ref);
329        }
330    }
331
332    /// Apply what the workers of a round asked, in the order they asked it.
333    pub fn apply(&mut self, changes: Vec<RetryChange>) {
334        for change in changes {
335            match change {
336                RetryChange::Plan { git_dir, item, attempt, due } => self.plan(git_dir.as_path(), &item, attempt, due),
337                RetryChange::Forget { git_dir, branch_ref } => self.forget(git_dir.as_path(), branch_ref.as_str()),
338            }
339        }
340    }
341
342    /// Remove and return the plans of this Git directory that are due at `now`.
343    pub fn take_due(&mut self, git_dir: &Path, now: Instant) -> Vec<Due> {
344        let (due, kept): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut self.waiting).into_iter().partition(|entry| { return entry.git_dir == git_dir && entry.due <= now; });
345        self.waiting = kept;
346        return due.into_iter().map(|entry| { return Due { git_dir: entry.git_dir, item: entry.item, attempt: entry.attempt }; }).collect();
347    }
348
349    /// How long until the first plan of this Git directory is due (zero for one already due), or nothing when none waits.
350    pub fn next_wait(&self, git_dir: &Path, now: Instant) -> Option<Duration> {
351        return self.waiting.iter().filter(|entry| { return entry.git_dir == git_dir; }).map(|entry| { return entry.due.saturating_duration_since(now); }).min();
352    }
353
354    /// The Git directories that have a plan, each once.
355    pub fn directories(&self) -> Vec<PathBuf> {
356        let mut found: Vec<PathBuf> = Vec::new();
357        for entry in &self.waiting {
358            if !found.contains(&entry.git_dir) {
359                found.push(entry.git_dir.clone());
360            }
361        }
362        return found;
363    }
364}
365
366/// The pauses and the list are controlled in the test module.
367#[cfg(test)]
368#[path = "push_retry_tests.rs"]
369mod tests;