use crate::config_types::ProxyConfig;
use crate::daemon_id::DaemonId;
use crate::pitchfork_toml::{PitchforkToml, PitchforkTomlDaemon};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const MAX_LABEL_LEN: usize = 63;
const MAX_HOSTNAME_LEN: usize = 253;
pub fn hostname_fits(host: &str) -> bool {
let s = crate::settings::settings();
host.len() + 1 + crate::proxy::effective_tld(&s).len() <= MAX_HOSTNAME_LEN
}
pub fn sanitize_label(input: &str) -> Option<String> {
let mut out = String::with_capacity(input.len());
for c in input.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
let trimmed = if trimmed.len() > MAX_LABEL_LEN {
trimmed[..MAX_LABEL_LEN].trim_end_matches('-')
} else {
trimmed
};
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkout {
pub primary: PathBuf,
pub worktree: Option<PathBuf>,
}
impl Checkout {
pub fn root(&self) -> &Path {
self.worktree.as_deref().unwrap_or(&self.primary)
}
}
fn parse_gitdir_pointer(dir: &Path, content: &str) -> Option<PathBuf> {
let target = content
.lines()
.find_map(|line| line.trim().strip_prefix("gitdir:"))?
.trim();
if target.is_empty() {
return None;
}
let path = PathBuf::from(target);
let path = if path.is_absolute() {
path
} else {
dir.join(path)
};
Some(normalize(&path))
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
part => out.push(part.as_os_str()),
}
}
out
}
fn canonicalize_best_effort(path: &Path) -> PathBuf {
let path = normalize(path);
let mut suffix: Vec<std::ffi::OsString> = Vec::new();
let mut current = path.as_path();
loop {
if let Ok(resolved) = current.canonicalize() {
let mut out = dunce::simplified(&resolved).to_path_buf();
for part in suffix.iter().rev() {
out.push(part);
}
return out;
}
let Some(parent) = current.parent() else {
return path;
};
match current.file_name() {
Some(name) => suffix.push(name.to_os_string()),
None => return path,
}
current = parent;
}
}
fn primary_from_worktree_gitdir(gitdir: &Path) -> Option<PathBuf> {
let worktrees_dir = gitdir.parent()?;
if worktrees_dir.file_name()? != "worktrees" {
return None;
}
let common = worktrees_dir.parent()?;
if common.file_name()? != ".git" {
return None;
}
common.parent().map(canonicalize_best_effort)
}
pub fn checkout_root_of(dir: &Path) -> PathBuf {
let checkout = detect_checkout(dir);
checkout.root().to_path_buf()
}
pub fn detect_checkout(dir: &Path) -> Checkout {
let start = canonicalize_best_effort(dir);
for current in start.ancestors() {
let git = current.join(".git");
if git.is_dir() {
return Checkout {
primary: current.to_path_buf(),
worktree: None,
};
}
if git.is_file() {
let primary = std::fs::read_to_string(&git)
.ok()
.and_then(|content| parse_gitdir_pointer(current, &content))
.and_then(|gitdir| primary_from_worktree_gitdir(&gitdir));
return match primary {
Some(primary) if primary != current => Checkout {
primary,
worktree: Some(current.to_path_buf()),
},
_ => Checkout {
primary: current.to_path_buf(),
worktree: None,
},
};
}
}
Checkout {
primary: start,
worktree: None,
}
}
pub fn project_label(primary: &Path) -> Option<String> {
let explicit = PitchforkToml::project_namespace_override(primary)
.ok()
.flatten()
.or_else(|| crate::extra_configs::namespace_for_dir(primary));
match explicit {
Some(ns) => sanitize_label(&ns),
None => sanitize_label(&primary.file_name()?.to_string_lossy()),
}
}
pub fn worktree_label(worktree: &Path) -> Option<String> {
if let Some(label) = PitchforkToml::project_worktree_label(worktree) {
return sanitize_label(&label);
}
sanitize_label(&worktree.file_name()?.to_string_lossy())
}
pub fn daemon_label(name: &str, proxy: Option<&ProxyConfig>) -> Option<String> {
if proxy.is_some_and(ProxyConfig::is_disabled) {
return None;
}
match proxy.and_then(ProxyConfig::label) {
Some(label) => sanitize_label(label),
None => sanitize_label(name),
}
}
fn join_labels(daemon: &str, worktree: Option<&str>, project: &str) -> String {
match worktree {
Some(wt) => format!("{daemon}.{wt}.{project}"),
None => format!("{daemon}.{project}"),
}
}
pub fn auto_host_for_daemon(id: &DaemonId, config: &PitchforkTomlDaemon) -> Option<String> {
config.port.as_ref()?;
let daemon = daemon_label(id.name(), config.proxy.as_ref())?;
let path = config.path.as_deref()?;
if crate::pitchfork_toml::is_global_config(path) {
return None;
}
let base = crate::pitchfork_toml::project_dir_for_config(path)?;
let checkout = detect_checkout(&base);
let project_label = project_label(&checkout.primary)?;
if project_label_is_ambiguous(&project_label, &checkout.primary) {
return None;
}
let project = project_hosts_for(&checkout.primary, &project_label)?;
let (hosts, worktree) = match &checkout.worktree {
Some(dir) => {
let label = worktree_label(dir)?;
(project.worktrees.get(&label)?, Some(label))
}
None => (&project.primary, None),
};
if hosts.daemons.get(&daemon).map(String::as_str) != Some(id.name()) {
return None;
}
let host = join_labels(&daemon, worktree.as_deref(), &project_label);
if !hostname_fits(&host) {
log::warn!(
"'{host}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS \
limit, so no hostname is assigned. Shorten the project, worktree or daemon name, \
or use a shorter proxy.tld."
);
return None;
}
Some(host)
}
pub fn host_for_daemon(
id: &DaemonId,
config: Option<&PitchforkTomlDaemon>,
global_slugs: &indexmap::IndexMap<String, crate::pitchfork_toml::SlugEntry>,
) -> Option<String> {
if let Some(slug) = PitchforkToml::find_slug_for_daemon_in_registry(id, global_slugs) {
if hostname_fits(&slug) {
return Some(slug);
}
log::warn!(
"Slug '{slug}' plus the configured proxy.tld is over the \
{MAX_HOSTNAME_LEN}-byte DNS limit, so it is not advertised for {id}."
);
}
auto_host_for_daemon(id, config?)
}
#[derive(Debug, Clone)]
pub struct CheckoutHosts {
pub dir: PathBuf,
pub namespace: String,
pub daemons: HashMap<String, String>,
}
impl CheckoutHosts {
fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
let pt = PitchforkToml::all_merged_from(dir).ok()?;
let mut daemons: HashMap<String, String> = HashMap::new();
let mut errors = Vec::new();
let mut colliding: Vec<String> = Vec::new();
for (id, config) in &pt.daemons {
if id.namespace() != namespace || config.port.is_none() {
continue;
}
let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
continue;
};
match daemons.get(&label) {
Some(other) => {
errors.push(format!(
"daemon hostname label '{label}' in {} is claimed by both '{other}' and \
'{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
dir.display(),
name = id.name(),
));
colliding.push(label);
}
None => {
daemons.insert(label, id.name().to_string());
}
}
}
for label in colliding {
daemons.remove(&label);
}
Some((
Self {
dir: dir.to_path_buf(),
namespace,
daemons,
},
errors,
))
}
pub fn labels(&self) -> Vec<String> {
let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
labels.sort();
labels
}
}
#[derive(Debug, Clone)]
pub struct ProjectHosts {
pub label: String,
pub primary: CheckoutHosts,
pub worktrees: HashMap<String, CheckoutHosts>,
}
impl ProjectHosts {
pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
std::iter::once(&self.primary).chain(self.worktrees.values())
}
pub fn worktree_labels(&self) -> Vec<String> {
let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
labels.sort();
labels
}
}
#[derive(Debug, Clone, Default)]
pub struct HostRegistry {
pub projects: HashMap<String, ProjectHosts>,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostTarget {
Daemon {
project: String,
worktree: Option<String>,
dir: PathBuf,
namespace: String,
daemon: String,
},
ProjectPage { project: String },
WorktreePage { project: String, worktree: String },
UnknownProject { known: Vec<String> },
UnknownDaemon {
project: String,
worktree: Option<String>,
known: Vec<String>,
},
}
fn group_worktrees(
project: &str,
found: Vec<(String, CheckoutHosts)>,
) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
let mut errors = Vec::new();
let mut colliding: Vec<String> = Vec::new();
for (label, hosts) in found {
match kept.get(&label) {
Some(existing) if existing.dir == hosts.dir => continue,
Some(existing) => {
errors.push(format!(
"worktree label '{label}' in project '{project}' is claimed by two \
directories: {} and {}. Set `worktree_label` in one of them.",
existing.dir.display(),
hosts.dir.display(),
));
colliding.push(label);
}
None => {
kept.insert(label, hosts);
}
}
}
for label in colliding {
kept.remove(&label);
}
(kept, errors)
}
const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
struct WorktreeCache {
entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
}
static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
once_cell::sync::Lazy::new(|| {
std::sync::Mutex::new(WorktreeCache {
entries: HashMap::new(),
})
});
fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
let now = std::time::Instant::now();
{
let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((expires_at, dirs)) = cache.entries.get(primary)
&& now < *expires_at
{
return std::sync::Arc::clone(dirs);
}
}
let dirs = std::sync::Arc::new(worktree_dirs(primary));
let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
cache.entries.insert(
primary.to_path_buf(),
(now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
);
dirs
}
pub fn warn_once(message: &str) {
static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
if seen.len() > 256 {
seen.clear();
}
if seen.insert(message.to_string()) {
log::warn!("{message}");
}
}
fn candidate_dirs() -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
for (_, entry) in PitchforkToml::read_global_namespaces() {
candidates.push(entry.dir);
}
for (_, entry) in PitchforkToml::read_global_slugs() {
if let Some(dir) = entry.resolve_dir() {
candidates.push(dir);
}
}
if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
for daemon in state.daemons.values() {
if let Some(dir) = &daemon.dir {
candidates.push(dir.clone());
}
}
}
candidates
}
fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
let mut seen: Vec<PathBuf> = Vec::new();
for dir in candidate_dirs() {
if !dir.exists() {
continue;
}
let other = detect_checkout(&dir).primary;
if other == primary || seen.contains(&other) {
continue;
}
seen.push(other.clone());
if project_label(&other).as_deref() == Some(label) {
return true;
}
}
false
}
fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
return vec![];
};
entries
.filter_map(|entry| {
let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
let git_file = PathBuf::from(gitdir.trim());
git_file.parent().map(Path::to_path_buf)
})
.collect()
}
fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
if !crate::settings::settings().general.worktree {
return vec![];
}
let mut dirs: Vec<PathBuf> = Vec::new();
let found = crate::proxy::worktree::discover_worktrees(primary)
.into_iter()
.map(|entry| entry.path)
.chain(linked_worktree_dirs(primary));
for path in found {
let Some(wt_dir) = detect_checkout(&path).worktree else {
continue; };
if !dirs.contains(&wt_dir) {
dirs.push(wt_dir);
}
}
dirs
}
fn build_project_hosts(
primary: &Path,
label: &str,
worktrees: &[PathBuf],
) -> Option<(ProjectHosts, Vec<String>)> {
let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
let mut project = ProjectHosts {
label: label.to_string(),
primary: primary_hosts,
worktrees: HashMap::new(),
};
let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
for wt_dir in worktrees {
let (Some(wt_label), Some((hosts, wt_errors))) =
(worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
else {
continue;
};
errors.extend(wt_errors);
found.push((wt_label, hosts));
}
let (worktrees, wt_errors) = group_worktrees(label, found);
project.worktrees = worktrees;
errors.extend(wt_errors);
Some((project, errors))
}
fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
let worktrees = cached_worktree_dirs(primary);
let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
for err in errors {
warn_once(&err);
}
Some(project)
}
impl HostRegistry {
pub fn build() -> Self {
Self::from_dirs(&candidate_dirs())
}
pub fn from_dirs(dirs: &[PathBuf]) -> Self {
let mut primaries: Vec<PathBuf> = Vec::new();
for dir in dirs {
if !dir.exists() {
continue;
}
let primary = detect_checkout(dir).primary;
if !primaries.contains(&primary) {
primaries.push(primary);
}
}
let mut registry = Self::default();
let mut colliding: Vec<String> = Vec::new();
for primary in primaries {
let Some(label) = project_label(&primary) else {
continue;
};
if let Some(existing) = registry.projects.get(&label) {
if existing.primary.dir != primary {
registry.errors.push(format!(
"project label '{label}' is claimed by two directories: {} and {}. \
Set a distinct top-level `namespace` in one of them.",
existing.primary.dir.display(),
primary.display(),
));
colliding.push(label);
}
continue;
}
let worktrees = worktree_dirs(&primary);
let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
continue;
};
registry.errors.extend(errors);
if project.checkouts().all(|c| c.daemons.is_empty()) {
continue;
}
registry.projects.insert(label, project);
}
for label in colliding {
registry.projects.remove(&label);
}
registry
}
pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
self.projects
.values()
.flat_map(ProjectHosts::checkouts)
.filter(|c| c.namespace == namespace && c.daemons.values().any(|n| n == daemon))
.count()
> 1
}
pub fn project_labels(&self) -> Vec<String> {
let mut labels: Vec<String> = self.projects.keys().cloned().collect();
labels.sort();
labels
}
pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
let labels: Vec<String> = subdomain
.split('.')
.map(|l| l.to_ascii_lowercase())
.collect();
let Some((project_label, rest)) = labels.split_last() else {
return HostTarget::UnknownProject {
known: self.project_labels(),
};
};
let Some(project) = self.projects.get(project_label) else {
return HostTarget::UnknownProject {
known: self.project_labels(),
};
};
if rest.is_empty() {
return HostTarget::ProjectPage {
project: project.label.clone(),
};
}
let (checkout, worktree, rest) = match rest.split_last() {
Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
None => (&project.primary, None, rest),
},
None => (&project.primary, None, rest),
};
let Some((daemon_label, extra)) = rest.split_last() else {
return HostTarget::WorktreePage {
project: project.label.clone(),
worktree: worktree.unwrap_or_default(),
};
};
if !extra.is_empty() && !wildcard {
return HostTarget::UnknownDaemon {
project: project.label.clone(),
worktree,
known: checkout.labels(),
};
}
match checkout.daemons.get(daemon_label) {
Some(name) => HostTarget::Daemon {
project: project.label.clone(),
worktree,
dir: checkout.dir.clone(),
namespace: checkout.namespace.clone(),
daemon: name.clone(),
},
None => HostTarget::UnknownDaemon {
project: project.label.clone(),
worktree,
known: checkout.labels(),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn canonical(path: &Path) -> PathBuf {
dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
}
fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
CheckoutHosts {
dir: PathBuf::from(dir),
namespace: namespace.to_string(),
daemons: daemons
.iter()
.map(|(l, n)| (l.to_string(), n.to_string()))
.collect(),
}
}
#[test]
fn test_sanitize_label() {
assert_eq!(sanitize_label("api").as_deref(), Some("api"));
assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
assert_eq!(
sanitize_label("feature/my_branch").as_deref(),
Some("feature-my-branch")
);
assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
assert_eq!(sanitize_label("---"), None);
assert_eq!(sanitize_label(""), None);
assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
}
#[test]
fn test_daemon_label_override_and_opt_out() {
assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
assert_eq!(
daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
Some("api")
);
assert_eq!(
daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
Some("web-ui")
);
assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
}
#[test]
fn test_detect_checkout_primary() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("my-repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
let found = detect_checkout(&repo.join("sub/dir"));
assert_eq!(found.primary, canonical(&repo));
assert_eq!(found.worktree, None);
}
#[test]
fn test_detect_checkout_linked_worktree() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("my-repo");
std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
let wt = temp.path().join("fix-1");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(
wt.join(".git"),
format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
)
.unwrap();
let found = detect_checkout(&wt);
assert_eq!(found.primary, canonical(&repo));
assert_eq!(found.worktree, Some(canonical(&wt)));
assert_eq!(found.root(), canonical(&wt));
}
#[test]
fn test_checkout_root_of_missing_directory() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("my-repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
assert_eq!(
checkout_root_of(&repo.join("gone/deeper")),
canonical(&repo)
);
}
#[cfg(unix)]
#[test]
fn test_detect_checkout_primary_spelling_matches_through_symlink() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("my-repo");
let admin = repo.join(".git/worktrees/fix-1");
std::fs::create_dir_all(&admin).unwrap();
let wt = temp.path().join("fix-1");
std::fs::create_dir_all(&wt).unwrap();
let alias = temp.path().join("alias");
symlink(&repo, &alias).unwrap();
std::fs::write(
wt.join(".git"),
format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
)
.unwrap();
assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
}
#[test]
fn test_detect_checkout_bare_repository_worktree() {
let temp = tempfile::tempdir().unwrap();
let bare = temp.path().join("my-repo.git");
let admin = bare.join("worktrees/fix-1");
std::fs::create_dir_all(&admin).unwrap();
let wt = temp.path().join("fix-1");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
let found = detect_checkout(&wt);
assert_eq!(found.primary, canonical(&wt));
assert_eq!(found.worktree, None);
}
#[test]
fn test_auto_host_skips_global_config_daemons() {
let config = PitchforkTomlDaemon {
run: "server".to_string(),
port: Some(crate::config_types::PortConfig {
expect: vec![3000],
..Default::default()
}),
path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
..PitchforkTomlDaemon::default()
};
let id = DaemonId::try_new("global", "api").unwrap();
assert_eq!(auto_host_for_daemon(&id, &config), None);
}
#[test]
fn test_host_for_daemon_prefers_a_registered_slug() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("slug-repo");
std::fs::create_dir_all(&repo).unwrap();
write_config(&repo, &[("api", "")]);
let (id, config) = daemon_config(&repo, "api");
let empty = indexmap::IndexMap::new();
assert_eq!(
host_for_daemon(&id, Some(&config), &empty).as_deref(),
Some("api.slug-repo")
);
let mut slugs = indexmap::IndexMap::new();
slugs.insert(
"myapp".to_string(),
crate::pitchfork_toml::SlugEntry {
dir: Some(repo.clone()),
namespace: Some(id.namespace().to_string()),
daemon: Some("api".to_string()),
},
);
assert_eq!(
host_for_daemon(&id, Some(&config), &slugs).as_deref(),
Some("myapp")
);
}
#[test]
fn test_detect_checkout_submodule_pointer() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("my-repo");
std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
let sub = repo.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
let found = detect_checkout(&sub);
assert_eq!(found.primary, canonical(&sub));
assert_eq!(found.worktree, None);
}
#[test]
fn test_detect_checkout_without_git() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path().join("plain");
std::fs::create_dir_all(&dir).unwrap();
let found = detect_checkout(&dir);
assert_eq!(found.primary, canonical(&dir));
assert_eq!(found.worktree, None);
}
fn registry() -> HostRegistry {
let mut projects = HashMap::new();
let mut worktrees = HashMap::new();
worktrees.insert(
"fix-1".to_string(),
checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
);
projects.insert(
"myproj".to_string(),
ProjectHosts {
label: "myproj".to_string(),
primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
worktrees,
},
);
HostRegistry {
projects,
errors: vec![],
}
}
#[test]
fn test_resolve_primary_checkout_daemon() {
let target = registry().resolve("api.myproj", true);
assert_eq!(
target,
HostTarget::Daemon {
project: "myproj".into(),
worktree: None,
dir: PathBuf::from("/repos/myproj"),
namespace: "myproj".into(),
daemon: "api".into(),
}
);
}
#[test]
fn test_resolve_worktree_daemon() {
let target = registry().resolve("web.fix-1.myproj", true);
assert_eq!(
target,
HostTarget::Daemon {
project: "myproj".into(),
worktree: Some("fix-1".into()),
dir: PathBuf::from("/repos/fix-1"),
namespace: "fix-1".into(),
daemon: "web".into(),
}
);
}
#[test]
fn test_resolve_is_case_insensitive() {
assert!(matches!(
registry().resolve("API.MyProj", true),
HostTarget::Daemon { .. }
));
}
#[test]
fn test_resolve_reserved_pages() {
assert_eq!(
registry().resolve("myproj", true),
HostTarget::ProjectPage {
project: "myproj".into()
}
);
assert_eq!(
registry().resolve("fix-1.myproj", true),
HostTarget::WorktreePage {
project: "myproj".into(),
worktree: "fix-1".into(),
}
);
}
#[test]
fn test_resolve_worktree_beats_daemon_of_same_name() {
let mut reg = registry();
reg.projects.get_mut("myproj").unwrap().worktrees.insert(
"api".to_string(),
checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
);
assert_eq!(
reg.resolve("api.myproj", true),
HostTarget::WorktreePage {
project: "myproj".into(),
worktree: "api".into(),
}
);
assert!(matches!(
reg.resolve("api.api.myproj", true),
HostTarget::Daemon { .. }
));
}
#[test]
fn test_resolve_wildcard_subdomain() {
let target = registry().resolve("tenant.api.myproj", true);
assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
assert!(matches!(
registry().resolve("tenant.api.myproj", false),
HostTarget::UnknownDaemon { .. }
));
}
#[test]
fn test_resolve_unknown_names() {
assert_eq!(
registry().resolve("api.other", true),
HostTarget::UnknownProject {
known: vec!["myproj".to_string()]
}
);
assert_eq!(
registry().resolve("nope.myproj", true),
HostTarget::UnknownDaemon {
project: "myproj".into(),
worktree: None,
known: vec!["api".to_string()],
}
);
}
fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
let repo = temp.join(name);
let admin = repo.join(format!(".git/worktrees/{worktree}"));
std::fs::create_dir_all(&admin).unwrap();
let wt = temp.join(worktree);
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
std::fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.unwrap();
(repo, wt)
}
#[test]
fn test_project_label_from_directory_name() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("My App");
std::fs::create_dir_all(&repo).unwrap();
assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
}
#[test]
fn test_project_label_from_explicit_namespace() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("checkout-dir");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
}
fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
let body: String = daemons
.iter()
.enumerate()
.map(|(i, (name, extra))| {
format!(
"[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
3000 + i
)
})
.collect();
std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
}
fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
let pt = PitchforkToml::all_merged_from(dir).unwrap();
let (id, config) = pt
.daemons
.iter()
.find(|(id, _)| id.name() == name)
.unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
(id.clone(), config.clone())
}
#[test]
fn test_auto_host_primary_and_worktree() {
let temp = tempfile::tempdir().unwrap();
let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
write_config(&repo, &[("api", "")]);
write_config(&wt, &[("api", "")]);
let (id, config) = daemon_config(&repo, "api");
assert_eq!(
auto_host_for_daemon(&id, &config).as_deref(),
Some("api.my-repo")
);
let (wt_id, wt_config) = daemon_config(&wt, "api");
assert_eq!(
auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
Some("api.fix-1.my-repo")
);
}
#[test]
fn test_worktree_label_override() {
let temp = tempfile::tempdir().unwrap();
let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
assert_eq!(
worktree_label(&wt).as_deref(),
Some("sleepy-kapitsa-9f02fc")
);
std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
}
#[test]
fn test_auto_host_requires_port_and_opt_in() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("opt-in-repo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(
repo.join("pitchfork.toml"),
"[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
[daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
[daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
[daemons.worker]\nrun = \"server\"\n",
)
.unwrap();
let host = |name: &str| {
let (id, config) = daemon_config(&repo, name);
auto_host_for_daemon(&id, &config)
};
assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
assert_eq!(host("admin"), None);
assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
assert_eq!(host("worker"), None);
}
#[test]
fn test_daemon_label_collision_drops_both() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("dupe-repo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(
repo.join("pitchfork.toml"),
"[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
[daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
[daemons.other]\nrun = \"server\"\nport = 3002\n",
)
.unwrap();
let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
assert_eq!(hosts.labels(), vec!["other".to_string()]);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
for name in ["foo_bar", "foo-bar"] {
let (id, config) = daemon_config(&repo, name);
assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
}
let (id, config) = daemon_config(&repo, "other");
assert_eq!(
auto_host_for_daemon(&id, &config).as_deref(),
Some("other.dupe-repo")
);
}
#[test]
fn test_hostname_fits() {
assert!(hostname_fits(&format!(
"{}.{}.{}",
"a".repeat(63),
"b".repeat(63),
"c".repeat(63)
)));
assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
}
#[test]
fn test_auto_host_none_for_colliding_worktrees() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("wt-repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
write_config(&repo, &[("api", "")]);
let mut worktrees = Vec::new();
for dir_name in ["fix-1", "fix.1"] {
let admin = repo.join(format!(".git/worktrees/{dir_name}"));
std::fs::create_dir_all(&admin).unwrap();
let wt = temp.path().join(dir_name);
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
std::fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.unwrap();
write_config(&wt, &[("api", "")]);
worktrees.push(wt);
}
for wt in &worktrees {
let (id, config) = daemon_config(wt, "api");
assert_eq!(
auto_host_for_daemon(&id, &config),
None,
"{} must not advertise a hostname",
wt.display()
);
}
let (id, config) = daemon_config(&repo, "api");
assert_eq!(
auto_host_for_daemon(&id, &config).as_deref(),
Some("api.wt-repo")
);
}
#[test]
fn test_shares_daemon_id() {
let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
(
label.to_string(),
ProjectHosts {
label: label.to_string(),
primary,
worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
},
)
};
let mut registry = HostRegistry {
projects: HashMap::from([project(
"myproj",
checkout("/repos/myproj", "myproj", &[("api", "api")]),
vec![(
"fix-1",
checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
)],
)]),
errors: vec![],
};
assert!(!registry.shares_daemon_id("myproj", "api"));
assert!(!registry.shares_daemon_id("fix-1", "api"));
registry
.projects
.get_mut("myproj")
.unwrap()
.worktrees
.insert(
"fix-2".to_string(),
checkout("/repos/fix-2", "myproj", &[("api", "api")]),
);
assert!(registry.shares_daemon_id("myproj", "api"));
let (label, other) = project(
"other",
checkout("/repos/other", "other", &[("api", "api")]),
vec![(
"fix-1",
checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
)],
);
registry.projects.insert(label, other);
assert!(registry.shares_daemon_id("fix-1", "api"));
assert!(!registry.shares_daemon_id("myproj", "worker"));
}
#[test]
fn test_worktree_label_collision_drops_both() {
let found = vec![
(
"fix-1".to_string(),
checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
),
(
"fix-1".to_string(),
checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
),
(
"fix-2".to_string(),
checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
),
];
let (kept, errors) = group_worktrees("myproj", found);
assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
}
#[test]
fn test_project_label_collision_routes_neither() {
let temp = tempfile::tempdir().unwrap();
let a = temp.path().join("a/shop");
let b = temp.path().join("b/shop");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
write_config(&a, &[("api", "")]);
write_config(&b, &[("api", "")]);
let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
assert!(registry.project_labels().is_empty());
assert_eq!(registry.errors.len(), 1);
assert!(
registry.errors[0].contains("shop"),
"{}",
registry.errors[0]
);
assert!(
registry.errors[0].contains(&canonical(&b).display().to_string()),
"{}",
registry.errors[0]
);
assert_eq!(
registry.resolve("api.shop", true),
HostTarget::UnknownProject { known: vec![] }
);
}
}