Skip to main content

git_policy_cli/conflict/
discarded_prune.rs

1//! What:
2//!  The bound on `<common git dir>/cli-git/discarded/`: copies older than a number of days are removed, and the folder is
3//!  kept under a size by removing the oldest copies first, never one younger than a day and never the copy just written.
4//!  Each removed copy leaves one line in the audit file, and `git cli-git doctor` reports the folder.
5//! Why:
6//!  The copies of conflicted files were never removed, so the folder grew for as long as the repository lived. They are the
7//!  only remaining content of a file a command replaced, so the bound errs toward keeping: both limits are settings of
8//!  `cli-git.config.jsonc` (`discardedCopies.maxAgeDays`, `discardedCopies.maxSize`, read by `config_discarded.rs`), a copy
9//!  younger than a day is out of reach of both, and the pass runs only after a new copy is complete and durable (so a kill while copying costs the older copies
10//!  nothing), and touches only directories named like the wrapper's own (`2026-11-02T14-01-07`, with an optional
11//!  `-<number>`). The folder is opened once without following a link and everything is read and removed through that
12//!  descriptor (`discarded_store.rs`). No lock is taken: passes may overlap, and a file is counted and audited by the one pass
13//!  whose removal of it succeeded, so overlapping passes split a directory's files between their audit lines and every copy
14//!  is audited once. A directory another pass removed first is not a problem. No commit waits on a pass. What a pass cannot do
15//!  is kept and said, never skipped silently.
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! const removed = await pruneDiscarded({ commonDir, nowMillis, limits, keep: newestDirectoryName });
20//! ```
21
22use 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
34/// Days after which a copy is removed unless the setting says otherwise.
35///
36/// No measurement sets this yet. Measured on this host (read-only, 2026-11-03T14:55Z): the folder `cli-git/discarded` does
37/// not exist in any repository with a Git directory within three levels of the home directory, and the audit file of the main
38/// repository (950 lines, 2026-10-21T00:35:32Z to 2026-11-03T14:15:51Z) holds no `saved-conflict` or `pruned` line, while the
39/// feature is a day old (commit 1d2f4bec8e, 2026-11-02T14:08:46-05:00). The value is the coordinator's earlier default, kept
40/// until growth can be measured; `doc/planning/cli-git-shipped-limits-audit.md`, "Defaults for saved conflict copies", has
41/// the measurement, the options and the open question about copies that may be the only copy of a person's work.
42pub const DEFAULT_MAX_AGE_DAYS: u64 = 30;
43
44/// Bytes the folder is kept under unless the setting says otherwise: one gibibyte. No measurement sets this yet; see
45/// `DEFAULT_MAX_AGE_DAYS`.
46pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
47
48/// A copy younger than this is never removed, by either limit. No measurement sets this yet; see `DEFAULT_MAX_AGE_DAYS`.
49pub const YOUNG_FLOOR_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
50
51/// Milliseconds in a day.
52const DAY_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
53
54/// The resolution of a directory name: it says the second of the save, and the save may have been made at any millisecond
55/// of that second, so an age is counted from the end of it.
56pub const STAMP_RESOLUTION_MILLISECONDS: i64 = 1000;
57
58/// How many copy names one audit line lists; the line also states the total.
59///  Exempt from a citation under `LQM` (class: display cap): it limits how much text a message, log field or record
60///  names, and no command result or duration depends on it. Classed once in `doc/planning/cli-git-shipped-limits-
61///  audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation, open to the owner's veto.
62const LISTED_COPIES: usize = 100;
63
64/// Module tag for diagnostics.
65const TAG: &str = "discardedPrune";
66
67/// The two limits.
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub struct Limits {
70    /// Days after which a copy goes.
71    pub max_age_days: u64,
72    /// Bytes the folder is kept under.
73    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
82/// What:
83///  A size as Git's integer settings write it: whole bytes, or a number followed by `k`, `m` or `g` (any case, powers of 1024).
84/// Why:
85///   `git config --type=int` reads these, so a person who writes `"512m"` means what Git would read; zero, a sign, a fraction
86///  and anything else is no size.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// parseSize('512m') // 536870912
91/// ```
92pub 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
106/// Days from 1970-01-01 to a civil date, the inverse of `iso_time::civil_from_days` (Howard Hinnant's `days_from_civil`).
107fn 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
117/// The days of a month of the proleptic Gregorian calendar, February's by the leap-year rule.
118fn 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
127/// What:
128///  The time a copy directory's name stands for, in milliseconds since the epoch, or nothing when the name is not one the
129///  wrapper makes.
130/// Why:
131///   The name (`2026-11-02T14-01-07`, or the same with `-<number>` for a second save in that second) is the time of the
132///  save in UTC; reading it, not the directory's modification time, keeps a copy that someone later edited or touched from
133///  looking young, and leaves every directory with another name alone.
134///
135/// In TS you'd write (pseudocode):
136/// ```ts
137/// parseStamp('2026-11-02T14-01-07-3') // 1793628067000
138/// ```
139pub 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/// One copy directory, as listed.
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct Stamped {
165    /// The directory's name.
166    pub name: String,
167    /// The time its name stands for.
168    pub millis: i64,
169    /// The bytes of the files it holds; wide, so that no sum of file lengths can wrap.
170    pub bytes: u128,
171    /// The directory the listing saw under that name.
172    pub identity: Identity,
173}
174
175/// What went wrong with the folder or one of its directories; each is kept, never guessed at.
176#[derive(Clone, Copy, Debug, Eq, PartialEq)]
177pub enum ProblemKind {
178    /// The folder cannot be used as a store: a link stands where it should be, or it cannot be opened.
179    StoreUnusable,
180    /// A directory could not be read, so its size is unknown and it is kept.
181    EntryUnreadable,
182    /// A directory was replaced or changed between the listing and the removal, and was kept.
183    EntryChanged,
184    /// A removal failed after some files were gone.
185    PartiallyRemoved,
186    /// A removal failed before anything was removed.
187    NotRemoved,
188    /// The audit file could not be opened for appending, so nothing was removed.
189    AuditUnwritable,
190    /// A directory was removed but its audit line could not be written.
191    RemovedWithoutAudit,
192}
193
194/// One thing a pass or a listing could not do, with the path it concerns and the reason in plain words.
195#[derive(Clone, Debug, Eq, PartialEq)]
196pub struct Problem {
197    /// The folder or directory concerned.
198    pub path: PathBuf,
199    /// What kind of trouble.
200    pub kind: ProblemKind,
201    /// The reason, as the system gave it.
202    pub detail: String,
203}
204
205/// What a listing found: the directories named like the wrapper's own, and what it could not read.
206#[derive(Clone, Debug, Default, Eq, PartialEq)]
207pub struct Listing {
208    /// The directories, oldest first.
209    pub entries: Vec<Stamped>,
210    /// What could not be read.
211    pub problems: Vec<Problem>,
212    /// How many files the entries hold.
213    pub files: usize,
214    /// The files each entry held when it was listed, by the entry's name, so that a removal can tell a directory that only
215    /// lost files (another pass removing them) from one that gained a file after it was judged.
216    pub listed_files: std::collections::HashMap<String, std::collections::HashSet<String>>,
217}
218
219impl Problem {
220    /// What:
221    ///  The sentence that tells a person what was kept or not done, which input it concerns and what to do about it.
222    /// Why:
223    ///   The copies are what a person may need back, so every case where the limits could not be applied, or an audit line
224    ///  is missing, is said where the person sees it (the notice of the command, and `git cli-git doctor`).
225    ///
226    /// In TS you'd write (pseudocode):
227    /// ```ts
228    /// `${problem.path} was kept: ${problem.detail}`
229    /// ```
230    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/// What a pass did.
263#[derive(Clone, Debug, Default, Eq, PartialEq)]
264pub struct PruneOutcome {
265    /// The directories removed, in order.
266    pub removed: Vec<Removed>,
267    /// What could not be done.
268    pub problems: Vec<Problem>,
269}
270
271/// Why a directory goes.
272#[derive(Clone, Copy, Debug, Eq, PartialEq)]
273pub enum Reason {
274    /// Older than the age limit.
275    Age,
276    /// The folder is over the size limit and this is among the oldest.
277    Size,
278}
279
280impl Reason {
281    /// The word in the audit line.
282    pub fn word(self) -> &'static str {
283        return match self {
284            Reason::Age => "age",
285            Reason::Size => "size",
286        };
287    }
288}
289
290/// What:
291///  Which directories to remove, as indexes into `entries` with the reason, from the time, the limits and the name to keep.
292/// Why:
293///   Pure, so the rule is tested without a disk: first every copy past the age limit; then, while the rest is over the
294///  size limit, the oldest of those that remain. A directory younger than a day, and the one named `keep`, is never chosen.
295///
296/// In TS you'd write (pseudocode):
297/// ```ts
298/// const doomed = plan(entries, nowMillis, limits, 'the-new-one') // [[0, 'age'], [3, 'size']]
299/// ```
300pub 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    // Counted from the end of the named second, in a width no clock or limit can overflow.
303    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
324/// What:
325///  Every directory of the folder that is named like the wrapper's own, oldest first, with its size and what it was when
326///  listed, and what could not be read.
327/// Why:
328///   The folder is opened without following a link and each directory is opened through it, so the sizes are those of the
329///  directories that a later removal will meet by identity. A directory that cannot be read has no size to judge by, so it
330///  is left out of the entries and named in the problems; a folder that is absent is simply empty.
331///
332/// In TS you'd write (pseudocode):
333/// ```ts
334/// const { entries, problems } = await listStamped(commonDir);
335/// ```
336pub 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
344/// The problem that says why the folder cannot be used.
345fn 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
353/// The listing of an opened folder; `saved_root` is the path the problems and the removal records show.
354fn 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
381/// The identity and contents of the directory `name`, nothing when `name` is not a directory (a link or a file), or the
382/// reason it cannot be read.
383fn 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        // Removed by another pass while it was read: nothing to count.
397        Err(Interrupted { error, .. }) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
398        Err(Interrupted { error, .. }) => Err(error.to_string()),
399    };
400}
401
402/// One removed directory.
403#[derive(Clone, Debug, Eq, PartialEq)]
404pub struct Removed {
405    /// The directory that was removed.
406    pub path: PathBuf,
407    /// Its bytes.
408    pub bytes: u128,
409    /// Why.
410    pub reason: Reason,
411}
412
413/// What:
414///  The audit line for one removed directory: the time, the path, the bytes, the reason, the limit it was judged against,
415///  the size of the folder before the pass, and the names of the copies in it.
416/// Why:
417///   A person looking for a file that vanished finds here that the wrapper removed its copy, when, and by which rule. The
418///  common fields are those of the other audit lines, so the file reads the same throughout.
419///
420/// In TS you'd write (pseudocode):
421/// ```ts
422/// JSON.stringify({ schemaVersion: 1, time, form: 'PruneDiscarded', outcome: 'pruned', reason, path, bytes, copies }) + '\n'
423/// ```
424pub 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
428/// The audit line of one directory with the outcome word `pruned` (all of it went) or `partial` (some of it went and the
429/// rest was kept).
430fn 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
453/// What:
454///  Removes what `plan` chooses from the folder, one directory at a time, and writes one audit line after each removal.
455/// Why:
456///   A line per directory, written once it is gone, means a kill between two removals leaves a line for each directory that
457///  went and a whole directory for each that did not; a directory that cannot be removed is left, said as a debug line, and
458///  has no line. No lock is taken and no commit waits on this; a directory that another pass removes first is that pass's.
459///
460/// In TS you'd write (pseudocode):
461/// ```ts
462/// const removed = await pruneFolder({ commonDir, nowMillis, limits, keep })
463/// ```
464pub 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
468/// `prune` with a step run for each chosen directory after the listing and before its removal: the controls use it to
469/// change the folder at the moment a pass is most exposed; `prune` passes a step that does nothing.
470pub 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    // Every removal is followed by its line, so a file that cannot take one stops the pass before the first removal.
489    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
504/// What a removal needs besides the entry: the folder, where the audit goes, the limits and the numbers its line states.
505struct Removal<'pass> {
506    /// The folder this pass opened.
507    store: &'pass Store,
508    /// The audit file.
509    audit_path: &'pass Path,
510    /// The limits the pass judges by.
511    limits: Limits,
512    /// The size of the folder before the pass.
513    folder_bytes: u128,
514    /// The time of the pass.
515    now_millis: i64,
516    /// The files each directory held when the pass listed it.
517    listed_files: &'pass std::collections::HashMap<String, std::collections::HashSet<String>>,
518}
519
520/// Writes the audit line of a directory of which `tally` went; said as a problem when the line cannot be written.
521fn 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
529/// What:
530///  Removes one chosen directory through the open folder: opens it by name without following a link, checks that it is the
531///  directory the listing saw, removes its files and subdirectories through its descriptor, then the empty directory, and
532///  audits what went.
533/// Why:
534///   The directory that was judged is the only thing removed: a name that now stands for another directory is kept and said,
535///  a link is never entered, and a failure part way is audited as partial and reported, never counted as a removal.
536///
537/// In TS you'd write (pseudocode):
538/// ```ts
539/// await removeEntry(context, entry, path, reason, outcome)
540/// ```
541fn 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        // Gone: another actor removed it, which is what this pass was going to do.
547        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        // The same directory whose change time moved: another pass removing its files does that, and so does a file added after
560        //  the listing. Only a directory that gained nothing is the directory that was judged.
561        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            // Removed by another pass while it was looked at.
568            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            // Another pass removed the emptied directory first. It audits what it removed; this pass audits what it removed.
602            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
616/// The name of the directory under the folder that holds a copy, or nothing for a path outside the folder.
617pub 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/// What the folder holds, for `git cli-git doctor`.
622#[derive(Clone, Debug, Eq, PartialEq)]
623pub struct Report {
624    /// Bytes of every copy.
625    pub bytes: u128,
626    /// How many files the copies are.
627    pub copies: usize,
628    /// How many saves (directories) they came from.
629    pub saves: usize,
630    /// The name of the oldest save, empty when no save could be read.
631    pub oldest: String,
632    /// What could not be read.
633    pub problems: Vec<Problem>,
634}
635
636/// The folder's size, copy count and oldest save and what could not be read, or nothing when it holds no copy and nothing
637/// went wrong.
638pub 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
652/// The doctor's text for the folder: empty when it holds no copy and nothing went wrong, otherwise one block with the size,
653/// the count, the oldest save and the limits that apply, then a sentence for each thing that could not be read.
654pub 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/// Pruning is controlled in the test module.
680#[cfg(test)]
681#[path = "discarded_prune_tests.rs"]
682mod tests;
683
684/// What a pass may touch and what it must say is controlled in the guard test module.
685#[cfg(test)]
686#[path = "discarded_prune_guard_tests.rs"]
687mod guard_tests;