Skip to main content

storage/worktree/
trees.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//! Worktrees: claim, merge, release.
7
8use std::path::{Path, PathBuf};
9
10use kernel::{ByteLen, consts_policy::WORKTREE_MAX_BYTES};
11
12use crate::error::StorageError;
13
14use super::landing::{CheckoutRun, Landing, PlannedMerge, check_out};
15use super::lease::{FileWork, WorktreeLease};
16use super::name::WorktreeName;
17use super::weight::{Weight, Written, measure, written};
18use kept::Standing;
19
20mod kept;
21mod stock;
22
23/// Where the trees live: inside the reserved subtree, because they are
24/// the city's own machinery rather than anybody's writable space. What a
25/// run may write is judged against the tree it works in.
26pub(super) const WORKTREE_DIR: &str = "worktrees";
27
28/// The city's trees.
29pub struct Worktrees {
30    pub(super) repo: git2::Repository,
31    pub(super) home: PathBuf,
32    /// Where the person placed the city; the alias check stops here.
33    pub(super) city_root: PathBuf,
34    ceiling: ByteLen,
35}
36
37// Hand-written because a git repository handle has no Debug: what a
38// reader of a failure needs is where the trees go and what they may
39// cost, not the handle.
40impl std::fmt::Debug for Worktrees {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("Worktrees")
43            .field("home", &self.home)
44            .field("ceiling", &self.ceiling)
45            .finish()
46    }
47}
48
49impl Worktrees {
50    /// Opens the city's repository as the source every tree branches
51    /// from.
52    ///
53    /// # Errors
54    /// Refuses a city with no repository. Initialising one here would
55    /// make two modules able to create the city's history.
56    pub fn open(city_root: &Path) -> Result<Worktrees, StorageError> {
57        let repo = open_city(city_root).map_err(|err| StorageError::Worktree {
58            op: "open the city repository",
59            detail: format!("{}: {err}", city_root.display()),
60        })?;
61        Ok(Worktrees::over(repo, city_root))
62    }
63
64    /// The trees of a repository already open at `city_root`.
65    pub(super) fn over(repo: git2::Repository, city_root: &Path) -> Worktrees {
66        Worktrees {
67            repo,
68            home: city_root.join(kernel::RESERVED_PREFIX).join(WORKTREE_DIR),
69            city_root: city_root.to_path_buf(),
70            ceiling: ByteLen::new(WORKTREE_MAX_BYTES),
71        }
72    }
73
74    /// Lends one node its tree: the one it was given before when that
75    /// tree is still on disk, reset to the node's branch, and a new one
76    /// otherwise.
77    ///
78    /// # Errors
79    /// Refuses a tree somebody holds, a city whose working tree exceeds
80    /// the ceiling, and a repository with no commit to branch from.
81    pub fn claim(
82        &self,
83        name: &WorktreeName,
84        scopes: &[String],
85    ) -> Result<WorktreeLease, StorageError> {
86        match self.standing(name)? {
87            Standing::Held => Err(StorageError::WorktreeBusy {
88                name: name.as_str().to_owned(),
89                detail: "another node holds this tree".to_owned(),
90            }),
91            Standing::Kept(tree) => self.reattach(name, &tree, scopes),
92            Standing::Absent => self.place(name),
93        }
94    }
95
96    /// Places a new tree for `name`, locked from the moment it exists:
97    /// the stock taken over when there is one to take, and a checkout of
98    /// the whole tree otherwise (`crates/storage/spec/Worktree/Trees/Stock.lean` §8-35).
99    fn place(&self, name: &WorktreeName) -> Result<WorktreeLease, StorageError> {
100        let city = self.refuse_oversized(name.as_str())?;
101        if self.repo.head().is_err() {
102            return Err(StorageError::Worktree {
103                op: "branch a worktree",
104                detail: "the city has no checkpoint yet, and a tree branches from a commit"
105                    .to_owned(),
106            });
107        }
108        // `standing` found no registration, so a directory under this
109        // name is what a placement that failed halfway left behind.
110        self.clear_unregistered(name.as_str())?;
111        if let Some(lease) = self.adopt(name, &city)? {
112            return Ok(lease);
113        }
114        // A node that has held a tree before still has its branch: the
115        // tree is a materialization, the branch is the line of work.
116        // Reattaching is what makes releasing a tree cheap enough to do
117        // between sessions.
118        let branch = match self
119            .repo
120            .find_branch(name.as_str(), git2::BranchType::Local)
121        {
122            Ok(branch) => Some(branch),
123            Err(err) if err.code() == git2::ErrorCode::NotFound => None,
124            Err(err) => {
125                return Err(StorageError::Worktree {
126                    op: "find a node branch",
127                    detail: format!("{}: {err}", name.as_str()),
128                });
129            }
130        };
131        self.add_tree(name, branch.as_ref().map(git2::Branch::get), &city)
132    }
133
134    /// Refuses a tree before it exists when the city working tree it
135    /// copies is over the ceiling, and otherwise answers what that
136    /// working tree weighed. `id` names the tree in the refusal.
137    pub(super) fn refuse_oversized(&self, id: &str) -> Result<Weight, StorageError> {
138        let source = self.repo.workdir().ok_or_else(|| StorageError::Worktree {
139            op: "find the city working tree",
140            detail: "the repository is bare".to_owned(),
141        })?;
142        let city = measure(source)?;
143        if city.bytes.get() > self.ceiling.get() {
144            return Err(StorageError::WorktreeBusy {
145                name: id.to_owned(),
146                detail: format!(
147                    "the city working tree is {} bytes and the ceiling is {}",
148                    city.bytes.get(),
149                    self.ceiling.get()
150                ),
151            });
152        }
153        Ok(city)
154    }
155
156    /// Checks out the tree for `name` on `reference` when one is given
157    /// and on a new branch from the trunk otherwise. `city` is the walk
158    /// the ceiling was judged on, counted into the lease's work.
159    pub(super) fn add_tree(
160        &self,
161        name: &WorktreeName,
162        reference: Option<&git2::Reference<'_>>,
163        city: &Weight,
164    ) -> Result<WorktreeLease, StorageError> {
165        let (path, checked_out) = self.check_out_tree(name.as_str(), reference)?;
166        Ok(WorktreeLease {
167            name: name.clone(),
168            disk: checked_out.bytes,
169            path,
170            work: FileWork {
171                created: checked_out.files,
172                walked: city.walked,
173                ..FileWork::default()
174            },
175        })
176    }
177
178    /// Registers the tree `id` under the tree home, locked, and checks it
179    /// out whole; answers where it is and what the checkout wrote.
180    pub(super) fn check_out_tree(
181        &self,
182        id: &str,
183        reference: Option<&git2::Reference<'_>>,
184    ) -> Result<(PathBuf, Written), StorageError> {
185        std::fs::create_dir_all(&self.home).map_err(|source| StorageError::Io {
186            op: "create the worktree home",
187            path: self.home.clone(),
188            source,
189        })?;
190        let path = self.home.join(id);
191        // The checkout lands at this name, and a name that is an alias
192        // would write the whole tree through it (`crates/storage/spec/Alias.lean` §8-25).
193        crate::alias::WriteTarget::within("place a worktree", &self.city_root, &path)?;
194        let mut opts = git2::WorktreeAddOptions::new();
195        opts.lock(true);
196        opts.reference(reference);
197        let tree =
198            self.repo
199                .worktree(id, &path, Some(&opts))
200                .map_err(|err| StorageError::Worktree {
201                    op: "add a worktree",
202                    detail: format!("{id}: {err}"),
203                })?;
204        // The size is read from the index the checkout just wrote rather
205        // than by walking the tree it wrote (`crates/storage/spec/Worktree.lean` §8-31).
206        Ok((path, written(&tree)?))
207    }
208
209    /// Decides a merge without making it.
210    ///
211    /// Fast-forward only. A node whose trunk moved underneath it does
212    /// not get its work merged on top of somebody else's by a machine:
213    /// it rebuilds on the trunk as it now stands and is verified again.
214    /// This is the same stance the draft desk takes about a room that
215    /// moved, and for the same reason - the party who knows whether the
216    /// work is still right is the one who did it.
217    ///
218    /// Every refusal happens here, before anything moves, and the commit
219    /// the trunk will point at is already known - so a caller can write
220    /// the line that announces the merge before the merge exists, and
221    /// still never announce one that was going to be refused.
222    ///
223    /// # Errors
224    /// Refuses an unknown branch, a repository with no commit, and a
225    /// merge that is not a fast-forward.
226    pub fn plan_merge(&self, name: &WorktreeName) -> Result<PlannedMerge<'_>, StorageError> {
227        let refuse = |op: &'static str, detail: String| StorageError::Worktree { op, detail };
228        let branch = self
229            .repo
230            .find_branch(name.as_str(), git2::BranchType::Local)
231            .map_err(|err| refuse("find a node branch", format!("{}: {err}", name.as_str())))?;
232        let theirs = branch
233            .get()
234            .peel_to_commit()
235            .map_err(|err| refuse("read a node branch", err.to_string()))?;
236        let head = self
237            .repo
238            .head()
239            .map_err(|err| refuse("read the city trunk", err.to_string()))?;
240        let ours = head
241            .peel_to_commit()
242            .map_err(|err| refuse("read the city trunk", err.to_string()))?;
243        // A git failure here is not an answer to the ancestry question.
244        // Reading it as "not a descendant" would report a stale trunk
245        // to somebody whose trunk is fine, and send them to rebuild
246        // work that needed no rebuilding.
247        let descends = theirs.id() == ours.id()
248            || self
249                .repo
250                .graph_descendant_of(theirs.id(), ours.id())
251                .map_err(|err| refuse("judge a fast-forward", err.to_string()))?;
252        if !descends {
253            return Err(StorageError::MergeStale {
254                name: name.as_str().to_owned(),
255                detail: format!("the trunk moved to {} after this node branched", ours.id()),
256            });
257        }
258        let tree = theirs
259            .tree()
260            .map_err(|err| refuse("read the node tree", err.to_string()))?;
261        check_out(&self.repo, &tree, CheckoutRun::DryRun)?;
262        Ok(PlannedMerge {
263            trees: self,
264            target: theirs.id(),
265        })
266    }
267
268    /// Writes the merge commit [`Worktrees::plan_merge`] settled on: two
269    /// parents, the node's tree, and the merging run's trailers.
270    pub(super) fn land_merge(
271        &self,
272        target: git2::Oid,
273        landing: &Landing<'_>,
274    ) -> Result<(), StorageError> {
275        let refuse = |op: &'static str, detail: String| StorageError::Worktree { op, detail };
276        let head = self
277            .repo
278            .head()
279            .map_err(|err| refuse("read the city trunk", err.to_string()))?;
280        let ours = head
281            .peel_to_commit()
282            .map_err(|err| refuse("read the city trunk", err.to_string()))?;
283        let theirs = self
284            .repo
285            .find_commit(target)
286            .map_err(|err| refuse("read the node commit", err.to_string()))?;
287        // The node's tree: the trunk is an ancestor by the fast-forward
288        // judgement, so it has nothing the node does not carry.
289        let tree = theirs
290            .tree()
291            .map_err(|err| refuse("read the node tree", err.to_string()))?;
292        let seconds = i64::try_from(landing.t.value().saturating_div(1000)).unwrap_or(0);
293        let when = git2::Time::new(seconds, 0);
294        let email = landing.of.email();
295        let signature = git2::Signature::new(landing.of.actor().as_str(), &email, &when)
296            .map_err(|err| refuse("build the merge signature", err.to_string()))?;
297        let message = format!(
298            "{}\n\n{}{}",
299            landing.subject,
300            landing.of.trailers(),
301            landing.reviewer(&self.city_root)
302        );
303        let merge = self
304            .repo
305            .commit(
306                None,
307                &signature,
308                &signature,
309                &message,
310                &tree,
311                &[&ours, &theirs],
312            )
313            .map_err(|err| refuse("commit the merge", err.to_string()))?;
314        check_out(&self.repo, &tree, CheckoutRun::Write)?;
315        let trunk = head.name().unwrap_or("HEAD");
316        self.repo
317            .reference_matching(trunk, merge, true, ours.id(), landing.subject)
318            .map_err(|err| refuse("move the city trunk", err.to_string()))?;
319        Ok(())
320    }
321
322    /// Gives a tree back and keeps its files for the node's next run:
323    /// the lock is the lease, so lifting it is the whole release.
324    ///
325    /// # Errors
326    /// Propagates a repository that cannot find or unlock the tree.
327    pub fn release(&self, lease: WorktreeLease) -> Result<(), StorageError> {
328        let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
329            op,
330            detail: format!("{}: {err}", lease.name().as_str()),
331        };
332        self.repo
333            .find_worktree(lease.name().as_str())
334            .map_err(|err| refuse("find a worktree", err))?
335            .unlock()
336            .map_err(|err| refuse("unlock a worktree", err))
337    }
338
339    /// Every node's tree the repository knows about, sorted. The stock
340    /// is no node's tree, and is not among them.
341    ///
342    /// # Errors
343    /// Propagates a repository that cannot list its worktrees.
344    pub fn live(&self) -> Result<Vec<WorktreeName>, StorageError> {
345        let names = self
346            .repo
347            .worktrees()
348            .map_err(|err| StorageError::Worktree {
349                op: "list worktrees",
350                detail: err.to_string(),
351            })?;
352        let mut out = Vec::new();
353        // A name git cannot render as UTF-8 was not written by this
354        // module, and it is not a tree this city can address.
355        for name in names.iter().flatten().flatten() {
356            if name != stock::STOCK {
357                out.push(WorktreeName::parse(name)?);
358            }
359        }
360        out.sort();
361        Ok(out)
362    }
363}
364
365/// Opens the city's repository with only its own config file read.
366pub(super) fn open_city(city_root: &Path) -> Result<git2::Repository, git2::Error> {
367    own_config(git2::Repository::open(city_root)?)
368}
369
370/// Opens the repository of one of the city's trees the same way.
371pub(super) fn open_tree(tree: &git2::Worktree) -> Result<git2::Repository, git2::Error> {
372    own_config(git2::Repository::open_from_worktree(tree)?)
373}
374
375// A User's global, XDG or system file can vanish between libgit2's look and
376// its stat (`crates/storage/spec/Worktree.lean` §8-9), so no later call
377// reads them.
378fn own_config(repo: git2::Repository) -> Result<git2::Repository, git2::Error> {
379    let mut config = git2::Config::new()?;
380    config.add_file(
381        &repo.commondir().join("config"),
382        git2::ConfigLevel::Local,
383        false,
384    )?;
385    repo.set_config(&config)?;
386    Ok(repo)
387}
388
389#[cfg(test)]
390#[allow(
391    clippy::unwrap_used,
392    clippy::expect_used,
393    clippy::panic,
394    clippy::indexing_slicing,
395    clippy::arithmetic_side_effects,
396    reason = "test code"
397)]
398pub(crate) mod tests;