objects/worktree/
worktree_reserved.rs1use std::path::{Component, Path};
17
18use crate::{
19 error::HeddleError,
20 object::{ReservedPathComponent, reserved_path_component, reserved_tree_entry_name},
21};
22
23#[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
36pub 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
43pub 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#[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}