sprawling-storage 0.0.12

The append-only, hash-chained Ledger of a sprawling city, its content store, checkpoints and worktrees.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! The trees a crash left behind, swept when the city opens.
//!
//! A released tree stays on disk for the next claim of its name within
//! one serving; when the city opens, the ledger's writer lock means no
//! run holds any tree, so every tree the city made is left over from an
//! earlier serving, a crashed one included. What each leaves is three
//! things: git's registration, the directory under the reserved subtree,
//! and the branch git made for it. This module takes back each of them,
//! and only what the city made (`crates/storage/spec/Worktree.lean` ยง8-9).

use std::path::{Path, PathBuf};

use crate::error::StorageError;

use super::name::WorktreeName;
use super::trees::{WORKTREE_DIR, Worktrees, open_city};

impl Worktrees {
    /// Takes back every tree under `<city>/.sprawling/worktrees/` that
    /// no name in `held` holds, and answers the names taken, sorted.
    ///
    /// # Errors
    /// Propagates a repository that cannot be read, a registration git
    /// will not prune, a branch it will not delete, and a directory that
    /// cannot be removed.
    pub fn sweep_abandoned(
        city_root: &Path,
        held: &[WorktreeName],
    ) -> Result<Vec<WorktreeName>, StorageError> {
        // A city that has never checkpointed has no repository, and so
        // no tree to leave behind.
        let repo = match open_city(city_root) {
            Ok(repo) => repo,
            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Vec::new()),
            Err(err) => {
                return Err(StorageError::Worktree {
                    op: "open the city repository",
                    detail: format!("{}: {err}", city_root.display()),
                });
            }
        };
        let trees = Worktrees::over(repo, city_root);
        let mut swept = trees.sweep_registered(held)?;
        swept.extend(trees.sweep_unregistered(held)?);
        swept.sort();
        swept.dedup();
        Ok(swept)
    }

    /// Unregisters every tree of the city's own that nobody holds, and
    /// the lease branch that goes with it.
    fn sweep_registered(&self, held: &[WorktreeName]) -> Result<Vec<WorktreeName>, StorageError> {
        let mut swept = Vec::new();
        for name in self.live()? {
            if held.contains(&name) || !self.made_here(&name)? {
                continue;
            }
            self.forget(name.as_str())?;
            self.drop_lease_branch(&name)?;
            swept.push(name);
        }
        Ok(swept)
    }

    /// Whether git's registration of `name` is exactly `home/<name>` of
    /// this city. The common git dir lists trees of every city and
    /// linked checkout that shares it, so a matching path suffix proves
    /// nothing; both sides are resolved on disk, because the city root
    /// this process was given may be spelled differently from the one
    /// the tree was claimed under.
    fn made_here(&self, name: &WorktreeName) -> Result<bool, StorageError> {
        let tree =
            self.repo
                .find_worktree(name.as_str())
                .map_err(|err| StorageError::Worktree {
                    op: "find a worktree",
                    detail: format!("{}: {err}", name.as_str()),
                })?;
        Ok(resolved(tree.path()) == resolved(&self.home.join(name.as_str())))
    }

    /// Deletes the branch git made for the tree `name`, unless its tip
    /// carries a commit the trunk does not have - that is a run's landed
    /// offer, and the pull request names it - or the person has it
    /// checked out, which makes it theirs.
    fn drop_lease_branch(&self, name: &WorktreeName) -> Result<(), StorageError> {
        let git_err = |op: &'static str| {
            move |err: git2::Error| StorageError::Worktree {
                op,
                detail: format!("{}: {err}", name.as_str()),
            }
        };
        let mut branch = match self
            .repo
            .find_branch(name.as_str(), git2::BranchType::Local)
        {
            Ok(branch) => branch,
            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
            Err(err) => return Err(git_err("find a lease branch")(err)),
        };
        let trunk = self
            .repo
            .head()
            .and_then(|head| head.peel_to_commit())
            .map_err(git_err("read the trunk"))?
            .id();
        let tip = branch
            .get()
            .peel_to_commit()
            .map_err(git_err("read a lease branch"))?
            .id();
        let reached = tip == trunk
            || self
                .repo
                .graph_descendant_of(trunk, tip)
                .map_err(git_err("judge a lease branch"))?;
        if reached && !branch.is_head() {
            branch.delete().map_err(git_err("delete a lease branch"))?;
        }
        Ok(())
    }

    /// Unregisters the tree `id`, taking its files with it where git will.
    ///
    /// A repository that has already forgotten the tree is the end
    /// state this asks for, so it is not a failure.
    pub(super) fn forget(&self, id: &str) -> Result<(), StorageError> {
        let admin = self.repo.commondir().join(WORKTREE_DIR).join(id);
        let tree = match self.repo.find_worktree(id) {
            Ok(tree) => tree,
            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
            // A registration git will not read still holds the name, and the
            // end state this asks for is the registration gone. git prunes
            // only what it can open, so the directory goes by hand: that is
            // what `git worktree prune` removes for a tree whose directory
            // is gone, and the tree it pointed at is not one to keep
            // (`crates/storage/spec/Worktree/Trees/Stock.lean` ยง8-35, the
            // same state `Stock::Broken` names).
            Err(_) if admin.is_dir() => {
                return std::fs::remove_dir_all(&admin).map_err(|source| StorageError::Worktree {
                    op: "take back a registration git will not read",
                    detail: format!("{id}: {source}"),
                });
            }
            Err(err) => {
                return Err(StorageError::Worktree {
                    op: "find a worktree",
                    detail: format!("{id}: {err}"),
                });
            }
        };
        let mut opts = git2::WorktreePruneOptions::new();
        // A lock on a tree whose directory is gone guards nothing.
        opts.valid(true).locked(true).working_tree(true);
        tree.prune(Some(&mut opts))
            .map_err(|err| StorageError::Worktree {
                op: "prune a worktree",
                detail: format!("{id}: {err}"),
            })
    }

    /// Removes the directory `id` under the city's tree home, which the
    /// caller knows git has no registration for: a placement or a
    /// stocking that failed halfway left it, and a checkout refuses to
    /// land on a directory that exists. The whole subtree is the city's
    /// machinery, so nothing in it is the person's.
    ///
    /// A name that is a link is refused rather than removed, because the
    /// placement it precedes would refuse it too (`crates/storage/spec/Alias.lean` ยง8-25).
    ///
    /// # Errors
    /// Refuses a name that is a link, and propagates a directory that
    /// exists and cannot be removed.
    pub(super) fn clear_unregistered(&self, id: &str) -> Result<(), StorageError> {
        let path = self.home.join(id);
        crate::alias::WriteTarget::within("place a worktree", &self.city_root, &path)?;
        match std::fs::remove_dir_all(&path) {
            Ok(()) => Ok(()),
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(source) => Err(StorageError::Io {
                op: "remove an abandoned worktree",
                path,
                source,
            }),
        }
    }

    /// Removes every directory under the city's tree home that git has
    /// no registration for and nobody holds: a claim or a release the
    /// process died inside. The whole subtree is the city's machinery,
    /// so nothing in it is the person's.
    fn sweep_unregistered(&self, held: &[WorktreeName]) -> Result<Vec<WorktreeName>, StorageError> {
        let io_err = |op: &'static str, path: &Path| {
            let path = path.to_path_buf();
            move |source| StorageError::Io { op, path, source }
        };
        let entries = match std::fs::read_dir(&self.home) {
            Ok(entries) => entries,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(err) => return Err(io_err("list the worktree home", &self.home)(err)),
        };
        let registered = self.live()?;
        let mut swept = Vec::new();
        for entry in entries {
            let entry = entry.map_err(io_err("list the worktree home", &self.home))?;
            // A name this module could not have written is not one it
            // may remove.
            let Some(name) = entry
                .file_name()
                .to_str()
                .and_then(|raw| WorktreeName::parse(raw).ok())
            else {
                continue;
            };
            if held.contains(&name) || registered.contains(&name) {
                continue;
            }
            let path = entry.path();
            std::fs::remove_dir_all(&path)
                .map_err(io_err("remove an abandoned worktree", &path))?;
            swept.push(name);
        }
        Ok(swept)
    }
}

/// `path` as the disk spells it: the whole path when it exists, else its
/// parent resolved and the last component kept, else `path` unchanged. A
/// crash may have removed the tree's own directory while its home stays.
///
/// A path that cannot be resolved keeps its spelling rather than failing
/// the sweep: the sweep then fails closed, because a registration spelled
/// differently from the home it is compared with is left alone.
fn resolved(path: &Path) -> PathBuf {
    std::fs::canonicalize(path)
        .or_else(|_| match (path.parent(), path.file_name()) {
            (Some(parent), Some(leaf)) => std::fs::canonicalize(parent).map(|dir| dir.join(leaf)),
            (None, _) | (_, None) => Ok(path.to_path_buf()),
        })
        .unwrap_or_else(|_| path.to_path_buf())
}

#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "test code")]
mod tests {
    use std::path::Path;

    use kernel::TimeMs;

    use super::super::trees::tests::{city, owner};
    use super::*;
    use crate::checkpoint::Checkpoint;

    /// What a sweep can touch, read straight from git and the disk.
    #[derive(Debug, PartialEq, Eq)]
    struct Left {
        registered: Vec<String>,
        dirs: Vec<String>,
        branches: Vec<String>,
        checkpoints: Vec<String>,
    }

    fn left(city_root: &Path) -> Left {
        let repo = git2::Repository::open(city_root).unwrap();
        let mut registered: Vec<String> = repo
            .worktrees()
            .unwrap()
            .iter()
            .flatten()
            .flatten()
            .map(str::to_owned)
            .collect();
        registered.sort();
        let home = city_root.join(kernel::RESERVED_PREFIX).join("worktrees");
        let mut dirs: Vec<String> = std::fs::read_dir(&home)
            .map(|entries| {
                entries
                    .map(|entry| entry.unwrap().file_name().into_string().unwrap())
                    .collect()
            })
            .unwrap_or_default();
        dirs.sort();
        let mut branches: Vec<String> = repo
            .branches(Some(git2::BranchType::Local))
            .unwrap()
            .map(|branch| branch.unwrap().0.name().unwrap().unwrap().to_owned())
            .collect();
        branches.sort();
        let mut checkpoints: Vec<String> = repo
            .references_glob("refs/sprawling/runs/*")
            .unwrap()
            .map(|reference| reference.unwrap().name().unwrap().to_owned())
            .collect();
        checkpoints.sort();
        Left {
            registered,
            dirs,
            branches,
            checkpoints,
        }
    }

    fn name(raw: &str) -> WorktreeName {
        WorktreeName::parse(raw).unwrap()
    }

    fn trunk(city_root: &Path) -> String {
        let repo = git2::Repository::open(city_root).unwrap();
        repo.head().unwrap().shorthand().unwrap().to_owned()
    }

    #[test]
    fn a_crash_left_lease_is_swept_when_the_city_opens() {
        let dir = tempfile::tempdir().unwrap();
        let trees = city(dir.path());
        // The process died holding the lease: nothing released it.
        drop(trees.claim(&name("run-1"), &[]).unwrap());
        drop(trees);

        let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();

        assert_eq!(
            (swept, left(dir.path())),
            (
                vec![name("run-1")],
                Left {
                    registered: vec![],
                    dirs: vec![],
                    branches: vec![trunk(dir.path())],
                    checkpoints: vec![],
                }
            )
        );
    }

    #[test]
    fn a_checkpoint_ref_survives_the_sweep() {
        let dir = tempfile::tempdir().unwrap();
        let trees = city(dir.path());
        drop(trees.claim(&name("run-1"), &[]).unwrap());
        drop(trees);
        std::fs::write(dir.path().join("lab").join("notes.md"), b"second\n").unwrap();
        Checkpoint::open(dir.path())
            .unwrap()
            .wave_pre(&["lab".to_owned()], TimeMs::new(2_000), &owner())
            .unwrap();
        let checkpoints = left(dir.path()).checkpoints;
        assert_eq!(checkpoints.len(), 1, "the fixture checkpoints once");

        let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();

        assert_eq!(
            (swept, left(dir.path()).checkpoints),
            (vec![name("run-1")], checkpoints)
        );
    }

    #[test]
    fn a_tree_under_another_citys_home_survives_the_sweep() {
        let dir = tempfile::tempdir().unwrap();
        let elsewhere = tempfile::tempdir().unwrap();
        drop(city(dir.path()));
        let foreign = elsewhere
            .path()
            .join(kernel::RESERVED_PREFIX)
            .join("worktrees")
            .join("run-9");
        std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
        let repo = git2::Repository::open(dir.path()).unwrap();
        repo.worktree("run-9", &foreign, None).unwrap();

        let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();

        assert_eq!(
            (swept, left(dir.path()).registered, foreign.exists()),
            (vec![], vec!["run-9".to_owned()], true)
        );
    }

    #[test]
    fn a_live_runs_tree_and_the_persons_own_tree_survive_the_sweep() {
        let dir = tempfile::tempdir().unwrap();
        let elsewhere = tempfile::tempdir().unwrap();
        let trees = city(dir.path());
        let live = trees.claim(&name("run-1"), &[]).unwrap();
        drop(trees.claim(&name("run-2"), &[]).unwrap());
        drop(trees);
        let repo = git2::Repository::open(dir.path()).unwrap();
        repo.worktree("mine", &elsewhere.path().join("mine"), None)
            .unwrap();

        let swept = Worktrees::sweep_abandoned(dir.path(), &[name("run-1")]).unwrap();

        let mut branches = vec!["mine".to_owned(), "run-1".to_owned(), trunk(dir.path())];
        branches.sort();
        assert_eq!(
            (swept, left(dir.path()), live.path().join("lab").exists()),
            (
                vec![name("run-2")],
                Left {
                    registered: vec!["mine".to_owned(), "run-1".to_owned()],
                    dirs: vec!["run-1".to_owned()],
                    branches,
                    checkpoints: vec![],
                },
                true
            )
        );
    }
}