1use crate::config_types::ProxyConfig;
20use crate::daemon_id::DaemonId;
21use crate::pitchfork_toml::{PitchforkToml, PitchforkTomlDaemon};
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25const MAX_LABEL_LEN: usize = 63;
27
28pub(crate) const MAX_HOSTNAME_LEN: usize = 253;
31
32pub fn hostname_fits(host: &str) -> bool {
39 let s = crate::settings::settings();
40 host.len() + 1 + crate::proxy::effective_tld(&s).len() <= MAX_HOSTNAME_LEN
41}
42
43pub fn sanitize_label(input: &str) -> Option<String> {
50 let mut out = String::with_capacity(input.len());
51 for c in input.chars() {
52 if c.is_ascii_alphanumeric() {
53 out.push(c.to_ascii_lowercase());
54 } else if !out.ends_with('-') {
55 out.push('-');
56 }
57 }
58 let trimmed = out.trim_matches('-');
59 let trimmed = if trimmed.len() > MAX_LABEL_LEN {
60 trimmed[..MAX_LABEL_LEN].trim_end_matches('-')
61 } else {
62 trimmed
63 };
64 (!trimmed.is_empty()).then(|| trimmed.to_string())
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Checkout {
72 pub primary: PathBuf,
74 pub worktree: Option<PathBuf>,
76}
77
78impl Checkout {
79 pub fn root(&self) -> &Path {
81 self.worktree.as_deref().unwrap_or(&self.primary)
82 }
83}
84
85fn parse_gitdir_pointer(dir: &Path, content: &str) -> Option<PathBuf> {
90 let target = content
91 .lines()
92 .find_map(|line| line.trim().strip_prefix("gitdir:"))?
93 .trim();
94 if target.is_empty() {
95 return None;
96 }
97 let path = PathBuf::from(target);
98 let path = if path.is_absolute() {
99 path
100 } else {
101 dir.join(path)
102 };
103 Some(normalize(&path))
104}
105
106fn normalize(path: &Path) -> PathBuf {
109 let mut out = PathBuf::new();
110 for part in path.components() {
111 match part {
112 std::path::Component::CurDir => {}
113 std::path::Component::ParentDir => {
114 out.pop();
115 }
116 part => out.push(part.as_os_str()),
117 }
118 }
119 out
120}
121
122fn canonicalize_best_effort(path: &Path) -> PathBuf {
131 let path = normalize(path);
132 let mut suffix: Vec<std::ffi::OsString> = Vec::new();
133 let mut current = path.as_path();
134 loop {
135 if let Ok(resolved) = current.canonicalize() {
136 let mut out = dunce::simplified(&resolved).to_path_buf();
137 for part in suffix.iter().rev() {
138 out.push(part);
139 }
140 return out;
141 }
142 let Some(parent) = current.parent() else {
143 return path;
144 };
145 match current.file_name() {
146 Some(name) => suffix.push(name.to_os_string()),
147 None => return path,
149 }
150 current = parent;
151 }
152}
153
154fn primary_from_worktree_gitdir(gitdir: &Path) -> Option<PathBuf> {
160 let worktrees_dir = gitdir.parent()?;
161 if worktrees_dir.file_name()? != "worktrees" {
162 return None;
163 }
164 let common = worktrees_dir.parent()?;
165 if common.file_name()? != ".git" {
170 return None;
171 }
172 common.parent().map(canonicalize_best_effort)
175}
176
177pub fn checkout_root_of(dir: &Path) -> PathBuf {
184 let checkout = detect_checkout(dir);
185 checkout.root().to_path_buf()
186}
187
188pub fn detect_checkout(dir: &Path) -> Checkout {
196 let start = canonicalize_best_effort(dir);
197 for current in start.ancestors() {
198 let git = current.join(".git");
199 if git.is_dir() {
200 return Checkout {
201 primary: current.to_path_buf(),
202 worktree: None,
203 };
204 }
205 if git.is_file() {
206 let primary = std::fs::read_to_string(&git)
207 .ok()
208 .and_then(|content| parse_gitdir_pointer(current, &content))
209 .and_then(|gitdir| primary_from_worktree_gitdir(&gitdir));
210 return match primary {
211 Some(primary) if primary != current => Checkout {
212 primary,
213 worktree: Some(current.to_path_buf()),
214 },
215 _ => Checkout {
218 primary: current.to_path_buf(),
219 worktree: None,
220 },
221 };
222 }
223 }
224 Checkout {
225 primary: start,
226 worktree: None,
227 }
228}
229
230pub fn validate_registered_label(label: &str) -> Result<(), String> {
238 let Some(clean) = sanitize_label(label) else {
239 return Err(format!(
240 "label '{label}' has no usable characters; use lowercase letters, digits and hyphens"
241 ));
242 };
243 if label.len() > MAX_LABEL_LEN {
244 return Err(format!(
245 "label '{label}' is {} characters; a hostname label is limited to {MAX_LABEL_LEN}",
246 label.len()
247 ));
248 }
249 if clean != label {
250 return Err(format!(
251 "label '{label}' is not a valid hostname label; use lowercase letters, digits and \
252 single hyphens between them (for example '{clean}')"
253 ));
254 }
255 if !hostname_fits(label) {
256 return Err(format!(
257 "label '{label}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS limit"
258 ));
259 }
260 Ok(())
261}
262
263pub fn project_label(primary: &Path) -> Option<String> {
271 let registered = crate::extra_configs::label_for_checkout(primary);
272 let explicit = PitchforkToml::project_namespace_override(primary)
273 .ok()
274 .flatten()
275 .or_else(|| crate::extra_configs::namespace_for_dir(primary));
276 pick_project_label(primary, registered.as_deref(), explicit.as_deref())
277}
278
279fn registered_project_label(primary: &Path) -> Option<String> {
284 crate::extra_configs::label_for_checkout(primary).and_then(|l| sanitize_label(&l))
285}
286
287fn pick_project_label(
292 primary: &Path,
293 registered: Option<&str>,
294 explicit_namespace: Option<&str>,
295) -> Option<String> {
296 if let Some(label) = registered.and_then(sanitize_label) {
297 return Some(label);
298 }
299 match explicit_namespace {
300 Some(ns) => sanitize_label(ns),
301 None => sanitize_label(&primary.file_name()?.to_string_lossy()),
302 }
303}
304
305pub fn worktree_label(worktree: &Path) -> Option<String> {
310 if let Some(label) = PitchforkToml::project_worktree_label(worktree) {
311 return sanitize_label(&label);
312 }
313 sanitize_label(&worktree.file_name()?.to_string_lossy())
314}
315
316pub fn daemon_label(name: &str, proxy: Option<&ProxyConfig>) -> Option<String> {
318 if proxy.is_some_and(ProxyConfig::is_disabled) {
319 return None;
320 }
321 match proxy.and_then(ProxyConfig::label) {
322 Some(label) => sanitize_label(label),
323 None => sanitize_label(name),
324 }
325}
326
327fn join_labels(daemon: &str, worktree: Option<&str>, project: &str) -> String {
329 match worktree {
330 Some(wt) => format!("{daemon}.{wt}.{project}"),
331 None => format!("{daemon}.{project}"),
332 }
333}
334
335pub fn auto_host_for_daemon(id: &DaemonId, config: &PitchforkTomlDaemon) -> Option<String> {
343 config.port.as_ref()?;
344 let daemon = daemon_label(id.name(), config.proxy.as_ref())?;
345 let path = config.path.as_deref()?;
346 if crate::pitchfork_toml::is_global_config(path) {
350 return None;
351 }
352 let base = crate::pitchfork_toml::project_dir_for_config(path)?;
353 let checkout = detect_checkout(&base);
354 let project_label = project_label(&checkout.primary)?;
355 if project_label_is_ambiguous(&project_label, &checkout.primary) {
356 return None;
357 }
358
359 let project = project_hosts_for(&checkout.primary, &project_label)?;
360 let (hosts, worktree) = match &checkout.worktree {
361 Some(dir) => {
362 let label = worktree_label(dir)?;
365 (project.worktrees.get(&label)?, Some(label))
366 }
367 None => (&project.primary, None),
368 };
369 if !routes_label_to(hosts, &daemon, id) {
370 return None;
371 }
372
373 let host = join_labels(&daemon, worktree.as_deref(), &project_label);
374 if !hostname_fits(&host) {
375 log::warn!(
376 "'{host}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS \
377 limit, so no hostname is assigned. Shorten the project, worktree or daemon name, \
378 or use a shorter proxy.tld."
379 );
380 return None;
381 }
382 Some(host)
383}
384
385fn routes_label_to(hosts: &CheckoutHosts, label: &str, id: &DaemonId) -> bool {
392 hosts.namespace == id.namespace()
393 && hosts
394 .daemons
395 .get(label)
396 .is_some_and(|d| d.name == id.name())
397}
398
399pub fn host_for_daemon(
405 id: &DaemonId,
406 config: Option<&PitchforkTomlDaemon>,
407 global_slugs: &indexmap::IndexMap<String, crate::pitchfork_toml::SlugEntry>,
408) -> Option<String> {
409 if let Some(slug) = PitchforkToml::find_slug_for_daemon_in_registry(id, global_slugs) {
413 if hostname_fits(&slug) {
414 return Some(slug);
415 }
416 log::warn!(
417 "Slug '{slug}' plus the configured proxy.tld is over the \
418 {MAX_HOSTNAME_LEN}-byte DNS limit, so it is not advertised for {id}."
419 );
420 }
421 auto_host_for_daemon(id, config?)
422}
423
424#[derive(Debug, Clone)]
428pub struct CheckoutHosts {
429 pub dir: PathBuf,
431 pub namespace: String,
433 pub daemons: HashMap<String, DaemonHost>,
435}
436
437#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct DaemonHost {
447 pub name: String,
449 pub proxy_tls: Option<crate::config_types::ProxyTlsMode>,
451 pub proxy_protocol: Option<crate::config_types::ProxyProtocol>,
453 pub proxy_tls_port: Option<u16>,
455}
456
457impl CheckoutHosts {
458 fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
465 let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
466 let pt = PitchforkToml::all_merged_from(dir).ok()?;
467 let mut daemons: HashMap<String, DaemonHost> = HashMap::new();
468 let mut errors = Vec::new();
469 let mut colliding: Vec<String> = Vec::new();
470 for (id, config) in &pt.daemons {
471 if id.namespace() != namespace || config.port.is_none() {
472 continue;
473 }
474 let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
475 continue;
476 };
477 match daemons.get(&label) {
478 Some(other) => {
479 errors.push(format!(
480 "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
481 '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
482 dir.display(),
483 other = other.name,
484 name = id.name(),
485 ));
486 colliding.push(label);
487 }
488 None => {
489 daemons.insert(
490 label,
491 DaemonHost {
492 name: id.name().to_string(),
493 proxy_tls: config.proxy_tls,
494 proxy_protocol: config.proxy_protocol,
495 proxy_tls_port: config.effective_proxy_tls_port(),
496 },
497 );
498 }
499 }
500 }
501 for label in colliding {
502 daemons.remove(&label);
503 }
504 Some((
505 Self {
506 dir: dir.to_path_buf(),
507 namespace,
508 daemons,
509 },
510 errors,
511 ))
512 }
513
514 pub fn labels(&self) -> Vec<String> {
516 let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
517 labels.sort();
518 labels
519 }
520}
521
522#[derive(Debug, Clone)]
524pub struct ProjectHosts {
525 pub label: String,
526 pub primary: CheckoutHosts,
527 pub worktrees: HashMap<String, CheckoutHosts>,
529}
530
531impl ProjectHosts {
532 pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
534 std::iter::once(&self.primary).chain(self.worktrees.values())
535 }
536
537 pub fn worktree_labels(&self) -> Vec<String> {
539 let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
540 labels.sort();
541 labels
542 }
543}
544
545#[derive(Debug, Clone, Default)]
547pub struct HostRegistry {
548 pub projects: HashMap<String, ProjectHosts>,
549 pub errors: Vec<String>,
551}
552
553#[derive(Debug, Clone, PartialEq, Eq)]
555pub enum HostTarget {
556 Daemon {
558 project: String,
559 worktree: Option<String>,
560 dir: PathBuf,
561 namespace: String,
562 daemon: String,
563 proxy_tls: Option<crate::config_types::ProxyTlsMode>,
566 proxy_protocol: Option<crate::config_types::ProxyProtocol>,
568 proxy_tls_port: Option<u16>,
570 },
571 ProjectPage { project: String },
573 WorktreePage { project: String, worktree: String },
575 UnknownProject { known: Vec<String> },
577 UnknownDaemon {
579 project: String,
580 worktree: Option<String>,
581 known: Vec<String>,
582 },
583}
584
585fn group_worktrees(
591 project: &str,
592 found: Vec<(String, CheckoutHosts)>,
593) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
594 let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
595 let mut errors = Vec::new();
596 let mut colliding: Vec<String> = Vec::new();
597 for (label, hosts) in found {
598 match kept.get(&label) {
599 Some(existing) if existing.dir == hosts.dir => continue,
600 Some(existing) => {
601 errors.push(format!(
602 "worktree label '{label}' in project '{project}' is claimed by two \
603 directories: {} and {}. Set `worktree_label` in one of them.",
604 existing.dir.display(),
605 hosts.dir.display(),
606 ));
607 colliding.push(label);
608 }
609 None => {
610 kept.insert(label, hosts);
611 }
612 }
613 }
614 for label in colliding {
615 kept.remove(&label);
616 }
617 (kept, errors)
618}
619
620const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
628
629struct WorktreeCache {
630 entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
631}
632
633static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
634 once_cell::sync::Lazy::new(|| {
635 std::sync::Mutex::new(WorktreeCache {
636 entries: HashMap::new(),
637 })
638 });
639
640fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
642 let now = std::time::Instant::now();
643 {
644 let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
645 if let Some((expires_at, dirs)) = cache.entries.get(primary)
646 && now < *expires_at
647 {
648 return std::sync::Arc::clone(dirs);
649 }
650 } let dirs = std::sync::Arc::new(worktree_dirs(primary));
653
654 let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
655 cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
656 cache.entries.insert(
657 primary.to_path_buf(),
658 (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
659 );
660 dirs
661}
662
663pub fn warn_once(message: &str) {
670 static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
671 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
672
673 let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
674 if seen.len() > 256 {
677 seen.clear();
678 }
679 if seen.insert(message.to_string()) {
680 log::warn!("{message}");
681 }
682}
683
684fn candidate_dirs() -> Vec<PathBuf> {
692 let mut candidates: Vec<PathBuf> = Vec::new();
693 for (_, entry) in PitchforkToml::read_global_namespaces() {
694 candidates.push(entry.dir);
695 }
696 for (_, entry) in PitchforkToml::read_global_slugs() {
697 if let Some(dir) = entry.resolve_dir() {
698 candidates.push(dir);
699 }
700 }
701 if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
702 for daemon in state.daemons.values() {
703 if let Some(dir) = &daemon.dir {
704 candidates.push(dir.clone());
705 }
706 }
707 }
708 candidates
709}
710
711fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
717 let mut seen: Vec<PathBuf> = Vec::new();
718 for dir in candidate_dirs() {
719 if !dir.exists() {
720 continue;
721 }
722 let other = detect_checkout(&dir).primary;
723 if other == primary || seen.contains(&other) {
724 continue;
725 }
726 seen.push(other.clone());
727 if project_label(&other).as_deref() == Some(label) {
728 return true;
729 }
730 }
731 false
732}
733
734fn project_label_collision_message(
740 label: &str,
741 first: &Path,
742 second: &Path,
743 from_registration: bool,
744) -> String {
745 let advice = if from_registration {
746 "Register a different label for one of them with \
747 `pitchfork config add <file> --label <other>`."
748 } else {
749 "Set a distinct top-level `namespace` in one of them."
750 };
751 format!(
752 "project label '{label}' is claimed by two directories: {} and {}. {advice}",
753 first.display(),
754 second.display(),
755 )
756}
757
758fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
765 let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
766 return vec![];
767 };
768 entries
769 .filter_map(|entry| {
770 let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
771 let git_file = PathBuf::from(gitdir.trim());
772 git_file.parent().map(Path::to_path_buf)
773 })
774 .collect()
775}
776
777fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
782 if !crate::settings::settings().general.worktree {
783 return vec![];
784 }
785 let mut dirs: Vec<PathBuf> = Vec::new();
786 let found = crate::proxy::worktree::discover_worktrees(primary)
787 .into_iter()
788 .map(|entry| entry.path)
789 .chain(linked_worktree_dirs(primary));
790 for path in found {
791 let Some(wt_dir) = detect_checkout(&path).worktree else {
792 continue; };
794 if !dirs.contains(&wt_dir) {
795 dirs.push(wt_dir);
796 }
797 }
798 dirs
799}
800
801fn build_project_hosts(
807 primary: &Path,
808 label: &str,
809 worktrees: &[PathBuf],
810) -> Option<(ProjectHosts, Vec<String>)> {
811 let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
812 let mut project = ProjectHosts {
813 label: label.to_string(),
814 primary: primary_hosts,
815 worktrees: HashMap::new(),
816 };
817
818 let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
819 for wt_dir in worktrees {
820 let (Some(wt_label), Some((hosts, wt_errors))) =
821 (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
822 else {
823 continue;
824 };
825 errors.extend(wt_errors);
826 found.push((wt_label, hosts));
827 }
828 let (worktrees, wt_errors) = group_worktrees(label, found);
829 project.worktrees = worktrees;
830 errors.extend(wt_errors);
831
832 Some((project, errors))
833}
834
835fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
838 let worktrees = cached_worktree_dirs(primary);
839 let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
840 for err in errors {
841 warn_once(&err);
842 }
843 Some(project)
844}
845
846impl HostRegistry {
847 pub fn build() -> Self {
857 Self::from_dirs(&candidate_dirs())
858 }
859
860 pub fn from_dirs(dirs: &[PathBuf]) -> Self {
862 let mut primaries: Vec<PathBuf> = Vec::new();
864 for dir in dirs {
865 if !dir.exists() {
866 continue;
867 }
868 let primary = detect_checkout(dir).primary;
869 if !primaries.contains(&primary) {
870 primaries.push(primary);
871 }
872 }
873
874 let mut registry = Self::default();
875 let mut colliding: Vec<String> = Vec::new();
876 for primary in primaries {
877 let Some(label) = project_label(&primary) else {
878 continue;
879 };
880
881 if let Some(existing) = registry.projects.get(&label) {
882 if existing.primary.dir != primary {
883 let registered = registered_project_label(&existing.primary.dir).is_some()
884 || registered_project_label(&primary).is_some();
885 registry.errors.push(project_label_collision_message(
886 &label,
887 &existing.primary.dir,
888 &primary,
889 registered,
890 ));
891 colliding.push(label);
895 }
896 continue;
897 }
898
899 let worktrees = worktree_dirs(&primary);
900 let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
901 continue;
902 };
903 registry.errors.extend(errors);
904 if project.checkouts().all(|c| c.daemons.is_empty()) {
908 continue;
909 }
910 registry.projects.insert(label, project);
911 }
912 for label in colliding {
913 registry.projects.remove(&label);
914 }
915
916 registry
917 }
918
919 pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
929 self.projects
930 .values()
931 .flat_map(ProjectHosts::checkouts)
932 .filter(|c| c.namespace == namespace && c.daemons.values().any(|d| d.name == daemon))
933 .count()
934 > 1
935 }
936
937 pub fn project_labels(&self) -> Vec<String> {
939 let mut labels: Vec<String> = self.projects.keys().cloned().collect();
940 labels.sort();
941 labels
942 }
943
944 pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
952 let labels: Vec<String> = subdomain
953 .split('.')
954 .map(|l| l.to_ascii_lowercase())
955 .collect();
956 let Some((project_label, rest)) = labels.split_last() else {
957 return HostTarget::UnknownProject {
958 known: self.project_labels(),
959 };
960 };
961 let Some(project) = self.projects.get(project_label) else {
962 return HostTarget::UnknownProject {
963 known: self.project_labels(),
964 };
965 };
966 if rest.is_empty() {
967 return HostTarget::ProjectPage {
968 project: project.label.clone(),
969 };
970 }
971
972 let (checkout, worktree, rest) = match rest.split_last() {
974 Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
975 Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
976 None => (&project.primary, None, rest),
977 },
978 None => (&project.primary, None, rest),
979 };
980
981 let Some((daemon_label, extra)) = rest.split_last() else {
982 return HostTarget::WorktreePage {
983 project: project.label.clone(),
984 worktree: worktree.unwrap_or_default(),
985 };
986 };
987 if !extra.is_empty() && !wildcard {
988 return HostTarget::UnknownDaemon {
989 project: project.label.clone(),
990 worktree,
991 known: checkout.labels(),
992 };
993 }
994
995 match checkout.daemons.get(daemon_label) {
996 Some(daemon) => HostTarget::Daemon {
997 project: project.label.clone(),
998 worktree,
999 dir: checkout.dir.clone(),
1000 namespace: checkout.namespace.clone(),
1001 daemon: daemon.name.clone(),
1002 proxy_tls: daemon.proxy_tls,
1003 proxy_protocol: daemon.proxy_protocol,
1004 proxy_tls_port: daemon.proxy_tls_port,
1005 },
1006 None => HostTarget::UnknownDaemon {
1007 project: project.label.clone(),
1008 worktree,
1009 known: checkout.labels(),
1010 },
1011 }
1012 }
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018
1019 fn canonical(path: &Path) -> PathBuf {
1022 dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
1023 }
1024
1025 fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
1026 CheckoutHosts {
1027 dir: PathBuf::from(dir),
1028 namespace: namespace.to_string(),
1029 daemons: daemons
1030 .iter()
1031 .map(|(l, n)| {
1032 (
1033 l.to_string(),
1034 DaemonHost {
1035 name: n.to_string(),
1036 proxy_tls: None,
1037 proxy_protocol: None,
1038 proxy_tls_port: None,
1039 },
1040 )
1041 })
1042 .collect(),
1043 }
1044 }
1045
1046 #[test]
1047 fn test_sanitize_label() {
1048 assert_eq!(sanitize_label("api").as_deref(), Some("api"));
1049 assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
1050 assert_eq!(
1051 sanitize_label("feature/my_branch").as_deref(),
1052 Some("feature-my-branch")
1053 );
1054 assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
1055 assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
1056 assert_eq!(sanitize_label("---"), None);
1057 assert_eq!(sanitize_label(""), None);
1058 assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
1059 }
1060
1061 #[test]
1062 fn test_daemon_label_override_and_opt_out() {
1063 assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
1064 assert_eq!(
1065 daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
1066 Some("api")
1067 );
1068 assert_eq!(
1069 daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
1070 Some("web-ui")
1071 );
1072 assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
1073 }
1074
1075 #[test]
1077 fn test_detect_checkout_primary() {
1078 let temp = tempfile::tempdir().unwrap();
1079 let repo = temp.path().join("my-repo");
1080 std::fs::create_dir_all(repo.join(".git")).unwrap();
1081 std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
1082
1083 let found = detect_checkout(&repo.join("sub/dir"));
1084 assert_eq!(found.primary, canonical(&repo));
1085 assert_eq!(found.worktree, None);
1086 }
1087
1088 #[test]
1090 fn test_detect_checkout_linked_worktree() {
1091 let temp = tempfile::tempdir().unwrap();
1092 let repo = temp.path().join("my-repo");
1093 std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
1094 let wt = temp.path().join("fix-1");
1095 std::fs::create_dir_all(&wt).unwrap();
1096 std::fs::write(
1097 wt.join(".git"),
1098 format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
1099 )
1100 .unwrap();
1101
1102 let found = detect_checkout(&wt);
1103 assert_eq!(found.primary, canonical(&repo));
1104 assert_eq!(found.worktree, Some(canonical(&wt)));
1105 assert_eq!(found.root(), canonical(&wt));
1106 }
1107
1108 #[test]
1111 fn test_checkout_root_of_missing_directory() {
1112 let temp = tempfile::tempdir().unwrap();
1113 let repo = temp.path().join("my-repo");
1114 std::fs::create_dir_all(repo.join(".git")).unwrap();
1115
1116 assert_eq!(
1117 checkout_root_of(&repo.join("gone/deeper")),
1118 canonical(&repo)
1119 );
1120 }
1121
1122 #[cfg(unix)]
1126 #[test]
1127 fn test_detect_checkout_primary_spelling_matches_through_symlink() {
1128 use std::os::unix::fs::symlink;
1129
1130 let temp = tempfile::tempdir().unwrap();
1131 let repo = temp.path().join("my-repo");
1132 let admin = repo.join(".git/worktrees/fix-1");
1133 std::fs::create_dir_all(&admin).unwrap();
1134 let wt = temp.path().join("fix-1");
1135 std::fs::create_dir_all(&wt).unwrap();
1136
1137 let alias = temp.path().join("alias");
1140 symlink(&repo, &alias).unwrap();
1141 std::fs::write(
1142 wt.join(".git"),
1143 format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
1144 )
1145 .unwrap();
1146
1147 assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1148 assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1149 }
1150
1151 #[test]
1155 fn test_detect_checkout_bare_repository_worktree() {
1156 let temp = tempfile::tempdir().unwrap();
1157 let bare = temp.path().join("my-repo.git");
1158 let admin = bare.join("worktrees/fix-1");
1159 std::fs::create_dir_all(&admin).unwrap();
1160 let wt = temp.path().join("fix-1");
1161 std::fs::create_dir_all(&wt).unwrap();
1162 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1163
1164 let found = detect_checkout(&wt);
1165 assert_eq!(found.primary, canonical(&wt));
1166 assert_eq!(found.worktree, None);
1167 }
1168
1169 #[test]
1172 fn test_auto_host_skips_global_config_daemons() {
1173 let config = PitchforkTomlDaemon {
1174 run: "server".into(),
1175 port: Some(crate::config_types::PortConfig {
1176 expect: vec![3000],
1177 ..Default::default()
1178 }),
1179 path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1180 ..PitchforkTomlDaemon::default()
1181 };
1182 let id = DaemonId::try_new("global", "api").unwrap();
1183 assert_eq!(auto_host_for_daemon(&id, &config), None);
1184 }
1185
1186 #[test]
1189 fn test_routes_label_to_matches_namespace_and_name() {
1190 let hosts = checkout("/repo", "repo", &[("api", "api"), ("www", "web")]);
1191 let id = |ns: &str, name: &str| DaemonId::try_new(ns, name).unwrap();
1192 assert!(routes_label_to(&hosts, "api", &id("repo", "api")));
1193 assert!(routes_label_to(&hosts, "www", &id("repo", "web")));
1194 assert!(!routes_label_to(&hosts, "api", &id("frontend", "api")));
1195 assert!(!routes_label_to(&hosts, "web", &id("repo", "web")));
1196 assert!(!routes_label_to(&hosts, "api", &id("repo", "web")));
1197 }
1198
1199 #[test]
1202 fn test_host_for_daemon_prefers_a_registered_slug() {
1203 let temp = tempfile::tempdir().unwrap();
1204 let repo = temp.path().join("slug-repo");
1205 std::fs::create_dir_all(&repo).unwrap();
1206 write_config(&repo, &[("api", "")]);
1207 let (id, config) = daemon_config(&repo, "api");
1208
1209 let empty = indexmap::IndexMap::new();
1210 assert_eq!(
1211 host_for_daemon(&id, Some(&config), &empty).as_deref(),
1212 Some("api.slug-repo")
1213 );
1214
1215 let mut slugs = indexmap::IndexMap::new();
1217 slugs.insert(
1218 "myapp".to_string(),
1219 crate::pitchfork_toml::SlugEntry {
1220 dir: Some(repo.clone()),
1221 namespace: Some(id.namespace().to_string()),
1222 daemon: Some("api".to_string()),
1223 },
1224 );
1225 assert_eq!(
1226 host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1227 Some("myapp")
1228 );
1229 }
1230
1231 #[test]
1234 fn test_detect_checkout_submodule_pointer() {
1235 let temp = tempfile::tempdir().unwrap();
1236 let repo = temp.path().join("my-repo");
1237 std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1238 let sub = repo.join("sub");
1239 std::fs::create_dir_all(&sub).unwrap();
1240 std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1241
1242 let found = detect_checkout(&sub);
1243 assert_eq!(found.primary, canonical(&sub));
1244 assert_eq!(found.worktree, None);
1245 }
1246
1247 #[test]
1249 fn test_detect_checkout_without_git() {
1250 let temp = tempfile::tempdir().unwrap();
1251 let dir = temp.path().join("plain");
1252 std::fs::create_dir_all(&dir).unwrap();
1253
1254 let found = detect_checkout(&dir);
1255 assert_eq!(found.primary, canonical(&dir));
1256 assert_eq!(found.worktree, None);
1257 }
1258
1259 fn registry() -> HostRegistry {
1260 let mut projects = HashMap::new();
1261 let mut worktrees = HashMap::new();
1262 worktrees.insert(
1263 "fix-1".to_string(),
1264 checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1265 );
1266 projects.insert(
1267 "myproj".to_string(),
1268 ProjectHosts {
1269 label: "myproj".to_string(),
1270 primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1271 worktrees,
1272 },
1273 );
1274 HostRegistry {
1275 projects,
1276 errors: vec![],
1277 }
1278 }
1279
1280 #[test]
1284 fn test_checkout_hosts_capture_proxy_tls() {
1285 let dir = tempfile::tempdir().unwrap();
1286 let project = dir.path().join("tlsproj");
1287 std::fs::create_dir_all(&project).unwrap();
1288 std::fs::write(
1289 project.join("pitchfork.toml"),
1290 "[daemons.secure]\nrun = \"serve\"\nport = [8443, 9443]\n\
1291 proxy_tls = \"passthrough\"\nproxy_tls_port = 9443\n\
1292 [daemons.plain]\nrun = \"serve\"\nport = 8080\n",
1293 )
1294 .unwrap();
1295
1296 let (hosts, errors) = CheckoutHosts::load(&project).expect("checkout loads");
1297 assert!(errors.is_empty(), "{errors:?}");
1298
1299 let secure = hosts.daemons.get("secure").expect("secure is routable");
1300 assert_eq!(secure.name, "secure");
1301 assert_eq!(
1302 secure.proxy_tls,
1303 Some(crate::config_types::ProxyTlsMode::Passthrough)
1304 );
1305 assert_eq!(secure.proxy_tls_port, Some(9443));
1306
1307 let plain = hosts.daemons.get("plain").expect("plain is routable");
1308 assert_eq!(plain.proxy_tls, None);
1309 assert_eq!(plain.proxy_tls_port, None);
1310 }
1311
1312 #[test]
1313 fn test_resolve_primary_checkout_daemon() {
1314 let target = registry().resolve("api.myproj", true);
1315 assert_eq!(
1316 target,
1317 HostTarget::Daemon {
1318 project: "myproj".into(),
1319 worktree: None,
1320 dir: PathBuf::from("/repos/myproj"),
1321 namespace: "myproj".into(),
1322 daemon: "api".into(),
1323 proxy_tls: None,
1324 proxy_protocol: None,
1325 proxy_tls_port: None,
1326 }
1327 );
1328 }
1329
1330 #[test]
1331 fn test_resolve_worktree_daemon() {
1332 let target = registry().resolve("web.fix-1.myproj", true);
1333 assert_eq!(
1334 target,
1335 HostTarget::Daemon {
1336 project: "myproj".into(),
1337 worktree: Some("fix-1".into()),
1338 dir: PathBuf::from("/repos/fix-1"),
1339 namespace: "fix-1".into(),
1340 daemon: "web".into(),
1341 proxy_tls: None,
1342 proxy_protocol: None,
1343 proxy_tls_port: None,
1344 }
1345 );
1346 }
1347
1348 #[test]
1349 fn test_resolve_is_case_insensitive() {
1350 assert!(matches!(
1351 registry().resolve("API.MyProj", true),
1352 HostTarget::Daemon { .. }
1353 ));
1354 }
1355
1356 #[test]
1358 fn test_resolve_reserved_pages() {
1359 assert_eq!(
1360 registry().resolve("myproj", true),
1361 HostTarget::ProjectPage {
1362 project: "myproj".into()
1363 }
1364 );
1365 assert_eq!(
1366 registry().resolve("fix-1.myproj", true),
1367 HostTarget::WorktreePage {
1368 project: "myproj".into(),
1369 worktree: "fix-1".into(),
1370 }
1371 );
1372 }
1373
1374 #[test]
1378 fn test_resolve_worktree_beats_daemon_of_same_name() {
1379 let mut reg = registry();
1380 reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1382 "api".to_string(),
1383 checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1384 );
1385 assert_eq!(
1386 reg.resolve("api.myproj", true),
1387 HostTarget::WorktreePage {
1388 project: "myproj".into(),
1389 worktree: "api".into(),
1390 }
1391 );
1392 assert!(matches!(
1394 reg.resolve("api.api.myproj", true),
1395 HostTarget::Daemon { .. }
1396 ));
1397 }
1398
1399 #[test]
1400 fn test_resolve_wildcard_subdomain() {
1401 let target = registry().resolve("tenant.api.myproj", true);
1402 assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1403 assert!(matches!(
1405 registry().resolve("tenant.api.myproj", false),
1406 HostTarget::UnknownDaemon { .. }
1407 ));
1408 }
1409
1410 #[test]
1411 fn test_resolve_unknown_names() {
1412 assert_eq!(
1413 registry().resolve("api.other", true),
1414 HostTarget::UnknownProject {
1415 known: vec!["myproj".to_string()]
1416 }
1417 );
1418 assert_eq!(
1419 registry().resolve("nope.myproj", true),
1420 HostTarget::UnknownDaemon {
1421 project: "myproj".into(),
1422 worktree: None,
1423 known: vec!["api".to_string()],
1424 }
1425 );
1426 }
1427
1428 fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1431 let repo = temp.join(name);
1432 let admin = repo.join(format!(".git/worktrees/{worktree}"));
1433 std::fs::create_dir_all(&admin).unwrap();
1434 let wt = temp.join(worktree);
1435 std::fs::create_dir_all(&wt).unwrap();
1436 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1437 std::fs::write(
1438 admin.join("gitdir"),
1439 format!("{}\n", wt.join(".git").display()),
1440 )
1441 .unwrap();
1442 (repo, wt)
1443 }
1444
1445 #[test]
1448 fn test_project_label_from_directory_name() {
1449 let temp = tempfile::tempdir().unwrap();
1450 let repo = temp.path().join("My App");
1451 std::fs::create_dir_all(&repo).unwrap();
1452 assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1453 }
1454
1455 #[test]
1457 fn test_pick_project_label_precedence() {
1458 let repo = Path::new("/work/Shop Repo");
1459 let ns = Some("shop-528f92b13a6784f0");
1460 assert_eq!(
1461 pick_project_label(repo, Some("shop"), ns).as_deref(),
1462 Some("shop")
1463 );
1464 assert_eq!(
1465 pick_project_label(repo, None, ns).as_deref(),
1466 Some("shop-528f92b13a6784f0")
1467 );
1468 assert_eq!(
1469 pick_project_label(repo, None, None).as_deref(),
1470 Some("shop-repo")
1471 );
1472 assert_eq!(
1474 pick_project_label(repo, Some("---"), ns).as_deref(),
1475 Some("shop-528f92b13a6784f0")
1476 );
1477 }
1478
1479 #[test]
1480 fn test_validate_registered_label() {
1481 assert!(validate_registered_label("shop").is_ok());
1482 assert!(validate_registered_label("my-shop-2").is_ok());
1483 assert!(validate_registered_label(&"a".repeat(63)).is_ok());
1484 for bad in [
1485 "Shop", "my_shop", "-shop", "shop-", "a--b", "a.b", "", "---",
1486 ] {
1487 assert!(validate_registered_label(bad).is_err(), "{bad:?}");
1488 }
1489 let long = validate_registered_label(&"a".repeat(64)).unwrap_err();
1490 assert!(long.contains("63"), "{long}");
1491 let hint = validate_registered_label("My Shop").unwrap_err();
1492 assert!(hint.contains("'my-shop'"), "{hint}");
1493 }
1494
1495 #[test]
1497 fn test_project_label_from_explicit_namespace() {
1498 let temp = tempfile::tempdir().unwrap();
1499 let repo = temp.path().join("checkout-dir");
1500 std::fs::create_dir_all(&repo).unwrap();
1501 std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1502 assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1503 }
1504
1505 fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1507 let body: String = daemons
1508 .iter()
1509 .enumerate()
1510 .map(|(i, (name, extra))| {
1511 format!(
1512 "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1513 3000 + i
1514 )
1515 })
1516 .collect();
1517 std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1518 }
1519
1520 fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1522 let pt = PitchforkToml::all_merged_from(dir).unwrap();
1523 let (id, config) = pt
1524 .daemons
1525 .iter()
1526 .find(|(id, _)| id.name() == name)
1527 .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1528 (id.clone(), config.clone())
1529 }
1530
1531 #[test]
1534 fn test_auto_host_primary_and_worktree() {
1535 let temp = tempfile::tempdir().unwrap();
1536 let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1537 write_config(&repo, &[("api", "")]);
1538 write_config(&wt, &[("api", "")]);
1539
1540 let (id, config) = daemon_config(&repo, "api");
1541 assert_eq!(
1542 auto_host_for_daemon(&id, &config).as_deref(),
1543 Some("api.my-repo")
1544 );
1545
1546 let (wt_id, wt_config) = daemon_config(&wt, "api");
1547 assert_eq!(
1548 auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1549 Some("api.fix-1.my-repo")
1550 );
1551 }
1552
1553 #[test]
1555 fn test_worktree_label_override() {
1556 let temp = tempfile::tempdir().unwrap();
1557 let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1558 assert_eq!(
1559 worktree_label(&wt).as_deref(),
1560 Some("sleepy-kapitsa-9f02fc")
1561 );
1562
1563 std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1564 assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1565 }
1566
1567 #[test]
1570 fn test_auto_host_requires_port_and_opt_in() {
1571 let temp = tempfile::tempdir().unwrap();
1572 let repo = temp.path().join("opt-in-repo");
1573 std::fs::create_dir_all(&repo).unwrap();
1574 std::fs::write(
1575 repo.join("pitchfork.toml"),
1576 "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1577 [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1578 [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1579 [daemons.worker]\nrun = \"server\"\n",
1580 )
1581 .unwrap();
1582
1583 let host = |name: &str| {
1584 let (id, config) = daemon_config(&repo, name);
1585 auto_host_for_daemon(&id, &config)
1586 };
1587 assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1588 assert_eq!(host("admin"), None);
1589 assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1590 assert_eq!(host("worker"), None);
1591 }
1592
1593 #[test]
1596 fn test_daemon_label_collision_drops_both() {
1597 let temp = tempfile::tempdir().unwrap();
1598 let repo = temp.path().join("dupe-repo");
1599 std::fs::create_dir_all(&repo).unwrap();
1600 std::fs::write(
1601 repo.join("pitchfork.toml"),
1602 "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1603 [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1604 [daemons.other]\nrun = \"server\"\nport = 3002\n",
1605 )
1606 .unwrap();
1607
1608 let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1609 assert_eq!(hosts.labels(), vec!["other".to_string()]);
1610 assert_eq!(errors.len(), 1);
1611 assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1612
1613 for name in ["foo_bar", "foo-bar"] {
1614 let (id, config) = daemon_config(&repo, name);
1615 assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1616 }
1617 let (id, config) = daemon_config(&repo, "other");
1618 assert_eq!(
1619 auto_host_for_daemon(&id, &config).as_deref(),
1620 Some("other.dupe-repo")
1621 );
1622 }
1623
1624 #[test]
1627 fn test_hostname_fits() {
1628 assert!(hostname_fits(&format!(
1630 "{}.{}.{}",
1631 "a".repeat(63),
1632 "b".repeat(63),
1633 "c".repeat(63)
1634 )));
1635 assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1636 }
1637
1638 #[test]
1641 fn test_auto_host_none_for_colliding_worktrees() {
1642 let temp = tempfile::tempdir().unwrap();
1643 let repo = temp.path().join("wt-repo");
1644 std::fs::create_dir_all(repo.join(".git")).unwrap();
1645 write_config(&repo, &[("api", "")]);
1646
1647 let mut worktrees = Vec::new();
1648 for dir_name in ["fix-1", "fix.1"] {
1649 let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1650 std::fs::create_dir_all(&admin).unwrap();
1651 let wt = temp.path().join(dir_name);
1652 std::fs::create_dir_all(&wt).unwrap();
1653 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1654 std::fs::write(
1655 admin.join("gitdir"),
1656 format!("{}\n", wt.join(".git").display()),
1657 )
1658 .unwrap();
1659 write_config(&wt, &[("api", "")]);
1660 worktrees.push(wt);
1661 }
1662
1663 for wt in &worktrees {
1664 let (id, config) = daemon_config(wt, "api");
1665 assert_eq!(
1666 auto_host_for_daemon(&id, &config),
1667 None,
1668 "{} must not advertise a hostname",
1669 wt.display()
1670 );
1671 }
1672 let (id, config) = daemon_config(&repo, "api");
1674 assert_eq!(
1675 auto_host_for_daemon(&id, &config).as_deref(),
1676 Some("api.wt-repo")
1677 );
1678 }
1679
1680 #[test]
1684 fn test_shares_daemon_id() {
1685 let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1686 (
1687 label.to_string(),
1688 ProjectHosts {
1689 label: label.to_string(),
1690 primary,
1691 worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1692 },
1693 )
1694 };
1695
1696 let mut registry = HostRegistry {
1698 projects: HashMap::from([project(
1699 "myproj",
1700 checkout("/repos/myproj", "myproj", &[("api", "api")]),
1701 vec![(
1702 "fix-1",
1703 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1704 )],
1705 )]),
1706 errors: vec![],
1707 };
1708 assert!(!registry.shares_daemon_id("myproj", "api"));
1709 assert!(!registry.shares_daemon_id("fix-1", "api"));
1710
1711 registry
1714 .projects
1715 .get_mut("myproj")
1716 .unwrap()
1717 .worktrees
1718 .insert(
1719 "fix-2".to_string(),
1720 checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1721 );
1722 assert!(registry.shares_daemon_id("myproj", "api"));
1723
1724 let (label, other) = project(
1727 "other",
1728 checkout("/repos/other", "other", &[("api", "api")]),
1729 vec![(
1730 "fix-1",
1731 checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1732 )],
1733 );
1734 registry.projects.insert(label, other);
1735 assert!(registry.shares_daemon_id("fix-1", "api"));
1736
1737 assert!(!registry.shares_daemon_id("myproj", "worker"));
1739 }
1740
1741 #[test]
1744 fn test_worktree_label_collision_drops_both() {
1745 let found = vec![
1746 (
1747 "fix-1".to_string(),
1748 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1749 ),
1750 (
1751 "fix-1".to_string(),
1752 checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1753 ),
1754 (
1755 "fix-2".to_string(),
1756 checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1757 ),
1758 ];
1759 let (kept, errors) = group_worktrees("myproj", found);
1760 assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1761 assert_eq!(errors.len(), 1);
1762 assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1763 assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1764 }
1765
1766 #[test]
1769 fn test_project_label_collision_message_depends_on_source() {
1770 let a = Path::new("/repos/a");
1771 let b = Path::new("/repos/b");
1772 let plain = project_label_collision_message("shop", a, b, false);
1773 assert!(plain.contains("`namespace`"), "{plain}");
1774 assert!(!plain.contains("--label"), "{plain}");
1775 let registered = project_label_collision_message("shop", a, b, true);
1776 assert!(
1777 registered.contains("pitchfork config add <file> --label <other>"),
1778 "{registered}"
1779 );
1780 assert!(!registered.contains("`namespace`"), "{registered}");
1781 for msg in [&plain, ®istered] {
1782 assert!(msg.contains("'shop'") && msg.contains("/repos/a") && msg.contains("/repos/b"));
1783 }
1784 }
1785
1786 #[test]
1789 fn test_project_label_collision_routes_neither() {
1790 let temp = tempfile::tempdir().unwrap();
1791 let a = temp.path().join("a/shop");
1792 let b = temp.path().join("b/shop");
1793 std::fs::create_dir_all(&a).unwrap();
1794 std::fs::create_dir_all(&b).unwrap();
1795 write_config(&a, &[("api", "")]);
1796 write_config(&b, &[("api", "")]);
1797
1798 let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1799 assert!(registry.project_labels().is_empty());
1800 assert_eq!(registry.errors.len(), 1);
1801 assert!(
1802 registry.errors[0].contains("shop"),
1803 "{}",
1804 registry.errors[0]
1805 );
1806 assert!(
1809 registry.errors[0].contains(&canonical(&b).display().to_string()),
1810 "{}",
1811 registry.errors[0]
1812 );
1813 assert_eq!(
1814 registry.resolve("api.shop", true),
1815 HostTarget::UnknownProject { known: vec![] }
1816 );
1817 }
1818}