1use super::conflict_sides::SAVED_DIRECTORY;
19use super::diagnostic_log::debug;
20use super::discard_audit::{AUDIT_FILE, append_line};
21use super::iso_time::format_iso_milliseconds;
22use super::json_record::{quote, string_array};
23use super::service_protocol::RELEASE;
24use std::path::{Path, PathBuf};
25
26pub const DEFAULT_MAX_AGE_DAYS: u64 = 30;
28
29pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
31
32pub const YOUNG_FLOOR_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
34
35const DAY_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
37
38const LISTED_COPIES: usize = 100;
40
41const TAG: &str = "discardedPrune";
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct Limits {
47 pub max_age_days: u64,
49 pub max_bytes: u64,
51}
52
53impl Default for Limits {
54 fn default() -> Self {
55 return Self { max_age_days: DEFAULT_MAX_AGE_DAYS, max_bytes: DEFAULT_MAX_BYTES };
56 }
57}
58
59pub fn parse_size(text: &str) -> Option<u64> {
70 let trimmed: &str = text.trim();
71 let (digits, unit): (&str, u64) = match trimmed.chars().last()?.to_ascii_lowercase() {
72 'k' => (&trimmed[..trimmed.len() - 1], 1024),
73 'm' => (&trimmed[..trimmed.len() - 1], 1024 * 1024),
74 'g' => (&trimmed[..trimmed.len() - 1], 1024 * 1024 * 1024),
75 _ => (trimmed, 1),
76 };
77 if digits.is_empty() || !digits.bytes().all(|byte| { return byte.is_ascii_digit(); }) {
78 return None;
79 }
80 return digits.parse::<u64>().ok()?.checked_mul(unit).filter(|bytes| { return *bytes > 0; });
81}
82
83fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
85 let shifted_year: i64 = if month <= 2 { year - 1 } else { year };
86 let era: i64 = shifted_year.div_euclid(400);
87 let year_of_era: i64 = shifted_year.rem_euclid(400);
88 let shifted_month: i64 = if month > 2 { month - 3 } else { month + 9 };
89 let day_of_year: i64 = (153 * shifted_month + 2) / 5 + day - 1;
90 let day_of_era: i64 = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
91 return era * 146_097 + day_of_era - 719_468;
92}
93
94pub fn parse_stamp(name: &str) -> Option<i64> {
107 let bytes: &[u8] = name.as_bytes();
108 if bytes.len() < 19 || !name.is_ascii() {
109 return None;
110 }
111 let shape: &[u8; 19] = b"dddd-dd-ddTdd-dd-dd";
112 let fits = bytes[..19].iter().zip(shape).all(|(found, wanted)| { return if *wanted == b'd' { found.is_ascii_digit() } else { found == wanted }; });
113 if !fits {
114 return None;
115 }
116 let suffix: &str = &name[19..];
117 if !(suffix.is_empty() || (suffix.starts_with('-') && suffix.len() > 1 && suffix[1..].bytes().all(|byte| { return byte.is_ascii_digit(); }))) {
118 return None;
119 }
120 let number = |from: usize, to: usize| -> i64 { return name[from..to].parse::<i64>().unwrap_or(0); };
121 let (year, month, day): (i64, i64, i64) = (number(0, 4), number(5, 7), number(8, 10));
122 let (hour, minute, second): (i64, i64, i64) = (number(11, 13), number(14, 16), number(17, 19));
123 if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 {
124 return None;
125 }
126 return Some(((days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60_000 + second * 1000);
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct Stamped {
132 pub name: String,
134 pub millis: i64,
136 pub bytes: u64,
138}
139
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub enum Reason {
143 Age,
145 Size,
147}
148
149impl Reason {
150 pub fn word(self) -> &'static str {
152 return match self {
153 Reason::Age => "age",
154 Reason::Size => "size",
155 };
156 }
157}
158
159pub fn plan(entries: &[Stamped], now_millis: i64, limits: Limits, keep: Option<&str>) -> Vec<(usize, Reason)> {
170 let age_limit: i64 = i64::try_from(limits.max_age_days).unwrap_or(i64::MAX).saturating_mul(DAY_MILLISECONDS);
171 let reachable = |entry: &Stamped| -> bool { return now_millis - entry.millis >= YOUNG_FLOOR_MILLISECONDS && keep != Some(entry.name.as_str()); };
172 let mut doomed: Vec<(usize, Reason)> = Vec::new();
173 for (index, entry) in entries.iter().enumerate() {
174 if reachable(entry) && now_millis - entry.millis > age_limit {
175 doomed.push((index, Reason::Age));
176 }
177 }
178 let mut total: u64 = entries.iter().enumerate().filter(|(index, _)| { return !doomed.iter().any(|(gone, _)| { return gone == index; }); }).map(|(_, entry)| { return entry.bytes; }).sum();
179 let mut oldest_first: Vec<usize> = (0..entries.len()).filter(|index| { return reachable(&entries[*index]) && !doomed.iter().any(|(gone, _)| { return gone == index; }); }).collect();
180 oldest_first.sort_by(|left, right| { return entries[*left].millis.cmp(&entries[*right].millis).then_with(|| { return entries[*left].name.cmp(&entries[*right].name); }); });
181 for index in oldest_first {
182 if total <= limits.max_bytes {
183 break;
184 }
185 total = total.saturating_sub(entries[index].bytes);
186 doomed.push((index, Reason::Size));
187 }
188 return doomed;
189}
190
191fn walk(root: &Path) -> (Vec<String>, u64) {
194 let mut names: Vec<String> = Vec::new();
195 let mut bytes: u64 = 0;
196 let mut pending: Vec<PathBuf> = vec![root.to_path_buf()];
197 while let Some(directory) = pending.pop() {
198 let Ok(listing) = std::fs::read_dir(&directory) else { continue };
199 for entry in listing.flatten() {
200 let Ok(kind) = entry.file_type() else { continue };
201 if kind.is_dir() {
202 pending.push(entry.path());
203 } else {
204 bytes += entry.metadata().map_or(0, |metadata| { return metadata.len(); });
205 names.push(entry.path().strip_prefix(root).unwrap_or(entry.path().as_path()).to_string_lossy().into_owned());
206 }
207 }
208 }
209 names.sort();
210 return (names, bytes);
211}
212
213pub fn list_stamped(saved_root: &Path) -> Vec<Stamped> {
215 let Ok(listing) = std::fs::read_dir(saved_root) else {
216 return Vec::new();
217 };
218 let mut found: Vec<Stamped> = Vec::new();
219 for entry in listing.flatten() {
220 let name: String = entry.file_name().to_string_lossy().into_owned();
221 let Some(millis) = parse_stamp(name.as_str()) else { continue };
222 if !entry.file_type().is_ok_and(|kind| { return kind.is_dir(); }) {
223 continue;
224 }
225 found.push(Stamped { name, millis, bytes: walk(entry.path().as_path()).1 });
226 }
227 found.sort_by(|left, right| { return left.millis.cmp(&right.millis).then_with(|| { return left.name.cmp(&right.name); }); });
228 return found;
229}
230
231#[derive(Clone, Debug, Eq, PartialEq)]
233pub struct Removed {
234 pub path: PathBuf,
236 pub bytes: u64,
238 pub reason: Reason,
240}
241
242pub fn prune_line(removed: &Removed, copies: &[String], limits: Limits, folder_bytes: u64, now_millis: i64) -> String {
254 let limit: u64 = match removed.reason {
255 Reason::Age => limits.max_age_days,
256 Reason::Size => limits.max_bytes,
257 };
258 let listed: Vec<String> = copies.iter().take(LISTED_COPIES).cloned().collect();
259 return format!(
260 "{{\"schemaVersion\":1,\"time\":{},\"level\":\"info\",\"timeMillis\":{},\"release\":{},\"pid\":{},\"form\":\"PruneDiscarded\",\
261 \"outcome\":\"pruned\",\"reason\":{},\"limit\":{limit},\"folderBytes\":{folder_bytes},\"path\":{},\"bytes\":{},\
262 \"copiesTotal\":{},\"copies\":{}}}\n",
263 quote(&format_iso_milliseconds(now_millis)),
264 quote(&now_millis.to_string()),
265 quote(RELEASE),
266 std::process::id(),
267 quote(removed.reason.word()),
268 quote(&removed.path.to_string_lossy()),
269 removed.bytes,
270 copies.len(),
271 string_array(&listed),
272 );
273}
274
275pub fn prune(common_dir: &Path, now_millis: i64, limits: Limits, keep: Option<&str>) -> Vec<Removed> {
287 let saved_root: PathBuf = common_dir.join(SAVED_DIRECTORY);
288 let entries: Vec<Stamped> = list_stamped(saved_root.as_path());
289 let folder_bytes: u64 = entries.iter().map(|entry| { return entry.bytes; }).sum();
290 let mut removed: Vec<Removed> = Vec::new();
291 for (index, reason) in plan(entries.as_slice(), now_millis, limits, keep) {
292 let entry: &Stamped = &entries[index];
293 let path: PathBuf = saved_root.join(entry.name.as_str());
294 let (copies, _) = walk(path.as_path());
295 if let Err(error) = std::fs::remove_dir_all(&path) {
296 debug(TAG, &format!("{} could not be removed: {error}", path.display()));
297 continue;
298 }
299 let gone: Removed = Removed { path, bytes: entry.bytes, reason };
300 let line: String = prune_line(&gone, copies.as_slice(), limits, folder_bytes, now_millis);
301 if let Err(error) = append_line(common_dir.join(AUDIT_FILE).as_path(), line.as_bytes()) {
302 debug(TAG, &format!("the audit record of {} could not be written: {error}", gone.path.display()));
303 }
304 removed.push(gone);
305 }
306 return removed;
307}
308
309pub fn directory_name_of(saved_root: &Path, copy: &Path) -> Option<String> {
311 return copy.strip_prefix(saved_root).ok()?.components().next().map(|first| { return first.as_os_str().to_string_lossy().into_owned(); });
312}
313
314#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct Report {
317 pub bytes: u64,
319 pub copies: usize,
321 pub saves: usize,
323 pub oldest: String,
325}
326
327pub fn report(common_dir: &Path) -> Option<Report> {
329 let saved_root: PathBuf = common_dir.join(SAVED_DIRECTORY);
330 let entries: Vec<Stamped> = list_stamped(saved_root.as_path());
331 let oldest: &Stamped = entries.first()?;
332 let copies: usize = entries.iter().map(|entry| { return walk(saved_root.join(entry.name.as_str()).as_path()).0.len(); }).sum();
333 return Some(Report { bytes: entries.iter().map(|entry| { return entry.bytes; }).sum(), copies, saves: entries.len(), oldest: oldest.name.clone() });
334}
335
336pub fn doctor_section(common_dir: &Path, limits: Limits) -> String {
339 let Some(found) = report(common_dir) else {
340 return String::new();
341 };
342 return format!(
343 "git cli-git doctor: discarded copies: {} holds {} bytes in {} copies from {} saves; the oldest save is {}. Copies older than {} days \
344 are removed, and the oldest are removed past {} bytes, never one younger than a day (`discardedCopies.maxAgeDays` and \
345 `discardedCopies.maxSize` in cli-git.config.jsonc change the limits).\n",
346 common_dir.join(SAVED_DIRECTORY).display(),
347 found.bytes,
348 found.copies,
349 found.saves,
350 found.oldest,
351 limits.max_age_days,
352 limits.max_bytes,
353 );
354}
355
356#[cfg(test)]
358#[path = "discarded_prune_tests.rs"]
359mod tests;