1use crate::config_types::ProxyConfig;
19use crate::daemon_id::DaemonId;
20use crate::pitchfork_toml::{PitchforkToml, PitchforkTomlDaemon};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24const MAX_LABEL_LEN: usize = 63;
26
27pub(crate) const MAX_HOSTNAME_LEN: usize = 253;
30
31pub fn hostname_fits(host: &str) -> bool {
38 let s = crate::settings::settings();
39 host.len() + 1 + crate::proxy::effective_tld(&s).len() <= MAX_HOSTNAME_LEN
40}
41
42pub fn sanitize_label(input: &str) -> Option<String> {
49 let mut out = String::with_capacity(input.len());
50 for c in input.chars() {
51 if c.is_ascii_alphanumeric() {
52 out.push(c.to_ascii_lowercase());
53 } else if !out.ends_with('-') {
54 out.push('-');
55 }
56 }
57 let trimmed = out.trim_matches('-');
58 let trimmed = if trimmed.len() > MAX_LABEL_LEN {
59 trimmed[..MAX_LABEL_LEN].trim_end_matches('-')
60 } else {
61 trimmed
62 };
63 (!trimmed.is_empty()).then(|| trimmed.to_string())
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Checkout {
71 pub primary: PathBuf,
73 pub worktree: Option<PathBuf>,
75}
76
77impl Checkout {
78 pub fn root(&self) -> &Path {
80 self.worktree.as_deref().unwrap_or(&self.primary)
81 }
82}
83
84fn parse_gitdir_pointer(dir: &Path, content: &str) -> Option<PathBuf> {
89 let target = content
90 .lines()
91 .find_map(|line| line.trim().strip_prefix("gitdir:"))?
92 .trim();
93 if target.is_empty() {
94 return None;
95 }
96 let path = PathBuf::from(target);
97 let path = if path.is_absolute() {
98 path
99 } else {
100 dir.join(path)
101 };
102 Some(normalize(&path))
103}
104
105fn normalize(path: &Path) -> PathBuf {
108 let mut out = PathBuf::new();
109 for part in path.components() {
110 match part {
111 std::path::Component::CurDir => {}
112 std::path::Component::ParentDir => {
113 out.pop();
114 }
115 part => out.push(part.as_os_str()),
116 }
117 }
118 out
119}
120
121fn canonicalize_best_effort(path: &Path) -> PathBuf {
130 let path = normalize(path);
131 let mut suffix: Vec<std::ffi::OsString> = Vec::new();
132 let mut current = path.as_path();
133 loop {
134 if let Ok(resolved) = current.canonicalize() {
135 let mut out = dunce::simplified(&resolved).to_path_buf();
136 for part in suffix.iter().rev() {
137 out.push(part);
138 }
139 return out;
140 }
141 let Some(parent) = current.parent() else {
142 return path;
143 };
144 match current.file_name() {
145 Some(name) => suffix.push(name.to_os_string()),
146 None => return path,
148 }
149 current = parent;
150 }
151}
152
153fn primary_from_worktree_gitdir(gitdir: &Path) -> Option<PathBuf> {
159 let worktrees_dir = gitdir.parent()?;
160 if worktrees_dir.file_name()? != "worktrees" {
161 return None;
162 }
163 let common = worktrees_dir.parent()?;
164 if common.file_name()? != ".git" {
169 return None;
170 }
171 common.parent().map(canonicalize_best_effort)
174}
175
176pub fn checkout_root_of(dir: &Path) -> PathBuf {
183 let checkout = detect_checkout(dir);
184 checkout.root().to_path_buf()
185}
186
187pub fn detect_checkout(dir: &Path) -> Checkout {
195 let start = canonicalize_best_effort(dir);
196 for current in start.ancestors() {
197 let git = current.join(".git");
198 if git.is_dir() {
199 return Checkout {
200 primary: current.to_path_buf(),
201 worktree: None,
202 };
203 }
204 if git.is_file() {
205 let primary = std::fs::read_to_string(&git)
206 .ok()
207 .and_then(|content| parse_gitdir_pointer(current, &content))
208 .and_then(|gitdir| primary_from_worktree_gitdir(&gitdir));
209 return match primary {
210 Some(primary) if primary != current => Checkout {
211 primary,
212 worktree: Some(current.to_path_buf()),
213 },
214 _ => Checkout {
217 primary: current.to_path_buf(),
218 worktree: None,
219 },
220 };
221 }
222 }
223 Checkout {
224 primary: start,
225 worktree: None,
226 }
227}
228
229pub fn project_label(primary: &Path) -> Option<String> {
236 let explicit = PitchforkToml::project_namespace_override(primary)
237 .ok()
238 .flatten()
239 .or_else(|| crate::extra_configs::namespace_for_dir(primary));
240 match explicit {
241 Some(ns) => sanitize_label(&ns),
242 None => sanitize_label(&primary.file_name()?.to_string_lossy()),
243 }
244}
245
246pub fn worktree_label(worktree: &Path) -> Option<String> {
251 if let Some(label) = PitchforkToml::project_worktree_label(worktree) {
252 return sanitize_label(&label);
253 }
254 sanitize_label(&worktree.file_name()?.to_string_lossy())
255}
256
257pub fn daemon_label(name: &str, proxy: Option<&ProxyConfig>) -> Option<String> {
259 if proxy.is_some_and(ProxyConfig::is_disabled) {
260 return None;
261 }
262 match proxy.and_then(ProxyConfig::label) {
263 Some(label) => sanitize_label(label),
264 None => sanitize_label(name),
265 }
266}
267
268fn join_labels(daemon: &str, worktree: Option<&str>, project: &str) -> String {
270 match worktree {
271 Some(wt) => format!("{daemon}.{wt}.{project}"),
272 None => format!("{daemon}.{project}"),
273 }
274}
275
276pub fn auto_host_for_daemon(id: &DaemonId, config: &PitchforkTomlDaemon) -> Option<String> {
284 config.port.as_ref()?;
285 let daemon = daemon_label(id.name(), config.proxy.as_ref())?;
286 let path = config.path.as_deref()?;
287 if crate::pitchfork_toml::is_global_config(path) {
291 return None;
292 }
293 let base = crate::pitchfork_toml::project_dir_for_config(path)?;
294 let checkout = detect_checkout(&base);
295 let project_label = project_label(&checkout.primary)?;
296 if project_label_is_ambiguous(&project_label, &checkout.primary) {
297 return None;
298 }
299
300 let project = project_hosts_for(&checkout.primary, &project_label)?;
301 let (hosts, worktree) = match &checkout.worktree {
302 Some(dir) => {
303 let label = worktree_label(dir)?;
306 (project.worktrees.get(&label)?, Some(label))
307 }
308 None => (&project.primary, None),
309 };
310 if hosts.daemons.get(&daemon).map(|d| d.name.as_str()) != Some(id.name()) {
312 return None;
313 }
314
315 let host = join_labels(&daemon, worktree.as_deref(), &project_label);
316 if !hostname_fits(&host) {
317 log::warn!(
318 "'{host}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS \
319 limit, so no hostname is assigned. Shorten the project, worktree or daemon name, \
320 or use a shorter proxy.tld."
321 );
322 return None;
323 }
324 Some(host)
325}
326
327pub fn host_for_daemon(
333 id: &DaemonId,
334 config: Option<&PitchforkTomlDaemon>,
335 global_slugs: &indexmap::IndexMap<String, crate::pitchfork_toml::SlugEntry>,
336) -> Option<String> {
337 if let Some(slug) = PitchforkToml::find_slug_for_daemon_in_registry(id, global_slugs) {
341 if hostname_fits(&slug) {
342 return Some(slug);
343 }
344 log::warn!(
345 "Slug '{slug}' plus the configured proxy.tld is over the \
346 {MAX_HOSTNAME_LEN}-byte DNS limit, so it is not advertised for {id}."
347 );
348 }
349 auto_host_for_daemon(id, config?)
350}
351
352#[derive(Debug, Clone)]
356pub struct CheckoutHosts {
357 pub dir: PathBuf,
359 pub namespace: String,
361 pub daemons: HashMap<String, DaemonHost>,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct DaemonHost {
375 pub name: String,
377 pub proxy_tls: Option<crate::config_types::ProxyTlsMode>,
379 pub proxy_tls_port: Option<u16>,
381}
382
383impl CheckoutHosts {
384 fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
391 let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
392 let pt = PitchforkToml::all_merged_from(dir).ok()?;
393 let mut daemons: HashMap<String, DaemonHost> = HashMap::new();
394 let mut errors = Vec::new();
395 let mut colliding: Vec<String> = Vec::new();
396 for (id, config) in &pt.daemons {
397 if id.namespace() != namespace || config.port.is_none() {
398 continue;
399 }
400 let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
401 continue;
402 };
403 match daemons.get(&label) {
404 Some(other) => {
405 errors.push(format!(
406 "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
407 '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
408 dir.display(),
409 other = other.name,
410 name = id.name(),
411 ));
412 colliding.push(label);
413 }
414 None => {
415 daemons.insert(
416 label,
417 DaemonHost {
418 name: id.name().to_string(),
419 proxy_tls: config.proxy_tls,
420 proxy_tls_port: config.effective_proxy_tls_port(),
421 },
422 );
423 }
424 }
425 }
426 for label in colliding {
427 daemons.remove(&label);
428 }
429 Some((
430 Self {
431 dir: dir.to_path_buf(),
432 namespace,
433 daemons,
434 },
435 errors,
436 ))
437 }
438
439 pub fn labels(&self) -> Vec<String> {
441 let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
442 labels.sort();
443 labels
444 }
445}
446
447#[derive(Debug, Clone)]
449pub struct ProjectHosts {
450 pub label: String,
451 pub primary: CheckoutHosts,
452 pub worktrees: HashMap<String, CheckoutHosts>,
454}
455
456impl ProjectHosts {
457 pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
459 std::iter::once(&self.primary).chain(self.worktrees.values())
460 }
461
462 pub fn worktree_labels(&self) -> Vec<String> {
464 let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
465 labels.sort();
466 labels
467 }
468}
469
470#[derive(Debug, Clone, Default)]
472pub struct HostRegistry {
473 pub projects: HashMap<String, ProjectHosts>,
474 pub errors: Vec<String>,
476}
477
478#[derive(Debug, Clone, PartialEq, Eq)]
480pub enum HostTarget {
481 Daemon {
483 project: String,
484 worktree: Option<String>,
485 dir: PathBuf,
486 namespace: String,
487 daemon: String,
488 proxy_tls: Option<crate::config_types::ProxyTlsMode>,
491 proxy_tls_port: Option<u16>,
493 },
494 ProjectPage { project: String },
496 WorktreePage { project: String, worktree: String },
498 UnknownProject { known: Vec<String> },
500 UnknownDaemon {
502 project: String,
503 worktree: Option<String>,
504 known: Vec<String>,
505 },
506}
507
508fn group_worktrees(
514 project: &str,
515 found: Vec<(String, CheckoutHosts)>,
516) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
517 let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
518 let mut errors = Vec::new();
519 let mut colliding: Vec<String> = Vec::new();
520 for (label, hosts) in found {
521 match kept.get(&label) {
522 Some(existing) if existing.dir == hosts.dir => continue,
523 Some(existing) => {
524 errors.push(format!(
525 "worktree label '{label}' in project '{project}' is claimed by two \
526 directories: {} and {}. Set `worktree_label` in one of them.",
527 existing.dir.display(),
528 hosts.dir.display(),
529 ));
530 colliding.push(label);
531 }
532 None => {
533 kept.insert(label, hosts);
534 }
535 }
536 }
537 for label in colliding {
538 kept.remove(&label);
539 }
540 (kept, errors)
541}
542
543const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
551
552struct WorktreeCache {
553 entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
554}
555
556static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
557 once_cell::sync::Lazy::new(|| {
558 std::sync::Mutex::new(WorktreeCache {
559 entries: HashMap::new(),
560 })
561 });
562
563fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
565 let now = std::time::Instant::now();
566 {
567 let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
568 if let Some((expires_at, dirs)) = cache.entries.get(primary)
569 && now < *expires_at
570 {
571 return std::sync::Arc::clone(dirs);
572 }
573 } let dirs = std::sync::Arc::new(worktree_dirs(primary));
576
577 let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
578 cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
579 cache.entries.insert(
580 primary.to_path_buf(),
581 (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
582 );
583 dirs
584}
585
586pub fn warn_once(message: &str) {
593 static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
594 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
595
596 let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
597 if seen.len() > 256 {
600 seen.clear();
601 }
602 if seen.insert(message.to_string()) {
603 log::warn!("{message}");
604 }
605}
606
607fn candidate_dirs() -> Vec<PathBuf> {
615 let mut candidates: Vec<PathBuf> = Vec::new();
616 for (_, entry) in PitchforkToml::read_global_namespaces() {
617 candidates.push(entry.dir);
618 }
619 for (_, entry) in PitchforkToml::read_global_slugs() {
620 if let Some(dir) = entry.resolve_dir() {
621 candidates.push(dir);
622 }
623 }
624 if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
625 for daemon in state.daemons.values() {
626 if let Some(dir) = &daemon.dir {
627 candidates.push(dir.clone());
628 }
629 }
630 }
631 candidates
632}
633
634fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
640 let mut seen: Vec<PathBuf> = Vec::new();
641 for dir in candidate_dirs() {
642 if !dir.exists() {
643 continue;
644 }
645 let other = detect_checkout(&dir).primary;
646 if other == primary || seen.contains(&other) {
647 continue;
648 }
649 seen.push(other.clone());
650 if project_label(&other).as_deref() == Some(label) {
651 return true;
652 }
653 }
654 false
655}
656
657fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
664 let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
665 return vec![];
666 };
667 entries
668 .filter_map(|entry| {
669 let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
670 let git_file = PathBuf::from(gitdir.trim());
671 git_file.parent().map(Path::to_path_buf)
672 })
673 .collect()
674}
675
676fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
681 if !crate::settings::settings().general.worktree {
682 return vec![];
683 }
684 let mut dirs: Vec<PathBuf> = Vec::new();
685 let found = crate::proxy::worktree::discover_worktrees(primary)
686 .into_iter()
687 .map(|entry| entry.path)
688 .chain(linked_worktree_dirs(primary));
689 for path in found {
690 let Some(wt_dir) = detect_checkout(&path).worktree else {
691 continue; };
693 if !dirs.contains(&wt_dir) {
694 dirs.push(wt_dir);
695 }
696 }
697 dirs
698}
699
700fn build_project_hosts(
706 primary: &Path,
707 label: &str,
708 worktrees: &[PathBuf],
709) -> Option<(ProjectHosts, Vec<String>)> {
710 let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
711 let mut project = ProjectHosts {
712 label: label.to_string(),
713 primary: primary_hosts,
714 worktrees: HashMap::new(),
715 };
716
717 let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
718 for wt_dir in worktrees {
719 let (Some(wt_label), Some((hosts, wt_errors))) =
720 (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
721 else {
722 continue;
723 };
724 errors.extend(wt_errors);
725 found.push((wt_label, hosts));
726 }
727 let (worktrees, wt_errors) = group_worktrees(label, found);
728 project.worktrees = worktrees;
729 errors.extend(wt_errors);
730
731 Some((project, errors))
732}
733
734fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
737 let worktrees = cached_worktree_dirs(primary);
738 let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
739 for err in errors {
740 warn_once(&err);
741 }
742 Some(project)
743}
744
745impl HostRegistry {
746 pub fn build() -> Self {
756 Self::from_dirs(&candidate_dirs())
757 }
758
759 pub fn from_dirs(dirs: &[PathBuf]) -> Self {
761 let mut primaries: Vec<PathBuf> = Vec::new();
763 for dir in dirs {
764 if !dir.exists() {
765 continue;
766 }
767 let primary = detect_checkout(dir).primary;
768 if !primaries.contains(&primary) {
769 primaries.push(primary);
770 }
771 }
772
773 let mut registry = Self::default();
774 let mut colliding: Vec<String> = Vec::new();
775 for primary in primaries {
776 let Some(label) = project_label(&primary) else {
777 continue;
778 };
779
780 if let Some(existing) = registry.projects.get(&label) {
781 if existing.primary.dir != primary {
782 registry.errors.push(format!(
783 "project label '{label}' is claimed by two directories: {} and {}. \
784 Set a distinct top-level `namespace` in one of them.",
785 existing.primary.dir.display(),
786 primary.display(),
787 ));
788 colliding.push(label);
792 }
793 continue;
794 }
795
796 let worktrees = worktree_dirs(&primary);
797 let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
798 continue;
799 };
800 registry.errors.extend(errors);
801 if project.checkouts().all(|c| c.daemons.is_empty()) {
805 continue;
806 }
807 registry.projects.insert(label, project);
808 }
809 for label in colliding {
810 registry.projects.remove(&label);
811 }
812
813 registry
814 }
815
816 pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
826 self.projects
827 .values()
828 .flat_map(ProjectHosts::checkouts)
829 .filter(|c| c.namespace == namespace && c.daemons.values().any(|d| d.name == daemon))
830 .count()
831 > 1
832 }
833
834 pub fn project_labels(&self) -> Vec<String> {
836 let mut labels: Vec<String> = self.projects.keys().cloned().collect();
837 labels.sort();
838 labels
839 }
840
841 pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
849 let labels: Vec<String> = subdomain
850 .split('.')
851 .map(|l| l.to_ascii_lowercase())
852 .collect();
853 let Some((project_label, rest)) = labels.split_last() else {
854 return HostTarget::UnknownProject {
855 known: self.project_labels(),
856 };
857 };
858 let Some(project) = self.projects.get(project_label) else {
859 return HostTarget::UnknownProject {
860 known: self.project_labels(),
861 };
862 };
863 if rest.is_empty() {
864 return HostTarget::ProjectPage {
865 project: project.label.clone(),
866 };
867 }
868
869 let (checkout, worktree, rest) = match rest.split_last() {
871 Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
872 Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
873 None => (&project.primary, None, rest),
874 },
875 None => (&project.primary, None, rest),
876 };
877
878 let Some((daemon_label, extra)) = rest.split_last() else {
879 return HostTarget::WorktreePage {
880 project: project.label.clone(),
881 worktree: worktree.unwrap_or_default(),
882 };
883 };
884 if !extra.is_empty() && !wildcard {
885 return HostTarget::UnknownDaemon {
886 project: project.label.clone(),
887 worktree,
888 known: checkout.labels(),
889 };
890 }
891
892 match checkout.daemons.get(daemon_label) {
893 Some(daemon) => HostTarget::Daemon {
894 project: project.label.clone(),
895 worktree,
896 dir: checkout.dir.clone(),
897 namespace: checkout.namespace.clone(),
898 daemon: daemon.name.clone(),
899 proxy_tls: daemon.proxy_tls,
900 proxy_tls_port: daemon.proxy_tls_port,
901 },
902 None => HostTarget::UnknownDaemon {
903 project: project.label.clone(),
904 worktree,
905 known: checkout.labels(),
906 },
907 }
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 fn canonical(path: &Path) -> PathBuf {
918 dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
919 }
920
921 fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
922 CheckoutHosts {
923 dir: PathBuf::from(dir),
924 namespace: namespace.to_string(),
925 daemons: daemons
926 .iter()
927 .map(|(l, n)| {
928 (
929 l.to_string(),
930 DaemonHost {
931 name: n.to_string(),
932 proxy_tls: None,
933 proxy_tls_port: None,
934 },
935 )
936 })
937 .collect(),
938 }
939 }
940
941 #[test]
942 fn test_sanitize_label() {
943 assert_eq!(sanitize_label("api").as_deref(), Some("api"));
944 assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
945 assert_eq!(
946 sanitize_label("feature/my_branch").as_deref(),
947 Some("feature-my-branch")
948 );
949 assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
950 assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
951 assert_eq!(sanitize_label("---"), None);
952 assert_eq!(sanitize_label(""), None);
953 assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
954 }
955
956 #[test]
957 fn test_daemon_label_override_and_opt_out() {
958 assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
959 assert_eq!(
960 daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
961 Some("api")
962 );
963 assert_eq!(
964 daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
965 Some("web-ui")
966 );
967 assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
968 }
969
970 #[test]
972 fn test_detect_checkout_primary() {
973 let temp = tempfile::tempdir().unwrap();
974 let repo = temp.path().join("my-repo");
975 std::fs::create_dir_all(repo.join(".git")).unwrap();
976 std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
977
978 let found = detect_checkout(&repo.join("sub/dir"));
979 assert_eq!(found.primary, canonical(&repo));
980 assert_eq!(found.worktree, None);
981 }
982
983 #[test]
985 fn test_detect_checkout_linked_worktree() {
986 let temp = tempfile::tempdir().unwrap();
987 let repo = temp.path().join("my-repo");
988 std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
989 let wt = temp.path().join("fix-1");
990 std::fs::create_dir_all(&wt).unwrap();
991 std::fs::write(
992 wt.join(".git"),
993 format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
994 )
995 .unwrap();
996
997 let found = detect_checkout(&wt);
998 assert_eq!(found.primary, canonical(&repo));
999 assert_eq!(found.worktree, Some(canonical(&wt)));
1000 assert_eq!(found.root(), canonical(&wt));
1001 }
1002
1003 #[test]
1006 fn test_checkout_root_of_missing_directory() {
1007 let temp = tempfile::tempdir().unwrap();
1008 let repo = temp.path().join("my-repo");
1009 std::fs::create_dir_all(repo.join(".git")).unwrap();
1010
1011 assert_eq!(
1012 checkout_root_of(&repo.join("gone/deeper")),
1013 canonical(&repo)
1014 );
1015 }
1016
1017 #[cfg(unix)]
1021 #[test]
1022 fn test_detect_checkout_primary_spelling_matches_through_symlink() {
1023 use std::os::unix::fs::symlink;
1024
1025 let temp = tempfile::tempdir().unwrap();
1026 let repo = temp.path().join("my-repo");
1027 let admin = repo.join(".git/worktrees/fix-1");
1028 std::fs::create_dir_all(&admin).unwrap();
1029 let wt = temp.path().join("fix-1");
1030 std::fs::create_dir_all(&wt).unwrap();
1031
1032 let alias = temp.path().join("alias");
1035 symlink(&repo, &alias).unwrap();
1036 std::fs::write(
1037 wt.join(".git"),
1038 format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
1039 )
1040 .unwrap();
1041
1042 assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1043 assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1044 }
1045
1046 #[test]
1050 fn test_detect_checkout_bare_repository_worktree() {
1051 let temp = tempfile::tempdir().unwrap();
1052 let bare = temp.path().join("my-repo.git");
1053 let admin = bare.join("worktrees/fix-1");
1054 std::fs::create_dir_all(&admin).unwrap();
1055 let wt = temp.path().join("fix-1");
1056 std::fs::create_dir_all(&wt).unwrap();
1057 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1058
1059 let found = detect_checkout(&wt);
1060 assert_eq!(found.primary, canonical(&wt));
1061 assert_eq!(found.worktree, None);
1062 }
1063
1064 #[test]
1067 fn test_auto_host_skips_global_config_daemons() {
1068 let config = PitchforkTomlDaemon {
1069 run: "server".to_string(),
1070 port: Some(crate::config_types::PortConfig {
1071 expect: vec![3000],
1072 ..Default::default()
1073 }),
1074 path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1075 ..PitchforkTomlDaemon::default()
1076 };
1077 let id = DaemonId::try_new("global", "api").unwrap();
1078 assert_eq!(auto_host_for_daemon(&id, &config), None);
1079 }
1080
1081 #[test]
1084 fn test_host_for_daemon_prefers_a_registered_slug() {
1085 let temp = tempfile::tempdir().unwrap();
1086 let repo = temp.path().join("slug-repo");
1087 std::fs::create_dir_all(&repo).unwrap();
1088 write_config(&repo, &[("api", "")]);
1089 let (id, config) = daemon_config(&repo, "api");
1090
1091 let empty = indexmap::IndexMap::new();
1092 assert_eq!(
1093 host_for_daemon(&id, Some(&config), &empty).as_deref(),
1094 Some("api.slug-repo")
1095 );
1096
1097 let mut slugs = indexmap::IndexMap::new();
1099 slugs.insert(
1100 "myapp".to_string(),
1101 crate::pitchfork_toml::SlugEntry {
1102 dir: Some(repo.clone()),
1103 namespace: Some(id.namespace().to_string()),
1104 daemon: Some("api".to_string()),
1105 },
1106 );
1107 assert_eq!(
1108 host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1109 Some("myapp")
1110 );
1111 }
1112
1113 #[test]
1116 fn test_detect_checkout_submodule_pointer() {
1117 let temp = tempfile::tempdir().unwrap();
1118 let repo = temp.path().join("my-repo");
1119 std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1120 let sub = repo.join("sub");
1121 std::fs::create_dir_all(&sub).unwrap();
1122 std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1123
1124 let found = detect_checkout(&sub);
1125 assert_eq!(found.primary, canonical(&sub));
1126 assert_eq!(found.worktree, None);
1127 }
1128
1129 #[test]
1131 fn test_detect_checkout_without_git() {
1132 let temp = tempfile::tempdir().unwrap();
1133 let dir = temp.path().join("plain");
1134 std::fs::create_dir_all(&dir).unwrap();
1135
1136 let found = detect_checkout(&dir);
1137 assert_eq!(found.primary, canonical(&dir));
1138 assert_eq!(found.worktree, None);
1139 }
1140
1141 fn registry() -> HostRegistry {
1142 let mut projects = HashMap::new();
1143 let mut worktrees = HashMap::new();
1144 worktrees.insert(
1145 "fix-1".to_string(),
1146 checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1147 );
1148 projects.insert(
1149 "myproj".to_string(),
1150 ProjectHosts {
1151 label: "myproj".to_string(),
1152 primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1153 worktrees,
1154 },
1155 );
1156 HostRegistry {
1157 projects,
1158 errors: vec![],
1159 }
1160 }
1161
1162 #[test]
1166 fn test_checkout_hosts_capture_proxy_tls() {
1167 let dir = tempfile::tempdir().unwrap();
1168 let project = dir.path().join("tlsproj");
1169 std::fs::create_dir_all(&project).unwrap();
1170 std::fs::write(
1171 project.join("pitchfork.toml"),
1172 "[daemons.secure]\nrun = \"serve\"\nport = [8443, 9443]\n\
1173 proxy_tls = \"passthrough\"\nproxy_tls_port = 9443\n\
1174 [daemons.plain]\nrun = \"serve\"\nport = 8080\n",
1175 )
1176 .unwrap();
1177
1178 let (hosts, errors) = CheckoutHosts::load(&project).expect("checkout loads");
1179 assert!(errors.is_empty(), "{errors:?}");
1180
1181 let secure = hosts.daemons.get("secure").expect("secure is routable");
1182 assert_eq!(secure.name, "secure");
1183 assert_eq!(
1184 secure.proxy_tls,
1185 Some(crate::config_types::ProxyTlsMode::Passthrough)
1186 );
1187 assert_eq!(secure.proxy_tls_port, Some(9443));
1188
1189 let plain = hosts.daemons.get("plain").expect("plain is routable");
1190 assert_eq!(plain.proxy_tls, None);
1191 assert_eq!(plain.proxy_tls_port, None);
1192 }
1193
1194 #[test]
1195 fn test_resolve_primary_checkout_daemon() {
1196 let target = registry().resolve("api.myproj", true);
1197 assert_eq!(
1198 target,
1199 HostTarget::Daemon {
1200 project: "myproj".into(),
1201 worktree: None,
1202 dir: PathBuf::from("/repos/myproj"),
1203 namespace: "myproj".into(),
1204 daemon: "api".into(),
1205 proxy_tls: None,
1206 proxy_tls_port: None,
1207 }
1208 );
1209 }
1210
1211 #[test]
1212 fn test_resolve_worktree_daemon() {
1213 let target = registry().resolve("web.fix-1.myproj", true);
1214 assert_eq!(
1215 target,
1216 HostTarget::Daemon {
1217 project: "myproj".into(),
1218 worktree: Some("fix-1".into()),
1219 dir: PathBuf::from("/repos/fix-1"),
1220 namespace: "fix-1".into(),
1221 daemon: "web".into(),
1222 proxy_tls: None,
1223 proxy_tls_port: None,
1224 }
1225 );
1226 }
1227
1228 #[test]
1229 fn test_resolve_is_case_insensitive() {
1230 assert!(matches!(
1231 registry().resolve("API.MyProj", true),
1232 HostTarget::Daemon { .. }
1233 ));
1234 }
1235
1236 #[test]
1238 fn test_resolve_reserved_pages() {
1239 assert_eq!(
1240 registry().resolve("myproj", true),
1241 HostTarget::ProjectPage {
1242 project: "myproj".into()
1243 }
1244 );
1245 assert_eq!(
1246 registry().resolve("fix-1.myproj", true),
1247 HostTarget::WorktreePage {
1248 project: "myproj".into(),
1249 worktree: "fix-1".into(),
1250 }
1251 );
1252 }
1253
1254 #[test]
1258 fn test_resolve_worktree_beats_daemon_of_same_name() {
1259 let mut reg = registry();
1260 reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1262 "api".to_string(),
1263 checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1264 );
1265 assert_eq!(
1266 reg.resolve("api.myproj", true),
1267 HostTarget::WorktreePage {
1268 project: "myproj".into(),
1269 worktree: "api".into(),
1270 }
1271 );
1272 assert!(matches!(
1274 reg.resolve("api.api.myproj", true),
1275 HostTarget::Daemon { .. }
1276 ));
1277 }
1278
1279 #[test]
1280 fn test_resolve_wildcard_subdomain() {
1281 let target = registry().resolve("tenant.api.myproj", true);
1282 assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1283 assert!(matches!(
1285 registry().resolve("tenant.api.myproj", false),
1286 HostTarget::UnknownDaemon { .. }
1287 ));
1288 }
1289
1290 #[test]
1291 fn test_resolve_unknown_names() {
1292 assert_eq!(
1293 registry().resolve("api.other", true),
1294 HostTarget::UnknownProject {
1295 known: vec!["myproj".to_string()]
1296 }
1297 );
1298 assert_eq!(
1299 registry().resolve("nope.myproj", true),
1300 HostTarget::UnknownDaemon {
1301 project: "myproj".into(),
1302 worktree: None,
1303 known: vec!["api".to_string()],
1304 }
1305 );
1306 }
1307
1308 fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1311 let repo = temp.join(name);
1312 let admin = repo.join(format!(".git/worktrees/{worktree}"));
1313 std::fs::create_dir_all(&admin).unwrap();
1314 let wt = temp.join(worktree);
1315 std::fs::create_dir_all(&wt).unwrap();
1316 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1317 std::fs::write(
1318 admin.join("gitdir"),
1319 format!("{}\n", wt.join(".git").display()),
1320 )
1321 .unwrap();
1322 (repo, wt)
1323 }
1324
1325 #[test]
1328 fn test_project_label_from_directory_name() {
1329 let temp = tempfile::tempdir().unwrap();
1330 let repo = temp.path().join("My App");
1331 std::fs::create_dir_all(&repo).unwrap();
1332 assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1333 }
1334
1335 #[test]
1337 fn test_project_label_from_explicit_namespace() {
1338 let temp = tempfile::tempdir().unwrap();
1339 let repo = temp.path().join("checkout-dir");
1340 std::fs::create_dir_all(&repo).unwrap();
1341 std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1342 assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1343 }
1344
1345 fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1347 let body: String = daemons
1348 .iter()
1349 .enumerate()
1350 .map(|(i, (name, extra))| {
1351 format!(
1352 "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1353 3000 + i
1354 )
1355 })
1356 .collect();
1357 std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1358 }
1359
1360 fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1362 let pt = PitchforkToml::all_merged_from(dir).unwrap();
1363 let (id, config) = pt
1364 .daemons
1365 .iter()
1366 .find(|(id, _)| id.name() == name)
1367 .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1368 (id.clone(), config.clone())
1369 }
1370
1371 #[test]
1374 fn test_auto_host_primary_and_worktree() {
1375 let temp = tempfile::tempdir().unwrap();
1376 let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1377 write_config(&repo, &[("api", "")]);
1378 write_config(&wt, &[("api", "")]);
1379
1380 let (id, config) = daemon_config(&repo, "api");
1381 assert_eq!(
1382 auto_host_for_daemon(&id, &config).as_deref(),
1383 Some("api.my-repo")
1384 );
1385
1386 let (wt_id, wt_config) = daemon_config(&wt, "api");
1387 assert_eq!(
1388 auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1389 Some("api.fix-1.my-repo")
1390 );
1391 }
1392
1393 #[test]
1395 fn test_worktree_label_override() {
1396 let temp = tempfile::tempdir().unwrap();
1397 let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1398 assert_eq!(
1399 worktree_label(&wt).as_deref(),
1400 Some("sleepy-kapitsa-9f02fc")
1401 );
1402
1403 std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1404 assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1405 }
1406
1407 #[test]
1410 fn test_auto_host_requires_port_and_opt_in() {
1411 let temp = tempfile::tempdir().unwrap();
1412 let repo = temp.path().join("opt-in-repo");
1413 std::fs::create_dir_all(&repo).unwrap();
1414 std::fs::write(
1415 repo.join("pitchfork.toml"),
1416 "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1417 [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1418 [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1419 [daemons.worker]\nrun = \"server\"\n",
1420 )
1421 .unwrap();
1422
1423 let host = |name: &str| {
1424 let (id, config) = daemon_config(&repo, name);
1425 auto_host_for_daemon(&id, &config)
1426 };
1427 assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1428 assert_eq!(host("admin"), None);
1429 assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1430 assert_eq!(host("worker"), None);
1431 }
1432
1433 #[test]
1436 fn test_daemon_label_collision_drops_both() {
1437 let temp = tempfile::tempdir().unwrap();
1438 let repo = temp.path().join("dupe-repo");
1439 std::fs::create_dir_all(&repo).unwrap();
1440 std::fs::write(
1441 repo.join("pitchfork.toml"),
1442 "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1443 [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1444 [daemons.other]\nrun = \"server\"\nport = 3002\n",
1445 )
1446 .unwrap();
1447
1448 let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1449 assert_eq!(hosts.labels(), vec!["other".to_string()]);
1450 assert_eq!(errors.len(), 1);
1451 assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1452
1453 for name in ["foo_bar", "foo-bar"] {
1454 let (id, config) = daemon_config(&repo, name);
1455 assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1456 }
1457 let (id, config) = daemon_config(&repo, "other");
1458 assert_eq!(
1459 auto_host_for_daemon(&id, &config).as_deref(),
1460 Some("other.dupe-repo")
1461 );
1462 }
1463
1464 #[test]
1467 fn test_hostname_fits() {
1468 assert!(hostname_fits(&format!(
1470 "{}.{}.{}",
1471 "a".repeat(63),
1472 "b".repeat(63),
1473 "c".repeat(63)
1474 )));
1475 assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1476 }
1477
1478 #[test]
1481 fn test_auto_host_none_for_colliding_worktrees() {
1482 let temp = tempfile::tempdir().unwrap();
1483 let repo = temp.path().join("wt-repo");
1484 std::fs::create_dir_all(repo.join(".git")).unwrap();
1485 write_config(&repo, &[("api", "")]);
1486
1487 let mut worktrees = Vec::new();
1488 for dir_name in ["fix-1", "fix.1"] {
1489 let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1490 std::fs::create_dir_all(&admin).unwrap();
1491 let wt = temp.path().join(dir_name);
1492 std::fs::create_dir_all(&wt).unwrap();
1493 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1494 std::fs::write(
1495 admin.join("gitdir"),
1496 format!("{}\n", wt.join(".git").display()),
1497 )
1498 .unwrap();
1499 write_config(&wt, &[("api", "")]);
1500 worktrees.push(wt);
1501 }
1502
1503 for wt in &worktrees {
1504 let (id, config) = daemon_config(wt, "api");
1505 assert_eq!(
1506 auto_host_for_daemon(&id, &config),
1507 None,
1508 "{} must not advertise a hostname",
1509 wt.display()
1510 );
1511 }
1512 let (id, config) = daemon_config(&repo, "api");
1514 assert_eq!(
1515 auto_host_for_daemon(&id, &config).as_deref(),
1516 Some("api.wt-repo")
1517 );
1518 }
1519
1520 #[test]
1524 fn test_shares_daemon_id() {
1525 let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1526 (
1527 label.to_string(),
1528 ProjectHosts {
1529 label: label.to_string(),
1530 primary,
1531 worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1532 },
1533 )
1534 };
1535
1536 let mut registry = HostRegistry {
1538 projects: HashMap::from([project(
1539 "myproj",
1540 checkout("/repos/myproj", "myproj", &[("api", "api")]),
1541 vec![(
1542 "fix-1",
1543 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1544 )],
1545 )]),
1546 errors: vec![],
1547 };
1548 assert!(!registry.shares_daemon_id("myproj", "api"));
1549 assert!(!registry.shares_daemon_id("fix-1", "api"));
1550
1551 registry
1554 .projects
1555 .get_mut("myproj")
1556 .unwrap()
1557 .worktrees
1558 .insert(
1559 "fix-2".to_string(),
1560 checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1561 );
1562 assert!(registry.shares_daemon_id("myproj", "api"));
1563
1564 let (label, other) = project(
1567 "other",
1568 checkout("/repos/other", "other", &[("api", "api")]),
1569 vec![(
1570 "fix-1",
1571 checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1572 )],
1573 );
1574 registry.projects.insert(label, other);
1575 assert!(registry.shares_daemon_id("fix-1", "api"));
1576
1577 assert!(!registry.shares_daemon_id("myproj", "worker"));
1579 }
1580
1581 #[test]
1584 fn test_worktree_label_collision_drops_both() {
1585 let found = vec![
1586 (
1587 "fix-1".to_string(),
1588 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1589 ),
1590 (
1591 "fix-1".to_string(),
1592 checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1593 ),
1594 (
1595 "fix-2".to_string(),
1596 checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1597 ),
1598 ];
1599 let (kept, errors) = group_worktrees("myproj", found);
1600 assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1601 assert_eq!(errors.len(), 1);
1602 assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1603 assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1604 }
1605
1606 #[test]
1609 fn test_project_label_collision_routes_neither() {
1610 let temp = tempfile::tempdir().unwrap();
1611 let a = temp.path().join("a/shop");
1612 let b = temp.path().join("b/shop");
1613 std::fs::create_dir_all(&a).unwrap();
1614 std::fs::create_dir_all(&b).unwrap();
1615 write_config(&a, &[("api", "")]);
1616 write_config(&b, &[("api", "")]);
1617
1618 let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1619 assert!(registry.project_labels().is_empty());
1620 assert_eq!(registry.errors.len(), 1);
1621 assert!(
1622 registry.errors[0].contains("shop"),
1623 "{}",
1624 registry.errors[0]
1625 );
1626 assert!(
1629 registry.errors[0].contains(&canonical(&b).display().to_string()),
1630 "{}",
1631 registry.errors[0]
1632 );
1633 assert_eq!(
1634 registry.resolve("api.shop", true),
1635 HostTarget::UnknownProject { known: vec![] }
1636 );
1637 }
1638}