1use std::path::{Path, PathBuf};
17
18use crate::error::StorageError;
19
20use super::name::WorktreeName;
21use super::trees::Worktrees;
22
23impl Worktrees {
24 pub fn sweep_abandoned(
32 city_root: &Path,
33 held: &[WorktreeName],
34 ) -> Result<Vec<WorktreeName>, StorageError> {
35 let repo = match git2::Repository::open(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 tree = match self.repo.find_worktree(id) {
134 Ok(tree) => tree,
135 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
136 Err(err) => {
137 return Err(StorageError::Worktree {
138 op: "find a worktree",
139 detail: format!("{id}: {err}"),
140 });
141 }
142 };
143 let mut opts = git2::WorktreePruneOptions::new();
144 opts.valid(true).locked(true).working_tree(true);
146 tree.prune(Some(&mut opts))
147 .map_err(|err| StorageError::Worktree {
148 op: "prune a worktree",
149 detail: format!("{id}: {err}"),
150 })
151 }
152
153 pub(super) fn clear_unregistered(&self, id: &str) -> Result<(), StorageError> {
166 let path = self.home.join(id);
167 crate::alias::WriteTarget::within("place a worktree", &self.city_root, &path)?;
168 match std::fs::remove_dir_all(&path) {
169 Ok(()) => Ok(()),
170 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
171 Err(source) => Err(StorageError::Io {
172 op: "remove an abandoned worktree",
173 path,
174 source,
175 }),
176 }
177 }
178
179 fn sweep_unregistered(&self, held: &[WorktreeName]) -> Result<Vec<WorktreeName>, StorageError> {
184 let io_err = |op: &'static str, path: &Path| {
185 let path = path.to_path_buf();
186 move |source| StorageError::Io { op, path, source }
187 };
188 let entries = match std::fs::read_dir(&self.home) {
189 Ok(entries) => entries,
190 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
191 Err(err) => return Err(io_err("list the worktree home", &self.home)(err)),
192 };
193 let registered = self.live()?;
194 let mut swept = Vec::new();
195 for entry in entries {
196 let entry = entry.map_err(io_err("list the worktree home", &self.home))?;
197 let Some(name) = entry
200 .file_name()
201 .to_str()
202 .and_then(|raw| WorktreeName::parse(raw).ok())
203 else {
204 continue;
205 };
206 if held.contains(&name) || registered.contains(&name) {
207 continue;
208 }
209 let path = entry.path();
210 std::fs::remove_dir_all(&path)
211 .map_err(io_err("remove an abandoned worktree", &path))?;
212 swept.push(name);
213 }
214 Ok(swept)
215 }
216}
217
218fn resolved(path: &Path) -> PathBuf {
226 std::fs::canonicalize(path)
227 .or_else(|_| match (path.parent(), path.file_name()) {
228 (Some(parent), Some(leaf)) => std::fs::canonicalize(parent).map(|dir| dir.join(leaf)),
229 (None, _) | (_, None) => Ok(path.to_path_buf()),
230 })
231 .unwrap_or_else(|_| path.to_path_buf())
232}
233
234#[cfg(test)]
235#[allow(clippy::unwrap_used, reason = "test code")]
236mod tests {
237 use std::path::Path;
238
239 use kernel::TimeMs;
240
241 use super::super::trees::tests::{city, owner};
242 use super::*;
243 use crate::checkpoint::Checkpoint;
244
245 #[derive(Debug, PartialEq, Eq)]
247 struct Left {
248 registered: Vec<String>,
249 dirs: Vec<String>,
250 branches: Vec<String>,
251 checkpoints: Vec<String>,
252 }
253
254 fn left(city_root: &Path) -> Left {
255 let repo = git2::Repository::open(city_root).unwrap();
256 let mut registered: Vec<String> = repo
257 .worktrees()
258 .unwrap()
259 .iter()
260 .flatten()
261 .flatten()
262 .map(str::to_owned)
263 .collect();
264 registered.sort();
265 let home = city_root.join(kernel::RESERVED_PREFIX).join("worktrees");
266 let mut dirs: Vec<String> = std::fs::read_dir(&home)
267 .map(|entries| {
268 entries
269 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
270 .collect()
271 })
272 .unwrap_or_default();
273 dirs.sort();
274 let mut branches: Vec<String> = repo
275 .branches(Some(git2::BranchType::Local))
276 .unwrap()
277 .map(|branch| branch.unwrap().0.name().unwrap().unwrap().to_owned())
278 .collect();
279 branches.sort();
280 let mut checkpoints: Vec<String> = repo
281 .references_glob("refs/sprawling/runs/*")
282 .unwrap()
283 .map(|reference| reference.unwrap().name().unwrap().to_owned())
284 .collect();
285 checkpoints.sort();
286 Left {
287 registered,
288 dirs,
289 branches,
290 checkpoints,
291 }
292 }
293
294 fn name(raw: &str) -> WorktreeName {
295 WorktreeName::parse(raw).unwrap()
296 }
297
298 fn trunk(city_root: &Path) -> String {
299 let repo = git2::Repository::open(city_root).unwrap();
300 repo.head().unwrap().shorthand().unwrap().to_owned()
301 }
302
303 #[test]
304 fn a_crash_left_lease_is_swept_when_the_city_opens() {
305 let dir = tempfile::tempdir().unwrap();
306 let trees = city(dir.path());
307 drop(trees.claim(&name("run-1"), &[]).unwrap());
309 drop(trees);
310
311 let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
312
313 assert_eq!(
314 (swept, left(dir.path())),
315 (
316 vec![name("run-1")],
317 Left {
318 registered: vec![],
319 dirs: vec![],
320 branches: vec![trunk(dir.path())],
321 checkpoints: vec![],
322 }
323 )
324 );
325 }
326
327 #[test]
328 fn a_checkpoint_ref_survives_the_sweep() {
329 let dir = tempfile::tempdir().unwrap();
330 let trees = city(dir.path());
331 drop(trees.claim(&name("run-1"), &[]).unwrap());
332 drop(trees);
333 std::fs::write(dir.path().join("lab").join("notes.md"), b"second\n").unwrap();
334 Checkpoint::open(dir.path())
335 .unwrap()
336 .wave_pre(&["lab".to_owned()], TimeMs::new(2_000), &owner())
337 .unwrap();
338 let checkpoints = left(dir.path()).checkpoints;
339 assert_eq!(checkpoints.len(), 1, "the fixture checkpoints once");
340
341 let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
342
343 assert_eq!(
344 (swept, left(dir.path()).checkpoints),
345 (vec![name("run-1")], checkpoints)
346 );
347 }
348
349 #[test]
350 fn a_tree_under_another_citys_home_survives_the_sweep() {
351 let dir = tempfile::tempdir().unwrap();
352 let elsewhere = tempfile::tempdir().unwrap();
353 drop(city(dir.path()));
354 let foreign = elsewhere
355 .path()
356 .join(kernel::RESERVED_PREFIX)
357 .join("worktrees")
358 .join("run-9");
359 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
360 let repo = git2::Repository::open(dir.path()).unwrap();
361 repo.worktree("run-9", &foreign, None).unwrap();
362
363 let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
364
365 assert_eq!(
366 (swept, left(dir.path()).registered, foreign.exists()),
367 (vec![], vec!["run-9".to_owned()], true)
368 );
369 }
370
371 #[test]
372 fn a_live_runs_tree_and_the_persons_own_tree_survive_the_sweep() {
373 let dir = tempfile::tempdir().unwrap();
374 let elsewhere = tempfile::tempdir().unwrap();
375 let trees = city(dir.path());
376 let live = trees.claim(&name("run-1"), &[]).unwrap();
377 drop(trees.claim(&name("run-2"), &[]).unwrap());
378 drop(trees);
379 let repo = git2::Repository::open(dir.path()).unwrap();
380 repo.worktree("mine", &elsewhere.path().join("mine"), None)
381 .unwrap();
382
383 let swept = Worktrees::sweep_abandoned(dir.path(), &[name("run-1")]).unwrap();
384
385 let mut branches = vec!["mine".to_owned(), "run-1".to_owned(), trunk(dir.path())];
386 branches.sort();
387 assert_eq!(
388 (swept, left(dir.path()), live.path().join("lab").exists()),
389 (
390 vec![name("run-2")],
391 Left {
392 registered: vec!["mine".to_owned(), "run-1".to_owned()],
393 dirs: vec!["run-1".to_owned()],
394 branches,
395 checkpoints: vec![],
396 },
397 true
398 )
399 );
400 }
401}