use super::conflict_sides::SAVED_DIRECTORY;
use super::diagnostic_log::debug;
use super::discard_audit::{AUDIT_FILE, append_line};
use super::iso_time::format_iso_milliseconds;
use super::json_record::{quote, string_array};
use super::service_protocol::RELEASE;
use std::path::{Path, PathBuf};
pub const DEFAULT_MAX_AGE_DAYS: u64 = 30;
pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
pub const YOUNG_FLOOR_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
const DAY_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
const LISTED_COPIES: usize = 100;
const TAG: &str = "discardedPrune";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Limits {
pub max_age_days: u64,
pub max_bytes: u64,
}
impl Default for Limits {
fn default() -> Self {
return Self { max_age_days: DEFAULT_MAX_AGE_DAYS, max_bytes: DEFAULT_MAX_BYTES };
}
}
pub fn parse_size(text: &str) -> Option<u64> {
let trimmed: &str = text.trim();
let (digits, unit): (&str, u64) = match trimmed.chars().last()?.to_ascii_lowercase() {
'k' => (&trimmed[..trimmed.len() - 1], 1024),
'm' => (&trimmed[..trimmed.len() - 1], 1024 * 1024),
'g' => (&trimmed[..trimmed.len() - 1], 1024 * 1024 * 1024),
_ => (trimmed, 1),
};
if digits.is_empty() || !digits.bytes().all(|byte| { return byte.is_ascii_digit(); }) {
return None;
}
return digits.parse::<u64>().ok()?.checked_mul(unit).filter(|bytes| { return *bytes > 0; });
}
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let shifted_year: i64 = if month <= 2 { year - 1 } else { year };
let era: i64 = shifted_year.div_euclid(400);
let year_of_era: i64 = shifted_year.rem_euclid(400);
let shifted_month: i64 = if month > 2 { month - 3 } else { month + 9 };
let day_of_year: i64 = (153 * shifted_month + 2) / 5 + day - 1;
let day_of_era: i64 = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
return era * 146_097 + day_of_era - 719_468;
}
pub fn parse_stamp(name: &str) -> Option<i64> {
let bytes: &[u8] = name.as_bytes();
if bytes.len() < 19 || !name.is_ascii() {
return None;
}
let shape: &[u8; 19] = b"dddd-dd-ddTdd-dd-dd";
let fits = bytes[..19].iter().zip(shape).all(|(found, wanted)| { return if *wanted == b'd' { found.is_ascii_digit() } else { found == wanted }; });
if !fits {
return None;
}
let suffix: &str = &name[19..];
if !(suffix.is_empty() || (suffix.starts_with('-') && suffix.len() > 1 && suffix[1..].bytes().all(|byte| { return byte.is_ascii_digit(); }))) {
return None;
}
let number = |from: usize, to: usize| -> i64 { return name[from..to].parse::<i64>().unwrap_or(0); };
let (year, month, day): (i64, i64, i64) = (number(0, 4), number(5, 7), number(8, 10));
let (hour, minute, second): (i64, i64, i64) = (number(11, 13), number(14, 16), number(17, 19));
if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 {
return None;
}
return Some(((days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60_000 + second * 1000);
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Stamped {
pub name: String,
pub millis: i64,
pub bytes: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Reason {
Age,
Size,
}
impl Reason {
pub fn word(self) -> &'static str {
return match self {
Reason::Age => "age",
Reason::Size => "size",
};
}
}
pub fn plan(entries: &[Stamped], now_millis: i64, limits: Limits, keep: Option<&str>) -> Vec<(usize, Reason)> {
let age_limit: i64 = i64::try_from(limits.max_age_days).unwrap_or(i64::MAX).saturating_mul(DAY_MILLISECONDS);
let reachable = |entry: &Stamped| -> bool { return now_millis - entry.millis >= YOUNG_FLOOR_MILLISECONDS && keep != Some(entry.name.as_str()); };
let mut doomed: Vec<(usize, Reason)> = Vec::new();
for (index, entry) in entries.iter().enumerate() {
if reachable(entry) && now_millis - entry.millis > age_limit {
doomed.push((index, Reason::Age));
}
}
let mut total: u64 = entries.iter().enumerate().filter(|(index, _)| { return !doomed.iter().any(|(gone, _)| { return gone == index; }); }).map(|(_, entry)| { return entry.bytes; }).sum();
let mut oldest_first: Vec<usize> = (0..entries.len()).filter(|index| { return reachable(&entries[*index]) && !doomed.iter().any(|(gone, _)| { return gone == index; }); }).collect();
oldest_first.sort_by(|left, right| { return entries[*left].millis.cmp(&entries[*right].millis).then_with(|| { return entries[*left].name.cmp(&entries[*right].name); }); });
for index in oldest_first {
if total <= limits.max_bytes {
break;
}
total = total.saturating_sub(entries[index].bytes);
doomed.push((index, Reason::Size));
}
return doomed;
}
fn walk(root: &Path) -> (Vec<String>, u64) {
let mut names: Vec<String> = Vec::new();
let mut bytes: u64 = 0;
let mut pending: Vec<PathBuf> = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(listing) = std::fs::read_dir(&directory) else { continue };
for entry in listing.flatten() {
let Ok(kind) = entry.file_type() else { continue };
if kind.is_dir() {
pending.push(entry.path());
} else {
bytes += entry.metadata().map_or(0, |metadata| { return metadata.len(); });
names.push(entry.path().strip_prefix(root).unwrap_or(entry.path().as_path()).to_string_lossy().into_owned());
}
}
}
names.sort();
return (names, bytes);
}
pub fn list_stamped(saved_root: &Path) -> Vec<Stamped> {
let Ok(listing) = std::fs::read_dir(saved_root) else {
return Vec::new();
};
let mut found: Vec<Stamped> = Vec::new();
for entry in listing.flatten() {
let name: String = entry.file_name().to_string_lossy().into_owned();
let Some(millis) = parse_stamp(name.as_str()) else { continue };
if !entry.file_type().is_ok_and(|kind| { return kind.is_dir(); }) {
continue;
}
found.push(Stamped { name, millis, bytes: walk(entry.path().as_path()).1 });
}
found.sort_by(|left, right| { return left.millis.cmp(&right.millis).then_with(|| { return left.name.cmp(&right.name); }); });
return found;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Removed {
pub path: PathBuf,
pub bytes: u64,
pub reason: Reason,
}
pub fn prune_line(removed: &Removed, copies: &[String], limits: Limits, folder_bytes: u64, now_millis: i64) -> String {
let limit: u64 = match removed.reason {
Reason::Age => limits.max_age_days,
Reason::Size => limits.max_bytes,
};
let listed: Vec<String> = copies.iter().take(LISTED_COPIES).cloned().collect();
return format!(
"{{\"schemaVersion\":1,\"time\":{},\"level\":\"info\",\"timeMillis\":{},\"release\":{},\"pid\":{},\"form\":\"PruneDiscarded\",\
\"outcome\":\"pruned\",\"reason\":{},\"limit\":{limit},\"folderBytes\":{folder_bytes},\"path\":{},\"bytes\":{},\
\"copiesTotal\":{},\"copies\":{}}}\n",
quote(&format_iso_milliseconds(now_millis)),
quote(&now_millis.to_string()),
quote(RELEASE),
std::process::id(),
quote(removed.reason.word()),
quote(&removed.path.to_string_lossy()),
removed.bytes,
copies.len(),
string_array(&listed),
);
}
pub fn prune(common_dir: &Path, now_millis: i64, limits: Limits, keep: Option<&str>) -> Vec<Removed> {
let saved_root: PathBuf = common_dir.join(SAVED_DIRECTORY);
let entries: Vec<Stamped> = list_stamped(saved_root.as_path());
let folder_bytes: u64 = entries.iter().map(|entry| { return entry.bytes; }).sum();
let mut removed: Vec<Removed> = Vec::new();
for (index, reason) in plan(entries.as_slice(), now_millis, limits, keep) {
let entry: &Stamped = &entries[index];
let path: PathBuf = saved_root.join(entry.name.as_str());
let (copies, _) = walk(path.as_path());
if let Err(error) = std::fs::remove_dir_all(&path) {
debug(TAG, &format!("{} could not be removed: {error}", path.display()));
continue;
}
let gone: Removed = Removed { path, bytes: entry.bytes, reason };
let line: String = prune_line(&gone, copies.as_slice(), limits, folder_bytes, now_millis);
if let Err(error) = append_line(common_dir.join(AUDIT_FILE).as_path(), line.as_bytes()) {
debug(TAG, &format!("the audit record of {} could not be written: {error}", gone.path.display()));
}
removed.push(gone);
}
return removed;
}
pub fn directory_name_of(saved_root: &Path, copy: &Path) -> Option<String> {
return copy.strip_prefix(saved_root).ok()?.components().next().map(|first| { return first.as_os_str().to_string_lossy().into_owned(); });
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Report {
pub bytes: u64,
pub copies: usize,
pub saves: usize,
pub oldest: String,
}
pub fn report(common_dir: &Path) -> Option<Report> {
let saved_root: PathBuf = common_dir.join(SAVED_DIRECTORY);
let entries: Vec<Stamped> = list_stamped(saved_root.as_path());
let oldest: &Stamped = entries.first()?;
let copies: usize = entries.iter().map(|entry| { return walk(saved_root.join(entry.name.as_str()).as_path()).0.len(); }).sum();
return Some(Report { bytes: entries.iter().map(|entry| { return entry.bytes; }).sum(), copies, saves: entries.len(), oldest: oldest.name.clone() });
}
pub fn doctor_section(common_dir: &Path, limits: Limits) -> String {
let Some(found) = report(common_dir) else {
return String::new();
};
return format!(
"git cli-git doctor: discarded copies: {} holds {} bytes in {} copies from {} saves; the oldest save is {}. Copies older than {} days \
are removed, and the oldest are removed past {} bytes, never one younger than a day (`discardedCopies.maxAgeDays` and \
`discardedCopies.maxSize` in cli-git.config.jsonc change the limits).\n",
common_dir.join(SAVED_DIRECTORY).display(),
found.bytes,
found.copies,
found.saves,
found.oldest,
limits.max_age_days,
limits.max_bytes,
);
}
#[cfg(test)]
#[path = "discarded_prune_tests.rs"]
mod tests;