Skip to main content

objects/worktree/
worktree_reserved.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Reserved worktree paths that user ignore rules cannot un-ignore.
3//!
4//! Root `.heddle/` holds identity material (`identity.toml`), credentials,
5//! and repository-engine state. Pointer checkout also writes cursor files
6//! beside the `.heddle` file (`.heddle.identity`, `.heddle.last-turn`,
7//! `.identity.lock`, `.identity.tmp.*`, `.last-turn.tmp.*`). Gitignore
8//! last-match-wins would otherwise let `!.heddle/` pull that tree into
9//! capture. Nested `.heddle/` directories (fixtures) stay ordinary content.
10//!
11//! Metadata-directory aliases (heddle#2028) are reserved the same way:
12//! `.git` and its case, NTFS and HFS+ spellings at every depth, and those of
13//! `.heddle` at the root. Checkout never writes them, so capture must
14//! never record them; a nested `.git` is another repository, as in Git.
15
16use std::path::{Component, Path};
17
18use crate::{
19    error::HeddleError,
20    object::{ReservedPathComponent, reserved_path_component, reserved_tree_entry_name},
21};
22
23/// Whether `path` is a reserved worktree-root Heddle artifact, or passes
24/// through a metadata-directory alias (`.git` at any depth, `.heddle` at the
25/// root; see [`crate::object::reserved_tree_entry_name`]).
26///
27/// The Heddle artifacts are root-anchored only: `examples/calculator/.heddle/`
28/// is not reserved. Leading `./` is skipped so `./.heddle/identity.toml`
29/// matches.
30#[must_use]
31pub fn is_reserved_worktree_path(path: &Path) -> bool {
32    first_normal_component(path).is_some_and(is_reserved_root_name)
33        || reserved_path_component(path.as_os_str().as_encoded_bytes(), false).is_some()
34}
35
36/// The reserved component of a worktree write to `rel_path`, if any: a
37/// `.git` alias at any depth, a `.heddle` alias at the root, or a
38/// `.gitmodules` alias when `symlink` (heddle#2028).
39pub fn reserved_worktree_write(rel_path: &Path, symlink: bool) -> Option<ReservedPathComponent> {
40    reserved_path_component(rel_path.as_os_str().as_encoded_bytes(), symlink)
41}
42
43/// Refuse a worktree write whose worktree-relative path is reserved (see
44/// [`reserved_worktree_write`]). For commands that write one path the user
45/// named; whole-tree writers skip such paths with a warning instead.
46pub fn check_worktree_write_path(rel_path: &Path, symlink: bool) -> Result<(), HeddleError> {
47    match reserved_worktree_write(rel_path, symlink) {
48        Some(reason) => Err(HeddleError::ReservedWorktreePath {
49            path: rel_path.to_path_buf(),
50            reason,
51        }),
52        None => Ok(()),
53    }
54}
55
56/// Whether a directory child is reserved without allocating a joined path.
57///
58/// Used by the walker prune so root `.heddle` and pointer-cursor artifacts
59/// are skipped even if a matcher is later refactored.
60#[must_use]
61pub fn is_reserved_directory_child(parent: &Path, name: &str) -> bool {
62    if is_reserved_worktree_path(parent) {
63        return true;
64    }
65    let at_root = is_worktree_root(parent);
66    (at_root && is_reserved_root_name(std::ffi::OsStr::new(name)))
67        || reserved_tree_entry_name(name.as_bytes(), at_root, false).is_some()
68}
69
70fn is_reserved_root_name(name: &std::ffi::OsStr) -> bool {
71    let Some(name) = name.to_str() else {
72        return false;
73    };
74    name == ".heddle"
75        || name == ".heddle.identity"
76        || name == ".heddle.last-turn"
77        || name == ".identity.lock"
78        || name == ".identity.tmp"
79        || name.starts_with(".identity.tmp.")
80        || name.starts_with(".last-turn.tmp.")
81}
82
83fn is_worktree_root(path: &Path) -> bool {
84    path.as_os_str().is_empty() || path == Path::new(".")
85}
86
87fn first_normal_component(path: &Path) -> Option<&std::ffi::OsStr> {
88    for component in path.components() {
89        match component {
90            Component::CurDir => continue,
91            Component::Normal(name) => return Some(name),
92            Component::ParentDir | Component::Prefix(_) | Component::RootDir => return None,
93        }
94    }
95    None
96}
97
98#[cfg(test)]
99mod tests {
100    use std::path::Path;
101
102    use super::{
103        check_worktree_write_path, is_reserved_directory_child, is_reserved_worktree_path,
104    };
105
106    #[test]
107    fn reserves_root_heddle_tree_and_identity() {
108        for path in [
109            ".heddle",
110            ".heddle/identity.toml",
111            ".heddle/objects/pack",
112            ".heddle/info/exclude",
113            "./.heddle/identity.toml",
114        ] {
115            assert!(
116                is_reserved_worktree_path(Path::new(path)),
117                "expected reserved: {path}"
118            );
119        }
120    }
121
122    #[test]
123    fn reserves_pointer_checkout_cursor_artifacts() {
124        for path in [
125            ".heddle.identity",
126            "./.heddle.identity",
127            ".heddle.last-turn",
128            ".identity.lock",
129            ".identity.tmp.123.0",
130            ".identity.tmp",
131            ".last-turn.tmp.123.0",
132        ] {
133            assert!(
134                is_reserved_worktree_path(Path::new(path)),
135                "expected reserved: {path}"
136            );
137        }
138    }
139
140    #[test]
141    fn does_not_reserve_nested_or_unrelated_paths() {
142        for path in [
143            "",
144            ".",
145            "src/main.rs",
146            "heddle",
147            ".heddleignore",
148            "examples/calculator/.heddle/identity.toml",
149            "examples/calculator/.heddle",
150            "examples/foo/.heddle.identity",
151            "examples/foo/.heddle.last-turn",
152            "examples/foo/.identity.lock",
153            "src/.identity.tmp.1.2",
154            "../.heddle/identity.toml",
155        ] {
156            assert!(
157                !is_reserved_worktree_path(Path::new(path)),
158                "expected not reserved: {path}"
159            );
160        }
161    }
162
163    #[test]
164    fn directory_child_reserves_root_heddle_and_descendants() {
165        assert!(is_reserved_directory_child(Path::new(""), ".heddle"));
166        assert!(is_reserved_directory_child(Path::new("."), ".heddle"));
167        assert!(is_reserved_directory_child(
168            Path::new(".heddle"),
169            "identity.toml"
170        ));
171        assert!(is_reserved_directory_child(
172            Path::new(""),
173            ".heddle.identity"
174        ));
175        assert!(is_reserved_directory_child(
176            Path::new("."),
177            ".identity.lock"
178        ));
179        assert!(is_reserved_directory_child(
180            Path::new(""),
181            ".identity.tmp.9.1"
182        ));
183        assert!(!is_reserved_directory_child(Path::new(""), "src"));
184        assert!(!is_reserved_directory_child(
185            Path::new("examples/calculator"),
186            ".heddle"
187        ));
188        assert!(!is_reserved_directory_child(
189            Path::new("examples/foo"),
190            ".heddle.identity"
191        ));
192    }
193
194    #[test]
195    fn reserves_metadata_directory_aliases() {
196        for path in [
197            ".git",
198            ".git/hooks/pre-commit",
199            "a/.git/hooks/x",
200            "a/b/.GIT/config",
201            "src/GIT~1",
202            ".git::$INDEX_ALLOCATION/x",
203            ".HEDDLE/config.toml",
204            ".heddle./config.toml",
205            "HEDDLE~1/hooks/pre-capture",
206        ] {
207            assert!(
208                is_reserved_worktree_path(Path::new(path)),
209                "expected reserved: {path}"
210            );
211            assert!(
212                check_worktree_write_path(Path::new(path), false).is_err(),
213                "expected write refused: {path}"
214            );
215        }
216        assert!(is_reserved_directory_child(Path::new("vendor/lib"), ".git"));
217        assert!(check_worktree_write_path(Path::new("sub/.gitmodules"), true).is_err());
218        assert!(check_worktree_write_path(Path::new("sub/.gitmodules"), false).is_ok());
219        assert!(is_reserved_directory_child(Path::new(""), ".HEDDLE"));
220        assert!(!is_reserved_directory_child(
221            Path::new("examples"),
222            ".HEDDLE"
223        ));
224    }
225
226    #[test]
227    fn writes_to_ordinary_dotfiles_are_allowed() {
228        for path in [
229            ".github/workflows/ci.yml",
230            ".gitignore",
231            ".heddleignore",
232            "examples/calculator/.heddle/config.toml",
233            "src/main.rs",
234        ] {
235            assert!(
236                check_worktree_write_path(Path::new(path), false).is_ok(),
237                "expected write allowed: {path}"
238            );
239        }
240    }
241}