use crate::bounded_push::positive_seconds;
use crate::push_retry_store::{StoredPlan, load, remove, save};
use crate::work_item::WorkItem;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub const RETRY_VARIABLE: &str = "CLI_GIT_PUSH_RETRY_SECONDS";
pub const PLAN_DIRECTORY: &str = "push-retries";
pub const FIRST_PAUSE: Duration = Duration::from_secs(10);
pub const LONGEST_PAUSE: Duration = Duration::from_secs(10 * 60);
pub const CONFLICT_PHRASE: &str = " conflicts in: ";
pub fn first_pause_from(value: Option<&std::ffi::OsStr>) -> Duration {
let Some(text) = value else { return FIRST_PAUSE };
return positive_seconds(RETRY_VARIABLE, text).unwrap_or(FIRST_PAUSE).min(LONGEST_PAUSE);
}
pub fn pause_before(attempt: u32, first: Duration) -> Duration {
let doublings: u32 = attempt.saturating_sub(1).min(30);
return first.saturating_mul(1_u32 << doublings).min(LONGEST_PAUSE);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FinalFailure {
Conflict,
Login,
Declined,
}
pub const REMOTE_REJECTED: &str = "[remote rejected]";
pub const HOOK_DECLINED: &str = "hook declined";
pub const LOGIN_PHRASES: [&str; 5] = ["Authentication failed for", "terminal prompts disabled", "returned error: 401", "returned error: 403", "Permission denied ("];
fn rejection_lines(output: &str) -> impl Iterator<Item = &str> {
return output.lines().map(str::trim).filter(|line| { return line.contains(REMOTE_REJECTED); });
}
pub fn final_failure(output: &str) -> Option<FinalFailure> {
if output.contains(CONFLICT_PHRASE) {
return Some(FinalFailure::Conflict);
}
if rejection_lines(output).any(|line| { return line.contains(HOOK_DECLINED); }) {
return Some(FinalFailure::Declined);
}
if LOGIN_PHRASES.iter().any(|phrase| { return output.contains(phrase); }) {
return Some(FinalFailure::Login);
}
return None;
}
pub fn unrecognised_rejection(output: &str) -> Option<String> {
if final_failure(output).is_some() {
return None;
}
return rejection_lines(output).next().map(str::to_string);
}
pub fn plain_words(cause: FinalFailure) -> Option<&'static str> {
return match cause {
FinalFailure::Conflict => None,
FinalFailure::Login => Some(
"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 \
permission, so the commit service stops pushing this branch until your next commit. Log in in a way the service can use (a credential \
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.",
),
FinalFailure::Declined => Some(
"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, \
so the commit service stops pushing this branch until your next commit. Fix what the message names, then push by hand or commit again.",
),
};
}
pub fn needs_the_person(output: &str) -> bool {
return final_failure(output).is_some();
}
#[derive(Clone, Debug)]
struct Waiting {
git_dir: PathBuf,
item: WorkItem,
attempt: u32,
due: Instant,
}
#[derive(Clone, Debug)]
pub struct Due {
pub git_dir: PathBuf,
pub item: WorkItem,
pub attempt: u32,
}
#[derive(Clone, Debug, Default)]
pub struct Retrying {
pub first: Option<Duration>,
pub due: Vec<Due>,
}
impl Retrying {
pub fn none() -> Self {
return Self { first: None, due: Vec::new() };
}
}
#[derive(Clone, Debug)]
pub enum RetryChange {
Plan {
git_dir: PathBuf,
item: WorkItem,
attempt: u32,
due: Instant,
},
Forget {
git_dir: PathBuf,
branch_ref: String,
},
}
#[derive(Debug)]
pub struct PushRetries {
first: Duration,
waiting: Vec<Waiting>,
store: Option<PathBuf>,
}
impl PushRetries {
pub fn from_environment() -> Self {
return Self::with_first_pause(first_pause_from(std::env::var_os(RETRY_VARIABLE).as_deref()));
}
pub fn with_first_pause(first: Duration) -> Self {
return Self { first, waiting: Vec::new(), store: None };
}
pub fn restored(first: Duration, directory: &Path) -> Self {
let mut list: Self = Self { first, waiting: Vec::new(), store: Some(directory.to_path_buf()) };
let now: Instant = Instant::now();
for saved in load(directory) {
list.waiting.push(Waiting { git_dir: saved.git_dir, item: saved.item, attempt: saved.attempt, due: now + pause_before(saved.attempt, first) });
}
return list;
}
pub fn restored_from_environment(directory: &Path) -> Self {
return Self::restored(first_pause_from(std::env::var_os(RETRY_VARIABLE).as_deref()), directory);
}
pub fn first(&self) -> Duration {
return self.first;
}
pub fn schedule(&mut self, git_dir: &Path, item: &WorkItem, attempt: u32, now: Instant) -> Duration {
let pause: Duration = pause_before(attempt, self.first);
self.plan(git_dir, item, attempt, now + pause);
return pause;
}
fn plan(&mut self, git_dir: &Path, item: &WorkItem, attempt: u32, due: Instant) {
self.waiting.retain(|entry| { return !(entry.git_dir == git_dir && entry.item.branch_ref == item.branch_ref); });
self.waiting.push(Waiting { git_dir: git_dir.to_path_buf(), item: item.clone(), attempt, due });
if let Some(directory) = self.store.as_deref()
&& let Err(reason) = save(directory, &StoredPlan { git_dir: git_dir.to_path_buf(), item: item.clone(), attempt })
{
crate::service_warn!("the plan to push {:?} again could not be kept for a restart: {reason}", item.branch_ref);
}
}
pub fn forget(&mut self, git_dir: &Path, branch_ref: &str) {
self.waiting.retain(|entry| { return !(entry.git_dir == git_dir && entry.item.branch_ref == branch_ref); });
if let Some(directory) = self.store.as_deref() {
remove(directory, git_dir, branch_ref);
}
}
pub fn apply(&mut self, changes: Vec<RetryChange>) {
for change in changes {
match change {
RetryChange::Plan { git_dir, item, attempt, due } => self.plan(git_dir.as_path(), &item, attempt, due),
RetryChange::Forget { git_dir, branch_ref } => self.forget(git_dir.as_path(), branch_ref.as_str()),
}
}
}
pub fn take_due(&mut self, git_dir: &Path, now: Instant) -> Vec<Due> {
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; });
self.waiting = kept;
return due.into_iter().map(|entry| { return Due { git_dir: entry.git_dir, item: entry.item, attempt: entry.attempt }; }).collect();
}
pub fn next_wait(&self, git_dir: &Path, now: Instant) -> Option<Duration> {
return self.waiting.iter().filter(|entry| { return entry.git_dir == git_dir; }).map(|entry| { return entry.due.saturating_duration_since(now); }).min();
}
pub fn directories(&self) -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = Vec::new();
for entry in &self.waiting {
if !found.contains(&entry.git_dir) {
found.push(entry.git_dir.clone());
}
}
return found;
}
}
#[cfg(test)]
#[path = "push_retry_tests.rs"]
mod tests;