Skip to main content

storage/worktree/trees/
stock.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 stock: one tree checked out at the trunk before any node asks for
7//! it, so that placing a node's tree is a rename rather than a checkout
8//! (`crates/storage/spec/Worktree/Trees/Stock.lean` §8-35).
9//!
10//! A checkout waits on the real-time scanner once for every file it
11//! creates, so any placement that creates the tree's files while a run
12//! waits costs what the tree holds. Taking the stock over creates none:
13//! the stock's directory and its registration are renamed to the node's,
14//! and the two link files git keeps between them are rewritten.
15
16use std::path::{Path, PathBuf};
17
18use super::super::lease::{FileWork, WorktreeLease};
19use super::super::name::WorktreeName;
20use super::super::weight::Weight;
21use super::Worktrees;
22use super::kept::restore;
23use super::open_tree;
24use crate::error::StorageError;
25
26/// The stock's id: its registration, its directory under the tree home
27/// and its branch. `+` is outside `WorktreeName`'s alphabet, so no node
28/// can be named this and no claim, release or merge reaches the stock;
29/// git admits it in a branch name.
30pub(super) const STOCK: &str = "+spare";
31
32/// Why a stock is locked: it is being checked out, and a tree half
33/// written is not one to hand to a node.
34const STOCKING: &str = "being stocked by sprawling";
35
36/// What the tree home holds under the stock's id.
37enum Stock {
38    Absent,
39    /// Being checked out by another call.
40    Busy,
41    /// Registered, but its directory or a link file is not what this
42    /// module writes: taken back and placed anew rather than handed out.
43    Broken,
44    Ready(git2::Worktree, Links),
45}
46
47/// The two link files between the stock's directory and its
48/// registration, as git wrote them, and where each lives.
49struct Links {
50    tree: PathBuf,
51    admin: PathBuf,
52    /// The tree's `.git` file, up to the stock's id.
53    dot_git: String,
54    /// The registration's `gitdir` file, up to the stock's id.
55    gitdir: String,
56}
57
58impl Links {
59    /// The two files as they read once the stock is `name`'s.
60    fn of(&self, name: &str) -> (String, String) {
61        (
62            format!("{}worktrees/{name}/\n", self.dot_git),
63            format!("{}/{name}/.git\n", self.gitdir),
64        )
65    }
66}
67
68impl Worktrees {
69    /// Keeps one tree checked out at the trunk for the next placement to
70    /// take over, and answers what that cost the filesystem: a whole
71    /// checkout when there is no stock, and the files the trunk changed
72    /// since the stock was last brought to it otherwise. A stock that
73    /// another call is checking out is left to it, at no cost.
74    ///
75    /// Call it where nobody waits on it: its cost is the placement's.
76    ///
77    /// # Errors
78    /// Refuses a city whose working tree exceeds the ceiling and a
79    /// repository with no commit, and propagates whatever git or the
80    /// disk refuses while the stock is checked out.
81    pub fn stock(&self) -> Result<FileWork, StorageError> {
82        match self.stock_standing()? {
83            Stock::Absent => self.place_stock(),
84            Stock::Busy => Ok(FileWork::default()),
85            Stock::Broken => {
86                self.forget(STOCK)?;
87                self.place_stock()
88            }
89            Stock::Ready(tree, _) => self.refresh_stock(&tree),
90        }
91    }
92
93    /// Takes the stock over as `name`'s tree and lends it, or answers
94    /// `None` when there is no stock to take: none was made, another
95    /// call is making it, or another placement took it first. `city` is
96    /// the walk the ceiling was judged on.
97    ///
98    /// The directory is renamed first, because that rename is the one
99    /// step two placements cannot both make; every later step leaves a
100    /// state the next claim of `name` takes back (`crates/storage/spec/Worktree/Trees/Stock.lean` §8-35).
101    pub(super) fn adopt(
102        &self,
103        name: &WorktreeName,
104        city: &Weight,
105    ) -> Result<Option<WorktreeLease>, StorageError> {
106        let Stock::Ready(_, links) = self.stock_standing()? else {
107            return Ok(None);
108        };
109        let target = self.home.join(name.as_str());
110        crate::alias::WriteTarget::within("place a worktree", &self.city_root, &target)?;
111        self.branch_at_trunk(name)?;
112        match std::fs::rename(&links.tree, &target) {
113            Ok(()) => {}
114            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
115            Err(source) => return Err(io("take the stock", &links.tree, source)),
116        }
117        let (dot_git, gitdir) = links.of(name.as_str());
118        let admin = links.admin.with_file_name(name.as_str());
119        write(&target.join(".git"), &dot_git)?;
120        std::fs::rename(&links.admin, &admin)
121            .map_err(|source| io("register the stock as a node's tree", &links.admin, source))?;
122        write(&admin.join("gitdir"), &gitdir)?;
123        let tree = self
124            .repo
125            .find_worktree(name.as_str())
126            .map_err(|err| git("find a taken stock", name.as_str(), &err))?;
127        let lease = self.reattach(name, &tree, &[])?;
128        Ok(Some(WorktreeLease {
129            work: FileWork {
130                walked: lease.work.walked.saturating_add(city.walked),
131                ..lease.work
132            },
133            ..lease
134        }))
135    }
136
137    /// Where the stock stands, read from git and the disk.
138    fn stock_standing(&self) -> Result<Stock, StorageError> {
139        let admin = self.repo.commondir().join(super::WORKTREE_DIR).join(STOCK);
140        let tree = match self.repo.find_worktree(STOCK) {
141            Ok(tree) => tree,
142            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Stock::Absent),
143            // A registration git will not read is one a cut takeover left
144            // behind - the tree renamed out from under it, or the write of
145            // its link torn - and `Broken` is the state for it: `forget`
146            // takes the registration back and the placement after it makes
147            // the stock whole (`crates/storage/spec/Worktree/Trees/Stock.lean`
148            // §8-35: 接管的步骤…进程可能在任何一步之后死掉,而 standing
149            // 收回指向不存在目录的登记; `every_cut_is_recovered`). Where no
150            // registration stands at all, the repository itself refused and
151            // the reason is carried.
152            Err(_) if admin.is_dir() => return Ok(Stock::Broken),
153            Err(err) => return Err(git("find the stock", STOCK, &err)),
154        };
155        let lock = match tree.is_locked() {
156            Ok(lock) => lock,
157            // The registration went away between the lookup and this read:
158            // a lane putting the stock back, or a `forget` taking back one
159            // git would not read. No registration is the state the next
160            // placement wants, so it is asked for rather than carried as a
161            // failure of the repository.
162            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Stock::Absent),
163            Err(err) => return Err(git("read the stock's lock", STOCK, &err)),
164        };
165        if let git2::WorktreeLockStatus::Locked(_) = lock {
166            return Ok(Stock::Busy);
167        }
168        let read = |path: &Path| match std::fs::read_to_string(path) {
169            Ok(text) => Ok(Some(text)),
170            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
171            Err(source) => Err(io("read the stock's links", path, source)),
172        };
173        let dot_git = read(&tree.path().join(".git"))?;
174        let gitdir = read(&admin.join("gitdir"))?;
175        let links = dot_git
176            .as_deref()
177            .and_then(|text| text.strip_suffix(&format!("worktrees/{STOCK}/\n")))
178            .zip(
179                gitdir
180                    .as_deref()
181                    .and_then(|text| text.strip_suffix(&format!("/{STOCK}/.git\n"))),
182            );
183        Ok(match links {
184            Some((dot_git, gitdir)) => {
185                let links = Links {
186                    tree: tree.path().to_path_buf(),
187                    admin,
188                    dot_git: dot_git.to_owned(),
189                    gitdir: gitdir.to_owned(),
190                };
191                Stock::Ready(tree, links)
192            }
193            None => Stock::Broken,
194        })
195    }
196
197    /// Checks a new stock out at the trunk, locked until it is whole.
198    fn place_stock(&self) -> Result<FileWork, StorageError> {
199        let city = self.refuse_oversized(STOCK)?;
200        self.clear_unregistered(STOCK)?;
201        let trunk = self
202            .repo
203            .head()
204            .and_then(|head| head.peel_to_commit())
205            .map_err(|err| git("read the city trunk", STOCK, &err))?;
206        let branch = self
207            .repo
208            .branch(STOCK, &trunk, true)
209            .map_err(|err| git("branch the stock", STOCK, &err))?;
210        let (_, written) = match self.check_out_tree(STOCK, Some(branch.get())) {
211            Ok(placed) => placed,
212            // A registration left behind locked would read as a stocking
213            // in progress for the rest of this serving.
214            Err(refusal) => return Err(self.take_back(refusal)),
215        };
216        let tree = self
217            .repo
218            .find_worktree(STOCK)
219            .map_err(|err| git("find the stock", STOCK, &err))?;
220        cache_trees(&tree)?;
221        tree.unlock()
222            .map_err(|err| git("unlock the stock", STOCK, &err))?;
223        Ok(FileWork {
224            created: written.files,
225            walked: city.walked,
226            ..FileWork::default()
227        })
228    }
229
230    /// Brings a whole stock up to the trunk, locked while it moves.
231    fn refresh_stock(&self, tree: &git2::Worktree) -> Result<FileWork, StorageError> {
232        tree.lock(Some(STOCKING))
233            .map_err(|err| git("lock the stock", STOCK, &err))?;
234        let trunk = self
235            .repo
236            .head()
237            .and_then(|head| head.peel_to_commit())
238            .map_err(|err| git("read the city trunk", STOCK, &err))?;
239        self.repo
240            .branch(STOCK, &trunk, true)
241            .map_err(|err| git("move the stock to the trunk", STOCK, &err))?;
242        // Unlocked even when the restore failed: a stock half moved is
243        // whole again after the restore a placement makes of it, while a
244        // lock left behind would read as a stocking in progress.
245        let work = restore(tree, STOCK, &[]).and_then(|work| cache_trees(tree).map(|()| work));
246        tree.unlock()
247            .map_err(|err| git("unlock the stock", STOCK, &err))?;
248        work
249    }
250
251    /// Unregisters a stock whose checkout failed and answers the
252    /// failure, or the failure to unregister it with the checkout's
253    /// failure in its detail.
254    fn take_back(&self, refusal: StorageError) -> StorageError {
255        match self.forget(STOCK) {
256            Ok(()) => refusal,
257            Err(left) => StorageError::Worktree {
258                op: "take back a stock that did not check out",
259                detail: format!("{left}; the checkout failed with: {refusal}"),
260            },
261        }
262    }
263
264    /// Makes `name`'s branch at the trunk unless the node already has
265    /// one: a node's branch is its line of work, and the stock is only
266    /// where its files come from.
267    fn branch_at_trunk(&self, name: &WorktreeName) -> Result<(), StorageError> {
268        match self
269            .repo
270            .find_branch(name.as_str(), git2::BranchType::Local)
271        {
272            Ok(_) => Ok(()),
273            Err(err) if err.code() == git2::ErrorCode::NotFound => {
274                let trunk = self
275                    .repo
276                    .head()
277                    .and_then(|head| head.peel_to_commit())
278                    .map_err(|err| git("read the city trunk", name.as_str(), &err))?;
279                self.repo
280                    .branch(name.as_str(), &trunk, false)
281                    .map(drop)
282                    .map_err(|err| git("branch a worktree", name.as_str(), &err))
283            }
284            Err(err) => Err(git("find a node branch", name.as_str(), &err)),
285        }
286    }
287}
288
289/// Reads the stock's index back from its head and writes it, so that the
290/// index carries the tree cache: the placement that takes the stock over
291/// then learns that the index writes the head's tree without hashing a
292/// single tree (`kept::index_writes_tree`). Reading back keeps the stat
293/// data of every entry whose blob and mode are unchanged, and writing the
294/// index after the checkout's last file leaves no entry racily clean.
295fn cache_trees(tree: &git2::Worktree) -> Result<(), StorageError> {
296    let repo = open_tree(tree).map_err(|err| git("open the stock", STOCK, &err))?;
297    let head = repo
298        .head()
299        .and_then(|head| head.peel_to_tree())
300        .map_err(|err| git("read the stock's branch", STOCK, &err))?;
301    let mut index = repo
302        .index()
303        .map_err(|err| git("read the stock's index", STOCK, &err))?;
304    index
305        .read_tree(&head)
306        .and_then(|()| index.write())
307        .map_err(|err| git("cache the stock's trees", STOCK, &err))
308}
309
310/// Writes one link file whole.
311fn write(path: &Path, text: &str) -> Result<(), StorageError> {
312    std::fs::write(path, text).map_err(|source| io("rewrite a worktree link", path, source))
313}
314
315fn io(op: &'static str, path: &Path, source: std::io::Error) -> StorageError {
316    StorageError::Io {
317        op,
318        path: path.to_path_buf(),
319        source,
320    }
321}
322
323fn git(op: &'static str, id: &str, err: &git2::Error) -> StorageError {
324    StorageError::Worktree {
325        op,
326        detail: format!("{id}: {err}"),
327    }
328}
329
330#[cfg(test)]
331#[allow(
332    clippy::unwrap_used,
333    clippy::arithmetic_side_effects,
334    reason = "test code"
335)]
336mod tests {
337    use std::path::Path;
338
339    use kernel::TimeMs;
340
341    use super::super::super::lease::FileWork;
342    use super::super::Landing;
343    use super::super::Worktrees;
344    use super::super::tests::{bulk_city, entries, name, owner};
345    use super::STOCK;
346    use crate::checkpoint::Checkpoint;
347
348    fn files_under(dir: &Path) -> u64 {
349        u64::try_from(std::fs::read_dir(dir).unwrap().count()).unwrap()
350    }
351
352    /// A cut takeover leaves the spare's registration pointing at a tree
353    /// that is gone, and the next `stock` takes that registration back and
354    /// makes the spare whole (`crates/storage/spec/Worktree/Trees/Stock.lean`
355    /// §8-35: 接管的步骤…进程可能在任何一步之后死掉,而 `standing` 收回指向
356    /// 不存在目录的登记; `every_cut_is_recovered`).
357    ///
358    /// The cut drawn here is a torn write of the registration's link, the
359    /// state libgit2 refuses to read outright: the lookup answers an error
360    /// that is not `NotFound`, so a `standing` that propagated it turned a
361    /// cut takeover into `E_STORAGE_FATAL` where the model promises a
362    /// stocking.
363    #[test]
364    fn a_registration_git_will_not_read_is_stocked_again() {
365        let dir = tempfile::tempdir().unwrap();
366        let trees = bulk_city(dir.path(), 8);
367        trees.stock().unwrap();
368        let registration = trees
369            .repo
370            .commondir()
371            .join(super::super::WORKTREE_DIR)
372            .join(STOCK);
373        std::fs::remove_file(registration.join("gitdir")).unwrap();
374
375        trees.stock().unwrap();
376        let placed = trees.claim(&name("node-1"), &["bulk".to_owned()]).unwrap();
377        assert_eq!(
378            std::fs::read_dir(placed.path().join("bulk"))
379                .unwrap()
380                .count(),
381            8,
382            "the stock is whole again after the cut registration was taken back"
383        );
384    }
385
386    /// `crates/storage/spec/Worktree/Trees/Stock.lean` §8-35: a placement from the stock creates no file,
387    /// whatever the tree holds - the files were written when the stock
388    /// was, where nobody waited. Judged at two sizes, so a placement
389    /// whose file count grows with the tree is a failure here.
390    #[test]
391    fn a_placement_from_the_stock_creates_no_file_at_either_size() {
392        for files in [32, 64] {
393            let dir = tempfile::tempdir().unwrap();
394            let trees = bulk_city(dir.path(), files);
395            let city = entries(dir.path());
396            let stocked = trees.stock().unwrap();
397            let city_at_claim = entries(dir.path());
398            let placed = trees.claim(&name("node-1"), &["bulk".to_owned()]).unwrap();
399            assert_eq!(
400                (
401                    stocked,
402                    placed.work(),
403                    files_under(&placed.path().join("bulk"))
404                ),
405                (
406                    FileWork {
407                        created: files,
408                        walked: city,
409                        ..FileWork::default()
410                    },
411                    FileWork {
412                        walked: city_at_claim + entries(placed.path()),
413                        ..FileWork::default()
414                    },
415                    files,
416                ),
417                "a stock and the placement that takes it at {files} files"
418            );
419        }
420    }
421
422    /// A stock made before the trunk moved is placed at the trunk: the
423    /// placement writes what the trunk changed since, the node's tree
424    /// is on the node's own branch, and what the node offers from it
425    /// merges like the offer of any other tree.
426    #[test]
427    fn a_stock_behind_the_trunk_is_placed_at_the_trunk_and_merges() {
428        let dir = tempfile::tempdir().unwrap();
429        let trees = bulk_city(dir.path(), 8);
430        trees.stock().unwrap();
431        std::fs::write(dir.path().join("bulk").join("file-0000.txt"), b"moved on").unwrap();
432        Checkpoint::open(dir.path())
433            .unwrap()
434            .land(&[], TimeMs::new(2_000), &owner(), "checkpoint: bulk")
435            .unwrap();
436
437        let bulk = ["bulk".to_owned()];
438        let placed = trees.claim(&name("node-1"), &bulk).unwrap();
439        let tree = placed.path().to_path_buf();
440        let repo = git2::Repository::open(&tree).unwrap();
441        assert_eq!(
442            (
443                placed.work().created,
444                placed.work().rewritten,
445                std::fs::read_to_string(tree.join("bulk").join("file-0000.txt")).unwrap(),
446                repo.head().unwrap().name().ok().map(str::to_owned),
447                trees.live().unwrap(),
448            ),
449            (
450                0,
451                1,
452                "moved on".to_owned(),
453                Some("refs/heads/node-1".to_owned()),
454                vec![name("node-1")],
455            ),
456            "the stock is the node's tree, at the trunk, on the node's branch"
457        );
458
459        std::fs::write(tree.join("bulk").join("file-0001.txt"), b"from the node").unwrap();
460        Checkpoint::open(&tree)
461            .unwrap()
462            .land(&bulk, TimeMs::new(3_000), &owner(), "offer: bulk")
463            .unwrap();
464        trees.release(placed).unwrap();
465        let of = owner();
466        trees
467            .plan_merge(&name("node-1"))
468            .unwrap()
469            .apply(&Landing {
470                t: TimeMs::new(4_000),
471                of: &of,
472                subject: "merge: node-1",
473                reviewed_by_person: false,
474            })
475            .unwrap();
476        assert_eq!(
477            std::fs::read_to_string(dir.path().join("bulk").join("file-0001.txt")).unwrap(),
478            "from the node"
479        );
480    }
481
482    /// Storage D28, counted: a room placed while the stock is still being
483    /// checked out pays one whole checkout of its own (N files at N, 2N
484    /// and 4N), and the stock being made is not lost - the next room
485    /// takes it over and creates nothing. Waiting for the stock instead
486    /// would save the second room's N files and no checkout overall,
487    /// because the stock it took would have to be made again.
488    #[test]
489    fn a_room_placed_while_the_stock_is_being_made_checks_out_once_and_the_stock_survives() {
490        for files in [8, 16, 32] {
491            let dir = tempfile::tempdir().unwrap();
492            let trees = bulk_city(dir.path(), files);
493            trees.stock().unwrap();
494            let first = trees.claim(&name("node-1"), &[]).unwrap().work().created;
495            let restocked = trees.stock().unwrap().created;
496            let making = git2::Repository::open(dir.path())
497                .unwrap()
498                .find_worktree(super::STOCK)
499                .unwrap();
500            making.lock(Some(super::STOCKING)).unwrap();
501            let during = trees.claim(&name("node-2"), &[]).unwrap().work().created;
502            making.unlock().unwrap();
503            let refreshed = trees.stock().unwrap().created;
504            let after = trees.claim(&name("node-3"), &[]).unwrap().work().created;
505            assert_eq!(
506                (first, restocked, during, refreshed, after),
507                (0, files, files, 0, 0),
508                "files created per step at {files} files"
509            );
510        }
511    }
512
513    /// The stock is nobody's tree: no node lists it, the sweep at open
514    /// leaves it, and the first placement after that takes it over.
515    #[test]
516    fn the_stock_is_no_nodes_tree_and_outlives_the_sweep() {
517        let dir = tempfile::tempdir().unwrap();
518        let trees = bulk_city(dir.path(), 8);
519        trees.stock().unwrap();
520        let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
521        let listed = trees.live().unwrap();
522        let placed = trees.claim(&name("node-1"), &[]).unwrap();
523        assert_eq!(
524            (swept, listed, placed.work().created),
525            (Vec::new(), Vec::new(), 0),
526            "the stock survives the sweep and the next placement creates nothing"
527        );
528    }
529}