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
27const 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(String::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, String>,
363}
364
365impl CheckoutHosts {
366 fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
373 let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
374 let pt = PitchforkToml::all_merged_from(dir).ok()?;
375 let mut daemons: HashMap<String, String> = HashMap::new();
376 let mut errors = Vec::new();
377 let mut colliding: Vec<String> = Vec::new();
378 for (id, config) in &pt.daemons {
379 if id.namespace() != namespace || config.port.is_none() {
380 continue;
381 }
382 let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
383 continue;
384 };
385 match daemons.get(&label) {
386 Some(other) => {
387 errors.push(format!(
388 "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
389 '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
390 dir.display(),
391 name = id.name(),
392 ));
393 colliding.push(label);
394 }
395 None => {
396 daemons.insert(label, id.name().to_string());
397 }
398 }
399 }
400 for label in colliding {
401 daemons.remove(&label);
402 }
403 Some((
404 Self {
405 dir: dir.to_path_buf(),
406 namespace,
407 daemons,
408 },
409 errors,
410 ))
411 }
412
413 pub fn labels(&self) -> Vec<String> {
415 let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
416 labels.sort();
417 labels
418 }
419}
420
421#[derive(Debug, Clone)]
423pub struct ProjectHosts {
424 pub label: String,
425 pub primary: CheckoutHosts,
426 pub worktrees: HashMap<String, CheckoutHosts>,
428}
429
430impl ProjectHosts {
431 pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
433 std::iter::once(&self.primary).chain(self.worktrees.values())
434 }
435
436 pub fn worktree_labels(&self) -> Vec<String> {
438 let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
439 labels.sort();
440 labels
441 }
442}
443
444#[derive(Debug, Clone, Default)]
446pub struct HostRegistry {
447 pub projects: HashMap<String, ProjectHosts>,
448 pub errors: Vec<String>,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq)]
454pub enum HostTarget {
455 Daemon {
457 project: String,
458 worktree: Option<String>,
459 dir: PathBuf,
460 namespace: String,
461 daemon: String,
462 },
463 ProjectPage { project: String },
465 WorktreePage { project: String, worktree: String },
467 UnknownProject { known: Vec<String> },
469 UnknownDaemon {
471 project: String,
472 worktree: Option<String>,
473 known: Vec<String>,
474 },
475}
476
477fn group_worktrees(
483 project: &str,
484 found: Vec<(String, CheckoutHosts)>,
485) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
486 let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
487 let mut errors = Vec::new();
488 let mut colliding: Vec<String> = Vec::new();
489 for (label, hosts) in found {
490 match kept.get(&label) {
491 Some(existing) if existing.dir == hosts.dir => continue,
492 Some(existing) => {
493 errors.push(format!(
494 "worktree label '{label}' in project '{project}' is claimed by two \
495 directories: {} and {}. Set `worktree_label` in one of them.",
496 existing.dir.display(),
497 hosts.dir.display(),
498 ));
499 colliding.push(label);
500 }
501 None => {
502 kept.insert(label, hosts);
503 }
504 }
505 }
506 for label in colliding {
507 kept.remove(&label);
508 }
509 (kept, errors)
510}
511
512const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
520
521struct WorktreeCache {
522 entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
523}
524
525static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
526 once_cell::sync::Lazy::new(|| {
527 std::sync::Mutex::new(WorktreeCache {
528 entries: HashMap::new(),
529 })
530 });
531
532fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
534 let now = std::time::Instant::now();
535 {
536 let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
537 if let Some((expires_at, dirs)) = cache.entries.get(primary)
538 && now < *expires_at
539 {
540 return std::sync::Arc::clone(dirs);
541 }
542 } let dirs = std::sync::Arc::new(worktree_dirs(primary));
545
546 let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
547 cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
548 cache.entries.insert(
549 primary.to_path_buf(),
550 (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
551 );
552 dirs
553}
554
555pub fn warn_once(message: &str) {
562 static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
563 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
564
565 let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
566 if seen.len() > 256 {
569 seen.clear();
570 }
571 if seen.insert(message.to_string()) {
572 log::warn!("{message}");
573 }
574}
575
576fn candidate_dirs() -> Vec<PathBuf> {
584 let mut candidates: Vec<PathBuf> = Vec::new();
585 for (_, entry) in PitchforkToml::read_global_namespaces() {
586 candidates.push(entry.dir);
587 }
588 for (_, entry) in PitchforkToml::read_global_slugs() {
589 if let Some(dir) = entry.resolve_dir() {
590 candidates.push(dir);
591 }
592 }
593 if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
594 for daemon in state.daemons.values() {
595 if let Some(dir) = &daemon.dir {
596 candidates.push(dir.clone());
597 }
598 }
599 }
600 candidates
601}
602
603fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
609 let mut seen: Vec<PathBuf> = Vec::new();
610 for dir in candidate_dirs() {
611 if !dir.exists() {
612 continue;
613 }
614 let other = detect_checkout(&dir).primary;
615 if other == primary || seen.contains(&other) {
616 continue;
617 }
618 seen.push(other.clone());
619 if project_label(&other).as_deref() == Some(label) {
620 return true;
621 }
622 }
623 false
624}
625
626fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
633 let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
634 return vec![];
635 };
636 entries
637 .filter_map(|entry| {
638 let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
639 let git_file = PathBuf::from(gitdir.trim());
640 git_file.parent().map(Path::to_path_buf)
641 })
642 .collect()
643}
644
645fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
650 if !crate::settings::settings().general.worktree {
651 return vec![];
652 }
653 let mut dirs: Vec<PathBuf> = Vec::new();
654 let found = crate::proxy::worktree::discover_worktrees(primary)
655 .into_iter()
656 .map(|entry| entry.path)
657 .chain(linked_worktree_dirs(primary));
658 for path in found {
659 let Some(wt_dir) = detect_checkout(&path).worktree else {
660 continue; };
662 if !dirs.contains(&wt_dir) {
663 dirs.push(wt_dir);
664 }
665 }
666 dirs
667}
668
669fn build_project_hosts(
675 primary: &Path,
676 label: &str,
677 worktrees: &[PathBuf],
678) -> Option<(ProjectHosts, Vec<String>)> {
679 let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
680 let mut project = ProjectHosts {
681 label: label.to_string(),
682 primary: primary_hosts,
683 worktrees: HashMap::new(),
684 };
685
686 let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
687 for wt_dir in worktrees {
688 let (Some(wt_label), Some((hosts, wt_errors))) =
689 (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
690 else {
691 continue;
692 };
693 errors.extend(wt_errors);
694 found.push((wt_label, hosts));
695 }
696 let (worktrees, wt_errors) = group_worktrees(label, found);
697 project.worktrees = worktrees;
698 errors.extend(wt_errors);
699
700 Some((project, errors))
701}
702
703fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
706 let worktrees = cached_worktree_dirs(primary);
707 let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
708 for err in errors {
709 warn_once(&err);
710 }
711 Some(project)
712}
713
714impl HostRegistry {
715 pub fn build() -> Self {
725 Self::from_dirs(&candidate_dirs())
726 }
727
728 pub fn from_dirs(dirs: &[PathBuf]) -> Self {
730 let mut primaries: Vec<PathBuf> = Vec::new();
732 for dir in dirs {
733 if !dir.exists() {
734 continue;
735 }
736 let primary = detect_checkout(dir).primary;
737 if !primaries.contains(&primary) {
738 primaries.push(primary);
739 }
740 }
741
742 let mut registry = Self::default();
743 let mut colliding: Vec<String> = Vec::new();
744 for primary in primaries {
745 let Some(label) = project_label(&primary) else {
746 continue;
747 };
748
749 if let Some(existing) = registry.projects.get(&label) {
750 if existing.primary.dir != primary {
751 registry.errors.push(format!(
752 "project label '{label}' is claimed by two directories: {} and {}. \
753 Set a distinct top-level `namespace` in one of them.",
754 existing.primary.dir.display(),
755 primary.display(),
756 ));
757 colliding.push(label);
761 }
762 continue;
763 }
764
765 let worktrees = worktree_dirs(&primary);
766 let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
767 continue;
768 };
769 registry.errors.extend(errors);
770 if project.checkouts().all(|c| c.daemons.is_empty()) {
774 continue;
775 }
776 registry.projects.insert(label, project);
777 }
778 for label in colliding {
779 registry.projects.remove(&label);
780 }
781
782 registry
783 }
784
785 pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
795 self.projects
796 .values()
797 .flat_map(ProjectHosts::checkouts)
798 .filter(|c| c.namespace == namespace && c.daemons.values().any(|n| n == daemon))
799 .count()
800 > 1
801 }
802
803 pub fn project_labels(&self) -> Vec<String> {
805 let mut labels: Vec<String> = self.projects.keys().cloned().collect();
806 labels.sort();
807 labels
808 }
809
810 pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
818 let labels: Vec<String> = subdomain
819 .split('.')
820 .map(|l| l.to_ascii_lowercase())
821 .collect();
822 let Some((project_label, rest)) = labels.split_last() else {
823 return HostTarget::UnknownProject {
824 known: self.project_labels(),
825 };
826 };
827 let Some(project) = self.projects.get(project_label) else {
828 return HostTarget::UnknownProject {
829 known: self.project_labels(),
830 };
831 };
832 if rest.is_empty() {
833 return HostTarget::ProjectPage {
834 project: project.label.clone(),
835 };
836 }
837
838 let (checkout, worktree, rest) = match rest.split_last() {
840 Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
841 Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
842 None => (&project.primary, None, rest),
843 },
844 None => (&project.primary, None, rest),
845 };
846
847 let Some((daemon_label, extra)) = rest.split_last() else {
848 return HostTarget::WorktreePage {
849 project: project.label.clone(),
850 worktree: worktree.unwrap_or_default(),
851 };
852 };
853 if !extra.is_empty() && !wildcard {
854 return HostTarget::UnknownDaemon {
855 project: project.label.clone(),
856 worktree,
857 known: checkout.labels(),
858 };
859 }
860
861 match checkout.daemons.get(daemon_label) {
862 Some(name) => HostTarget::Daemon {
863 project: project.label.clone(),
864 worktree,
865 dir: checkout.dir.clone(),
866 namespace: checkout.namespace.clone(),
867 daemon: name.clone(),
868 },
869 None => HostTarget::UnknownDaemon {
870 project: project.label.clone(),
871 worktree,
872 known: checkout.labels(),
873 },
874 }
875 }
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881
882 fn canonical(path: &Path) -> PathBuf {
885 dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
886 }
887
888 fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
889 CheckoutHosts {
890 dir: PathBuf::from(dir),
891 namespace: namespace.to_string(),
892 daemons: daemons
893 .iter()
894 .map(|(l, n)| (l.to_string(), n.to_string()))
895 .collect(),
896 }
897 }
898
899 #[test]
900 fn test_sanitize_label() {
901 assert_eq!(sanitize_label("api").as_deref(), Some("api"));
902 assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
903 assert_eq!(
904 sanitize_label("feature/my_branch").as_deref(),
905 Some("feature-my-branch")
906 );
907 assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
908 assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
909 assert_eq!(sanitize_label("---"), None);
910 assert_eq!(sanitize_label(""), None);
911 assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
912 }
913
914 #[test]
915 fn test_daemon_label_override_and_opt_out() {
916 assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
917 assert_eq!(
918 daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
919 Some("api")
920 );
921 assert_eq!(
922 daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
923 Some("web-ui")
924 );
925 assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
926 }
927
928 #[test]
930 fn test_detect_checkout_primary() {
931 let temp = tempfile::tempdir().unwrap();
932 let repo = temp.path().join("my-repo");
933 std::fs::create_dir_all(repo.join(".git")).unwrap();
934 std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
935
936 let found = detect_checkout(&repo.join("sub/dir"));
937 assert_eq!(found.primary, canonical(&repo));
938 assert_eq!(found.worktree, None);
939 }
940
941 #[test]
943 fn test_detect_checkout_linked_worktree() {
944 let temp = tempfile::tempdir().unwrap();
945 let repo = temp.path().join("my-repo");
946 std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
947 let wt = temp.path().join("fix-1");
948 std::fs::create_dir_all(&wt).unwrap();
949 std::fs::write(
950 wt.join(".git"),
951 format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
952 )
953 .unwrap();
954
955 let found = detect_checkout(&wt);
956 assert_eq!(found.primary, canonical(&repo));
957 assert_eq!(found.worktree, Some(canonical(&wt)));
958 assert_eq!(found.root(), canonical(&wt));
959 }
960
961 #[test]
964 fn test_checkout_root_of_missing_directory() {
965 let temp = tempfile::tempdir().unwrap();
966 let repo = temp.path().join("my-repo");
967 std::fs::create_dir_all(repo.join(".git")).unwrap();
968
969 assert_eq!(
970 checkout_root_of(&repo.join("gone/deeper")),
971 canonical(&repo)
972 );
973 }
974
975 #[cfg(unix)]
979 #[test]
980 fn test_detect_checkout_primary_spelling_matches_through_symlink() {
981 use std::os::unix::fs::symlink;
982
983 let temp = tempfile::tempdir().unwrap();
984 let repo = temp.path().join("my-repo");
985 let admin = repo.join(".git/worktrees/fix-1");
986 std::fs::create_dir_all(&admin).unwrap();
987 let wt = temp.path().join("fix-1");
988 std::fs::create_dir_all(&wt).unwrap();
989
990 let alias = temp.path().join("alias");
993 symlink(&repo, &alias).unwrap();
994 std::fs::write(
995 wt.join(".git"),
996 format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
997 )
998 .unwrap();
999
1000 assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1001 assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1002 }
1003
1004 #[test]
1008 fn test_detect_checkout_bare_repository_worktree() {
1009 let temp = tempfile::tempdir().unwrap();
1010 let bare = temp.path().join("my-repo.git");
1011 let admin = bare.join("worktrees/fix-1");
1012 std::fs::create_dir_all(&admin).unwrap();
1013 let wt = temp.path().join("fix-1");
1014 std::fs::create_dir_all(&wt).unwrap();
1015 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1016
1017 let found = detect_checkout(&wt);
1018 assert_eq!(found.primary, canonical(&wt));
1019 assert_eq!(found.worktree, None);
1020 }
1021
1022 #[test]
1025 fn test_auto_host_skips_global_config_daemons() {
1026 let config = PitchforkTomlDaemon {
1027 run: "server".to_string(),
1028 port: Some(crate::config_types::PortConfig {
1029 expect: vec![3000],
1030 ..Default::default()
1031 }),
1032 path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1033 ..PitchforkTomlDaemon::default()
1034 };
1035 let id = DaemonId::try_new("global", "api").unwrap();
1036 assert_eq!(auto_host_for_daemon(&id, &config), None);
1037 }
1038
1039 #[test]
1042 fn test_host_for_daemon_prefers_a_registered_slug() {
1043 let temp = tempfile::tempdir().unwrap();
1044 let repo = temp.path().join("slug-repo");
1045 std::fs::create_dir_all(&repo).unwrap();
1046 write_config(&repo, &[("api", "")]);
1047 let (id, config) = daemon_config(&repo, "api");
1048
1049 let empty = indexmap::IndexMap::new();
1050 assert_eq!(
1051 host_for_daemon(&id, Some(&config), &empty).as_deref(),
1052 Some("api.slug-repo")
1053 );
1054
1055 let mut slugs = indexmap::IndexMap::new();
1057 slugs.insert(
1058 "myapp".to_string(),
1059 crate::pitchfork_toml::SlugEntry {
1060 dir: Some(repo.clone()),
1061 namespace: Some(id.namespace().to_string()),
1062 daemon: Some("api".to_string()),
1063 },
1064 );
1065 assert_eq!(
1066 host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1067 Some("myapp")
1068 );
1069 }
1070
1071 #[test]
1074 fn test_detect_checkout_submodule_pointer() {
1075 let temp = tempfile::tempdir().unwrap();
1076 let repo = temp.path().join("my-repo");
1077 std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1078 let sub = repo.join("sub");
1079 std::fs::create_dir_all(&sub).unwrap();
1080 std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1081
1082 let found = detect_checkout(&sub);
1083 assert_eq!(found.primary, canonical(&sub));
1084 assert_eq!(found.worktree, None);
1085 }
1086
1087 #[test]
1089 fn test_detect_checkout_without_git() {
1090 let temp = tempfile::tempdir().unwrap();
1091 let dir = temp.path().join("plain");
1092 std::fs::create_dir_all(&dir).unwrap();
1093
1094 let found = detect_checkout(&dir);
1095 assert_eq!(found.primary, canonical(&dir));
1096 assert_eq!(found.worktree, None);
1097 }
1098
1099 fn registry() -> HostRegistry {
1100 let mut projects = HashMap::new();
1101 let mut worktrees = HashMap::new();
1102 worktrees.insert(
1103 "fix-1".to_string(),
1104 checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1105 );
1106 projects.insert(
1107 "myproj".to_string(),
1108 ProjectHosts {
1109 label: "myproj".to_string(),
1110 primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1111 worktrees,
1112 },
1113 );
1114 HostRegistry {
1115 projects,
1116 errors: vec![],
1117 }
1118 }
1119
1120 #[test]
1121 fn test_resolve_primary_checkout_daemon() {
1122 let target = registry().resolve("api.myproj", true);
1123 assert_eq!(
1124 target,
1125 HostTarget::Daemon {
1126 project: "myproj".into(),
1127 worktree: None,
1128 dir: PathBuf::from("/repos/myproj"),
1129 namespace: "myproj".into(),
1130 daemon: "api".into(),
1131 }
1132 );
1133 }
1134
1135 #[test]
1136 fn test_resolve_worktree_daemon() {
1137 let target = registry().resolve("web.fix-1.myproj", true);
1138 assert_eq!(
1139 target,
1140 HostTarget::Daemon {
1141 project: "myproj".into(),
1142 worktree: Some("fix-1".into()),
1143 dir: PathBuf::from("/repos/fix-1"),
1144 namespace: "fix-1".into(),
1145 daemon: "web".into(),
1146 }
1147 );
1148 }
1149
1150 #[test]
1151 fn test_resolve_is_case_insensitive() {
1152 assert!(matches!(
1153 registry().resolve("API.MyProj", true),
1154 HostTarget::Daemon { .. }
1155 ));
1156 }
1157
1158 #[test]
1160 fn test_resolve_reserved_pages() {
1161 assert_eq!(
1162 registry().resolve("myproj", true),
1163 HostTarget::ProjectPage {
1164 project: "myproj".into()
1165 }
1166 );
1167 assert_eq!(
1168 registry().resolve("fix-1.myproj", true),
1169 HostTarget::WorktreePage {
1170 project: "myproj".into(),
1171 worktree: "fix-1".into(),
1172 }
1173 );
1174 }
1175
1176 #[test]
1180 fn test_resolve_worktree_beats_daemon_of_same_name() {
1181 let mut reg = registry();
1182 reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1184 "api".to_string(),
1185 checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1186 );
1187 assert_eq!(
1188 reg.resolve("api.myproj", true),
1189 HostTarget::WorktreePage {
1190 project: "myproj".into(),
1191 worktree: "api".into(),
1192 }
1193 );
1194 assert!(matches!(
1196 reg.resolve("api.api.myproj", true),
1197 HostTarget::Daemon { .. }
1198 ));
1199 }
1200
1201 #[test]
1202 fn test_resolve_wildcard_subdomain() {
1203 let target = registry().resolve("tenant.api.myproj", true);
1204 assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1205 assert!(matches!(
1207 registry().resolve("tenant.api.myproj", false),
1208 HostTarget::UnknownDaemon { .. }
1209 ));
1210 }
1211
1212 #[test]
1213 fn test_resolve_unknown_names() {
1214 assert_eq!(
1215 registry().resolve("api.other", true),
1216 HostTarget::UnknownProject {
1217 known: vec!["myproj".to_string()]
1218 }
1219 );
1220 assert_eq!(
1221 registry().resolve("nope.myproj", true),
1222 HostTarget::UnknownDaemon {
1223 project: "myproj".into(),
1224 worktree: None,
1225 known: vec!["api".to_string()],
1226 }
1227 );
1228 }
1229
1230 fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1233 let repo = temp.join(name);
1234 let admin = repo.join(format!(".git/worktrees/{worktree}"));
1235 std::fs::create_dir_all(&admin).unwrap();
1236 let wt = temp.join(worktree);
1237 std::fs::create_dir_all(&wt).unwrap();
1238 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1239 std::fs::write(
1240 admin.join("gitdir"),
1241 format!("{}\n", wt.join(".git").display()),
1242 )
1243 .unwrap();
1244 (repo, wt)
1245 }
1246
1247 #[test]
1250 fn test_project_label_from_directory_name() {
1251 let temp = tempfile::tempdir().unwrap();
1252 let repo = temp.path().join("My App");
1253 std::fs::create_dir_all(&repo).unwrap();
1254 assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1255 }
1256
1257 #[test]
1259 fn test_project_label_from_explicit_namespace() {
1260 let temp = tempfile::tempdir().unwrap();
1261 let repo = temp.path().join("checkout-dir");
1262 std::fs::create_dir_all(&repo).unwrap();
1263 std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1264 assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1265 }
1266
1267 fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1269 let body: String = daemons
1270 .iter()
1271 .enumerate()
1272 .map(|(i, (name, extra))| {
1273 format!(
1274 "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1275 3000 + i
1276 )
1277 })
1278 .collect();
1279 std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1280 }
1281
1282 fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1284 let pt = PitchforkToml::all_merged_from(dir).unwrap();
1285 let (id, config) = pt
1286 .daemons
1287 .iter()
1288 .find(|(id, _)| id.name() == name)
1289 .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1290 (id.clone(), config.clone())
1291 }
1292
1293 #[test]
1296 fn test_auto_host_primary_and_worktree() {
1297 let temp = tempfile::tempdir().unwrap();
1298 let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1299 write_config(&repo, &[("api", "")]);
1300 write_config(&wt, &[("api", "")]);
1301
1302 let (id, config) = daemon_config(&repo, "api");
1303 assert_eq!(
1304 auto_host_for_daemon(&id, &config).as_deref(),
1305 Some("api.my-repo")
1306 );
1307
1308 let (wt_id, wt_config) = daemon_config(&wt, "api");
1309 assert_eq!(
1310 auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1311 Some("api.fix-1.my-repo")
1312 );
1313 }
1314
1315 #[test]
1317 fn test_worktree_label_override() {
1318 let temp = tempfile::tempdir().unwrap();
1319 let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1320 assert_eq!(
1321 worktree_label(&wt).as_deref(),
1322 Some("sleepy-kapitsa-9f02fc")
1323 );
1324
1325 std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1326 assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1327 }
1328
1329 #[test]
1332 fn test_auto_host_requires_port_and_opt_in() {
1333 let temp = tempfile::tempdir().unwrap();
1334 let repo = temp.path().join("opt-in-repo");
1335 std::fs::create_dir_all(&repo).unwrap();
1336 std::fs::write(
1337 repo.join("pitchfork.toml"),
1338 "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1339 [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1340 [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1341 [daemons.worker]\nrun = \"server\"\n",
1342 )
1343 .unwrap();
1344
1345 let host = |name: &str| {
1346 let (id, config) = daemon_config(&repo, name);
1347 auto_host_for_daemon(&id, &config)
1348 };
1349 assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1350 assert_eq!(host("admin"), None);
1351 assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1352 assert_eq!(host("worker"), None);
1353 }
1354
1355 #[test]
1358 fn test_daemon_label_collision_drops_both() {
1359 let temp = tempfile::tempdir().unwrap();
1360 let repo = temp.path().join("dupe-repo");
1361 std::fs::create_dir_all(&repo).unwrap();
1362 std::fs::write(
1363 repo.join("pitchfork.toml"),
1364 "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1365 [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1366 [daemons.other]\nrun = \"server\"\nport = 3002\n",
1367 )
1368 .unwrap();
1369
1370 let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1371 assert_eq!(hosts.labels(), vec!["other".to_string()]);
1372 assert_eq!(errors.len(), 1);
1373 assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1374
1375 for name in ["foo_bar", "foo-bar"] {
1376 let (id, config) = daemon_config(&repo, name);
1377 assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1378 }
1379 let (id, config) = daemon_config(&repo, "other");
1380 assert_eq!(
1381 auto_host_for_daemon(&id, &config).as_deref(),
1382 Some("other.dupe-repo")
1383 );
1384 }
1385
1386 #[test]
1389 fn test_hostname_fits() {
1390 assert!(hostname_fits(&format!(
1392 "{}.{}.{}",
1393 "a".repeat(63),
1394 "b".repeat(63),
1395 "c".repeat(63)
1396 )));
1397 assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1398 }
1399
1400 #[test]
1403 fn test_auto_host_none_for_colliding_worktrees() {
1404 let temp = tempfile::tempdir().unwrap();
1405 let repo = temp.path().join("wt-repo");
1406 std::fs::create_dir_all(repo.join(".git")).unwrap();
1407 write_config(&repo, &[("api", "")]);
1408
1409 let mut worktrees = Vec::new();
1410 for dir_name in ["fix-1", "fix.1"] {
1411 let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1412 std::fs::create_dir_all(&admin).unwrap();
1413 let wt = temp.path().join(dir_name);
1414 std::fs::create_dir_all(&wt).unwrap();
1415 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1416 std::fs::write(
1417 admin.join("gitdir"),
1418 format!("{}\n", wt.join(".git").display()),
1419 )
1420 .unwrap();
1421 write_config(&wt, &[("api", "")]);
1422 worktrees.push(wt);
1423 }
1424
1425 for wt in &worktrees {
1426 let (id, config) = daemon_config(wt, "api");
1427 assert_eq!(
1428 auto_host_for_daemon(&id, &config),
1429 None,
1430 "{} must not advertise a hostname",
1431 wt.display()
1432 );
1433 }
1434 let (id, config) = daemon_config(&repo, "api");
1436 assert_eq!(
1437 auto_host_for_daemon(&id, &config).as_deref(),
1438 Some("api.wt-repo")
1439 );
1440 }
1441
1442 #[test]
1446 fn test_shares_daemon_id() {
1447 let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1448 (
1449 label.to_string(),
1450 ProjectHosts {
1451 label: label.to_string(),
1452 primary,
1453 worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1454 },
1455 )
1456 };
1457
1458 let mut registry = HostRegistry {
1460 projects: HashMap::from([project(
1461 "myproj",
1462 checkout("/repos/myproj", "myproj", &[("api", "api")]),
1463 vec![(
1464 "fix-1",
1465 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1466 )],
1467 )]),
1468 errors: vec![],
1469 };
1470 assert!(!registry.shares_daemon_id("myproj", "api"));
1471 assert!(!registry.shares_daemon_id("fix-1", "api"));
1472
1473 registry
1476 .projects
1477 .get_mut("myproj")
1478 .unwrap()
1479 .worktrees
1480 .insert(
1481 "fix-2".to_string(),
1482 checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1483 );
1484 assert!(registry.shares_daemon_id("myproj", "api"));
1485
1486 let (label, other) = project(
1489 "other",
1490 checkout("/repos/other", "other", &[("api", "api")]),
1491 vec![(
1492 "fix-1",
1493 checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1494 )],
1495 );
1496 registry.projects.insert(label, other);
1497 assert!(registry.shares_daemon_id("fix-1", "api"));
1498
1499 assert!(!registry.shares_daemon_id("myproj", "worker"));
1501 }
1502
1503 #[test]
1506 fn test_worktree_label_collision_drops_both() {
1507 let found = vec![
1508 (
1509 "fix-1".to_string(),
1510 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1511 ),
1512 (
1513 "fix-1".to_string(),
1514 checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1515 ),
1516 (
1517 "fix-2".to_string(),
1518 checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1519 ),
1520 ];
1521 let (kept, errors) = group_worktrees("myproj", found);
1522 assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1523 assert_eq!(errors.len(), 1);
1524 assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1525 assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1526 }
1527
1528 #[test]
1531 fn test_project_label_collision_routes_neither() {
1532 let temp = tempfile::tempdir().unwrap();
1533 let a = temp.path().join("a/shop");
1534 let b = temp.path().join("b/shop");
1535 std::fs::create_dir_all(&a).unwrap();
1536 std::fs::create_dir_all(&b).unwrap();
1537 write_config(&a, &[("api", "")]);
1538 write_config(&b, &[("api", "")]);
1539
1540 let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1541 assert!(registry.project_labels().is_empty());
1542 assert_eq!(registry.errors.len(), 1);
1543 assert!(
1544 registry.errors[0].contains("shop"),
1545 "{}",
1546 registry.errors[0]
1547 );
1548 assert!(
1551 registry.errors[0].contains(&canonical(&b).display().to_string()),
1552 "{}",
1553 registry.errors[0]
1554 );
1555 assert_eq!(
1556 registry.resolve("api.shop", true),
1557 HostTarget::UnknownProject { known: vec![] }
1558 );
1559 }
1560}