Skip to main content

storage/worktree/trees/
kept.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//! A tree kept for its node between runs: whether somebody holds it,
7//! and how it is taken back into use.
8
9use super::super::lease::{FileWork, WorktreeLease};
10use super::super::name::WorktreeName;
11use super::super::weight::measure;
12use super::Worktrees;
13use super::open_tree;
14use crate::error::StorageError;
15
16/// Why a tree is locked: a run holds it.
17const LEASE_REASON: &str = "held by a sprawling run";
18
19/// What [`Worktrees::claim`] finds under a name.
20pub(super) enum Standing {
21    Held,
22    Kept(git2::Worktree),
23    Absent,
24}
25
26impl Worktrees {
27    /// Lifts every lease a previous writer of this city left behind.
28    ///
29    /// A city has one writer, and `_writer` is the proof that the caller
30    /// is it: while it is held, no lock can belong to a run that is
31    /// still alive, so every lock is one a dead process never gave
32    /// back. A city with no repository has no trees to free.
33    ///
34    /// # Errors
35    /// Propagates a repository that cannot list, read or unlock its
36    /// trees.
37    pub fn lift_abandoned_leases(
38        city_root: &std::path::Path,
39        _writer: &crate::JsonlLedger,
40    ) -> Result<(), StorageError> {
41        let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
42            op,
43            detail: format!("{}: {err}", city_root.display()),
44        };
45        let repo = match git2::Repository::open(city_root) {
46            Ok(repo) => repo,
47            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
48            Err(err) => return Err(refuse("open the city repository", err)),
49        };
50        let names = repo
51            .worktrees()
52            .map_err(|err| refuse("list worktrees", err))?;
53        for name in names.iter().flatten().flatten() {
54            let tree = repo
55                .find_worktree(name)
56                .map_err(|err| refuse("find a worktree", err))?;
57            let lock = tree
58                .is_locked()
59                .map_err(|err| refuse("read a worktree lock", err))?;
60            match lock {
61                git2::WorktreeLockStatus::Locked(_) => tree
62                    .unlock()
63                    .map_err(|err| refuse("unlock a worktree", err))?,
64                git2::WorktreeLockStatus::Unlocked => {}
65            }
66        }
67        Ok(())
68    }
69
70    /// Where `name` stands: held under a lock, kept on disk for its
71    /// node, or not there at all.
72    ///
73    /// A registration with no directory under it is what an interrupted
74    /// placement or a person deleting the tree by hand leaves, and it is
75    /// taken back here - the same reflex the index has about a cache it
76    /// doubts - so that `E_WORKTREE_BUSY` keeps meaning that somebody is
77    /// working.
78    pub(super) fn standing(&self, name: &WorktreeName) -> Result<Standing, StorageError> {
79        let tree = match self.repo.find_worktree(name.as_str()) {
80            Ok(tree) => tree,
81            Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Standing::Absent),
82            Err(err) => {
83                return Err(StorageError::Worktree {
84                    op: "find a worktree",
85                    detail: format!("{}: {err}", name.as_str()),
86                });
87            }
88        };
89        if !tree.path().exists() {
90            self.forget(name.as_str())?;
91            return Ok(Standing::Absent);
92        }
93        let lock = tree.is_locked().map_err(|err| StorageError::Worktree {
94            op: "read a worktree lock",
95            detail: format!("{}: {err}", name.as_str()),
96        })?;
97        Ok(match lock {
98            git2::WorktreeLockStatus::Locked(_) => Standing::Held,
99            git2::WorktreeLockStatus::Unlocked => Standing::Kept(tree),
100        })
101    }
102
103    /// Takes a kept tree back into use: the node's branch moved up to
104    /// the trunk when the trunk already holds all of it, the index read
105    /// back from that branch head, every tracked file under `scopes`
106    /// forced to it and every untracked one there removed, then the
107    /// lock. Git rewrites only the files that differ, and only inside
108    /// the scope, so a node's second run costs what it left behind in
109    /// its own scope, not the size of the city.
110    ///
111    /// The index is read back whole before the narrowed checkout, unless
112    /// it already writes the branch head's tree, because that checkout
113    /// moves only the entries it writes: an entry outside the scope that
114    /// names anything but the branch head - the last run's commit after
115    /// the branch followed the trunk, or a path a run staged and never
116    /// offered - would be committed by the next scoped offer. The
117    /// comparison comes first because reading back walks the whole tree,
118    /// and a reclaim that finds the branch and the index where the last
119    /// run left them is the common case.
120    pub(super) fn reattach(
121        &self,
122        name: &WorktreeName,
123        tree: &git2::Worktree,
124        scopes: &[String],
125    ) -> Result<WorktreeLease, StorageError> {
126        self.follow_trunk(name)?;
127        let work = restore(tree, name.as_str(), scopes)?;
128        tree.lock(Some(LEASE_REASON))
129            .map_err(|err| StorageError::Worktree {
130                op: "lock a worktree",
131                detail: format!("{}: {err}", name.as_str()),
132            })?;
133        let weight = measure(tree.path())?;
134        Ok(WorktreeLease {
135            name: name.clone(),
136            path: tree.path().to_path_buf(),
137            disk: weight.bytes,
138            work: FileWork {
139                walked: weight.walked,
140                ..work
141            },
142        })
143    }
144
145    /// Moves `name`'s branch to the trunk when the trunk descends from
146    /// it. A branch with work the trunk lacks stays where it is: that
147    /// work is still waiting for its merge.
148    fn follow_trunk(&self, name: &WorktreeName) -> Result<(), StorageError> {
149        let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
150            op,
151            detail: format!("{}: {err}", name.as_str()),
152        };
153        let mut branch = self
154            .repo
155            .find_branch(name.as_str(), git2::BranchType::Local)
156            .map_err(|err| refuse("find a node branch", err))?;
157        let tip = branch
158            .get()
159            .peel_to_commit()
160            .map_err(|err| refuse("read a node branch", err))?
161            .id();
162        let trunk = self
163            .repo
164            .head()
165            .and_then(|head| head.peel_to_commit())
166            .map_err(|err| refuse("read the city trunk", err))?
167            .id();
168        let behind = tip != trunk
169            && self
170                .repo
171                .graph_descendant_of(trunk, tip)
172                .map_err(|err| refuse("judge a fast-forward", err))?;
173        if behind {
174            branch
175                .get_mut()
176                .set_target(trunk, "follow the trunk")
177                .map_err(|err| refuse("move a node branch", err))?;
178        }
179        Ok(())
180    }
181}
182
183/// Puts `tree` on `branch` and forces every tracked file under `scopes`
184/// to its head, removing what is untracked there, and counts what that
185/// wrote (`crates/storage/spec/Worktree.lean` §8-31). The index is read back from the head first
186/// when it does not already write the head's tree, for the reason
187/// [`Worktrees::reattach`] gives.
188///
189/// The worktree's HEAD is pointed at `branch` when it names anything
190/// else: a stock taken over by a node still names the stock's branch
191/// (`crates/storage/spec/Worktree/Trees/Stock.lean` §8-35), and a checkout of the wrong head would hand the
192/// node another line of work.
193///
194/// # Errors
195/// Propagates a tree whose repository, head, index or checkout git
196/// refuses.
197pub(super) fn restore(
198    tree: &git2::Worktree,
199    branch: &str,
200    scopes: &[String],
201) -> Result<FileWork, StorageError> {
202    let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
203        op,
204        detail: format!("{branch}: {err}"),
205    };
206    let repo = open_tree(tree).map_err(|err| refuse("open a kept worktree", err))?;
207    let wanted = format!("refs/heads/{branch}");
208    let pointed = repo
209        .find_reference("HEAD")
210        .map_err(|err| refuse("read a kept worktree's HEAD", err))?;
211    if pointed.symbolic_target_bytes() != Some(wanted.as_bytes()) {
212        repo.reference_symbolic("HEAD", &wanted, true, "point the tree at its node's branch")
213            .map_err(|err| refuse("point a kept worktree at its branch", err))?;
214    }
215    let head = repo
216        .head()
217        .and_then(|head| head.peel_to_tree())
218        .map_err(|err| refuse("read a kept worktree's branch", err))?;
219    let mut index = repo
220        .index()
221        .map_err(|err| refuse("read a kept worktree's index", err))?;
222    if !index_writes_tree(&mut index, head.id())
223        .map_err(|err| refuse("compare a kept worktree's index", err))?
224    {
225        index
226            .read_tree(&head)
227            .and_then(|()| index.write())
228            .map_err(|err| refuse("reset a kept worktree's index", err))?;
229    }
230    let mut work = FileWork::default();
231    let mut checkout = git2::build::CheckoutBuilder::new();
232    checkout
233        .force()
234        .remove_untracked(true)
235        .notify_on(
236            git2::CheckoutNotificationType::UPDATED | git2::CheckoutNotificationType::UNTRACKED,
237        )
238        .notify(|kind, _, _, target, workdir| {
239            count_change(&mut work, kind, (target.is_some(), workdir.is_some()));
240            true
241        });
242    for spec in crate::checkpoint::scan::pathspec::of(scopes) {
243        checkout.path(spec);
244    }
245    repo.checkout_head(Some(&mut checkout))
246        .map_err(|err| refuse("reset a kept worktree", err))?;
247    drop(checkout);
248    Ok(work)
249}
250
251/// Counts one change a reattaching checkout announced before making it
252/// (`crates/storage/spec/Worktree.lean` §8-31). `present` says whether the branch head holds the
253/// path and whether the disk does: a tracked file the head lacks is
254/// removed, one the disk lacks is created, and any other is rewritten;
255/// an untracked entry is removed.
256fn count_change(work: &mut FileWork, kind: git2::CheckoutNotificationType, present: (bool, bool)) {
257    let count = if kind.contains(git2::CheckoutNotificationType::UNTRACKED) {
258        &mut work.removed
259    } else {
260        match present {
261            (false, _) => &mut work.removed,
262            (true, false) => &mut work.created,
263            (true, true) => &mut work.rewritten,
264        }
265    };
266    *count = count.saturating_add(1);
267}
268
269/// Writes the tree `index` holds into the object store and says whether
270/// it is `tree`.
271///
272/// Where no entry changed since the index last wrote or read a tree, the
273/// index's own tree cache answers without hashing anything; otherwise
274/// only the subtrees whose entries changed are written. An index holding
275/// a conflict writes no tree, and so is not `tree`.
276fn index_writes_tree(index: &mut git2::Index, tree: git2::Oid) -> Result<bool, git2::Error> {
277    match index.write_tree() {
278        Ok(written) => Ok(written == tree),
279        Err(err) if err.code() == git2::ErrorCode::Unmerged => Ok(false),
280        Err(err) => Err(err),
281    }
282}
283
284#[cfg(test)]
285#[allow(
286    clippy::unwrap_used,
287    clippy::arithmetic_side_effects,
288    reason = "test code"
289)]
290mod tests {
291    use super::super::super::lease::FileWork;
292    use super::super::Landing;
293    use super::super::tests::{bulk_city, city, entries, name, owner};
294    use crate::checkpoint::Checkpoint;
295    use kernel::TimeMs;
296    use std::path::Path;
297
298    fn read(path: &Path) -> String {
299        std::fs::read_to_string(path).unwrap()
300    }
301
302    /// `crates/storage/spec/Worktree.lean` §8-31: a placement writes the tree's files once and walks
303    /// the city once; a reclaim writes what the last run changed in its
304    /// scope and nothing else. Judged at two sizes, so a cost that grows
305    /// with the tree where it should not is a failure here rather than a
306    /// slower reading somewhere else.
307    #[test]
308    fn claiming_a_tree_costs_what_it_places_and_restores() {
309        for files in [32, 64] {
310            let dir = tempfile::tempdir().unwrap();
311            let trees = bulk_city(dir.path(), files);
312            let scope = ["bulk".to_owned()];
313            let city = entries(dir.path());
314
315            let placed = trees.claim(&name("node-1"), &scope).unwrap();
316            let tree = placed.path().to_path_buf();
317            assert_eq!(
318                placed.work(),
319                FileWork {
320                    created: files,
321                    rewritten: 0,
322                    removed: 0,
323                    walked: city,
324                },
325                "a placement at {files} files"
326            );
327
328            std::fs::write(tree.join("bulk").join("file-0000.txt"), b"changed by a run").unwrap();
329            std::fs::write(tree.join("bulk").join("stray.txt"), b"left by a run").unwrap();
330            trees.release(placed).unwrap();
331            let restored = trees.claim(&name("node-1"), &scope).unwrap();
332            assert_eq!(
333                restored.work(),
334                FileWork {
335                    created: 0,
336                    rewritten: 1,
337                    removed: 1,
338                    walked: entries(&tree),
339                },
340                "a reclaim after a run at {files} files"
341            );
342
343            trees.release(restored).unwrap();
344            let unchanged = trees.claim(&name("node-1"), &scope).unwrap();
345            assert_eq!(
346                unchanged.work(),
347                FileWork {
348                    walked: entries(&tree),
349                    ..FileWork::default()
350                },
351                "a reclaim with nothing to restore at {files} files"
352            );
353        }
354    }
355
356    /// A kept tree claimed again writes only its scope, and what the node
357    /// offers from it moves only that scope: the trunk's work elsewhere,
358    /// which this tree never checked out, lands in the city untouched.
359    #[test]
360    fn a_kept_tree_claimed_again_checks_out_only_its_scope_and_offers_only_that() {
361        let dir = tempfile::tempdir().unwrap();
362        let trees = city(dir.path());
363        let city_docs = dir.path().join("docs").join("plan.md");
364        let land_city = |t: u64| {
365            Checkpoint::open(dir.path())
366                .unwrap()
367                .land(&[], TimeMs::new(t), &owner(), "checkpoint: city")
368                .unwrap()
369        };
370        std::fs::create_dir_all(city_docs.parent().unwrap()).unwrap();
371        std::fs::write(
372            &city_docs, b"v1
373",
374        )
375        .unwrap();
376        land_city(1_500);
377        let lab = ["lab".to_owned()];
378        trees
379            .release(trees.claim(&name("node-1"), &lab).unwrap())
380            .unwrap();
381        std::fs::write(
382            &city_docs, b"v2
383",
384        )
385        .unwrap();
386        std::fs::write(
387            dir.path().join("lab").join("notes.md"),
388            b"second
389",
390        )
391        .unwrap();
392        let trunk = land_city(2_000);
393
394        let again = trees.claim(&name("node-1"), &lab).unwrap();
395        assert_eq!(
396            (
397                read(&again.path().join("lab").join("notes.md")),
398                read(&again.path().join("docs").join("plan.md"))
399            ),
400            (
401                "second
402"
403                .to_owned(),
404                "v1
405"
406                .to_owned()
407            ),
408            "the scope follows the trunk and nothing outside it is written"
409        );
410
411        std::fs::write(
412            again.path().join("lab").join("notes.md"),
413            b"from the node
414",
415        )
416        .unwrap();
417        Checkpoint::open(again.path())
418            .unwrap()
419            .land(&lab, TimeMs::new(3_000), &owner(), "offer: lab")
420            .unwrap();
421        let of = owner();
422        trees
423            .plan_merge(again.name())
424            .unwrap()
425            .apply(&Landing {
426                t: TimeMs::new(4_000),
427                of: &of,
428                subject: "merge: node-1",
429                reviewed_by_person: false,
430            })
431            .unwrap();
432        let repo = git2::Repository::open(dir.path()).unwrap();
433        let before = repo
434            .find_commit(git2::Oid::from_str(&trunk).unwrap())
435            .unwrap()
436            .tree()
437            .unwrap();
438        let after = repo.head().unwrap().peel_to_tree().unwrap();
439        let moved: Vec<String> = repo
440            .diff_tree_to_tree(Some(&before), Some(&after), None)
441            .unwrap()
442            .deltas()
443            .filter_map(|delta| delta.new_file().path().map(|p| p.display().to_string()))
444            .collect();
445        assert_eq!(
446            (moved, read(&city_docs)),
447            (
448                vec!["lab/notes.md".to_owned()],
449                "v2
450"
451                .to_owned()
452            ),
453            "the merge moves the scope alone"
454        );
455    }
456
457    /// A path a run staged outside its scope and never offered does not
458    /// ride along with the node's next offer, although the trunk did not
459    /// move in between: the next claim finds an index that no longer
460    /// names the branch head, and reads it back.
461    #[test]
462    fn a_path_staged_outside_the_scope_does_not_ride_along_with_the_next_offer() {
463        let dir = tempfile::tempdir().unwrap();
464        let trees = city(dir.path());
465        let lab = ["lab".to_owned()];
466        let first = trees.claim(&name("node-1"), &lab).unwrap();
467        let stray = first.path().join("docs").join("stray.md");
468        std::fs::create_dir_all(stray.parent().unwrap()).unwrap();
469        std::fs::write(&stray, b"not this node's").unwrap();
470        let staging = git2::Repository::open(first.path()).unwrap();
471        let mut index = staging.index().unwrap();
472        index.add_path(Path::new("docs/stray.md")).unwrap();
473        index.write().unwrap();
474        trees.release(first).unwrap();
475
476        let again = trees.claim(&name("node-1"), &lab).unwrap();
477        std::fs::write(again.path().join("lab").join("notes.md"), b"from the node").unwrap();
478        Checkpoint::open(again.path())
479            .unwrap()
480            .land(&lab, TimeMs::new(3_000), &owner(), "offer: lab")
481            .unwrap();
482        let offered = git2::Repository::open(again.path()).unwrap();
483        let head = offered.head().unwrap().peel_to_commit().unwrap();
484        let moved: Vec<String> = offered
485            .diff_tree_to_tree(
486                Some(&head.parent(0).unwrap().tree().unwrap()),
487                Some(&head.tree().unwrap()),
488                None,
489            )
490            .unwrap()
491            .deltas()
492            .filter_map(|delta| delta.new_file().path().map(|p| p.display().to_string()))
493            .collect();
494        assert_eq!(
495            moved,
496            vec!["lab/notes.md".to_owned()],
497            "the offer moves the scope alone"
498        );
499    }
500
501    /// A node's next run finds its tree where it left it: releasing gives
502    /// the lease back and keeps the files, so the next claim neither
503    /// measures the city nor checks the whole tree out again.
504    #[test]
505    fn a_released_tree_stays_on_disk_for_the_nodes_next_run() {
506        let dir = tempfile::tempdir().unwrap();
507        let trees = city(dir.path());
508        let lease = trees.claim(&name("node-1"), &[]).unwrap();
509        let path = lease.path().to_path_buf();
510        let notes = path.join("lab").join("notes.md");
511        let written = std::fs::metadata(&notes).unwrap().modified().unwrap();
512
513        trees.release(lease).unwrap();
514        assert!(notes.exists(), "a released tree keeps its files");
515        assert_eq!(trees.live().unwrap(), vec![name("node-1")]);
516
517        let again = trees.claim(&name("node-1"), &[]).unwrap();
518        // The same directory, spelled two ways: git answers with the long
519        // name, and a Windows runner's temp directory hands out its 8.3 one.
520        assert_eq!(
521            std::fs::canonicalize(again.path()).unwrap(),
522            std::fs::canonicalize(&path).unwrap()
523        );
524        assert_eq!(
525            std::fs::metadata(&notes).unwrap().modified().unwrap(),
526            written,
527            "a file the node's branch already holds is not written again"
528        );
529        assert!(dir.path().join("lab").join("notes.md").exists());
530    }
531
532    /// A kept tree whose node has nothing unmerged follows the trunk: a
533    /// node that starts from where the city was when it last ran would do
534    /// its next work on files somebody has since changed, and every merge
535    /// of it would be refused as stale.
536    #[test]
537    fn a_kept_tree_with_nothing_unmerged_starts_from_the_trunk_as_it_now_stands() {
538        let dir = tempfile::tempdir().unwrap();
539        let trees = city(dir.path());
540        trees
541            .release(trees.claim(&name("node-1"), &[]).unwrap())
542            .unwrap();
543        std::fs::write(
544            dir.path().join("lab").join("notes.md"),
545            b"second
546    ",
547        )
548        .unwrap();
549        Checkpoint::open(dir.path())
550            .unwrap()
551            .land(&[], TimeMs::new(2_000), &owner(), "checkpoint: lab")
552            .unwrap();
553
554        let again = trees.claim(&name("node-1"), &[]).unwrap();
555        assert_eq!(
556            std::fs::read_to_string(again.path().join("lab").join("notes.md")).unwrap(),
557            "second
558    "
559        );
560    }
561}