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::Worktrees;
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 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    /// 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 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        // A lock on a tree whose directory is gone guards nothing.
145        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    /// Removes the directory `id` under the city's tree home, which the
154    /// caller knows git has no registration for: a placement or a
155    /// stocking that failed halfway left it, and a checkout refuses to
156    /// land on a directory that exists. The whole subtree is the city's
157    /// machinery, so nothing in it is the person's.
158    ///
159    /// A name that is a link is refused rather than removed, because the
160    /// placement it precedes would refuse it too (`crates/storage/spec/Alias.lean` §8-25).
161    ///
162    /// # Errors
163    /// Refuses a name that is a link, and propagates a directory that
164    /// exists and cannot be removed.
165    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    /// Removes every directory under the city's tree home that git has
180    /// no registration for and nobody holds: a claim or a release the
181    /// process died inside. The whole subtree is the city's machinery,
182    /// so nothing in it is the person's.
183    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            // A name this module could not have written is not one it
198            // may remove.
199            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
218/// `path` as the disk spells it: the whole path when it exists, else its
219/// parent resolved and the last component kept, else `path` unchanged. A
220/// crash may have removed the tree's own directory while its home stays.
221///
222/// A path that cannot be resolved keeps its spelling rather than failing
223/// the sweep: the sweep then fails closed, because a registration spelled
224/// differently from the home it is compared with is left alone.
225fn 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    /// What a sweep can touch, read straight from git and the disk.
246    #[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        // The process died holding the lease: nothing released it.
308        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}