Skip to main content

storage/worktree/
back.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//! Going back to a point, and taking one file back from a point.
7//!
8//! `crates/storage/spec/Worktree/Back.lean` holds which properties these two
9//! must keep (`crates/storage/spec/Worktree/Back.lean` ยง8-27): a tree opened at a point holds that
10//! point's files, a live name or an existing line of work is refused
11//! rather than replaced, and neither step touches the trunk or another
12//! run's tree.
13
14use std::path::{Component, Path};
15
16use kernel::GitOid;
17use kernel::event::record::FileRestored;
18
19use crate::alias::WriteTarget;
20use crate::bundle::landing::{Bits, land};
21use crate::error::StorageError;
22use crate::real_fs::RealFs;
23
24use super::lease::WorktreeLease;
25use super::name::WorktreeName;
26use super::trees::Worktrees;
27
28impl Worktrees {
29    /// Opens a new tree whose branch starts at `point`. The trunk and the
30    /// city working tree do not move: another run may be writing, and
31    /// the trunk is where its work lands.
32    ///
33    /// # Errors
34    /// `WorktreeBusy` when the name holds a live tree or a branch, or the
35    /// city working tree is over the ceiling; `Worktree` when `point` is
36    /// not a commit of the city.
37    pub fn claim_at(
38        &self,
39        name: &WorktreeName,
40        point: &GitOid,
41    ) -> Result<WorktreeLease, StorageError> {
42        let busy = |detail: &str| StorageError::WorktreeBusy {
43            name: name.as_str().to_owned(),
44            detail: detail.to_owned(),
45        };
46        if self.live()?.contains(name) {
47            return Err(busy("a live tree holds this name"));
48        }
49        let city = self.refuse_oversized(name.as_str())?;
50        let commit = self.commit_at(point)?;
51        // Never forced: an existing branch is a line of work, and
52        // replacing it would drop somebody's writes.
53        let branch = self
54            .repo
55            .branch(name.as_str(), &commit, false)
56            .map_err(|err| {
57                if err.code() == git2::ErrorCode::Exists {
58                    busy("a line of work already has this name")
59                } else {
60                    StorageError::Worktree {
61                        op: "branch at the point to go back to",
62                        detail: format!("{point}: {err}"),
63                    }
64                }
65            })?;
66        match self.add_tree(name, Some(branch.get()), &city) {
67            Ok(lease) => Ok(lease),
68            Err(refusal) => {
69                // The branch was made for this tree alone; left behind, it
70                // would hold the name as a line of work nobody started.
71                let mut branch = branch;
72                branch.delete().map_err(|err| StorageError::Worktree {
73                    op: "remove the branch of a tree that did not open",
74                    detail: format!("{}: {err}; the tree failed with: {refusal}", name.as_str()),
75                })?;
76                Err(refusal)
77            }
78        }
79    }
80
81    /// Takes `path` back from `point` into the tree `lease` holds: the
82    /// point's bytes when it holds the file, and no file when it does
83    /// not. No other tree and not the trunk are touched. Returns the
84    /// `file_restored` record of this step for the caller to append.
85    ///
86    /// # Errors
87    /// Refuses a path that is empty, absolute, climbs, or names the
88    /// reserved subtree; a point that is not a commit of the city; and a
89    /// path the point holds as something other than a file.
90    pub fn restore_file(
91        &self,
92        lease: &WorktreeLease,
93        point: &GitOid,
94        path: &Path,
95    ) -> Result<FileRestored, StorageError> {
96        let refuse = |detail: String| StorageError::Worktree {
97            op: "restore a file from a point",
98            detail,
99        };
100        let inside = in_tree(path).ok_or_else(|| {
101            refuse(format!(
102                "{} is not a plain path inside the tree",
103                path.display()
104            ))
105        })?;
106        let tree = self
107            .commit_at(point)?
108            .tree()
109            .map_err(|err| refuse(format!("{point}: {err}")))?;
110        let target = WriteTarget::within(
111            "restore a file from a point",
112            lease.path(),
113            &lease.path().join(path),
114        )?;
115        let io = |source: std::io::Error| StorageError::Io {
116            op: "restore a file from a point",
117            path: target.as_path().to_path_buf(),
118            source,
119        };
120        let restored = FileRestored {
121            name: lease.name().as_str().to_owned(),
122            path: inside,
123            point: *point,
124        };
125        let entry = match tree.get_path(Path::new(&restored.path)) {
126            Ok(entry) => entry,
127            Err(err) if err.code() == git2::ErrorCode::NotFound => {
128                return match std::fs::remove_file(target.as_path()) {
129                    Ok(()) => Ok(restored),
130                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(restored),
131                    Err(err) => Err(io(err)),
132                };
133            }
134            Err(err) => return Err(refuse(format!("{}: {err}", path.display()))),
135        };
136        let object = entry
137            .to_object(&self.repo)
138            .map_err(|err| refuse(format!("{}: {err}", path.display())))?;
139        let blob = object
140            .as_blob()
141            .ok_or_else(|| refuse(format!("{} is not a file at {point}", path.display())))?;
142        if let Some(parent) = target.as_path().parent() {
143            std::fs::create_dir_all(parent).map_err(io)?;
144        }
145        land(&mut RealFs::new(), target, blob.content(), Bits::OfReplaced)?;
146        Ok(restored)
147    }
148
149    fn commit_at(&self, point: &GitOid) -> Result<git2::Commit<'_>, StorageError> {
150        let missing = |err: git2::Error| StorageError::Worktree {
151            op: "find the point to go back to",
152            detail: format!("{point}: {err}"),
153        };
154        let oid = git2::Oid::from_str(&point.to_string()).map_err(missing)?;
155        self.repo.find_commit(oid).map_err(missing)
156    }
157}
158
159/// `path` in the spelling a git tree uses, or `None` when it is empty,
160/// leaves the tree, names the reserved subtree, or holds a segment Win32
161/// folds onto another name (a trailing `.` or space, a `:` stream
162/// suffix, or a `~` and a digit, the shape of an 8.3 short name), because
163/// `.git.` and, where the volume makes short names, `GIT~1` there name
164/// the tree's `.git` link.
165fn in_tree(path: &Path) -> Option<String> {
166    let segments = path
167        .components()
168        .map(|component| match component {
169            Component::Normal(segment) => segment.to_str().filter(|segment| {
170                !segment.ends_with(['.', ' '])
171                    && !segment.contains(':')
172                    && !segment
173                        .split('~')
174                        .skip(1)
175                        .any(|after| after.starts_with(|c: char| c.is_ascii_digit()))
176            }),
177            Component::Prefix(_)
178            | Component::RootDir
179            | Component::CurDir
180            | Component::ParentDir => None,
181        })
182        .collect::<Option<Vec<_>>>()?;
183    (!segments.is_empty() && crate::reserved::outside_reserved(path)).then(|| segments.join("/"))
184}
185
186#[cfg(test)]
187#[allow(
188    clippy::unwrap_used,
189    clippy::expect_used,
190    clippy::panic,
191    clippy::indexing_slicing,
192    reason = "test code"
193)]
194mod tests;