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_tls_port: Option<u16>,
453}
454
455impl CheckoutHosts {
456 fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
463 let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
464 let pt = PitchforkToml::all_merged_from(dir).ok()?;
465 let mut daemons: HashMap<String, DaemonHost> = HashMap::new();
466 let mut errors = Vec::new();
467 let mut colliding: Vec<String> = Vec::new();
468 for (id, config) in &pt.daemons {
469 if id.namespace() != namespace || config.port.is_none() {
470 continue;
471 }
472 let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
473 continue;
474 };
475 match daemons.get(&label) {
476 Some(other) => {
477 errors.push(format!(
478 "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
479 '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
480 dir.display(),
481 other = other.name,
482 name = id.name(),
483 ));
484 colliding.push(label);
485 }
486 None => {
487 daemons.insert(
488 label,
489 DaemonHost {
490 name: id.name().to_string(),
491 proxy_tls: config.proxy_tls,
492 proxy_tls_port: config.effective_proxy_tls_port(),
493 },
494 );
495 }
496 }
497 }
498 for label in colliding {
499 daemons.remove(&label);
500 }
501 Some((
502 Self {
503 dir: dir.to_path_buf(),
504 namespace,
505 daemons,
506 },
507 errors,
508 ))
509 }
510
511 pub fn labels(&self) -> Vec<String> {
513 let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
514 labels.sort();
515 labels
516 }
517}
518
519#[derive(Debug, Clone)]
521pub struct ProjectHosts {
522 pub label: String,
523 pub primary: CheckoutHosts,
524 pub worktrees: HashMap<String, CheckoutHosts>,
526}
527
528impl ProjectHosts {
529 pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
531 std::iter::once(&self.primary).chain(self.worktrees.values())
532 }
533
534 pub fn worktree_labels(&self) -> Vec<String> {
536 let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
537 labels.sort();
538 labels
539 }
540}
541
542#[derive(Debug, Clone, Default)]
544pub struct HostRegistry {
545 pub projects: HashMap<String, ProjectHosts>,
546 pub errors: Vec<String>,
548}
549
550#[derive(Debug, Clone, PartialEq, Eq)]
552pub enum HostTarget {
553 Daemon {
555 project: String,
556 worktree: Option<String>,
557 dir: PathBuf,
558 namespace: String,
559 daemon: String,
560 proxy_tls: Option<crate::config_types::ProxyTlsMode>,
563 proxy_tls_port: Option<u16>,
565 },
566 ProjectPage { project: String },
568 WorktreePage { project: String, worktree: String },
570 UnknownProject { known: Vec<String> },
572 UnknownDaemon {
574 project: String,
575 worktree: Option<String>,
576 known: Vec<String>,
577 },
578}
579
580fn group_worktrees(
586 project: &str,
587 found: Vec<(String, CheckoutHosts)>,
588) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
589 let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
590 let mut errors = Vec::new();
591 let mut colliding: Vec<String> = Vec::new();
592 for (label, hosts) in found {
593 match kept.get(&label) {
594 Some(existing) if existing.dir == hosts.dir => continue,
595 Some(existing) => {
596 errors.push(format!(
597 "worktree label '{label}' in project '{project}' is claimed by two \
598 directories: {} and {}. Set `worktree_label` in one of them.",
599 existing.dir.display(),
600 hosts.dir.display(),
601 ));
602 colliding.push(label);
603 }
604 None => {
605 kept.insert(label, hosts);
606 }
607 }
608 }
609 for label in colliding {
610 kept.remove(&label);
611 }
612 (kept, errors)
613}
614
615const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
623
624struct WorktreeCache {
625 entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
626}
627
628static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
629 once_cell::sync::Lazy::new(|| {
630 std::sync::Mutex::new(WorktreeCache {
631 entries: HashMap::new(),
632 })
633 });
634
635fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
637 let now = std::time::Instant::now();
638 {
639 let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
640 if let Some((expires_at, dirs)) = cache.entries.get(primary)
641 && now < *expires_at
642 {
643 return std::sync::Arc::clone(dirs);
644 }
645 } let dirs = std::sync::Arc::new(worktree_dirs(primary));
648
649 let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
650 cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
651 cache.entries.insert(
652 primary.to_path_buf(),
653 (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
654 );
655 dirs
656}
657
658pub fn warn_once(message: &str) {
665 static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
666 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
667
668 let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
669 if seen.len() > 256 {
672 seen.clear();
673 }
674 if seen.insert(message.to_string()) {
675 log::warn!("{message}");
676 }
677}
678
679fn candidate_dirs() -> Vec<PathBuf> {
687 let mut candidates: Vec<PathBuf> = Vec::new();
688 for (_, entry) in PitchforkToml::read_global_namespaces() {
689 candidates.push(entry.dir);
690 }
691 for (_, entry) in PitchforkToml::read_global_slugs() {
692 if let Some(dir) = entry.resolve_dir() {
693 candidates.push(dir);
694 }
695 }
696 if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
697 for daemon in state.daemons.values() {
698 if let Some(dir) = &daemon.dir {
699 candidates.push(dir.clone());
700 }
701 }
702 }
703 candidates
704}
705
706fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
712 let mut seen: Vec<PathBuf> = Vec::new();
713 for dir in candidate_dirs() {
714 if !dir.exists() {
715 continue;
716 }
717 let other = detect_checkout(&dir).primary;
718 if other == primary || seen.contains(&other) {
719 continue;
720 }
721 seen.push(other.clone());
722 if project_label(&other).as_deref() == Some(label) {
723 return true;
724 }
725 }
726 false
727}
728
729fn project_label_collision_message(
735 label: &str,
736 first: &Path,
737 second: &Path,
738 from_registration: bool,
739) -> String {
740 let advice = if from_registration {
741 "Register a different label for one of them with \
742 `pitchfork config add <file> --label <other>`."
743 } else {
744 "Set a distinct top-level `namespace` in one of them."
745 };
746 format!(
747 "project label '{label}' is claimed by two directories: {} and {}. {advice}",
748 first.display(),
749 second.display(),
750 )
751}
752
753fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
760 let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
761 return vec![];
762 };
763 entries
764 .filter_map(|entry| {
765 let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
766 let git_file = PathBuf::from(gitdir.trim());
767 git_file.parent().map(Path::to_path_buf)
768 })
769 .collect()
770}
771
772fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
777 if !crate::settings::settings().general.worktree {
778 return vec![];
779 }
780 let mut dirs: Vec<PathBuf> = Vec::new();
781 let found = crate::proxy::worktree::discover_worktrees(primary)
782 .into_iter()
783 .map(|entry| entry.path)
784 .chain(linked_worktree_dirs(primary));
785 for path in found {
786 let Some(wt_dir) = detect_checkout(&path).worktree else {
787 continue; };
789 if !dirs.contains(&wt_dir) {
790 dirs.push(wt_dir);
791 }
792 }
793 dirs
794}
795
796fn build_project_hosts(
802 primary: &Path,
803 label: &str,
804 worktrees: &[PathBuf],
805) -> Option<(ProjectHosts, Vec<String>)> {
806 let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
807 let mut project = ProjectHosts {
808 label: label.to_string(),
809 primary: primary_hosts,
810 worktrees: HashMap::new(),
811 };
812
813 let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
814 for wt_dir in worktrees {
815 let (Some(wt_label), Some((hosts, wt_errors))) =
816 (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
817 else {
818 continue;
819 };
820 errors.extend(wt_errors);
821 found.push((wt_label, hosts));
822 }
823 let (worktrees, wt_errors) = group_worktrees(label, found);
824 project.worktrees = worktrees;
825 errors.extend(wt_errors);
826
827 Some((project, errors))
828}
829
830fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
833 let worktrees = cached_worktree_dirs(primary);
834 let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
835 for err in errors {
836 warn_once(&err);
837 }
838 Some(project)
839}
840
841impl HostRegistry {
842 pub fn build() -> Self {
852 Self::from_dirs(&candidate_dirs())
853 }
854
855 pub fn from_dirs(dirs: &[PathBuf]) -> Self {
857 let mut primaries: Vec<PathBuf> = Vec::new();
859 for dir in dirs {
860 if !dir.exists() {
861 continue;
862 }
863 let primary = detect_checkout(dir).primary;
864 if !primaries.contains(&primary) {
865 primaries.push(primary);
866 }
867 }
868
869 let mut registry = Self::default();
870 let mut colliding: Vec<String> = Vec::new();
871 for primary in primaries {
872 let Some(label) = project_label(&primary) else {
873 continue;
874 };
875
876 if let Some(existing) = registry.projects.get(&label) {
877 if existing.primary.dir != primary {
878 let registered = registered_project_label(&existing.primary.dir).is_some()
879 || registered_project_label(&primary).is_some();
880 registry.errors.push(project_label_collision_message(
881 &label,
882 &existing.primary.dir,
883 &primary,
884 registered,
885 ));
886 colliding.push(label);
890 }
891 continue;
892 }
893
894 let worktrees = worktree_dirs(&primary);
895 let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
896 continue;
897 };
898 registry.errors.extend(errors);
899 if project.checkouts().all(|c| c.daemons.is_empty()) {
903 continue;
904 }
905 registry.projects.insert(label, project);
906 }
907 for label in colliding {
908 registry.projects.remove(&label);
909 }
910
911 registry
912 }
913
914 pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
924 self.projects
925 .values()
926 .flat_map(ProjectHosts::checkouts)
927 .filter(|c| c.namespace == namespace && c.daemons.values().any(|d| d.name == daemon))
928 .count()
929 > 1
930 }
931
932 pub fn project_labels(&self) -> Vec<String> {
934 let mut labels: Vec<String> = self.projects.keys().cloned().collect();
935 labels.sort();
936 labels
937 }
938
939 pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
947 let labels: Vec<String> = subdomain
948 .split('.')
949 .map(|l| l.to_ascii_lowercase())
950 .collect();
951 let Some((project_label, rest)) = labels.split_last() else {
952 return HostTarget::UnknownProject {
953 known: self.project_labels(),
954 };
955 };
956 let Some(project) = self.projects.get(project_label) else {
957 return HostTarget::UnknownProject {
958 known: self.project_labels(),
959 };
960 };
961 if rest.is_empty() {
962 return HostTarget::ProjectPage {
963 project: project.label.clone(),
964 };
965 }
966
967 let (checkout, worktree, rest) = match rest.split_last() {
969 Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
970 Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
971 None => (&project.primary, None, rest),
972 },
973 None => (&project.primary, None, rest),
974 };
975
976 let Some((daemon_label, extra)) = rest.split_last() else {
977 return HostTarget::WorktreePage {
978 project: project.label.clone(),
979 worktree: worktree.unwrap_or_default(),
980 };
981 };
982 if !extra.is_empty() && !wildcard {
983 return HostTarget::UnknownDaemon {
984 project: project.label.clone(),
985 worktree,
986 known: checkout.labels(),
987 };
988 }
989
990 match checkout.daemons.get(daemon_label) {
991 Some(daemon) => HostTarget::Daemon {
992 project: project.label.clone(),
993 worktree,
994 dir: checkout.dir.clone(),
995 namespace: checkout.namespace.clone(),
996 daemon: daemon.name.clone(),
997 proxy_tls: daemon.proxy_tls,
998 proxy_tls_port: daemon.proxy_tls_port,
999 },
1000 None => HostTarget::UnknownDaemon {
1001 project: project.label.clone(),
1002 worktree,
1003 known: checkout.labels(),
1004 },
1005 }
1006 }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 fn canonical(path: &Path) -> PathBuf {
1016 dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
1017 }
1018
1019 fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
1020 CheckoutHosts {
1021 dir: PathBuf::from(dir),
1022 namespace: namespace.to_string(),
1023 daemons: daemons
1024 .iter()
1025 .map(|(l, n)| {
1026 (
1027 l.to_string(),
1028 DaemonHost {
1029 name: n.to_string(),
1030 proxy_tls: None,
1031 proxy_tls_port: None,
1032 },
1033 )
1034 })
1035 .collect(),
1036 }
1037 }
1038
1039 #[test]
1040 fn test_sanitize_label() {
1041 assert_eq!(sanitize_label("api").as_deref(), Some("api"));
1042 assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
1043 assert_eq!(
1044 sanitize_label("feature/my_branch").as_deref(),
1045 Some("feature-my-branch")
1046 );
1047 assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
1048 assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
1049 assert_eq!(sanitize_label("---"), None);
1050 assert_eq!(sanitize_label(""), None);
1051 assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
1052 }
1053
1054 #[test]
1055 fn test_daemon_label_override_and_opt_out() {
1056 assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
1057 assert_eq!(
1058 daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
1059 Some("api")
1060 );
1061 assert_eq!(
1062 daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
1063 Some("web-ui")
1064 );
1065 assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
1066 }
1067
1068 #[test]
1070 fn test_detect_checkout_primary() {
1071 let temp = tempfile::tempdir().unwrap();
1072 let repo = temp.path().join("my-repo");
1073 std::fs::create_dir_all(repo.join(".git")).unwrap();
1074 std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
1075
1076 let found = detect_checkout(&repo.join("sub/dir"));
1077 assert_eq!(found.primary, canonical(&repo));
1078 assert_eq!(found.worktree, None);
1079 }
1080
1081 #[test]
1083 fn test_detect_checkout_linked_worktree() {
1084 let temp = tempfile::tempdir().unwrap();
1085 let repo = temp.path().join("my-repo");
1086 std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
1087 let wt = temp.path().join("fix-1");
1088 std::fs::create_dir_all(&wt).unwrap();
1089 std::fs::write(
1090 wt.join(".git"),
1091 format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
1092 )
1093 .unwrap();
1094
1095 let found = detect_checkout(&wt);
1096 assert_eq!(found.primary, canonical(&repo));
1097 assert_eq!(found.worktree, Some(canonical(&wt)));
1098 assert_eq!(found.root(), canonical(&wt));
1099 }
1100
1101 #[test]
1104 fn test_checkout_root_of_missing_directory() {
1105 let temp = tempfile::tempdir().unwrap();
1106 let repo = temp.path().join("my-repo");
1107 std::fs::create_dir_all(repo.join(".git")).unwrap();
1108
1109 assert_eq!(
1110 checkout_root_of(&repo.join("gone/deeper")),
1111 canonical(&repo)
1112 );
1113 }
1114
1115 #[cfg(unix)]
1119 #[test]
1120 fn test_detect_checkout_primary_spelling_matches_through_symlink() {
1121 use std::os::unix::fs::symlink;
1122
1123 let temp = tempfile::tempdir().unwrap();
1124 let repo = temp.path().join("my-repo");
1125 let admin = repo.join(".git/worktrees/fix-1");
1126 std::fs::create_dir_all(&admin).unwrap();
1127 let wt = temp.path().join("fix-1");
1128 std::fs::create_dir_all(&wt).unwrap();
1129
1130 let alias = temp.path().join("alias");
1133 symlink(&repo, &alias).unwrap();
1134 std::fs::write(
1135 wt.join(".git"),
1136 format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
1137 )
1138 .unwrap();
1139
1140 assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1141 assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1142 }
1143
1144 #[test]
1148 fn test_detect_checkout_bare_repository_worktree() {
1149 let temp = tempfile::tempdir().unwrap();
1150 let bare = temp.path().join("my-repo.git");
1151 let admin = bare.join("worktrees/fix-1");
1152 std::fs::create_dir_all(&admin).unwrap();
1153 let wt = temp.path().join("fix-1");
1154 std::fs::create_dir_all(&wt).unwrap();
1155 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1156
1157 let found = detect_checkout(&wt);
1158 assert_eq!(found.primary, canonical(&wt));
1159 assert_eq!(found.worktree, None);
1160 }
1161
1162 #[test]
1165 fn test_auto_host_skips_global_config_daemons() {
1166 let config = PitchforkTomlDaemon {
1167 run: "server".into(),
1168 port: Some(crate::config_types::PortConfig {
1169 expect: vec![3000],
1170 ..Default::default()
1171 }),
1172 path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1173 ..PitchforkTomlDaemon::default()
1174 };
1175 let id = DaemonId::try_new("global", "api").unwrap();
1176 assert_eq!(auto_host_for_daemon(&id, &config), None);
1177 }
1178
1179 #[test]
1182 fn test_routes_label_to_matches_namespace_and_name() {
1183 let hosts = checkout("/repo", "repo", &[("api", "api"), ("www", "web")]);
1184 let id = |ns: &str, name: &str| DaemonId::try_new(ns, name).unwrap();
1185 assert!(routes_label_to(&hosts, "api", &id("repo", "api")));
1186 assert!(routes_label_to(&hosts, "www", &id("repo", "web")));
1187 assert!(!routes_label_to(&hosts, "api", &id("frontend", "api")));
1188 assert!(!routes_label_to(&hosts, "web", &id("repo", "web")));
1189 assert!(!routes_label_to(&hosts, "api", &id("repo", "web")));
1190 }
1191
1192 #[test]
1195 fn test_host_for_daemon_prefers_a_registered_slug() {
1196 let temp = tempfile::tempdir().unwrap();
1197 let repo = temp.path().join("slug-repo");
1198 std::fs::create_dir_all(&repo).unwrap();
1199 write_config(&repo, &[("api", "")]);
1200 let (id, config) = daemon_config(&repo, "api");
1201
1202 let empty = indexmap::IndexMap::new();
1203 assert_eq!(
1204 host_for_daemon(&id, Some(&config), &empty).as_deref(),
1205 Some("api.slug-repo")
1206 );
1207
1208 let mut slugs = indexmap::IndexMap::new();
1210 slugs.insert(
1211 "myapp".to_string(),
1212 crate::pitchfork_toml::SlugEntry {
1213 dir: Some(repo.clone()),
1214 namespace: Some(id.namespace().to_string()),
1215 daemon: Some("api".to_string()),
1216 },
1217 );
1218 assert_eq!(
1219 host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1220 Some("myapp")
1221 );
1222 }
1223
1224 #[test]
1227 fn test_detect_checkout_submodule_pointer() {
1228 let temp = tempfile::tempdir().unwrap();
1229 let repo = temp.path().join("my-repo");
1230 std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1231 let sub = repo.join("sub");
1232 std::fs::create_dir_all(&sub).unwrap();
1233 std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1234
1235 let found = detect_checkout(&sub);
1236 assert_eq!(found.primary, canonical(&sub));
1237 assert_eq!(found.worktree, None);
1238 }
1239
1240 #[test]
1242 fn test_detect_checkout_without_git() {
1243 let temp = tempfile::tempdir().unwrap();
1244 let dir = temp.path().join("plain");
1245 std::fs::create_dir_all(&dir).unwrap();
1246
1247 let found = detect_checkout(&dir);
1248 assert_eq!(found.primary, canonical(&dir));
1249 assert_eq!(found.worktree, None);
1250 }
1251
1252 fn registry() -> HostRegistry {
1253 let mut projects = HashMap::new();
1254 let mut worktrees = HashMap::new();
1255 worktrees.insert(
1256 "fix-1".to_string(),
1257 checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1258 );
1259 projects.insert(
1260 "myproj".to_string(),
1261 ProjectHosts {
1262 label: "myproj".to_string(),
1263 primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1264 worktrees,
1265 },
1266 );
1267 HostRegistry {
1268 projects,
1269 errors: vec![],
1270 }
1271 }
1272
1273 #[test]
1277 fn test_checkout_hosts_capture_proxy_tls() {
1278 let dir = tempfile::tempdir().unwrap();
1279 let project = dir.path().join("tlsproj");
1280 std::fs::create_dir_all(&project).unwrap();
1281 std::fs::write(
1282 project.join("pitchfork.toml"),
1283 "[daemons.secure]\nrun = \"serve\"\nport = [8443, 9443]\n\
1284 proxy_tls = \"passthrough\"\nproxy_tls_port = 9443\n\
1285 [daemons.plain]\nrun = \"serve\"\nport = 8080\n",
1286 )
1287 .unwrap();
1288
1289 let (hosts, errors) = CheckoutHosts::load(&project).expect("checkout loads");
1290 assert!(errors.is_empty(), "{errors:?}");
1291
1292 let secure = hosts.daemons.get("secure").expect("secure is routable");
1293 assert_eq!(secure.name, "secure");
1294 assert_eq!(
1295 secure.proxy_tls,
1296 Some(crate::config_types::ProxyTlsMode::Passthrough)
1297 );
1298 assert_eq!(secure.proxy_tls_port, Some(9443));
1299
1300 let plain = hosts.daemons.get("plain").expect("plain is routable");
1301 assert_eq!(plain.proxy_tls, None);
1302 assert_eq!(plain.proxy_tls_port, None);
1303 }
1304
1305 #[test]
1306 fn test_resolve_primary_checkout_daemon() {
1307 let target = registry().resolve("api.myproj", true);
1308 assert_eq!(
1309 target,
1310 HostTarget::Daemon {
1311 project: "myproj".into(),
1312 worktree: None,
1313 dir: PathBuf::from("/repos/myproj"),
1314 namespace: "myproj".into(),
1315 daemon: "api".into(),
1316 proxy_tls: None,
1317 proxy_tls_port: None,
1318 }
1319 );
1320 }
1321
1322 #[test]
1323 fn test_resolve_worktree_daemon() {
1324 let target = registry().resolve("web.fix-1.myproj", true);
1325 assert_eq!(
1326 target,
1327 HostTarget::Daemon {
1328 project: "myproj".into(),
1329 worktree: Some("fix-1".into()),
1330 dir: PathBuf::from("/repos/fix-1"),
1331 namespace: "fix-1".into(),
1332 daemon: "web".into(),
1333 proxy_tls: None,
1334 proxy_tls_port: None,
1335 }
1336 );
1337 }
1338
1339 #[test]
1340 fn test_resolve_is_case_insensitive() {
1341 assert!(matches!(
1342 registry().resolve("API.MyProj", true),
1343 HostTarget::Daemon { .. }
1344 ));
1345 }
1346
1347 #[test]
1349 fn test_resolve_reserved_pages() {
1350 assert_eq!(
1351 registry().resolve("myproj", true),
1352 HostTarget::ProjectPage {
1353 project: "myproj".into()
1354 }
1355 );
1356 assert_eq!(
1357 registry().resolve("fix-1.myproj", true),
1358 HostTarget::WorktreePage {
1359 project: "myproj".into(),
1360 worktree: "fix-1".into(),
1361 }
1362 );
1363 }
1364
1365 #[test]
1369 fn test_resolve_worktree_beats_daemon_of_same_name() {
1370 let mut reg = registry();
1371 reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1373 "api".to_string(),
1374 checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1375 );
1376 assert_eq!(
1377 reg.resolve("api.myproj", true),
1378 HostTarget::WorktreePage {
1379 project: "myproj".into(),
1380 worktree: "api".into(),
1381 }
1382 );
1383 assert!(matches!(
1385 reg.resolve("api.api.myproj", true),
1386 HostTarget::Daemon { .. }
1387 ));
1388 }
1389
1390 #[test]
1391 fn test_resolve_wildcard_subdomain() {
1392 let target = registry().resolve("tenant.api.myproj", true);
1393 assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1394 assert!(matches!(
1396 registry().resolve("tenant.api.myproj", false),
1397 HostTarget::UnknownDaemon { .. }
1398 ));
1399 }
1400
1401 #[test]
1402 fn test_resolve_unknown_names() {
1403 assert_eq!(
1404 registry().resolve("api.other", true),
1405 HostTarget::UnknownProject {
1406 known: vec!["myproj".to_string()]
1407 }
1408 );
1409 assert_eq!(
1410 registry().resolve("nope.myproj", true),
1411 HostTarget::UnknownDaemon {
1412 project: "myproj".into(),
1413 worktree: None,
1414 known: vec!["api".to_string()],
1415 }
1416 );
1417 }
1418
1419 fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1422 let repo = temp.join(name);
1423 let admin = repo.join(format!(".git/worktrees/{worktree}"));
1424 std::fs::create_dir_all(&admin).unwrap();
1425 let wt = temp.join(worktree);
1426 std::fs::create_dir_all(&wt).unwrap();
1427 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1428 std::fs::write(
1429 admin.join("gitdir"),
1430 format!("{}\n", wt.join(".git").display()),
1431 )
1432 .unwrap();
1433 (repo, wt)
1434 }
1435
1436 #[test]
1439 fn test_project_label_from_directory_name() {
1440 let temp = tempfile::tempdir().unwrap();
1441 let repo = temp.path().join("My App");
1442 std::fs::create_dir_all(&repo).unwrap();
1443 assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1444 }
1445
1446 #[test]
1448 fn test_pick_project_label_precedence() {
1449 let repo = Path::new("/work/Shop Repo");
1450 let ns = Some("shop-528f92b13a6784f0");
1451 assert_eq!(
1452 pick_project_label(repo, Some("shop"), ns).as_deref(),
1453 Some("shop")
1454 );
1455 assert_eq!(
1456 pick_project_label(repo, None, ns).as_deref(),
1457 Some("shop-528f92b13a6784f0")
1458 );
1459 assert_eq!(
1460 pick_project_label(repo, None, None).as_deref(),
1461 Some("shop-repo")
1462 );
1463 assert_eq!(
1465 pick_project_label(repo, Some("---"), ns).as_deref(),
1466 Some("shop-528f92b13a6784f0")
1467 );
1468 }
1469
1470 #[test]
1471 fn test_validate_registered_label() {
1472 assert!(validate_registered_label("shop").is_ok());
1473 assert!(validate_registered_label("my-shop-2").is_ok());
1474 assert!(validate_registered_label(&"a".repeat(63)).is_ok());
1475 for bad in [
1476 "Shop", "my_shop", "-shop", "shop-", "a--b", "a.b", "", "---",
1477 ] {
1478 assert!(validate_registered_label(bad).is_err(), "{bad:?}");
1479 }
1480 let long = validate_registered_label(&"a".repeat(64)).unwrap_err();
1481 assert!(long.contains("63"), "{long}");
1482 let hint = validate_registered_label("My Shop").unwrap_err();
1483 assert!(hint.contains("'my-shop'"), "{hint}");
1484 }
1485
1486 #[test]
1488 fn test_project_label_from_explicit_namespace() {
1489 let temp = tempfile::tempdir().unwrap();
1490 let repo = temp.path().join("checkout-dir");
1491 std::fs::create_dir_all(&repo).unwrap();
1492 std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1493 assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1494 }
1495
1496 fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1498 let body: String = daemons
1499 .iter()
1500 .enumerate()
1501 .map(|(i, (name, extra))| {
1502 format!(
1503 "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1504 3000 + i
1505 )
1506 })
1507 .collect();
1508 std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1509 }
1510
1511 fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1513 let pt = PitchforkToml::all_merged_from(dir).unwrap();
1514 let (id, config) = pt
1515 .daemons
1516 .iter()
1517 .find(|(id, _)| id.name() == name)
1518 .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1519 (id.clone(), config.clone())
1520 }
1521
1522 #[test]
1525 fn test_auto_host_primary_and_worktree() {
1526 let temp = tempfile::tempdir().unwrap();
1527 let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1528 write_config(&repo, &[("api", "")]);
1529 write_config(&wt, &[("api", "")]);
1530
1531 let (id, config) = daemon_config(&repo, "api");
1532 assert_eq!(
1533 auto_host_for_daemon(&id, &config).as_deref(),
1534 Some("api.my-repo")
1535 );
1536
1537 let (wt_id, wt_config) = daemon_config(&wt, "api");
1538 assert_eq!(
1539 auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1540 Some("api.fix-1.my-repo")
1541 );
1542 }
1543
1544 #[test]
1546 fn test_worktree_label_override() {
1547 let temp = tempfile::tempdir().unwrap();
1548 let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1549 assert_eq!(
1550 worktree_label(&wt).as_deref(),
1551 Some("sleepy-kapitsa-9f02fc")
1552 );
1553
1554 std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1555 assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1556 }
1557
1558 #[test]
1561 fn test_auto_host_requires_port_and_opt_in() {
1562 let temp = tempfile::tempdir().unwrap();
1563 let repo = temp.path().join("opt-in-repo");
1564 std::fs::create_dir_all(&repo).unwrap();
1565 std::fs::write(
1566 repo.join("pitchfork.toml"),
1567 "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1568 [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1569 [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1570 [daemons.worker]\nrun = \"server\"\n",
1571 )
1572 .unwrap();
1573
1574 let host = |name: &str| {
1575 let (id, config) = daemon_config(&repo, name);
1576 auto_host_for_daemon(&id, &config)
1577 };
1578 assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1579 assert_eq!(host("admin"), None);
1580 assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1581 assert_eq!(host("worker"), None);
1582 }
1583
1584 #[test]
1587 fn test_daemon_label_collision_drops_both() {
1588 let temp = tempfile::tempdir().unwrap();
1589 let repo = temp.path().join("dupe-repo");
1590 std::fs::create_dir_all(&repo).unwrap();
1591 std::fs::write(
1592 repo.join("pitchfork.toml"),
1593 "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1594 [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1595 [daemons.other]\nrun = \"server\"\nport = 3002\n",
1596 )
1597 .unwrap();
1598
1599 let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1600 assert_eq!(hosts.labels(), vec!["other".to_string()]);
1601 assert_eq!(errors.len(), 1);
1602 assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1603
1604 for name in ["foo_bar", "foo-bar"] {
1605 let (id, config) = daemon_config(&repo, name);
1606 assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1607 }
1608 let (id, config) = daemon_config(&repo, "other");
1609 assert_eq!(
1610 auto_host_for_daemon(&id, &config).as_deref(),
1611 Some("other.dupe-repo")
1612 );
1613 }
1614
1615 #[test]
1618 fn test_hostname_fits() {
1619 assert!(hostname_fits(&format!(
1621 "{}.{}.{}",
1622 "a".repeat(63),
1623 "b".repeat(63),
1624 "c".repeat(63)
1625 )));
1626 assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1627 }
1628
1629 #[test]
1632 fn test_auto_host_none_for_colliding_worktrees() {
1633 let temp = tempfile::tempdir().unwrap();
1634 let repo = temp.path().join("wt-repo");
1635 std::fs::create_dir_all(repo.join(".git")).unwrap();
1636 write_config(&repo, &[("api", "")]);
1637
1638 let mut worktrees = Vec::new();
1639 for dir_name in ["fix-1", "fix.1"] {
1640 let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1641 std::fs::create_dir_all(&admin).unwrap();
1642 let wt = temp.path().join(dir_name);
1643 std::fs::create_dir_all(&wt).unwrap();
1644 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1645 std::fs::write(
1646 admin.join("gitdir"),
1647 format!("{}\n", wt.join(".git").display()),
1648 )
1649 .unwrap();
1650 write_config(&wt, &[("api", "")]);
1651 worktrees.push(wt);
1652 }
1653
1654 for wt in &worktrees {
1655 let (id, config) = daemon_config(wt, "api");
1656 assert_eq!(
1657 auto_host_for_daemon(&id, &config),
1658 None,
1659 "{} must not advertise a hostname",
1660 wt.display()
1661 );
1662 }
1663 let (id, config) = daemon_config(&repo, "api");
1665 assert_eq!(
1666 auto_host_for_daemon(&id, &config).as_deref(),
1667 Some("api.wt-repo")
1668 );
1669 }
1670
1671 #[test]
1675 fn test_shares_daemon_id() {
1676 let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1677 (
1678 label.to_string(),
1679 ProjectHosts {
1680 label: label.to_string(),
1681 primary,
1682 worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1683 },
1684 )
1685 };
1686
1687 let mut registry = HostRegistry {
1689 projects: HashMap::from([project(
1690 "myproj",
1691 checkout("/repos/myproj", "myproj", &[("api", "api")]),
1692 vec![(
1693 "fix-1",
1694 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1695 )],
1696 )]),
1697 errors: vec![],
1698 };
1699 assert!(!registry.shares_daemon_id("myproj", "api"));
1700 assert!(!registry.shares_daemon_id("fix-1", "api"));
1701
1702 registry
1705 .projects
1706 .get_mut("myproj")
1707 .unwrap()
1708 .worktrees
1709 .insert(
1710 "fix-2".to_string(),
1711 checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1712 );
1713 assert!(registry.shares_daemon_id("myproj", "api"));
1714
1715 let (label, other) = project(
1718 "other",
1719 checkout("/repos/other", "other", &[("api", "api")]),
1720 vec![(
1721 "fix-1",
1722 checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1723 )],
1724 );
1725 registry.projects.insert(label, other);
1726 assert!(registry.shares_daemon_id("fix-1", "api"));
1727
1728 assert!(!registry.shares_daemon_id("myproj", "worker"));
1730 }
1731
1732 #[test]
1735 fn test_worktree_label_collision_drops_both() {
1736 let found = vec![
1737 (
1738 "fix-1".to_string(),
1739 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1740 ),
1741 (
1742 "fix-1".to_string(),
1743 checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1744 ),
1745 (
1746 "fix-2".to_string(),
1747 checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1748 ),
1749 ];
1750 let (kept, errors) = group_worktrees("myproj", found);
1751 assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1752 assert_eq!(errors.len(), 1);
1753 assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1754 assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1755 }
1756
1757 #[test]
1760 fn test_project_label_collision_message_depends_on_source() {
1761 let a = Path::new("/repos/a");
1762 let b = Path::new("/repos/b");
1763 let plain = project_label_collision_message("shop", a, b, false);
1764 assert!(plain.contains("`namespace`"), "{plain}");
1765 assert!(!plain.contains("--label"), "{plain}");
1766 let registered = project_label_collision_message("shop", a, b, true);
1767 assert!(
1768 registered.contains("pitchfork config add <file> --label <other>"),
1769 "{registered}"
1770 );
1771 assert!(!registered.contains("`namespace`"), "{registered}");
1772 for msg in [&plain, ®istered] {
1773 assert!(msg.contains("'shop'") && msg.contains("/repos/a") && msg.contains("/repos/b"));
1774 }
1775 }
1776
1777 #[test]
1780 fn test_project_label_collision_routes_neither() {
1781 let temp = tempfile::tempdir().unwrap();
1782 let a = temp.path().join("a/shop");
1783 let b = temp.path().join("b/shop");
1784 std::fs::create_dir_all(&a).unwrap();
1785 std::fs::create_dir_all(&b).unwrap();
1786 write_config(&a, &[("api", "")]);
1787 write_config(&b, &[("api", "")]);
1788
1789 let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1790 assert!(registry.project_labels().is_empty());
1791 assert_eq!(registry.errors.len(), 1);
1792 assert!(
1793 registry.errors[0].contains("shop"),
1794 "{}",
1795 registry.errors[0]
1796 );
1797 assert!(
1800 registry.errors[0].contains(&canonical(&b).display().to_string()),
1801 "{}",
1802 registry.errors[0]
1803 );
1804 assert_eq!(
1805 registry.resolve("api.shop", true),
1806 HostTarget::UnknownProject { known: vec![] }
1807 );
1808 }
1809}