Skip to main content

git_policy_cli/
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), takes no lock, and touches only directories named like the wrapper's own (`2026-11-02T14-01-07`, with an
11//!  optional `-<number>`).
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const removed = await pruneDiscarded({ commonDir, nowMillis, limits, keep: newestDirectoryName });
16//! ```
17
18use 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
26/// Days after which a copy is removed unless the setting says otherwise.
27pub const DEFAULT_MAX_AGE_DAYS: u64 = 30;
28
29/// Bytes the folder is kept under unless the setting says otherwise: one gibibyte.
30pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
31
32/// A copy younger than this is never removed, by either limit.
33pub const YOUNG_FLOOR_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
34
35/// Milliseconds in a day.
36const DAY_MILLISECONDS: i64 = 24 * 60 * 60 * 1000;
37
38/// How many copy names one audit line lists; the line also states the total.
39const LISTED_COPIES: usize = 100;
40
41/// Module tag for diagnostics.
42const TAG: &str = "discardedPrune";
43
44/// The two limits.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct Limits {
47    /// Days after which a copy goes.
48    pub max_age_days: u64,
49    /// Bytes the folder is kept under.
50    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
59/// What:
60///  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).
61/// Why:
62///   `git config --type=int` reads these, so a person who writes `"512m"` means what Git would read; zero, a sign, a fraction
63///  and anything else is no size.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// parseSize('512m') // 536870912
68/// ```
69pub 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
83/// Days from 1970-01-01 to a civil date, the inverse of `iso_time::civil_from_days` (Howard Hinnant's `days_from_civil`).
84fn 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
94/// What:
95///  The time a copy directory's name stands for, in milliseconds since the epoch, or nothing when the name is not one the
96///  wrapper makes.
97/// Why:
98///   The name (`2026-11-02T14-01-07`, or the same with `-<number>` for a second save in that second) is the time of the
99///  save in UTC; reading it, not the directory's modification time, keeps a copy that someone later edited or touched from
100///  looking young, and leaves every directory with another name alone.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// parseStamp('2026-11-02T14-01-07-3') // 1793628067000
105/// ```
106pub 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/// One copy directory, as listed.
130#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct Stamped {
132    /// The directory's name.
133    pub name: String,
134    /// The time its name stands for.
135    pub millis: i64,
136    /// The bytes of the files it holds.
137    pub bytes: u64,
138}
139
140/// Why a directory goes.
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub enum Reason {
143    /// Older than the age limit.
144    Age,
145    /// The folder is over the size limit and this is among the oldest.
146    Size,
147}
148
149impl Reason {
150    /// The word in the audit line.
151    pub fn word(self) -> &'static str {
152        return match self {
153            Reason::Age => "age",
154            Reason::Size => "size",
155        };
156    }
157}
158
159/// What:
160///  Which directories to remove, as indexes into `entries` with the reason, from the time, the limits and the name to keep.
161/// Why:
162///   Pure, so the rule is tested without a disk: first every copy past the age limit; then, while the rest is over the
163///  size limit, the oldest of those that remain. A directory younger than a day, and the one named `keep`, is never chosen.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// const doomed = plan(entries, nowMillis, limits, 'the-new-one') // [[0, 'age'], [3, 'size']]
168/// ```
169pub 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
191/// The files below a directory (symbolic links counted by their own size, never followed), as relative names and the sum of
192/// their sizes; a work stack walks it, so a deep tree takes no recursion.
193fn 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
213/// Every directory of the folder that is named like the wrapper's own, with its size; none when the folder is absent.
214pub 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/// One removed directory.
232#[derive(Clone, Debug, Eq, PartialEq)]
233pub struct Removed {
234    /// The directory that was removed.
235    pub path: PathBuf,
236    /// Its bytes.
237    pub bytes: u64,
238    /// Why.
239    pub reason: Reason,
240}
241
242/// What:
243///  The audit line for one removed directory: the time, the path, the bytes, the reason, the limit it was judged against,
244///  the size of the folder before the pass, and the names of the copies in it.
245/// Why:
246///   A person looking for a file that vanished finds here that the wrapper removed its copy, when, and by which rule. The
247///  common fields are those of the other audit lines, so the file reads the same throughout.
248///
249/// In TS you'd write (pseudocode):
250/// ```ts
251/// JSON.stringify({ schemaVersion: 1, time, form: 'PruneDiscarded', outcome: 'pruned', reason, path, bytes, copies }) + '\n'
252/// ```
253pub 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
275/// What:
276///  Removes what `plan` chooses from the folder, one directory at a time, and writes one audit line after each removal.
277/// Why:
278///   A line per directory, written once it is gone, means a kill between two removals leaves a line for each directory that
279///  went and a whole directory for each that did not; a directory that cannot be removed is left, said as a debug line, and
280///  has no line. No lock is taken and no commit waits on this.
281///
282/// In TS you'd write (pseudocode):
283/// ```ts
284/// const removed = await pruneFolder({ commonDir, nowMillis, limits, keep })
285/// ```
286pub 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
309/// The name of the directory under the folder that holds a copy, or nothing for a path outside the folder.
310pub 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/// What the folder holds, for `git cli-git doctor`.
315#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct Report {
317    /// Bytes of every copy.
318    pub bytes: u64,
319    /// How many files the copies are.
320    pub copies: usize,
321    /// How many saves (directories) they came from.
322    pub saves: usize,
323    /// The name of the oldest save.
324    pub oldest: String,
325}
326
327/// The folder's size, copy count and oldest save, or nothing when it holds no copy.
328pub 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
336/// The doctor's text for the folder: empty when it holds no copy, otherwise one block with the size, the count, the oldest
337/// save and the limits that apply.
338pub 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/// Pruning is controlled in the test module.
357#[cfg(test)]
358#[path = "discarded_prune_tests.rs"]
359mod tests;