Skip to main content

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}