sprawling-storage 0.0.11

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
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
// 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 stock: one tree checked out at the trunk before any node asks for
//! it, so that placing a node's tree is a rename rather than a checkout
//! (`crates/storage/spec/Worktree/Trees/Stock.lean` §8-35).
//!
//! A checkout waits on the real-time scanner once for every file it
//! creates, so any placement that creates the tree's files while a run
//! waits costs what the tree holds. Taking the stock over creates none:
//! the stock's directory and its registration are renamed to the node's,
//! and the two link files git keeps between them are rewritten.

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

use super::super::lease::{FileWork, WorktreeLease};
use super::super::name::WorktreeName;
use super::super::weight::Weight;
use super::Worktrees;
use super::kept::restore;
use super::open_tree;
use crate::error::StorageError;

/// The stock's id: its registration, its directory under the tree home
/// and its branch. `+` is outside `WorktreeName`'s alphabet, so no node
/// can be named this and no claim, release or merge reaches the stock;
/// git admits it in a branch name.
pub(super) const STOCK: &str = "+spare";

/// Why a stock is locked: it is being checked out, and a tree half
/// written is not one to hand to a node.
const STOCKING: &str = "being stocked by sprawling";

/// What the tree home holds under the stock's id.
enum Stock {
    Absent,
    /// Being checked out by another call.
    Busy,
    /// Registered, but its directory or a link file is not what this
    /// module writes: taken back and placed anew rather than handed out.
    Broken,
    Ready(git2::Worktree, Links),
}

/// The two link files between the stock's directory and its
/// registration, as git wrote them, and where each lives.
struct Links {
    tree: PathBuf,
    admin: PathBuf,
    /// The tree's `.git` file, up to the stock's id.
    dot_git: String,
    /// The registration's `gitdir` file, up to the stock's id.
    gitdir: String,
}

impl Links {
    /// The two files as they read once the stock is `name`'s.
    fn of(&self, name: &str) -> (String, String) {
        (
            format!("{}worktrees/{name}/\n", self.dot_git),
            format!("{}/{name}/.git\n", self.gitdir),
        )
    }
}

impl Worktrees {
    /// Keeps one tree checked out at the trunk for the next placement to
    /// take over, and answers what that cost the filesystem: a whole
    /// checkout when there is no stock, and the files the trunk changed
    /// since the stock was last brought to it otherwise. A stock that
    /// another call is checking out is left to it, at no cost.
    ///
    /// Call it where nobody waits on it: its cost is the placement's.
    ///
    /// # Errors
    /// Refuses a city whose working tree exceeds the ceiling and a
    /// repository with no commit, and propagates whatever git or the
    /// disk refuses while the stock is checked out.
    pub fn stock(&self) -> Result<FileWork, StorageError> {
        match self.stock_standing()? {
            Stock::Absent => self.place_stock(),
            Stock::Busy => Ok(FileWork::default()),
            Stock::Broken => {
                self.forget(STOCK)?;
                self.place_stock()
            }
            Stock::Ready(tree, _) => self.refresh_stock(&tree),
        }
    }

    /// Takes the stock over as `name`'s tree and lends it, or answers
    /// `None` when there is no stock to take: none was made, another
    /// call is making it, or another placement took it first. `city` is
    /// the walk the ceiling was judged on.
    ///
    /// The directory is renamed first, because that rename is the one
    /// step two placements cannot both make; every later step leaves a
    /// state the next claim of `name` takes back (`crates/storage/spec/Worktree/Trees/Stock.lean` §8-35).
    pub(super) fn adopt(
        &self,
        name: &WorktreeName,
        city: &Weight,
    ) -> Result<Option<WorktreeLease>, StorageError> {
        let Stock::Ready(_, links) = self.stock_standing()? else {
            return Ok(None);
        };
        let target = self.home.join(name.as_str());
        crate::alias::WriteTarget::within("place a worktree", &self.city_root, &target)?;
        self.branch_at_trunk(name)?;
        match std::fs::rename(&links.tree, &target) {
            Ok(()) => {}
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(source) => return Err(io("take the stock", &links.tree, source)),
        }
        let (dot_git, gitdir) = links.of(name.as_str());
        let admin = links.admin.with_file_name(name.as_str());
        write(&target.join(".git"), &dot_git)?;
        std::fs::rename(&links.admin, &admin)
            .map_err(|source| io("register the stock as a node's tree", &links.admin, source))?;
        write(&admin.join("gitdir"), &gitdir)?;
        let tree = self
            .repo
            .find_worktree(name.as_str())
            .map_err(|err| git("find a taken stock", name.as_str(), &err))?;
        let lease = self.reattach(name, &tree, &[])?;
        Ok(Some(WorktreeLease {
            work: FileWork {
                walked: lease.work.walked.saturating_add(city.walked),
                ..lease.work
            },
            ..lease
        }))
    }

    /// Where the stock stands, read from git and the disk.
    fn stock_standing(&self) -> Result<Stock, StorageError> {
        let admin = self.repo.commondir().join(super::WORKTREE_DIR).join(STOCK);
        let tree = match self.repo.find_worktree(STOCK) {
            Ok(tree) => tree,
            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Stock::Absent),
            // A registration git will not read is one a cut takeover left
            // behind - the tree renamed out from under it, or the write of
            // its link torn - and `Broken` is the state for it: `forget`
            // takes the registration back and the placement after it makes
            // the stock whole (`crates/storage/spec/Worktree/Trees/Stock.lean`
            // §8-35: 接管的步骤…进程可能在任何一步之后死掉,而 standing
            // 收回指向不存在目录的登记; `every_cut_is_recovered`). Where no
            // registration stands at all, the repository itself refused and
            // the reason is carried.
            Err(_) if admin.is_dir() => return Ok(Stock::Broken),
            Err(err) => return Err(git("find the stock", STOCK, &err)),
        };
        let lock = match tree.is_locked() {
            Ok(lock) => lock,
            // The registration went away between the lookup and this read:
            // a lane putting the stock back, or a `forget` taking back one
            // git would not read. No registration is the state the next
            // placement wants, so it is asked for rather than carried as a
            // failure of the repository.
            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Stock::Absent),
            Err(err) => return Err(git("read the stock's lock", STOCK, &err)),
        };
        if let git2::WorktreeLockStatus::Locked(_) = lock {
            return Ok(Stock::Busy);
        }
        let read = |path: &Path| match std::fs::read_to_string(path) {
            Ok(text) => Ok(Some(text)),
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(source) => Err(io("read the stock's links", path, source)),
        };
        let dot_git = read(&tree.path().join(".git"))?;
        let gitdir = read(&admin.join("gitdir"))?;
        let links = dot_git
            .as_deref()
            .and_then(|text| text.strip_suffix(&format!("worktrees/{STOCK}/\n")))
            .zip(
                gitdir
                    .as_deref()
                    .and_then(|text| text.strip_suffix(&format!("/{STOCK}/.git\n"))),
            );
        Ok(match links {
            Some((dot_git, gitdir)) => {
                let links = Links {
                    tree: tree.path().to_path_buf(),
                    admin,
                    dot_git: dot_git.to_owned(),
                    gitdir: gitdir.to_owned(),
                };
                Stock::Ready(tree, links)
            }
            None => Stock::Broken,
        })
    }

    /// Checks a new stock out at the trunk, locked until it is whole.
    fn place_stock(&self) -> Result<FileWork, StorageError> {
        let city = self.refuse_oversized(STOCK)?;
        self.clear_unregistered(STOCK)?;
        let trunk = self
            .repo
            .head()
            .and_then(|head| head.peel_to_commit())
            .map_err(|err| git("read the city trunk", STOCK, &err))?;
        let branch = self
            .repo
            .branch(STOCK, &trunk, true)
            .map_err(|err| git("branch the stock", STOCK, &err))?;
        let (_, written) = match self.check_out_tree(STOCK, Some(branch.get())) {
            Ok(placed) => placed,
            // A registration left behind locked would read as a stocking
            // in progress for the rest of this serving.
            Err(refusal) => return Err(self.take_back(refusal)),
        };
        let tree = self
            .repo
            .find_worktree(STOCK)
            .map_err(|err| git("find the stock", STOCK, &err))?;
        cache_trees(&tree)?;
        tree.unlock()
            .map_err(|err| git("unlock the stock", STOCK, &err))?;
        Ok(FileWork {
            created: written.files,
            walked: city.walked,
            ..FileWork::default()
        })
    }

    /// Brings a whole stock up to the trunk, locked while it moves.
    fn refresh_stock(&self, tree: &git2::Worktree) -> Result<FileWork, StorageError> {
        tree.lock(Some(STOCKING))
            .map_err(|err| git("lock the stock", STOCK, &err))?;
        let trunk = self
            .repo
            .head()
            .and_then(|head| head.peel_to_commit())
            .map_err(|err| git("read the city trunk", STOCK, &err))?;
        self.repo
            .branch(STOCK, &trunk, true)
            .map_err(|err| git("move the stock to the trunk", STOCK, &err))?;
        // Unlocked even when the restore failed: a stock half moved is
        // whole again after the restore a placement makes of it, while a
        // lock left behind would read as a stocking in progress.
        let work = restore(tree, STOCK, &[]).and_then(|work| cache_trees(tree).map(|()| work));
        tree.unlock()
            .map_err(|err| git("unlock the stock", STOCK, &err))?;
        work
    }

    /// Unregisters a stock whose checkout failed and answers the
    /// failure, or the failure to unregister it with the checkout's
    /// failure in its detail.
    fn take_back(&self, refusal: StorageError) -> StorageError {
        match self.forget(STOCK) {
            Ok(()) => refusal,
            Err(left) => StorageError::Worktree {
                op: "take back a stock that did not check out",
                detail: format!("{left}; the checkout failed with: {refusal}"),
            },
        }
    }

    /// Makes `name`'s branch at the trunk unless the node already has
    /// one: a node's branch is its line of work, and the stock is only
    /// where its files come from.
    fn branch_at_trunk(&self, name: &WorktreeName) -> Result<(), StorageError> {
        match self
            .repo
            .find_branch(name.as_str(), git2::BranchType::Local)
        {
            Ok(_) => Ok(()),
            Err(err) if err.code() == git2::ErrorCode::NotFound => {
                let trunk = self
                    .repo
                    .head()
                    .and_then(|head| head.peel_to_commit())
                    .map_err(|err| git("read the city trunk", name.as_str(), &err))?;
                self.repo
                    .branch(name.as_str(), &trunk, false)
                    .map(drop)
                    .map_err(|err| git("branch a worktree", name.as_str(), &err))
            }
            Err(err) => Err(git("find a node branch", name.as_str(), &err)),
        }
    }
}

/// Reads the stock's index back from its head and writes it, so that the
/// index carries the tree cache: the placement that takes the stock over
/// then learns that the index writes the head's tree without hashing a
/// single tree (`kept::index_writes_tree`). Reading back keeps the stat
/// data of every entry whose blob and mode are unchanged, and writing the
/// index after the checkout's last file leaves no entry racily clean.
fn cache_trees(tree: &git2::Worktree) -> Result<(), StorageError> {
    let repo = open_tree(tree).map_err(|err| git("open the stock", STOCK, &err))?;
    let head = repo
        .head()
        .and_then(|head| head.peel_to_tree())
        .map_err(|err| git("read the stock's branch", STOCK, &err))?;
    let mut index = repo
        .index()
        .map_err(|err| git("read the stock's index", STOCK, &err))?;
    index
        .read_tree(&head)
        .and_then(|()| index.write())
        .map_err(|err| git("cache the stock's trees", STOCK, &err))
}

/// Writes one link file whole.
fn write(path: &Path, text: &str) -> Result<(), StorageError> {
    std::fs::write(path, text).map_err(|source| io("rewrite a worktree link", path, source))
}

fn io(op: &'static str, path: &Path, source: std::io::Error) -> StorageError {
    StorageError::Io {
        op,
        path: path.to_path_buf(),
        source,
    }
}

fn git(op: &'static str, id: &str, err: &git2::Error) -> StorageError {
    StorageError::Worktree {
        op,
        detail: format!("{id}: {err}"),
    }
}

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

    use kernel::TimeMs;

    use super::super::super::lease::FileWork;
    use super::super::Landing;
    use super::super::Worktrees;
    use super::super::tests::{bulk_city, entries, name, owner};
    use super::STOCK;
    use crate::checkpoint::Checkpoint;

    fn files_under(dir: &Path) -> u64 {
        u64::try_from(std::fs::read_dir(dir).unwrap().count()).unwrap()
    }

    /// A cut takeover leaves the spare's registration pointing at a tree
    /// that is gone, and the next `stock` takes that registration back and
    /// makes the spare whole (`crates/storage/spec/Worktree/Trees/Stock.lean`
    /// §8-35: 接管的步骤…进程可能在任何一步之后死掉,而 `standing` 收回指向
    /// 不存在目录的登记; `every_cut_is_recovered`).
    ///
    /// The cut drawn here is a torn write of the registration's link, the
    /// state libgit2 refuses to read outright: the lookup answers an error
    /// that is not `NotFound`, so a `standing` that propagated it turned a
    /// cut takeover into `E_STORAGE_FATAL` where the model promises a
    /// stocking.
    #[test]
    fn a_registration_git_will_not_read_is_stocked_again() {
        let dir = tempfile::tempdir().unwrap();
        let trees = bulk_city(dir.path(), 8);
        trees.stock().unwrap();
        let registration = trees
            .repo
            .commondir()
            .join(super::super::WORKTREE_DIR)
            .join(STOCK);
        std::fs::remove_file(registration.join("gitdir")).unwrap();

        trees.stock().unwrap();
        let placed = trees.claim(&name("node-1"), &["bulk".to_owned()]).unwrap();
        assert_eq!(
            std::fs::read_dir(placed.path().join("bulk"))
                .unwrap()
                .count(),
            8,
            "the stock is whole again after the cut registration was taken back"
        );
    }

    /// `crates/storage/spec/Worktree/Trees/Stock.lean` §8-35: a placement from the stock creates no file,
    /// whatever the tree holds - the files were written when the stock
    /// was, where nobody waited. Judged at two sizes, so a placement
    /// whose file count grows with the tree is a failure here.
    #[test]
    fn a_placement_from_the_stock_creates_no_file_at_either_size() {
        for files in [32, 64] {
            let dir = tempfile::tempdir().unwrap();
            let trees = bulk_city(dir.path(), files);
            let city = entries(dir.path());
            let stocked = trees.stock().unwrap();
            let city_at_claim = entries(dir.path());
            let placed = trees.claim(&name("node-1"), &["bulk".to_owned()]).unwrap();
            assert_eq!(
                (
                    stocked,
                    placed.work(),
                    files_under(&placed.path().join("bulk"))
                ),
                (
                    FileWork {
                        created: files,
                        walked: city,
                        ..FileWork::default()
                    },
                    FileWork {
                        walked: city_at_claim + entries(placed.path()),
                        ..FileWork::default()
                    },
                    files,
                ),
                "a stock and the placement that takes it at {files} files"
            );
        }
    }

    /// A stock made before the trunk moved is placed at the trunk: the
    /// placement writes what the trunk changed since, the node's tree
    /// is on the node's own branch, and what the node offers from it
    /// merges like the offer of any other tree.
    #[test]
    fn a_stock_behind_the_trunk_is_placed_at_the_trunk_and_merges() {
        let dir = tempfile::tempdir().unwrap();
        let trees = bulk_city(dir.path(), 8);
        trees.stock().unwrap();
        std::fs::write(dir.path().join("bulk").join("file-0000.txt"), b"moved on").unwrap();
        Checkpoint::open(dir.path())
            .unwrap()
            .land(&[], TimeMs::new(2_000), &owner(), "checkpoint: bulk")
            .unwrap();

        let bulk = ["bulk".to_owned()];
        let placed = trees.claim(&name("node-1"), &bulk).unwrap();
        let tree = placed.path().to_path_buf();
        let repo = git2::Repository::open(&tree).unwrap();
        assert_eq!(
            (
                placed.work().created,
                placed.work().rewritten,
                std::fs::read_to_string(tree.join("bulk").join("file-0000.txt")).unwrap(),
                repo.head().unwrap().name().ok().map(str::to_owned),
                trees.live().unwrap(),
            ),
            (
                0,
                1,
                "moved on".to_owned(),
                Some("refs/heads/node-1".to_owned()),
                vec![name("node-1")],
            ),
            "the stock is the node's tree, at the trunk, on the node's branch"
        );

        std::fs::write(tree.join("bulk").join("file-0001.txt"), b"from the node").unwrap();
        Checkpoint::open(&tree)
            .unwrap()
            .land(&bulk, TimeMs::new(3_000), &owner(), "offer: bulk")
            .unwrap();
        trees.release(placed).unwrap();
        let of = owner();
        trees
            .plan_merge(&name("node-1"))
            .unwrap()
            .apply(&Landing {
                t: TimeMs::new(4_000),
                of: &of,
                subject: "merge: node-1",
                reviewed_by_person: false,
            })
            .unwrap();
        assert_eq!(
            std::fs::read_to_string(dir.path().join("bulk").join("file-0001.txt")).unwrap(),
            "from the node"
        );
    }

    /// Storage D28, counted: a room placed while the stock is still being
    /// checked out pays one whole checkout of its own (N files at N, 2N
    /// and 4N), and the stock being made is not lost - the next room
    /// takes it over and creates nothing. Waiting for the stock instead
    /// would save the second room's N files and no checkout overall,
    /// because the stock it took would have to be made again.
    #[test]
    fn a_room_placed_while_the_stock_is_being_made_checks_out_once_and_the_stock_survives() {
        for files in [8, 16, 32] {
            let dir = tempfile::tempdir().unwrap();
            let trees = bulk_city(dir.path(), files);
            trees.stock().unwrap();
            let first = trees.claim(&name("node-1"), &[]).unwrap().work().created;
            let restocked = trees.stock().unwrap().created;
            let making = git2::Repository::open(dir.path())
                .unwrap()
                .find_worktree(super::STOCK)
                .unwrap();
            making.lock(Some(super::STOCKING)).unwrap();
            let during = trees.claim(&name("node-2"), &[]).unwrap().work().created;
            making.unlock().unwrap();
            let refreshed = trees.stock().unwrap().created;
            let after = trees.claim(&name("node-3"), &[]).unwrap().work().created;
            assert_eq!(
                (first, restocked, during, refreshed, after),
                (0, files, files, 0, 0),
                "files created per step at {files} files"
            );
        }
    }

    /// The stock is nobody's tree: no node lists it, the sweep at open
    /// leaves it, and the first placement after that takes it over.
    #[test]
    fn the_stock_is_no_nodes_tree_and_outlives_the_sweep() {
        let dir = tempfile::tempdir().unwrap();
        let trees = bulk_city(dir.path(), 8);
        trees.stock().unwrap();
        let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
        let listed = trees.live().unwrap();
        let placed = trees.claim(&name("node-1"), &[]).unwrap();
        assert_eq!(
            (swept, listed, placed.work().created),
            (Vec::new(), Vec::new(), 0),
            "the stock survives the sweep and the next placement creates nothing"
        );
    }
}