majordomus_cli/worktree/path.rs
1//! The one derivation: repository → container, and container + branch → worktree path.
2//!
3//! ```text
4//! container_root(<parent>/<repo>) = <parent>/<repo>-wt
5//! expected_path(<parent>/<repo>-wt, feature/x/y) = <parent>/<repo>-wt/feature/x/y
6//! ```
7//!
8//! A branch name is filesystem-derived input and is treated as such. [`BranchName`] carries
9//! git's own rules for a reference name — no `..`, no component starting with `.`, none
10//! ending in `.lock`, no control characters, none of `~ ^ : ? * [ \`, no `@{`, no leading
11//! `-` — so that every component of a valid name is an ordinary directory name and the join
12//! cannot leave the container. [`expected_path`] proves that anyway, lexically, on every
13//! call: a derivation that trusts its own validation is one refactor away from a traversal.
14
15use std::path::{Component, Path, PathBuf};
16
17use super::error::{Result, WorktreeError};
18
19/// What is appended to the primary checkout's directory name to name the container. The
20/// one place the convention is written; every path, document and message derives from it.
21pub const CONTAINER_SUFFIX: &str = "-wt";
22
23/// A branch name git would accept for `refs/heads/`, whose components are therefore safe
24/// directory names.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct BranchName(String);
27
28impl BranchName {
29 /// Validate a branch name by git's reference rules, offline.
30 ///
31 /// ```
32 /// use majordomus_cli::worktree::BranchName;
33 /// assert!(BranchName::parse("feature/improve-cli").is_ok());
34 /// assert!(BranchName::parse("feature/providers/openai-streaming").is_ok());
35 /// assert!(BranchName::parse("žluťoučký/kůň").is_ok());
36 /// for bad in ["", "/", "a/", "/a", "a//b", "a/../b", "..", ".hidden", "a/.b", "a.lock",
37 /// "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b", "a\\b", "a@{b", "-x",
38 /// "a.", "a/b.lock/c", "a\u{7f}b", "a\nb"] {
39 /// assert!(BranchName::parse(bad).is_err(), "{bad:?} should be refused");
40 /// }
41 /// ```
42 pub fn parse(name: &str) -> Result<Self> {
43 match validate(name) {
44 Ok(()) => Ok(BranchName(name.to_string())),
45 Err(reason) => Err(WorktreeError::InvalidBranchName {
46 given: name.to_string(),
47 reason,
48 }),
49 }
50 }
51
52 /// The name.
53 pub fn as_str(&self) -> &str {
54 &self.0
55 }
56
57 /// The relative path under the container: the name's components, one directory each.
58 ///
59 /// ```
60 /// use majordomus_cli::worktree::BranchName;
61 /// use std::path::PathBuf;
62 /// let b = BranchName::parse("fix/cockpit/reconnect").unwrap();
63 /// assert_eq!(b.relative_path(), PathBuf::from("fix/cockpit/reconnect"));
64 /// ```
65 pub fn relative_path(&self) -> PathBuf {
66 let mut p = PathBuf::new();
67 for c in self.0.split('/') {
68 p.push(c);
69 }
70 p
71 }
72
73 /// The components, in order.
74 pub fn components(&self) -> impl Iterator<Item = &str> {
75 self.0.split('/')
76 }
77}
78
79impl std::fmt::Display for BranchName {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.write_str(&self.0)
82 }
83}
84
85/// Why a branch name cannot be one, in one line; `Ok(())` when it can.
86///
87/// These are git's rules for a reference under `refs/heads/` (`git check-ref-format`),
88/// written here so that a path can be derived without a subprocess. Git stays the
89/// authority at the moment a branch is created, and the crate's tests hold the two
90/// implementations to the same verdict over generated names.
91pub fn validate(name: &str) -> std::result::Result<(), String> {
92 if name.is_empty() {
93 return Err("it is empty".into());
94 }
95 if name.starts_with('-') {
96 return Err("it begins with `-`, which git reads as an option".into());
97 }
98 if name.starts_with('/') {
99 return Err("it begins with `/`".into());
100 }
101 if name.ends_with('/') {
102 return Err("it ends with `/`".into());
103 }
104 if name.ends_with('.') {
105 return Err("it ends with `.`".into());
106 }
107 if name.contains("..") {
108 return Err("it contains `..`".into());
109 }
110 if name.contains("@{") {
111 return Err("it contains `@{`".into());
112 }
113 if name.contains("//") {
114 return Err("it contains an empty component (`//`)".into());
115 }
116 for ch in name.chars() {
117 if ch.is_ascii_control() {
118 return Err("it contains a control character".into());
119 }
120 if matches!(ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') {
121 return Err(format!(
122 "it contains `{ch}`, which git does not allow in a reference"
123 ));
124 }
125 }
126 for component in name.split('/') {
127 if component.starts_with('.') {
128 return Err(format!(
129 "component `{component}` begins with `.`; git refuses it, and it would be a hidden directory"
130 ));
131 }
132 if component.ends_with(".lock") {
133 return Err(format!("component `{component}` ends with `.lock`"));
134 }
135 }
136 Ok(())
137}
138
139/// The container of a repository whose primary checkout is `primary`: the checkout's
140/// sibling, named after it with [`CONTAINER_SUFFIX`] appended. Never read from anywhere,
141/// never configured.
142///
143/// ```
144/// use majordomus_cli::worktree::container_root;
145/// use std::path::{Path, PathBuf};
146/// assert_eq!(container_root(Path::new("/a/foo")).unwrap(), PathBuf::from("/a/foo-wt"));
147/// assert_eq!(container_root(Path::new("/src/acme/backend")).unwrap(), PathBuf::from("/src/acme/backend-wt"));
148/// assert_eq!(container_root(Path::new("/a/some.repo")).unwrap(), PathBuf::from("/a/some.repo-wt"));
149/// assert!(container_root(Path::new("/")).is_err());
150/// ```
151pub fn container_root(primary: &Path) -> Result<PathBuf> {
152 let name = primary
153 .file_name()
154 .map(|n| n.to_string_lossy().to_string())
155 .filter(|n| !n.is_empty());
156 let parent = primary
157 .parent()
158 .filter(|p| !p.as_os_str().is_empty())
159 .map(Path::to_path_buf);
160 match (parent, name) {
161 (Some(parent), Some(name)) => Ok(parent.join(format!("{name}{CONTAINER_SUFFIX}"))),
162 _ => Err(WorktreeError::NoParentDirectory {
163 primary: primary.to_path_buf(),
164 }),
165 }
166}
167
168/// Where the worktree of `branch` belongs: the branch's components under the container.
169///
170/// Proved on every call, lexically and without touching the filesystem, to be strictly
171/// below the container: every component of the result after the container's own is a
172/// normal component, so the path cannot escape, cannot be the container itself, and cannot
173/// carry `.` or `..`.
174///
175/// ```
176/// use majordomus_cli::worktree::{expected_path, BranchName};
177/// use std::path::{Path, PathBuf};
178/// let root = Path::new("/a/foo-wt");
179/// let b = BranchName::parse("feature/providers/openai-streaming").unwrap();
180/// assert_eq!(expected_path(root, &b).unwrap(), PathBuf::from("/a/foo-wt/feature/providers/openai-streaming"));
181/// ```
182pub fn expected_path(container: &Path, branch: &BranchName) -> Result<PathBuf> {
183 let path = container.join(branch.relative_path());
184 let escape = || WorktreeError::PathEscape {
185 branch: branch.as_str().to_string(),
186 path: path.clone(),
187 root: container.to_path_buf(),
188 };
189 if !path.starts_with(container) || path == container {
190 return Err(escape());
191 }
192 let extra = path.strip_prefix(container).map_err(|_| escape())?;
193 if extra.as_os_str().is_empty()
194 || !extra
195 .components()
196 .all(|c| matches!(c, Component::Normal(_)))
197 {
198 return Err(escape());
199 }
200 Ok(path)
201}
202
203/// The label a detached worktree is shown under. Display only: a detached HEAD has no
204/// branch and therefore no canonical path, and nothing ever moves a worktree under this
205/// name.
206///
207/// ```
208/// use majordomus_cli::worktree::detached_label;
209/// assert_eq!(detached_label("6df5eaf11efa4ccdcbca8f1990763f749256390e"), "detached/6df5eaf11efa");
210/// assert_eq!(detached_label("abc"), "detached/abc");
211/// ```
212pub fn detached_label(head: &str) -> String {
213 format!("detached/{}", &head[..12.min(head.len())])
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn hierarchy_is_preserved_never_flattened() {
222 let root = Path::new("/a/foo-wt");
223 for (branch, rel) in [
224 ("feature/foo", "feature/foo"),
225 ("feature/foo/bar", "feature/foo/bar"),
226 ("fix/test", "fix/test"),
227 ("refactor/a/b/c", "refactor/a/b/c"),
228 ("main", "main"),
229 ] {
230 let b = BranchName::parse(branch).unwrap();
231 assert_eq!(expected_path(root, &b).unwrap(), root.join(rel));
232 }
233 }
234
235 #[test]
236 fn the_container_is_the_sibling_whatever_the_parent() {
237 assert_eq!(
238 container_root(Path::new("/srv/dev/prismatic-majordomus")).unwrap(),
239 PathBuf::from("/srv/dev/prismatic-majordomus-wt")
240 );
241 assert_eq!(
242 container_root(Path::new("/a/b/c/d")).unwrap(),
243 PathBuf::from("/a/b/c/d-wt")
244 );
245 }
246
247 #[test]
248 fn a_traversal_cannot_be_a_branch_name_and_cannot_escape() {
249 for bad in ["../x", "a/../../etc", "..", "./x", "a/./b"] {
250 assert!(
251 BranchName::parse(bad).is_err(),
252 "{bad} parsed as a branch name"
253 );
254 }
255 }
256
257 #[test]
258 fn a_derived_path_is_always_strictly_below_the_container() {
259 let root = Path::new("/a/foo-wt");
260 let b = BranchName::parse("x").unwrap();
261 let p = expected_path(root, &b).unwrap();
262 assert!(p.starts_with(root) && p != root);
263 assert_eq!(p.parent(), Some(root));
264 }
265
266 #[test]
267 fn unicode_components_are_ordinary_directory_names() {
268 let root = Path::new("/a/foo-wt");
269 let b = BranchName::parse("feature/žluťoučký-kůň").unwrap();
270 assert_eq!(
271 expected_path(root, &b).unwrap(),
272 PathBuf::from("/a/foo-wt/feature/žluťoučký-kůň")
273 );
274 }
275}