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