use std::path::{Path, PathBuf};
use crate::error::WorktreeError;
pub fn validate_branch_component(s: &str) -> Result<(), WorktreeError> {
match s.chars().next() {
None | Some('-' | '.') => {
return Err(WorktreeError::InvalidBranchName(s.to_string()));
}
Some(_) => {}
}
if s.contains("..") || s.contains('/') {
return Err(WorktreeError::InvalidBranchName(s.to_string()));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(WorktreeError::InvalidBranchName(s.to_string()));
}
Ok(())
}
pub fn canonicalize_root(root: &Path, repo_root: &Path) -> Result<PathBuf, WorktreeError> {
let candidate = if root.is_relative() {
repo_root.join(root)
} else {
root.to_path_buf()
};
let canonical_repo = std::fs::canonicalize(repo_root)?;
let existing_ancestor = nearest_existing_ancestor(&candidate);
let canonical_ancestor = std::fs::canonicalize(&existing_ancestor)?;
if !canonical_ancestor.starts_with(&canonical_repo) {
let suffix = candidate
.strip_prefix(&existing_ancestor)
.unwrap_or_else(|_| Path::new(""));
return Err(WorktreeError::RootOutsideRepo(
canonical_ancestor.join(suffix),
));
}
std::fs::create_dir_all(&candidate)?;
let canonical_root = std::fs::canonicalize(&candidate)?;
if !canonical_root.starts_with(&canonical_repo) {
return Err(WorktreeError::RootOutsideRepo(canonical_root));
}
Ok(canonical_root)
}
fn nearest_existing_ancestor(path: &Path) -> PathBuf {
let mut current = path;
loop {
if current.exists() {
return current.to_path_buf();
}
match current.parent() {
Some(parent) if !parent.as_os_str().is_empty() => current = parent,
_ => return current.to_path_buf(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[test]
fn valid_simple() {
assert!(validate_branch_component("agent-42").is_ok());
assert!(validate_branch_component("feat.work").is_ok());
assert!(validate_branch_component("A_B_C").is_ok());
assert!(validate_branch_component("abc123").is_ok());
}
#[test]
fn rejects_empty() {
assert!(validate_branch_component("").is_err());
}
#[test]
fn rejects_leading_dash() {
assert!(validate_branch_component("-bad").is_err());
}
#[test]
fn rejects_leading_dot() {
assert!(validate_branch_component(".git").is_err());
}
#[test]
fn rejects_double_dot() {
assert!(validate_branch_component("a..b").is_err());
assert!(validate_branch_component("../escape").is_err());
}
#[test]
fn rejects_slash() {
assert!(validate_branch_component("a/b").is_err());
}
#[test]
fn rejects_special_chars() {
assert!(validate_branch_component("ag@nt").is_err());
assert!(validate_branch_component("ag nt").is_err());
assert!(validate_branch_component("ag:nt").is_err());
}
#[test]
fn root_inside_repo_is_ok() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
let canonical_repo = std::fs::canonicalize(repo).unwrap();
let result = canonicalize_root(std::path::Path::new("worktrees"), repo);
assert!(result.is_ok(), "expected Ok, got: {result:?}");
assert!(result.unwrap().starts_with(&canonical_repo));
}
#[test]
fn root_outside_repo_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("inner");
std::fs::create_dir_all(&repo).unwrap();
let parent = dir.path().to_path_buf();
let err = canonicalize_root(&parent, &repo).unwrap_err();
assert_matches!(err, WorktreeError::RootOutsideRepo(_));
}
#[test]
fn absolute_root_inside_repo_is_ok() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
let sub = repo.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let result = canonicalize_root(&sub, repo);
assert!(result.is_ok());
}
#[test]
fn root_outside_repo_rejected_before_any_directory_created() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("inner");
std::fs::create_dir_all(&repo).unwrap();
let escaping_candidate = dir.path().join("escaped/nested/deep");
let err = canonicalize_root(&escaping_candidate, &repo).unwrap_err();
assert_matches!(err, WorktreeError::RootOutsideRepo(_));
assert!(
!dir.path().join("escaped").exists(),
"containment check must reject before create_dir_all mutates the filesystem"
);
}
}