1use std::path::{Path, PathBuf};
17
18use crate::error::StorageError;
19
20use super::name::WorktreeName;
21use super::trees::{WORKTREE_DIR, Worktrees, open_city};
22
23impl Worktrees {
24 pub fn sweep_abandoned(
32 city_root: &Path,
33 held: &[WorktreeName],
34 ) -> Result<Vec<WorktreeName>, StorageError> {
35 let repo = match open_city(city_root) {
38 Ok(repo) => repo,
39 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Vec::new()),
40 Err(err) => {
41 return Err(StorageError::Worktree {
42 op: "open the city repository",
43 detail: format!("{}: {err}", city_root.display()),
44 });
45 }
46 };
47 let trees = Worktrees::over(repo, city_root);
48 let mut swept = trees.sweep_registered(held)?;
49 swept.extend(trees.sweep_unregistered(held)?);
50 swept.sort();
51 swept.dedup();
52 Ok(swept)
53 }
54
55 fn sweep_registered(&self, held: &[WorktreeName]) -> Result<Vec<WorktreeName>, StorageError> {
58 let mut swept = Vec::new();
59 for name in self.live()? {
60 if held.contains(&name) || !self.made_here(&name)? {
61 continue;
62 }
63 self.forget(name.as_str())?;
64 self.drop_lease_branch(&name)?;
65 swept.push(name);
66 }
67 Ok(swept)
68 }
69
70 fn made_here(&self, name: &WorktreeName) -> Result<bool, StorageError> {
77 let tree =
78 self.repo
79 .find_worktree(name.as_str())
80 .map_err(|err| StorageError::Worktree {
81 op: "find a worktree",
82 detail: format!("{}: {err}", name.as_str()),
83 })?;
84 Ok(resolved(tree.path()) == resolved(&self.home.join(name.as_str())))
85 }
86
87 fn drop_lease_branch(&self, name: &WorktreeName) -> Result<(), StorageError> {
92 let git_err = |op: &'static str| {
93 move |err: git2::Error| StorageError::Worktree {
94 op,
95 detail: format!("{}: {err}", name.as_str()),
96 }
97 };
98 let mut branch = match self
99 .repo
100 .find_branch(name.as_str(), git2::BranchType::Local)
101 {
102 Ok(branch) => branch,
103 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
104 Err(err) => return Err(git_err("find a lease branch")(err)),
105 };
106 let trunk = self
107 .repo
108 .head()
109 .and_then(|head| head.peel_to_commit())
110 .map_err(git_err("read the trunk"))?
111 .id();
112 let tip = branch
113 .get()
114 .peel_to_commit()
115 .map_err(git_err("read a lease branch"))?
116 .id();
117 let reached = tip == trunk
118 || self
119 .repo
120 .graph_descendant_of(trunk, tip)
121 .map_err(git_err("judge a lease branch"))?;
122 if reached && !branch.is_head() {
123 branch.delete().map_err(git_err("delete a lease branch"))?;
124 }
125 Ok(())
126 }
127
128 pub(super) fn forget(&self, id: &str) -> Result<(), StorageError> {
133 let admin = self.repo.commondir().join(WORKTREE_DIR).join(id);
134 let tree = match self.repo.find_worktree(id) {
135 Ok(tree) => tree,
136 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
137 Err(_) if admin.is_dir() => {
145 return std::fs::remove_dir_all(&admin).map_err(|source| StorageError::Worktree {
146 op: "take back a registration git will not read",
147 detail: format!("{id}: {source}"),
148 });
149 }
150 Err(err) => {
151 return Err(StorageError::Worktree {
152 op: "find a worktree",
153 detail: format!("{id}: {err}"),
154 });
155 }
156 };
157 let mut opts = git2::WorktreePruneOptions::new();
158 opts.valid(true).locked(true).working_tree(true);
160 tree.prune(Some(&mut opts))
161 .map_err(|err| StorageError::Worktree {
162 op: "prune a worktree",
163 detail: format!("{id}: {err}"),
164 })
165 }
166
167 pub(super) fn clear_unregistered(&self, id: &str) -> Result<(), StorageError> {
180 let path = self.home.join(id);
181 crate::alias::WriteTarget::within("place a worktree", &self.city_root, &path)?;
182 match std::fs::remove_dir_all(&path) {
183 Ok(()) => Ok(()),
184 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
185 Err(source) => Err(StorageError::Io {
186 op: "remove an abandoned worktree",
187 path,
188 source,
189 }),
190 }
191 }
192
193 fn sweep_unregistered(&self, held: &[WorktreeName]) -> Result<Vec<WorktreeName>, StorageError> {
198 let io_err = |op: &'static str, path: &Path| {
199 let path = path.to_path_buf();
200 move |source| StorageError::Io { op, path, source }
201 };
202 let entries = match std::fs::read_dir(&self.home) {
203 Ok(entries) => entries,
204 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
205 Err(err) => return Err(io_err("list the worktree home", &self.home)(err)),
206 };
207 let registered = self.live()?;
208 let mut swept = Vec::new();
209 for entry in entries {
210 let entry = entry.map_err(io_err("list the worktree home", &self.home))?;
211 let Some(name) = entry
214 .file_name()
215 .to_str()
216 .and_then(|raw| WorktreeName::parse(raw).ok())
217 else {
218 continue;
219 };
220 if held.contains(&name) || registered.contains(&name) {
221 continue;
222 }
223 let path = entry.path();
224 std::fs::remove_dir_all(&path)
225 .map_err(io_err("remove an abandoned worktree", &path))?;
226 swept.push(name);
227 }
228 Ok(swept)
229 }
230}
231
232fn resolved(path: &Path) -> PathBuf {
240 std::fs::canonicalize(path)
241 .or_else(|_| match (path.parent(), path.file_name()) {
242 (Some(parent), Some(leaf)) => std::fs::canonicalize(parent).map(|dir| dir.join(leaf)),
243 (None, _) | (_, None) => Ok(path.to_path_buf()),
244 })
245 .unwrap_or_else(|_| path.to_path_buf())
246}
247
248#[cfg(test)]
249#[allow(clippy::unwrap_used, reason = "test code")]
250mod tests {
251 use std::path::Path;
252
253 use kernel::TimeMs;
254
255 use super::super::trees::tests::{city, owner};
256 use super::*;
257 use crate::checkpoint::Checkpoint;
258
259 #[derive(Debug, PartialEq, Eq)]
261 struct Left {
262 registered: Vec<String>,
263 dirs: Vec<String>,
264 branches: Vec<String>,
265 checkpoints: Vec<String>,
266 }
267
268 fn left(city_root: &Path) -> Left {
269 let repo = git2::Repository::open(city_root).unwrap();
270 let mut registered: Vec<String> = repo
271 .worktrees()
272 .unwrap()
273 .iter()
274 .flatten()
275 .flatten()
276 .map(str::to_owned)
277 .collect();
278 registered.sort();
279 let home = city_root.join(kernel::RESERVED_PREFIX).join("worktrees");
280 let mut dirs: Vec<String> = std::fs::read_dir(&home)
281 .map(|entries| {
282 entries
283 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
284 .collect()
285 })
286 .unwrap_or_default();
287 dirs.sort();
288 let mut branches: Vec<String> = repo
289 .branches(Some(git2::BranchType::Local))
290 .unwrap()
291 .map(|branch| branch.unwrap().0.name().unwrap().unwrap().to_owned())
292 .collect();
293 branches.sort();
294 let mut checkpoints: Vec<String> = repo
295 .references_glob("refs/sprawling/runs/*")
296 .unwrap()
297 .map(|reference| reference.unwrap().name().unwrap().to_owned())
298 .collect();
299 checkpoints.sort();
300 Left {
301 registered,
302 dirs,
303 branches,
304 checkpoints,
305 }
306 }
307
308 fn name(raw: &str) -> WorktreeName {
309 WorktreeName::parse(raw).unwrap()
310 }
311
312 fn trunk(city_root: &Path) -> String {
313 let repo = git2::Repository::open(city_root).unwrap();
314 repo.head().unwrap().shorthand().unwrap().to_owned()
315 }
316
317 #[test]
318 fn a_crash_left_lease_is_swept_when_the_city_opens() {
319 let dir = tempfile::tempdir().unwrap();
320 let trees = city(dir.path());
321 drop(trees.claim(&name("run-1"), &[]).unwrap());
323 drop(trees);
324
325 let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
326
327 assert_eq!(
328 (swept, left(dir.path())),
329 (
330 vec![name("run-1")],
331 Left {
332 registered: vec![],
333 dirs: vec![],
334 branches: vec![trunk(dir.path())],
335 checkpoints: vec![],
336 }
337 )
338 );
339 }
340
341 #[test]
342 fn a_checkpoint_ref_survives_the_sweep() {
343 let dir = tempfile::tempdir().unwrap();
344 let trees = city(dir.path());
345 drop(trees.claim(&name("run-1"), &[]).unwrap());
346 drop(trees);
347 std::fs::write(dir.path().join("lab").join("notes.md"), b"second\n").unwrap();
348 Checkpoint::open(dir.path())
349 .unwrap()
350 .wave_pre(&["lab".to_owned()], TimeMs::new(2_000), &owner())
351 .unwrap();
352 let checkpoints = left(dir.path()).checkpoints;
353 assert_eq!(checkpoints.len(), 1, "the fixture checkpoints once");
354
355 let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
356
357 assert_eq!(
358 (swept, left(dir.path()).checkpoints),
359 (vec![name("run-1")], checkpoints)
360 );
361 }
362
363 #[test]
364 fn a_tree_under_another_citys_home_survives_the_sweep() {
365 let dir = tempfile::tempdir().unwrap();
366 let elsewhere = tempfile::tempdir().unwrap();
367 drop(city(dir.path()));
368 let foreign = elsewhere
369 .path()
370 .join(kernel::RESERVED_PREFIX)
371 .join("worktrees")
372 .join("run-9");
373 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
374 let repo = git2::Repository::open(dir.path()).unwrap();
375 repo.worktree("run-9", &foreign, None).unwrap();
376
377 let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
378
379 assert_eq!(
380 (swept, left(dir.path()).registered, foreign.exists()),
381 (vec![], vec!["run-9".to_owned()], true)
382 );
383 }
384
385 #[test]
386 fn a_live_runs_tree_and_the_persons_own_tree_survive_the_sweep() {
387 let dir = tempfile::tempdir().unwrap();
388 let elsewhere = tempfile::tempdir().unwrap();
389 let trees = city(dir.path());
390 let live = trees.claim(&name("run-1"), &[]).unwrap();
391 drop(trees.claim(&name("run-2"), &[]).unwrap());
392 drop(trees);
393 let repo = git2::Repository::open(dir.path()).unwrap();
394 repo.worktree("mine", &elsewhere.path().join("mine"), None)
395 .unwrap();
396
397 let swept = Worktrees::sweep_abandoned(dir.path(), &[name("run-1")]).unwrap();
398
399 let mut branches = vec!["mine".to_owned(), "run-1".to_owned(), trunk(dir.path())];
400 branches.sort();
401 assert_eq!(
402 (swept, left(dir.path()), live.path().join("lab").exists()),
403 (
404 vec![name("run-2")],
405 Left {
406 registered: vec!["mine".to_owned(), "run-1".to_owned()],
407 dirs: vec!["run-1".to_owned()],
408 branches,
409 checkpoints: vec![],
410 },
411 true
412 )
413 );
414 }
415}