Skip to main content

storage/worktree/
sweep.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The trees a crash left behind, swept when the city opens.
7//!
8//! A released tree stays on disk for the next claim of its name within
9//! one serving; when the city opens, the ledger's writer lock means no
10//! run holds any tree, so every tree the city made is left over from an
11//! earlier serving, a crashed one included. What each leaves is three
12//! things: git's registration, the directory under the reserved subtree,
13//! and the branch git made for it. This module takes back each of them,
14//! and only what the city made (`crates/storage/spec/Worktree.lean` §8-9).
15
16use 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    /// Takes back every tree under `<city>/.sprawling/worktrees/` that
25    /// no name in `held` holds, and answers the names taken, sorted.
26    ///
27    /// # Errors
28    /// Propagates a repository that cannot be read, a registration git
29    /// will not prune, a branch it will not delete, and a directory that
30    /// cannot be removed.
31    pub fn sweep_abandoned(
32        city_root: &Path,
33        held: &[WorktreeName],
34    ) -> Result<Vec<WorktreeName>, StorageError> {
35        // A city that has never checkpointed has no repository, and so
36        // no tree to leave behind.
37        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    /// Unregisters every tree of the city's own that nobody holds, and
56    /// the lease branch that goes with it.
57    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    /// Whether git's registration of `name` is exactly `home/<name>` of
71    /// this city. The common git dir lists trees of every city and
72    /// linked checkout that shares it, so a matching path suffix proves
73    /// nothing; both sides are resolved on disk, because the city root
74    /// this process was given may be spelled differently from the one
75    /// the tree was claimed under.
76    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    /// Deletes the branch git made for the tree `name`, unless its tip
88    /// carries a commit the trunk does not have - that is a run's landed
89    /// offer, and the pull request names it - or the person has it
90    /// checked out, which makes it theirs.
91    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    /// Unregisters the tree `id`, taking its files with it where git will.
129    ///
130    /// A repository that has already forgotten the tree is the end
131    /// state this asks for, so it is not a failure.
132    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            // A registration git will not read still holds the name, and the
138            // end state this asks for is the registration gone. git prunes
139            // only what it can open, so the directory goes by hand: that is
140            // what `git worktree prune` removes for a tree whose directory
141            // is gone, and the tree it pointed at is not one to keep
142            // (`crates/storage/spec/Worktree/Trees/Stock.lean` §8-35, the
143            // same state `Stock::Broken` names).
144            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        // A lock on a tree whose directory is gone guards nothing.
159        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    /// Removes the directory `id` under the city's tree home, which the
168    /// caller knows git has no registration for: a placement or a
169    /// stocking that failed halfway left it, and a checkout refuses to
170    /// land on a directory that exists. The whole subtree is the city's
171    /// machinery, so nothing in it is the person's.
172    ///
173    /// A name that is a link is refused rather than removed, because the
174    /// placement it precedes would refuse it too (`crates/storage/spec/Alias.lean` §8-25).
175    ///
176    /// # Errors
177    /// Refuses a name that is a link, and propagates a directory that
178    /// exists and cannot be removed.
179    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    /// Removes every directory under the city's tree home that git has
194    /// no registration for and nobody holds: a claim or a release the
195    /// process died inside. The whole subtree is the city's machinery,
196    /// so nothing in it is the person's.
197    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            // A name this module could not have written is not one it
212            // may remove.
213            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
232/// `path` as the disk spells it: the whole path when it exists, else its
233/// parent resolved and the last component kept, else `path` unchanged. A
234/// crash may have removed the tree's own directory while its home stays.
235///
236/// A path that cannot be resolved keeps its spelling rather than failing
237/// the sweep: the sweep then fails closed, because a registration spelled
238/// differently from the home it is compared with is left alone.
239fn 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    /// What a sweep can touch, read straight from git and the disk.
260    #[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        // The process died holding the lease: nothing released it.
322        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}