1use crate::conflict_sides::SAVED_DIRECTORY;
23use crate::diagnostic_log::debug;
24use crate::discard_audit::{AUDIT_FILE, append_line, check_appendable};
25use crate::discarded_store::{Interrupted, OpenFailure, Store, Tally};
26use crate::iso_time::format_iso_milliseconds;
27use crate::json_record::{quote, string_array};
28use crate::service_protocol::RELEASE;
29use std::ffi::OsStr;
30use std::path::{Path, PathBuf};
31
32pub use crate::discarded_store::Identity;
33
34pub const DEFAULT_MAX_AGE_DAYS: u64 = 30;
43
44pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
47
48pub const YOUNG_FLOOR_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
50
51const DAY_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
53
54pub const STAMP_RESOLUTION_MILLISECONDS: i64 = 1000;
57
58const LISTED_COPIES: usize = 100;
63
64const TAG: &str = "discardedPrune";
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub struct Limits {
70 pub max_age_days: u64,
72 pub max_bytes: u64,
74}
75
76impl Default for Limits {
77 fn default() -> Self {
78 return Self { max_age_days: DEFAULT_MAX_AGE_DAYS, max_bytes: DEFAULT_MAX_BYTES };
79 }
80}
81
82pub fn parse_size(text: &str) -> Option<u64> {
93 let trimmed: &str = text.trim();
94 let (digits, unit): (&str, u64) = match trimmed.chars().last()?.to_ascii_lowercase() {
95 'k' => (&trimmed[..trimmed.len() - 1], 1024),
96 'm' => (&trimmed[..trimmed.len() - 1], 1024 * 1024),
97 'g' => (&trimmed[..trimmed.len() - 1], 1024 * 1024 * 1024),
98 _ => (trimmed, 1),
99 };
100 if digits.is_empty() || !digits.bytes().all(|byte| { return byte.is_ascii_digit(); }) {
101 return None;
102 }
103 return digits.parse::<u64>().ok()?.checked_mul(unit).filter(|bytes| { return *bytes > 0; });
104}
105
106fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
108 let shifted_year: i64 = if month <= 2 { year - 1 } else { year };
109 let era: i64 = shifted_year.div_euclid(400);
110 let year_of_era: i64 = shifted_year.rem_euclid(400);
111 let shifted_month: i64 = if month > 2 { month - 3 } else { month + 9 };
112 let day_of_year: i64 = (153 * shifted_month + 2) / 5 + day - 1;
113 let day_of_era: i64 = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
114 return era * 146_097 + day_of_era - 719_468;
115}
116
117fn days_in_month(year: i64, month: i64) -> i64 {
119 return match month {
120 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
121 4 | 6 | 9 | 11 => 30,
122 _ if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
123 _ => 28,
124 };
125}
126
127pub fn parse_stamp(name: &str) -> Option<i64> {
140 let bytes: &[u8] = name.as_bytes();
141 if bytes.len() < 19 || !name.is_ascii() {
142 return None;
143 }
144 let shape: &[u8; 19] = b"dddd-dd-ddTdd-dd-dd";
145 let fits = bytes[..19].iter().zip(shape).all(|(found, wanted)| { return if *wanted == b'd' { found.is_ascii_digit() } else { found == wanted }; });
146 if !fits {
147 return None;
148 }
149 let suffix: &str = &name[19..];
150 if !(suffix.is_empty() || (suffix.starts_with('-') && suffix.len() > 1 && suffix[1..].bytes().all(|byte| { return byte.is_ascii_digit(); }))) {
151 return None;
152 }
153 let number = |from: usize, to: usize| -> i64 { return name[from..to].parse::<i64>().unwrap_or(0); };
154 let (year, month, day): (i64, i64, i64) = (number(0, 4), number(5, 7), number(8, 10));
155 let (hour, minute, second): (i64, i64, i64) = (number(11, 13), number(14, 16), number(17, 19));
156 if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) || hour > 23 || minute > 59 || second > 59 {
157 return None;
158 }
159 return Some(((days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60_000 + second * 1000);
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct Stamped {
165 pub name: String,
167 pub millis: i64,
169 pub bytes: u128,
171 pub identity: Identity,
173}
174
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
177pub enum ProblemKind {
178 StoreUnusable,
180 EntryUnreadable,
182 EntryChanged,
184 PartiallyRemoved,
186 NotRemoved,
188 AuditUnwritable,
190 RemovedWithoutAudit,
192}
193
194#[derive(Clone, Debug, Eq, PartialEq)]
196pub struct Problem {
197 pub path: PathBuf,
199 pub kind: ProblemKind,
201 pub detail: String,
203}
204
205#[derive(Clone, Debug, Default, Eq, PartialEq)]
207pub struct Listing {
208 pub entries: Vec<Stamped>,
210 pub problems: Vec<Problem>,
212 pub files: usize,
214 pub listed_files: std::collections::HashMap<String, std::collections::HashSet<String>>,
217}
218
219impl Problem {
220 pub fn message(&self) -> String {
231 let path = self.path.display();
232 let detail: &str = self.detail.as_str();
233 return match self.kind {
234 ProblemKind::StoreUnusable => format!(
235 "cli-git: the saved copies of replaced files in {path} were not pruned, and the age and size limits did not apply: {detail}. \
236 Make it a real directory (move the copies there and remove the link) and the next save prunes it again.\n"
237 ),
238 ProblemKind::EntryUnreadable => format!(
239 "cli-git: {path} was kept and not counted: it cannot be read ({detail}). Fix its permissions or remove it yourself; \
240 until then the size limit sees the folder as smaller than it is.\n"
241 ),
242 ProblemKind::EntryChanged => format!(
243 "cli-git: {path} was kept: it was replaced or changed while the pass ran ({detail}), so it is not the directory the pass judged.\n"
244 ),
245 ProblemKind::PartiallyRemoved => format!(
246 "cli-git: {path} was only partly removed ({detail}). The audit file lists the copies that went and the rest are still there; \
247 fix the cause and the next pass finishes the removal.\n"
248 ),
249 ProblemKind::NotRemoved => format!("cli-git: {path} could not be removed and was left whole ({detail}). The next pass tries again.\n"),
250 ProblemKind::AuditUnwritable => format!(
251 "cli-git: no saved copy was removed because the audit file {path} cannot be appended to ({detail}); every removal needs its \
252 line there. Fix the file or its permissions and the next pass removes what the limits allow.\n"
253 ),
254 ProblemKind::RemovedWithoutAudit => format!(
255 "cli-git: {path} was removed but its line in the audit file could not be written ({detail}). Its copies were older or \
256 larger than the limits allow.\n"
257 ),
258 };
259 }
260}
261
262#[derive(Clone, Debug, Default, Eq, PartialEq)]
264pub struct PruneOutcome {
265 pub removed: Vec<Removed>,
267 pub problems: Vec<Problem>,
269}
270
271#[derive(Clone, Copy, Debug, Eq, PartialEq)]
273pub enum Reason {
274 Age,
276 Size,
278}
279
280impl Reason {
281 pub fn word(self) -> &'static str {
283 return match self {
284 Reason::Age => "age",
285 Reason::Size => "size",
286 };
287 }
288}
289
290pub fn plan(entries: &[Stamped], now_millis: i64, limits: Limits, keep: Option<&str>) -> Vec<(usize, Reason)> {
301 let age_limit: i128 = i128::from(limits.max_age_days) * i128::from(DAY_MILLISECONDS);
302 let age_of = |entry: &Stamped| -> i128 { return i128::from(now_millis) - (i128::from(entry.millis) + i128::from(STAMP_RESOLUTION_MILLISECONDS)); };
304 let reachable = |entry: &Stamped| -> bool { return age_of(entry) >= i128::from(YOUNG_FLOOR_MILLISECONDS) && keep != Some(entry.name.as_str()); };
305 let mut doomed: Vec<(usize, Reason)> = Vec::new();
306 for (index, entry) in entries.iter().enumerate() {
307 if reachable(entry) && age_of(entry) > age_limit {
308 doomed.push((index, Reason::Age));
309 }
310 }
311 let mut total: u128 = entries.iter().enumerate().filter(|(index, _)| { return !doomed.iter().any(|(gone, _)| { return gone == index; }); }).map(|(_, entry)| { return entry.bytes; }).sum();
312 let mut oldest_first: Vec<usize> = (0..entries.len()).filter(|index| { return reachable(&entries[*index]) && !doomed.iter().any(|(gone, _)| { return gone == index; }); }).collect();
313 oldest_first.sort_by(|left, right| { return entries[*left].millis.cmp(&entries[*right].millis).then_with(|| { return entries[*left].name.cmp(&entries[*right].name); }); });
314 for index in oldest_first {
315 if total <= u128::from(limits.max_bytes) {
316 break;
317 }
318 total = total.saturating_sub(entries[index].bytes);
319 doomed.push((index, Reason::Size));
320 }
321 return doomed;
322}
323
324pub fn list_stamped(common_dir: &Path) -> Listing {
337 return match Store::open(common_dir, SAVED_DIRECTORY) {
338 Ok(Some(store)) => list_in(&store, common_dir.join(SAVED_DIRECTORY).as_path()),
339 Ok(None) => Listing::default(),
340 Err(failure) => Listing { problems: vec![unusable(common_dir.join(SAVED_DIRECTORY).as_path(), &failure)], ..Listing::default() },
341 };
342}
343
344fn unusable(saved_root: &Path, failure: &OpenFailure) -> Problem {
346 let detail: String = match failure {
347 OpenFailure::Link => String::from("a symbolic link, or something that is not a directory, stands at it or at its parent `cli-git`, and a removal never follows one"),
348 OpenFailure::Refused(error) => format!("it cannot be opened: {error}"),
349 };
350 return Problem { path: saved_root.to_path_buf(), kind: ProblemKind::StoreUnusable, detail };
351}
352
353fn list_in(store: &Store, saved_root: &Path) -> Listing {
355 let mut listing: Listing = Listing::default();
356 let names = match store.names() {
357 Ok(found) => found,
358 Err(error) => {
359 listing.problems.push(Problem { path: saved_root.to_path_buf(), kind: ProblemKind::StoreUnusable, detail: format!("it cannot be read: {error}") });
360 return listing;
361 }
362 };
363 for name in names {
364 let text: String = name.to_string_lossy().into_owned();
365 let Some(millis) = parse_stamp(text.as_str()) else { continue };
366 let path: PathBuf = saved_root.join(text.as_str());
367 match survey_entry(store, name.as_os_str()) {
368 Ok(None) => {}
369 Ok(Some((identity, tally))) => {
370 listing.files += tally.names.len();
371 listing.entries.push(Stamped { name: text.clone(), millis, bytes: tally.bytes, identity });
372 listing.listed_files.insert(text, tally.names.into_iter().collect());
373 }
374 Err(detail) => listing.problems.push(Problem { path, kind: ProblemKind::EntryUnreadable, detail }),
375 }
376 }
377 listing.entries.sort_by(|left, right| { return left.millis.cmp(&right.millis).then_with(|| { return left.name.cmp(&right.name); }); });
378 return listing;
379}
380
381fn survey_entry(store: &Store, name: &OsStr) -> Result<Option<(Identity, Tally)>, String> {
384 let Some(seen) = store.directory_identity(name).map_err(|error| { return error.to_string(); })? else {
385 return Ok(None);
386 };
387 let Some(directory) = store.open_directory(name).map_err(|error| { return error.to_string(); })? else {
388 return Ok(None);
389 };
390 let opened: Identity = directory.identity().map_err(|error| { return error.to_string(); })?;
391 if opened != seen {
392 return Err(String::from("it was replaced while it was being read"));
393 }
394 return match directory.survey() {
395 Ok(tally) => Ok(Some((opened, tally))),
396 Err(Interrupted { error, .. }) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
398 Err(Interrupted { error, .. }) => Err(error.to_string()),
399 };
400}
401
402#[derive(Clone, Debug, Eq, PartialEq)]
404pub struct Removed {
405 pub path: PathBuf,
407 pub bytes: u128,
409 pub reason: Reason,
411}
412
413pub fn prune_line(removed: &Removed, copies: &[String], limits: Limits, folder_bytes: u128, now_millis: i64) -> String {
425 return audit_line("pruned", removed, copies, limits, folder_bytes, now_millis);
426}
427
428fn audit_line(outcome: &str, removed: &Removed, copies: &[String], limits: Limits, folder_bytes: u128, now_millis: i64) -> String {
431 let limit: u64 = match removed.reason {
432 Reason::Age => limits.max_age_days,
433 Reason::Size => limits.max_bytes,
434 };
435 let listed: Vec<String> = copies.iter().take(LISTED_COPIES).cloned().collect();
436 return format!(
437 "{{\"schemaVersion\":1,\"time\":{},\"level\":\"info\",\"timeMillis\":{},\"release\":{},\"pid\":{},\"form\":\"PruneDiscarded\",\
438 \"outcome\":{},\"reason\":{},\"limit\":{limit},\"folderBytes\":{folder_bytes},\"path\":{},\"bytes\":{},\
439 \"copiesTotal\":{},\"copies\":{}}}\n",
440 quote(&format_iso_milliseconds(now_millis)),
441 quote(&now_millis.to_string()),
442 quote(RELEASE),
443 std::process::id(),
444 quote(outcome),
445 quote(removed.reason.word()),
446 quote(&removed.path.to_string_lossy()),
447 removed.bytes,
448 copies.len(),
449 string_array(&listed),
450 );
451}
452
453pub fn prune(common_dir: &Path, now_millis: i64, limits: Limits, keep: Option<&str>) -> PruneOutcome {
465 return prune_with(common_dir, now_millis, limits, keep, &mut |_candidate: &Path| {});
466}
467
468pub fn prune_with(common_dir: &Path, now_millis: i64, limits: Limits, keep: Option<&str>, before_removal: &mut dyn FnMut(&Path)) -> PruneOutcome {
471 let saved_root: PathBuf = common_dir.join(SAVED_DIRECTORY);
472 let mut outcome: PruneOutcome = PruneOutcome::default();
473 let store: Store = match Store::open(common_dir, SAVED_DIRECTORY) {
474 Ok(Some(opened)) => opened,
475 Ok(None) => return outcome,
476 Err(failure) => {
477 outcome.problems.push(unusable(saved_root.as_path(), &failure));
478 return outcome;
479 }
480 };
481 let listing: Listing = list_in(&store, saved_root.as_path());
482 outcome.problems.extend(listing.problems);
483 let doomed: Vec<(usize, Reason)> = plan(listing.entries.as_slice(), now_millis, limits, keep);
484 if doomed.is_empty() {
485 return outcome;
486 }
487 let audit_path: PathBuf = common_dir.join(AUDIT_FILE);
488 if let Err(error) = check_appendable(audit_path.as_path()) {
490 outcome.problems.push(Problem { path: audit_path, kind: ProblemKind::AuditUnwritable, detail: error.to_string() });
491 return outcome;
492 }
493 let folder_bytes: u128 = listing.entries.iter().map(|entry| { return entry.bytes; }).sum();
494 for (index, reason) in doomed {
495 let entry: &Stamped = &listing.entries[index];
496 let path: PathBuf = saved_root.join(entry.name.as_str());
497 before_removal(path.as_path());
498 let mut context: Removal = Removal { store: &store, audit_path: audit_path.as_path(), limits, folder_bytes, now_millis, listed_files: &listing.listed_files };
499 remove_entry(&mut context, entry, path, reason, &mut outcome);
500 }
501 return outcome;
502}
503
504struct Removal<'pass> {
506 store: &'pass Store,
508 audit_path: &'pass Path,
510 limits: Limits,
512 folder_bytes: u128,
514 now_millis: i64,
516 listed_files: &'pass std::collections::HashMap<String, std::collections::HashSet<String>>,
518}
519
520fn write_audit(context: &Removal, outcome_word: &str, removed: &Removed, tally: &Tally, outcome: &mut PruneOutcome) {
522 let line: String = audit_line(outcome_word, removed, tally.names.as_slice(), context.limits, context.folder_bytes, context.now_millis);
523 if let Err(error) = append_line(context.audit_path, line.as_bytes()) {
524 debug(TAG, &format!("the audit record of {} could not be written: {error}", removed.path.display()));
525 outcome.problems.push(Problem { path: removed.path.clone(), kind: ProblemKind::RemovedWithoutAudit, detail: error.to_string() });
526 }
527}
528
529fn remove_entry(context: &mut Removal, entry: &Stamped, path: PathBuf, reason: Reason, outcome: &mut PruneOutcome) {
542 let name: &OsStr = OsStr::new(entry.name.as_str());
543 let kept = |kind: ProblemKind, detail: String| -> Problem { return Problem { path: path.clone(), kind, detail }; };
544 let directory = match context.store.open_directory(name) {
545 Ok(Some(opened)) => opened,
546 Ok(None) => return,
548 Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
549 outcome.problems.push(kept(ProblemKind::EntryChanged, String::from("the name is now a link or a file")));
550 return;
551 }
552 Err(error) => {
553 outcome.problems.push(kept(ProblemKind::NotRemoved, error.to_string()));
554 return;
555 }
556 };
557 match directory.identity() {
558 Ok(found) if found == entry.identity => {}
559 Ok(found) if found.device == entry.identity.device && found.inode == entry.identity.inode => match directory.survey() {
562 Ok(now) if now.names.iter().all(|file| { return context.listed_files.get(entry.name.as_str()).is_some_and(|listed| { return listed.contains(file); }); }) => {}
563 Ok(_) => {
564 outcome.problems.push(kept(ProblemKind::EntryChanged, String::from("a file was added to it after the pass listed it")));
565 return;
566 }
567 Err(Interrupted { error, .. }) if error.kind() == std::io::ErrorKind::NotFound => return,
569 Err(Interrupted { error, .. }) => {
570 outcome.problems.push(kept(ProblemKind::NotRemoved, error.to_string()));
571 return;
572 }
573 },
574 Ok(_) => {
575 outcome.problems.push(kept(ProblemKind::EntryChanged, String::from("the name now stands for another directory")));
576 return;
577 }
578 Err(error) => {
579 outcome.problems.push(kept(ProblemKind::NotRemoved, error.to_string()));
580 return;
581 }
582 }
583 let mut gone: Removed = Removed { path: path.clone(), bytes: 0, reason };
584 let tally: Tally = match directory.empty() {
585 Ok(emptied) => emptied,
586 Err(Interrupted { tally, error }) => {
587 if tally.names.is_empty() {
588 outcome.problems.push(kept(ProblemKind::NotRemoved, error.to_string()));
589 } else {
590 gone.bytes = tally.bytes;
591 write_audit(context, "partial", &gone, &tally, outcome);
592 outcome.problems.push(kept(ProblemKind::PartiallyRemoved, error.to_string()));
593 }
594 return;
595 }
596 };
597 gone.bytes = tally.bytes;
598 match context.store.remove_empty_directory(name, entry.identity) {
599 Ok(true) => {}
600 Ok(false) => match context.store.directory_identity(name) {
601 Ok(None) if tally.names.is_empty() => return,
603 Ok(None) => {}
604 _ => outcome.problems.push(kept(ProblemKind::EntryChanged, String::from("the name now stands for another directory, which was kept; the copies judged are gone"))),
605 },
606 Err(error) => {
607 write_audit(context, "partial", &gone, &tally, outcome);
608 outcome.problems.push(kept(ProblemKind::PartiallyRemoved, format!("its copies are gone but the empty directory could not be removed: {error}")));
609 return;
610 }
611 }
612 write_audit(context, "pruned", &gone, &tally, outcome);
613 outcome.removed.push(gone);
614}
615
616pub fn directory_name_of(saved_root: &Path, copy: &Path) -> Option<String> {
618 return copy.strip_prefix(saved_root).ok()?.components().next().map(|first| { return first.as_os_str().to_string_lossy().into_owned(); });
619}
620
621#[derive(Clone, Debug, Eq, PartialEq)]
623pub struct Report {
624 pub bytes: u128,
626 pub copies: usize,
628 pub saves: usize,
630 pub oldest: String,
632 pub problems: Vec<Problem>,
634}
635
636pub fn report(common_dir: &Path) -> Option<Report> {
639 let listing: Listing = list_stamped(common_dir);
640 if listing.entries.is_empty() && listing.problems.is_empty() {
641 return None;
642 }
643 return Some(Report {
644 bytes: listing.entries.iter().map(|entry| { return entry.bytes; }).sum(),
645 copies: listing.files,
646 saves: listing.entries.len(),
647 oldest: listing.entries.first().map_or_else(String::new, |entry| { return entry.name.clone(); }),
648 problems: listing.problems,
649 });
650}
651
652pub fn doctor_section(common_dir: &Path, limits: Limits) -> String {
655 let Some(found) = report(common_dir) else {
656 return String::new();
657 };
658 let mut text: String = String::new();
659 if found.saves > 0 {
660 text = format!(
661 "git cli-git doctor: discarded copies: {} holds {} bytes in {} copies from {} saves; the oldest save is {}. Copies older than {} days \
662 are removed, and the oldest are removed past {} bytes, never one younger than a day (`discardedCopies.maxAgeDays` and \
663 `discardedCopies.maxSize` in cli-git.config.jsonc change the limits).\n",
664 common_dir.join(SAVED_DIRECTORY).display(),
665 found.bytes,
666 found.copies,
667 found.saves,
668 found.oldest,
669 limits.max_age_days,
670 limits.max_bytes,
671 );
672 }
673 for problem in &found.problems {
674 text.push_str(problem.message().replacen("cli-git: ", "git cli-git doctor: discarded copies: ", 1).as_str());
675 }
676 return text;
677}
678
679#[cfg(test)]
681#[path = "discarded_prune_tests.rs"]
682mod tests;
683
684#[cfg(test)]
686#[path = "discarded_prune_guard_tests.rs"]
687mod guard_tests;