dev_prune/adapters/
pycache.rs1use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
24use anyhow::{Result, anyhow};
25use std::path::{Path, PathBuf};
26use walkdir::WalkDir;
27
28const CACHEDIR_TAG_SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55";
32
33fn has_cachedir_tag(dir: &Path) -> bool {
35 std::fs::read(dir.join("CACHEDIR.TAG"))
36 .is_ok_and(|bytes| bytes.starts_with(CACHEDIR_TAG_SIGNATURE))
37}
38
39fn is_target_cache_dir(entry: &walkdir::DirEntry) -> bool {
45 let name = entry.file_name().to_string_lossy();
46 match name.as_ref() {
47 "__pycache__" => true,
48 ".pytest_cache" | ".mypy_cache" | ".ruff_cache" => has_cachedir_tag(entry.path()),
49 _ => false,
50 }
51}
52
53struct Scan {
55 caches: Vec<PathBuf>,
57 has_py: bool,
59}
60
61fn scan(repo_root: &Path) -> Scan {
71 let mut caches = Vec::new();
72 let mut has_py = false;
73 let mut walk = WalkDir::new(repo_root).follow_links(false).into_iter();
74
75 while let Some(entry) = walk.next() {
76 let Ok(entry) = entry else { continue };
77
78 if entry.depth() == 0 {
80 continue;
81 }
82
83 if entry.file_type().is_dir() {
84 if is_target_cache_dir(&entry) {
85 caches.push(entry.path().to_path_buf());
86 walk.skip_current_dir();
87 } else if !crate::workspace::is_scannable(&entry) {
88 walk.skip_current_dir();
89 }
90 continue;
91 }
92
93 if !has_py
94 && entry
95 .path()
96 .extension()
97 .is_some_and(|ext| ext.eq_ignore_ascii_case("py"))
98 {
99 has_py = true;
100 }
101 }
102
103 Scan { caches, has_py }
104}
105
106pub struct Pycache;
108
109impl PackageManager for Pycache {
110 fn name(&self) -> &'static str {
111 "pycache"
112 }
113
114 fn detect(&self, path: &Path) -> bool {
115 path.join(".git").exists() && !scan(path).caches.is_empty()
116 }
117
118 fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
119 scan(path)
120 .caches
121 .into_iter()
122 .map(|dir| {
123 let name = dir
124 .file_name()
125 .map(|n| n.to_string_lossy().into_owned())
126 .unwrap_or_default();
127 BloatDir {
128 name,
129 size_bytes: dir_size(&dir),
130 path: dir,
131 shared_bytes: 0,
132 }
133 })
134 .collect()
135 }
136
137 fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
140 if !scan(path).has_py {
141 return Err(anyhow!(
142 "no `.py` file anywhere under the repository: nothing to regenerate a \
143 Python bytecode or tool cache from."
144 ));
145 }
146 Ok(())
147 }
148
149 fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
150 println!(
151 "Python caches regenerate on their own: __pycache__ the next time these \
152 modules are imported, .pytest_cache/.mypy_cache/.ruff_cache the next time \
153 pytest, mypy or ruff runs"
154 );
155 Ok(())
156 }
157
158 fn restore_named(
159 &self,
160 _path: &Path,
161 dir_name: &str,
162 _runtime: Option<&str>,
163 _timeout: std::time::Duration,
164 ) -> Result<()> {
165 let hint = match Path::new(dir_name).file_name().and_then(|n| n.to_str()) {
166 Some("__pycache__") => "Python regenerates it the next time these modules are imported",
167 Some(".pytest_cache") => "pytest regenerates it on its next run",
168 Some(".mypy_cache") => "mypy regenerates it on its next run",
169 Some(".ruff_cache") => "ruff regenerates it on its next run",
170 _ => "it regenerates the next time the tool that made it runs again",
171 };
172 println!("{dir_name} will come back: {hint}");
173 Ok(())
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use std::fs;
181 use tempfile::tempdir;
182
183 fn tag(dir: &Path) {
184 fs::write(
185 dir.join("CACHEDIR.TAG"),
186 "Signature: 8a477f597d28d172789f06886806bc55\n",
187 )
188 .unwrap();
189 }
190
191 #[test]
192 fn test_name() {
193 assert_eq!(Pycache.name(), "pycache");
194 }
195
196 #[test]
197 fn pycache_is_not_opt_in() {
198 assert!(!Pycache.opt_in());
199 }
200
201 #[test]
202 fn test_detect_positive() {
203 let dir = tempdir().unwrap();
204 fs::create_dir(dir.path().join(".git")).unwrap();
205 fs::create_dir(dir.path().join("__pycache__")).unwrap();
206 assert!(Pycache.detect(dir.path()));
207 }
208
209 #[test]
210 fn detect_requires_the_repository_root_not_just_a_cache() {
211 let dir = tempdir().unwrap();
214 fs::create_dir(dir.path().join("__pycache__")).unwrap();
215 assert!(!Pycache.detect(dir.path()));
216 }
217
218 #[test]
219 fn test_detect_negative() {
220 let dir = tempdir().unwrap();
221 fs::create_dir(dir.path().join(".git")).unwrap();
222 assert!(!Pycache.detect(dir.path()));
223 }
224
225 #[test]
226 fn claims_pycache_by_name_alone() {
227 let dir = tempdir().unwrap();
228 fs::create_dir(dir.path().join(".git")).unwrap();
229 fs::create_dir_all(dir.path().join("pkg/__pycache__")).unwrap();
230
231 let dirs = Pycache.bloat_dirs(dir.path());
232 assert_eq!(dirs.len(), 1);
233 assert_eq!(dirs[0].name, "__pycache__");
234 }
235
236 #[test]
237 fn a_dot_mypy_cache_without_the_tag_is_left_alone() {
238 let dir = tempdir().unwrap();
239 fs::create_dir(dir.path().join(".git")).unwrap();
240 fs::create_dir(dir.path().join(".mypy_cache")).unwrap();
242
243 assert!(Pycache.bloat_dirs(dir.path()).is_empty());
244 }
245
246 #[test]
247 fn a_tagged_dot_mypy_cache_is_claimed() {
248 let dir = tempdir().unwrap();
249 fs::create_dir(dir.path().join(".git")).unwrap();
250 let cache = dir.path().join(".mypy_cache");
251 fs::create_dir(&cache).unwrap();
252 tag(&cache);
253
254 let dirs = Pycache.bloat_dirs(dir.path());
255 assert_eq!(dirs.len(), 1);
256 assert_eq!(dirs[0].name, ".mypy_cache");
257 }
258
259 #[test]
260 fn finds_every_cache_kind_anywhere_in_the_tree() {
261 let dir = tempdir().unwrap();
262 fs::create_dir(dir.path().join(".git")).unwrap();
263 fs::create_dir_all(dir.path().join("src/pkg/__pycache__")).unwrap();
264 fs::create_dir_all(dir.path().join("tests/__pycache__")).unwrap();
265 let pytest = dir.path().join(".pytest_cache");
266 fs::create_dir(&pytest).unwrap();
267 tag(&pytest);
268 let ruff = dir.path().join(".ruff_cache");
269 fs::create_dir(&ruff).unwrap();
270 tag(&ruff);
271
272 let mut names: Vec<String> = Pycache
273 .bloat_dirs(dir.path())
274 .into_iter()
275 .map(|b| b.name)
276 .collect();
277 names.sort();
278 assert_eq!(
279 names,
280 vec![".pytest_cache", ".ruff_cache", "__pycache__", "__pycache__"]
281 );
282 }
283
284 #[test]
285 fn never_descends_into_a_virtual_environment() {
286 let dir = tempdir().unwrap();
287 fs::create_dir(dir.path().join(".git")).unwrap();
288 let venv = dir.path().join(".venv");
289 fs::create_dir_all(venv.join("lib/site-packages/dep/__pycache__")).unwrap();
290 fs::write(venv.join("pyvenv.cfg"), "").unwrap();
291
292 assert!(Pycache.bloat_dirs(dir.path()).is_empty());
295 }
296
297 #[test]
298 fn never_descends_into_node_modules_or_target() {
299 let dir = tempdir().unwrap();
300 fs::create_dir(dir.path().join(".git")).unwrap();
301 fs::create_dir_all(dir.path().join("node_modules/some-native-dep/__pycache__")).unwrap();
302 fs::create_dir_all(dir.path().join("target/debug/__pycache__")).unwrap();
303
304 assert!(Pycache.bloat_dirs(dir.path()).is_empty());
305 }
306
307 #[test]
308 fn never_descends_into_a_nested_repository() {
309 let dir = tempdir().unwrap();
310 fs::create_dir(dir.path().join(".git")).unwrap();
311 let nested = dir.path().join("vendored-tool");
312 fs::create_dir_all(nested.join("__pycache__")).unwrap();
313 fs::create_dir(nested.join(".git")).unwrap();
314
315 assert!(Pycache.bloat_dirs(dir.path()).is_empty());
316 }
317
318 #[test]
319 fn enforce_lockfile_refuses_with_no_python_source_anywhere() {
320 let dir = tempdir().unwrap();
321 fs::create_dir(dir.path().join(".git")).unwrap();
322 fs::create_dir(dir.path().join("__pycache__")).unwrap();
323
324 assert!(
325 Pycache
326 .enforce_lockfile(dir.path(), EnforcePolicy::default())
327 .is_err()
328 );
329
330 fs::write(dir.path().join("main.py"), "").unwrap();
331 assert!(
332 Pycache
333 .enforce_lockfile(dir.path(), EnforcePolicy::default())
334 .is_ok()
335 );
336 }
337
338 #[test]
339 fn restore_named_matches_by_basename_not_the_whole_relative_path() {
340 let dir = tempdir().unwrap();
341 assert!(
344 Pycache
345 .restore_named(
346 dir.path(),
347 "src/pkg/__pycache__",
348 None,
349 std::time::Duration::from_secs(1),
350 )
351 .is_ok()
352 );
353 }
354}