Skip to main content

grit_lib/
prune_packed.rs

1//! Library implementation of `prune-packed`.
2//!
3//! Removes loose objects that are already stored in a pack file, freeing
4//! disk space without losing any object data.
5
6use crate::error::{Error, Result};
7use crate::objects::ObjectId;
8use crate::pack::read_local_pack_indexes;
9use std::collections::HashSet;
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13
14/// Options controlling the behaviour of [`prune_packed_objects`].
15#[derive(Debug, Clone, Copy, Default)]
16pub struct PrunePackedOptions {
17    /// When `true`, print what would be deleted without actually deleting.
18    pub dry_run: bool,
19    /// When `true`, suppress informational output.
20    pub quiet: bool,
21}
22
23/// Remove loose objects that are already stored in a pack file.
24///
25/// For each loose object under `objects_dir` whose [`ObjectId`] appears in
26/// at least one local pack index, the file is deleted (or, with
27/// [`PrunePackedOptions::dry_run`], the deletion command is printed to
28/// `stdout`).  Empty two-char prefix directories are removed afterwards.
29///
30/// Returns the list of paths that were (or would be) removed.
31///
32/// # Errors
33///
34/// - [`Error::Io`] for directory or file access failures.
35pub fn prune_packed_objects(objects_dir: &Path, opts: PrunePackedOptions) -> Result<Vec<PathBuf>> {
36    let packed_ids = collect_packed_ids(objects_dir)?;
37    if packed_ids.is_empty() {
38        return Ok(Vec::new());
39    }
40
41    let mut removed = Vec::new();
42    let rd = match fs::read_dir(objects_dir) {
43        Ok(rd) => rd,
44        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
45        Err(err) => return Err(Error::Io(err)),
46    };
47
48    for entry in rd {
49        let entry = entry.map_err(Error::Io)?;
50        let dir_name = entry.file_name().to_string_lossy().to_string();
51
52        // Only process two-hex-char prefix subdirectories.
53        if dir_name.len() != 2
54            || !dir_name.chars().all(|c| c.is_ascii_hexdigit())
55            || !entry.path().is_dir()
56        {
57            continue;
58        }
59
60        let sub_rd = match fs::read_dir(entry.path()) {
61            Ok(rd) => rd,
62            Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
63            Err(err) => return Err(Error::Io(err)),
64        };
65
66        for file in sub_rd {
67            let file = file.map_err(Error::Io)?;
68            let file_name = file.file_name().to_string_lossy().to_string();
69            // Loose object filenames are exactly 38 hex chars.
70            if file_name.len() != 38 || !file_name.chars().all(|c| c.is_ascii_hexdigit()) {
71                continue;
72            }
73
74            let hex = format!("{dir_name}{file_name}");
75            let oid: ObjectId = match hex.parse() {
76                Ok(id) => id,
77                Err(_) => continue,
78            };
79
80            if !packed_ids.contains(&oid) {
81                continue;
82            }
83
84            let obj_path = file.path();
85            if opts.dry_run {
86                println!("rm -f {}", obj_path.display());
87            } else {
88                match fs::remove_file(&obj_path) {
89                    Ok(()) => {}
90                    Err(err) if err.kind() == io::ErrorKind::NotFound => {}
91                    Err(err) => return Err(Error::Io(err)),
92                }
93            }
94            removed.push(obj_path);
95        }
96
97        // Try to remove the now-possibly-empty prefix directory.
98        if !opts.dry_run {
99            let _ = fs::remove_dir(entry.path());
100        }
101    }
102
103    Ok(removed)
104}
105
106/// Build the set of all object IDs present in local pack indexes.
107fn collect_packed_ids(objects_dir: &Path) -> Result<HashSet<ObjectId>> {
108    let indexes = read_local_pack_indexes(objects_dir)?;
109    let mut ids = HashSet::new();
110    for idx in indexes {
111        for entry in idx.entries {
112            ids.insert(entry.oid);
113        }
114    }
115    Ok(ids)
116}
117
118#[cfg(test)]
119mod tests {
120    #![allow(clippy::unwrap_used, clippy::expect_used)]
121
122    use super::*;
123    use crate::objects::ObjectKind;
124    use crate::odb::Odb;
125    use tempfile::TempDir;
126
127    #[test]
128    fn no_packs_leaves_loose_objects_intact() {
129        let dir = TempDir::new().unwrap();
130        let odb = Odb::new(dir.path());
131        let oid = odb.write(ObjectKind::Blob, b"hello").unwrap();
132
133        let opts = PrunePackedOptions {
134            dry_run: false,
135            quiet: true,
136        };
137        let removed = prune_packed_objects(dir.path(), opts).unwrap();
138        assert!(removed.is_empty());
139        assert!(odb.exists(&oid));
140    }
141
142    #[test]
143    fn dry_run_does_not_delete_files() {
144        let dir = TempDir::new().unwrap();
145        let odb = Odb::new(dir.path());
146        let oid = odb.write(ObjectKind::Blob, b"dry run test").unwrap();
147
148        // No pack indexes — nothing would be pruned.
149        let opts = PrunePackedOptions {
150            dry_run: true,
151            quiet: false,
152        };
153        let removed = prune_packed_objects(dir.path(), opts).unwrap();
154        assert!(removed.is_empty());
155        assert!(odb.exists(&oid));
156    }
157}