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_dir(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_dir(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 hosts.daemons.get(&daemon).map(|d| d.name.as_str()) != Some(id.name()) {
371 return None;
372 }
373
374 let host = join_labels(&daemon, worktree.as_deref(), &project_label);
375 if !hostname_fits(&host) {
376 log::warn!(
377 "'{host}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS \
378 limit, so no hostname is assigned. Shorten the project, worktree or daemon name, \
379 or use a shorter proxy.tld."
380 );
381 return None;
382 }
383 Some(host)
384}
385
386pub fn host_for_daemon(
392 id: &DaemonId,
393 config: Option<&PitchforkTomlDaemon>,
394 global_slugs: &indexmap::IndexMap<String, crate::pitchfork_toml::SlugEntry>,
395) -> Option<String> {
396 if let Some(slug) = PitchforkToml::find_slug_for_daemon_in_registry(id, global_slugs) {
400 if hostname_fits(&slug) {
401 return Some(slug);
402 }
403 log::warn!(
404 "Slug '{slug}' plus the configured proxy.tld is over the \
405 {MAX_HOSTNAME_LEN}-byte DNS limit, so it is not advertised for {id}."
406 );
407 }
408 auto_host_for_daemon(id, config?)
409}
410
411#[derive(Debug, Clone)]
415pub struct CheckoutHosts {
416 pub dir: PathBuf,
418 pub namespace: String,
420 pub daemons: HashMap<String, DaemonHost>,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct DaemonHost {
434 pub name: String,
436 pub proxy_tls: Option<crate::config_types::ProxyTlsMode>,
438 pub proxy_tls_port: Option<u16>,
440}
441
442impl CheckoutHosts {
443 fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
450 let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
451 let pt = PitchforkToml::all_merged_from(dir).ok()?;
452 let mut daemons: HashMap<String, DaemonHost> = HashMap::new();
453 let mut errors = Vec::new();
454 let mut colliding: Vec<String> = Vec::new();
455 for (id, config) in &pt.daemons {
456 if id.namespace() != namespace || config.port.is_none() {
457 continue;
458 }
459 let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
460 continue;
461 };
462 match daemons.get(&label) {
463 Some(other) => {
464 errors.push(format!(
465 "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
466 '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
467 dir.display(),
468 other = other.name,
469 name = id.name(),
470 ));
471 colliding.push(label);
472 }
473 None => {
474 daemons.insert(
475 label,
476 DaemonHost {
477 name: id.name().to_string(),
478 proxy_tls: config.proxy_tls,
479 proxy_tls_port: config.effective_proxy_tls_port(),
480 },
481 );
482 }
483 }
484 }
485 for label in colliding {
486 daemons.remove(&label);
487 }
488 Some((
489 Self {
490 dir: dir.to_path_buf(),
491 namespace,
492 daemons,
493 },
494 errors,
495 ))
496 }
497
498 pub fn labels(&self) -> Vec<String> {
500 let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
501 labels.sort();
502 labels
503 }
504}
505
506#[derive(Debug, Clone)]
508pub struct ProjectHosts {
509 pub label: String,
510 pub primary: CheckoutHosts,
511 pub worktrees: HashMap<String, CheckoutHosts>,
513}
514
515impl ProjectHosts {
516 pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
518 std::iter::once(&self.primary).chain(self.worktrees.values())
519 }
520
521 pub fn worktree_labels(&self) -> Vec<String> {
523 let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
524 labels.sort();
525 labels
526 }
527}
528
529#[derive(Debug, Clone, Default)]
531pub struct HostRegistry {
532 pub projects: HashMap<String, ProjectHosts>,
533 pub errors: Vec<String>,
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
539pub enum HostTarget {
540 Daemon {
542 project: String,
543 worktree: Option<String>,
544 dir: PathBuf,
545 namespace: String,
546 daemon: String,
547 proxy_tls: Option<crate::config_types::ProxyTlsMode>,
550 proxy_tls_port: Option<u16>,
552 },
553 ProjectPage { project: String },
555 WorktreePage { project: String, worktree: String },
557 UnknownProject { known: Vec<String> },
559 UnknownDaemon {
561 project: String,
562 worktree: Option<String>,
563 known: Vec<String>,
564 },
565}
566
567fn group_worktrees(
573 project: &str,
574 found: Vec<(String, CheckoutHosts)>,
575) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
576 let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
577 let mut errors = Vec::new();
578 let mut colliding: Vec<String> = Vec::new();
579 for (label, hosts) in found {
580 match kept.get(&label) {
581 Some(existing) if existing.dir == hosts.dir => continue,
582 Some(existing) => {
583 errors.push(format!(
584 "worktree label '{label}' in project '{project}' is claimed by two \
585 directories: {} and {}. Set `worktree_label` in one of them.",
586 existing.dir.display(),
587 hosts.dir.display(),
588 ));
589 colliding.push(label);
590 }
591 None => {
592 kept.insert(label, hosts);
593 }
594 }
595 }
596 for label in colliding {
597 kept.remove(&label);
598 }
599 (kept, errors)
600}
601
602const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
610
611struct WorktreeCache {
612 entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
613}
614
615static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
616 once_cell::sync::Lazy::new(|| {
617 std::sync::Mutex::new(WorktreeCache {
618 entries: HashMap::new(),
619 })
620 });
621
622fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
624 let now = std::time::Instant::now();
625 {
626 let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
627 if let Some((expires_at, dirs)) = cache.entries.get(primary)
628 && now < *expires_at
629 {
630 return std::sync::Arc::clone(dirs);
631 }
632 } let dirs = std::sync::Arc::new(worktree_dirs(primary));
635
636 let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
637 cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
638 cache.entries.insert(
639 primary.to_path_buf(),
640 (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
641 );
642 dirs
643}
644
645pub fn warn_once(message: &str) {
652 static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
653 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
654
655 let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
656 if seen.len() > 256 {
659 seen.clear();
660 }
661 if seen.insert(message.to_string()) {
662 log::warn!("{message}");
663 }
664}
665
666fn candidate_dirs() -> Vec<PathBuf> {
674 let mut candidates: Vec<PathBuf> = Vec::new();
675 for (_, entry) in PitchforkToml::read_global_namespaces() {
676 candidates.push(entry.dir);
677 }
678 for (_, entry) in PitchforkToml::read_global_slugs() {
679 if let Some(dir) = entry.resolve_dir() {
680 candidates.push(dir);
681 }
682 }
683 if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
684 for daemon in state.daemons.values() {
685 if let Some(dir) = &daemon.dir {
686 candidates.push(dir.clone());
687 }
688 }
689 }
690 candidates
691}
692
693fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
699 let mut seen: Vec<PathBuf> = Vec::new();
700 for dir in candidate_dirs() {
701 if !dir.exists() {
702 continue;
703 }
704 let other = detect_checkout(&dir).primary;
705 if other == primary || seen.contains(&other) {
706 continue;
707 }
708 seen.push(other.clone());
709 if project_label(&other).as_deref() == Some(label) {
710 return true;
711 }
712 }
713 false
714}
715
716fn project_label_collision_message(
722 label: &str,
723 first: &Path,
724 second: &Path,
725 from_registration: bool,
726) -> String {
727 let advice = if from_registration {
728 "Register a different label for one of them with \
729 `pitchfork config add <file> --label <other>`."
730 } else {
731 "Set a distinct top-level `namespace` in one of them."
732 };
733 format!(
734 "project label '{label}' is claimed by two directories: {} and {}. {advice}",
735 first.display(),
736 second.display(),
737 )
738}
739
740fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
747 let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
748 return vec![];
749 };
750 entries
751 .filter_map(|entry| {
752 let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
753 let git_file = PathBuf::from(gitdir.trim());
754 git_file.parent().map(Path::to_path_buf)
755 })
756 .collect()
757}
758
759fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
764 if !crate::settings::settings().general.worktree {
765 return vec![];
766 }
767 let mut dirs: Vec<PathBuf> = Vec::new();
768 let found = crate::proxy::worktree::discover_worktrees(primary)
769 .into_iter()
770 .map(|entry| entry.path)
771 .chain(linked_worktree_dirs(primary));
772 for path in found {
773 let Some(wt_dir) = detect_checkout(&path).worktree else {
774 continue; };
776 if !dirs.contains(&wt_dir) {
777 dirs.push(wt_dir);
778 }
779 }
780 dirs
781}
782
783fn build_project_hosts(
789 primary: &Path,
790 label: &str,
791 worktrees: &[PathBuf],
792) -> Option<(ProjectHosts, Vec<String>)> {
793 let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
794 let mut project = ProjectHosts {
795 label: label.to_string(),
796 primary: primary_hosts,
797 worktrees: HashMap::new(),
798 };
799
800 let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
801 for wt_dir in worktrees {
802 let (Some(wt_label), Some((hosts, wt_errors))) =
803 (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
804 else {
805 continue;
806 };
807 errors.extend(wt_errors);
808 found.push((wt_label, hosts));
809 }
810 let (worktrees, wt_errors) = group_worktrees(label, found);
811 project.worktrees = worktrees;
812 errors.extend(wt_errors);
813
814 Some((project, errors))
815}
816
817fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
820 let worktrees = cached_worktree_dirs(primary);
821 let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
822 for err in errors {
823 warn_once(&err);
824 }
825 Some(project)
826}
827
828impl HostRegistry {
829 pub fn build() -> Self {
839 Self::from_dirs(&candidate_dirs())
840 }
841
842 pub fn from_dirs(dirs: &[PathBuf]) -> Self {
844 let mut primaries: Vec<PathBuf> = Vec::new();
846 for dir in dirs {
847 if !dir.exists() {
848 continue;
849 }
850 let primary = detect_checkout(dir).primary;
851 if !primaries.contains(&primary) {
852 primaries.push(primary);
853 }
854 }
855
856 let mut registry = Self::default();
857 let mut colliding: Vec<String> = Vec::new();
858 for primary in primaries {
859 let Some(label) = project_label(&primary) else {
860 continue;
861 };
862
863 if let Some(existing) = registry.projects.get(&label) {
864 if existing.primary.dir != primary {
865 let registered = registered_project_label(&existing.primary.dir).is_some()
866 || registered_project_label(&primary).is_some();
867 registry.errors.push(project_label_collision_message(
868 &label,
869 &existing.primary.dir,
870 &primary,
871 registered,
872 ));
873 colliding.push(label);
877 }
878 continue;
879 }
880
881 let worktrees = worktree_dirs(&primary);
882 let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
883 continue;
884 };
885 registry.errors.extend(errors);
886 if project.checkouts().all(|c| c.daemons.is_empty()) {
890 continue;
891 }
892 registry.projects.insert(label, project);
893 }
894 for label in colliding {
895 registry.projects.remove(&label);
896 }
897
898 registry
899 }
900
901 pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
911 self.projects
912 .values()
913 .flat_map(ProjectHosts::checkouts)
914 .filter(|c| c.namespace == namespace && c.daemons.values().any(|d| d.name == daemon))
915 .count()
916 > 1
917 }
918
919 pub fn project_labels(&self) -> Vec<String> {
921 let mut labels: Vec<String> = self.projects.keys().cloned().collect();
922 labels.sort();
923 labels
924 }
925
926 pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
934 let labels: Vec<String> = subdomain
935 .split('.')
936 .map(|l| l.to_ascii_lowercase())
937 .collect();
938 let Some((project_label, rest)) = labels.split_last() else {
939 return HostTarget::UnknownProject {
940 known: self.project_labels(),
941 };
942 };
943 let Some(project) = self.projects.get(project_label) else {
944 return HostTarget::UnknownProject {
945 known: self.project_labels(),
946 };
947 };
948 if rest.is_empty() {
949 return HostTarget::ProjectPage {
950 project: project.label.clone(),
951 };
952 }
953
954 let (checkout, worktree, rest) = match rest.split_last() {
956 Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
957 Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
958 None => (&project.primary, None, rest),
959 },
960 None => (&project.primary, None, rest),
961 };
962
963 let Some((daemon_label, extra)) = rest.split_last() else {
964 return HostTarget::WorktreePage {
965 project: project.label.clone(),
966 worktree: worktree.unwrap_or_default(),
967 };
968 };
969 if !extra.is_empty() && !wildcard {
970 return HostTarget::UnknownDaemon {
971 project: project.label.clone(),
972 worktree,
973 known: checkout.labels(),
974 };
975 }
976
977 match checkout.daemons.get(daemon_label) {
978 Some(daemon) => HostTarget::Daemon {
979 project: project.label.clone(),
980 worktree,
981 dir: checkout.dir.clone(),
982 namespace: checkout.namespace.clone(),
983 daemon: daemon.name.clone(),
984 proxy_tls: daemon.proxy_tls,
985 proxy_tls_port: daemon.proxy_tls_port,
986 },
987 None => HostTarget::UnknownDaemon {
988 project: project.label.clone(),
989 worktree,
990 known: checkout.labels(),
991 },
992 }
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999
1000 fn canonical(path: &Path) -> PathBuf {
1003 dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
1004 }
1005
1006 fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
1007 CheckoutHosts {
1008 dir: PathBuf::from(dir),
1009 namespace: namespace.to_string(),
1010 daemons: daemons
1011 .iter()
1012 .map(|(l, n)| {
1013 (
1014 l.to_string(),
1015 DaemonHost {
1016 name: n.to_string(),
1017 proxy_tls: None,
1018 proxy_tls_port: None,
1019 },
1020 )
1021 })
1022 .collect(),
1023 }
1024 }
1025
1026 #[test]
1027 fn test_sanitize_label() {
1028 assert_eq!(sanitize_label("api").as_deref(), Some("api"));
1029 assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
1030 assert_eq!(
1031 sanitize_label("feature/my_branch").as_deref(),
1032 Some("feature-my-branch")
1033 );
1034 assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
1035 assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
1036 assert_eq!(sanitize_label("---"), None);
1037 assert_eq!(sanitize_label(""), None);
1038 assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
1039 }
1040
1041 #[test]
1042 fn test_daemon_label_override_and_opt_out() {
1043 assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
1044 assert_eq!(
1045 daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
1046 Some("api")
1047 );
1048 assert_eq!(
1049 daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
1050 Some("web-ui")
1051 );
1052 assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
1053 }
1054
1055 #[test]
1057 fn test_detect_checkout_primary() {
1058 let temp = tempfile::tempdir().unwrap();
1059 let repo = temp.path().join("my-repo");
1060 std::fs::create_dir_all(repo.join(".git")).unwrap();
1061 std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
1062
1063 let found = detect_checkout(&repo.join("sub/dir"));
1064 assert_eq!(found.primary, canonical(&repo));
1065 assert_eq!(found.worktree, None);
1066 }
1067
1068 #[test]
1070 fn test_detect_checkout_linked_worktree() {
1071 let temp = tempfile::tempdir().unwrap();
1072 let repo = temp.path().join("my-repo");
1073 std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
1074 let wt = temp.path().join("fix-1");
1075 std::fs::create_dir_all(&wt).unwrap();
1076 std::fs::write(
1077 wt.join(".git"),
1078 format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
1079 )
1080 .unwrap();
1081
1082 let found = detect_checkout(&wt);
1083 assert_eq!(found.primary, canonical(&repo));
1084 assert_eq!(found.worktree, Some(canonical(&wt)));
1085 assert_eq!(found.root(), canonical(&wt));
1086 }
1087
1088 #[test]
1091 fn test_checkout_root_of_missing_directory() {
1092 let temp = tempfile::tempdir().unwrap();
1093 let repo = temp.path().join("my-repo");
1094 std::fs::create_dir_all(repo.join(".git")).unwrap();
1095
1096 assert_eq!(
1097 checkout_root_of(&repo.join("gone/deeper")),
1098 canonical(&repo)
1099 );
1100 }
1101
1102 #[cfg(unix)]
1106 #[test]
1107 fn test_detect_checkout_primary_spelling_matches_through_symlink() {
1108 use std::os::unix::fs::symlink;
1109
1110 let temp = tempfile::tempdir().unwrap();
1111 let repo = temp.path().join("my-repo");
1112 let admin = repo.join(".git/worktrees/fix-1");
1113 std::fs::create_dir_all(&admin).unwrap();
1114 let wt = temp.path().join("fix-1");
1115 std::fs::create_dir_all(&wt).unwrap();
1116
1117 let alias = temp.path().join("alias");
1120 symlink(&repo, &alias).unwrap();
1121 std::fs::write(
1122 wt.join(".git"),
1123 format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
1124 )
1125 .unwrap();
1126
1127 assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1128 assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1129 }
1130
1131 #[test]
1135 fn test_detect_checkout_bare_repository_worktree() {
1136 let temp = tempfile::tempdir().unwrap();
1137 let bare = temp.path().join("my-repo.git");
1138 let admin = bare.join("worktrees/fix-1");
1139 std::fs::create_dir_all(&admin).unwrap();
1140 let wt = temp.path().join("fix-1");
1141 std::fs::create_dir_all(&wt).unwrap();
1142 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1143
1144 let found = detect_checkout(&wt);
1145 assert_eq!(found.primary, canonical(&wt));
1146 assert_eq!(found.worktree, None);
1147 }
1148
1149 #[test]
1152 fn test_auto_host_skips_global_config_daemons() {
1153 let config = PitchforkTomlDaemon {
1154 run: "server".into(),
1155 port: Some(crate::config_types::PortConfig {
1156 expect: vec![3000],
1157 ..Default::default()
1158 }),
1159 path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1160 ..PitchforkTomlDaemon::default()
1161 };
1162 let id = DaemonId::try_new("global", "api").unwrap();
1163 assert_eq!(auto_host_for_daemon(&id, &config), None);
1164 }
1165
1166 #[test]
1169 fn test_host_for_daemon_prefers_a_registered_slug() {
1170 let temp = tempfile::tempdir().unwrap();
1171 let repo = temp.path().join("slug-repo");
1172 std::fs::create_dir_all(&repo).unwrap();
1173 write_config(&repo, &[("api", "")]);
1174 let (id, config) = daemon_config(&repo, "api");
1175
1176 let empty = indexmap::IndexMap::new();
1177 assert_eq!(
1178 host_for_daemon(&id, Some(&config), &empty).as_deref(),
1179 Some("api.slug-repo")
1180 );
1181
1182 let mut slugs = indexmap::IndexMap::new();
1184 slugs.insert(
1185 "myapp".to_string(),
1186 crate::pitchfork_toml::SlugEntry {
1187 dir: Some(repo.clone()),
1188 namespace: Some(id.namespace().to_string()),
1189 daemon: Some("api".to_string()),
1190 },
1191 );
1192 assert_eq!(
1193 host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1194 Some("myapp")
1195 );
1196 }
1197
1198 #[test]
1201 fn test_detect_checkout_submodule_pointer() {
1202 let temp = tempfile::tempdir().unwrap();
1203 let repo = temp.path().join("my-repo");
1204 std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1205 let sub = repo.join("sub");
1206 std::fs::create_dir_all(&sub).unwrap();
1207 std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1208
1209 let found = detect_checkout(&sub);
1210 assert_eq!(found.primary, canonical(&sub));
1211 assert_eq!(found.worktree, None);
1212 }
1213
1214 #[test]
1216 fn test_detect_checkout_without_git() {
1217 let temp = tempfile::tempdir().unwrap();
1218 let dir = temp.path().join("plain");
1219 std::fs::create_dir_all(&dir).unwrap();
1220
1221 let found = detect_checkout(&dir);
1222 assert_eq!(found.primary, canonical(&dir));
1223 assert_eq!(found.worktree, None);
1224 }
1225
1226 fn registry() -> HostRegistry {
1227 let mut projects = HashMap::new();
1228 let mut worktrees = HashMap::new();
1229 worktrees.insert(
1230 "fix-1".to_string(),
1231 checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1232 );
1233 projects.insert(
1234 "myproj".to_string(),
1235 ProjectHosts {
1236 label: "myproj".to_string(),
1237 primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1238 worktrees,
1239 },
1240 );
1241 HostRegistry {
1242 projects,
1243 errors: vec![],
1244 }
1245 }
1246
1247 #[test]
1251 fn test_checkout_hosts_capture_proxy_tls() {
1252 let dir = tempfile::tempdir().unwrap();
1253 let project = dir.path().join("tlsproj");
1254 std::fs::create_dir_all(&project).unwrap();
1255 std::fs::write(
1256 project.join("pitchfork.toml"),
1257 "[daemons.secure]\nrun = \"serve\"\nport = [8443, 9443]\n\
1258 proxy_tls = \"passthrough\"\nproxy_tls_port = 9443\n\
1259 [daemons.plain]\nrun = \"serve\"\nport = 8080\n",
1260 )
1261 .unwrap();
1262
1263 let (hosts, errors) = CheckoutHosts::load(&project).expect("checkout loads");
1264 assert!(errors.is_empty(), "{errors:?}");
1265
1266 let secure = hosts.daemons.get("secure").expect("secure is routable");
1267 assert_eq!(secure.name, "secure");
1268 assert_eq!(
1269 secure.proxy_tls,
1270 Some(crate::config_types::ProxyTlsMode::Passthrough)
1271 );
1272 assert_eq!(secure.proxy_tls_port, Some(9443));
1273
1274 let plain = hosts.daemons.get("plain").expect("plain is routable");
1275 assert_eq!(plain.proxy_tls, None);
1276 assert_eq!(plain.proxy_tls_port, None);
1277 }
1278
1279 #[test]
1280 fn test_resolve_primary_checkout_daemon() {
1281 let target = registry().resolve("api.myproj", true);
1282 assert_eq!(
1283 target,
1284 HostTarget::Daemon {
1285 project: "myproj".into(),
1286 worktree: None,
1287 dir: PathBuf::from("/repos/myproj"),
1288 namespace: "myproj".into(),
1289 daemon: "api".into(),
1290 proxy_tls: None,
1291 proxy_tls_port: None,
1292 }
1293 );
1294 }
1295
1296 #[test]
1297 fn test_resolve_worktree_daemon() {
1298 let target = registry().resolve("web.fix-1.myproj", true);
1299 assert_eq!(
1300 target,
1301 HostTarget::Daemon {
1302 project: "myproj".into(),
1303 worktree: Some("fix-1".into()),
1304 dir: PathBuf::from("/repos/fix-1"),
1305 namespace: "fix-1".into(),
1306 daemon: "web".into(),
1307 proxy_tls: None,
1308 proxy_tls_port: None,
1309 }
1310 );
1311 }
1312
1313 #[test]
1314 fn test_resolve_is_case_insensitive() {
1315 assert!(matches!(
1316 registry().resolve("API.MyProj", true),
1317 HostTarget::Daemon { .. }
1318 ));
1319 }
1320
1321 #[test]
1323 fn test_resolve_reserved_pages() {
1324 assert_eq!(
1325 registry().resolve("myproj", true),
1326 HostTarget::ProjectPage {
1327 project: "myproj".into()
1328 }
1329 );
1330 assert_eq!(
1331 registry().resolve("fix-1.myproj", true),
1332 HostTarget::WorktreePage {
1333 project: "myproj".into(),
1334 worktree: "fix-1".into(),
1335 }
1336 );
1337 }
1338
1339 #[test]
1343 fn test_resolve_worktree_beats_daemon_of_same_name() {
1344 let mut reg = registry();
1345 reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1347 "api".to_string(),
1348 checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1349 );
1350 assert_eq!(
1351 reg.resolve("api.myproj", true),
1352 HostTarget::WorktreePage {
1353 project: "myproj".into(),
1354 worktree: "api".into(),
1355 }
1356 );
1357 assert!(matches!(
1359 reg.resolve("api.api.myproj", true),
1360 HostTarget::Daemon { .. }
1361 ));
1362 }
1363
1364 #[test]
1365 fn test_resolve_wildcard_subdomain() {
1366 let target = registry().resolve("tenant.api.myproj", true);
1367 assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1368 assert!(matches!(
1370 registry().resolve("tenant.api.myproj", false),
1371 HostTarget::UnknownDaemon { .. }
1372 ));
1373 }
1374
1375 #[test]
1376 fn test_resolve_unknown_names() {
1377 assert_eq!(
1378 registry().resolve("api.other", true),
1379 HostTarget::UnknownProject {
1380 known: vec!["myproj".to_string()]
1381 }
1382 );
1383 assert_eq!(
1384 registry().resolve("nope.myproj", true),
1385 HostTarget::UnknownDaemon {
1386 project: "myproj".into(),
1387 worktree: None,
1388 known: vec!["api".to_string()],
1389 }
1390 );
1391 }
1392
1393 fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1396 let repo = temp.join(name);
1397 let admin = repo.join(format!(".git/worktrees/{worktree}"));
1398 std::fs::create_dir_all(&admin).unwrap();
1399 let wt = temp.join(worktree);
1400 std::fs::create_dir_all(&wt).unwrap();
1401 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1402 std::fs::write(
1403 admin.join("gitdir"),
1404 format!("{}\n", wt.join(".git").display()),
1405 )
1406 .unwrap();
1407 (repo, wt)
1408 }
1409
1410 #[test]
1413 fn test_project_label_from_directory_name() {
1414 let temp = tempfile::tempdir().unwrap();
1415 let repo = temp.path().join("My App");
1416 std::fs::create_dir_all(&repo).unwrap();
1417 assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1418 }
1419
1420 #[test]
1422 fn test_pick_project_label_precedence() {
1423 let repo = Path::new("/work/Shop Repo");
1424 let ns = Some("shop-528f92b13a6784f0");
1425 assert_eq!(
1426 pick_project_label(repo, Some("shop"), ns).as_deref(),
1427 Some("shop")
1428 );
1429 assert_eq!(
1430 pick_project_label(repo, None, ns).as_deref(),
1431 Some("shop-528f92b13a6784f0")
1432 );
1433 assert_eq!(
1434 pick_project_label(repo, None, None).as_deref(),
1435 Some("shop-repo")
1436 );
1437 assert_eq!(
1439 pick_project_label(repo, Some("---"), ns).as_deref(),
1440 Some("shop-528f92b13a6784f0")
1441 );
1442 }
1443
1444 #[test]
1445 fn test_validate_registered_label() {
1446 assert!(validate_registered_label("shop").is_ok());
1447 assert!(validate_registered_label("my-shop-2").is_ok());
1448 assert!(validate_registered_label(&"a".repeat(63)).is_ok());
1449 for bad in [
1450 "Shop", "my_shop", "-shop", "shop-", "a--b", "a.b", "", "---",
1451 ] {
1452 assert!(validate_registered_label(bad).is_err(), "{bad:?}");
1453 }
1454 let long = validate_registered_label(&"a".repeat(64)).unwrap_err();
1455 assert!(long.contains("63"), "{long}");
1456 let hint = validate_registered_label("My Shop").unwrap_err();
1457 assert!(hint.contains("'my-shop'"), "{hint}");
1458 }
1459
1460 #[test]
1462 fn test_project_label_from_explicit_namespace() {
1463 let temp = tempfile::tempdir().unwrap();
1464 let repo = temp.path().join("checkout-dir");
1465 std::fs::create_dir_all(&repo).unwrap();
1466 std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1467 assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1468 }
1469
1470 fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1472 let body: String = daemons
1473 .iter()
1474 .enumerate()
1475 .map(|(i, (name, extra))| {
1476 format!(
1477 "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1478 3000 + i
1479 )
1480 })
1481 .collect();
1482 std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1483 }
1484
1485 fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1487 let pt = PitchforkToml::all_merged_from(dir).unwrap();
1488 let (id, config) = pt
1489 .daemons
1490 .iter()
1491 .find(|(id, _)| id.name() == name)
1492 .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1493 (id.clone(), config.clone())
1494 }
1495
1496 #[test]
1499 fn test_auto_host_primary_and_worktree() {
1500 let temp = tempfile::tempdir().unwrap();
1501 let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1502 write_config(&repo, &[("api", "")]);
1503 write_config(&wt, &[("api", "")]);
1504
1505 let (id, config) = daemon_config(&repo, "api");
1506 assert_eq!(
1507 auto_host_for_daemon(&id, &config).as_deref(),
1508 Some("api.my-repo")
1509 );
1510
1511 let (wt_id, wt_config) = daemon_config(&wt, "api");
1512 assert_eq!(
1513 auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1514 Some("api.fix-1.my-repo")
1515 );
1516 }
1517
1518 #[test]
1520 fn test_worktree_label_override() {
1521 let temp = tempfile::tempdir().unwrap();
1522 let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1523 assert_eq!(
1524 worktree_label(&wt).as_deref(),
1525 Some("sleepy-kapitsa-9f02fc")
1526 );
1527
1528 std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1529 assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1530 }
1531
1532 #[test]
1535 fn test_auto_host_requires_port_and_opt_in() {
1536 let temp = tempfile::tempdir().unwrap();
1537 let repo = temp.path().join("opt-in-repo");
1538 std::fs::create_dir_all(&repo).unwrap();
1539 std::fs::write(
1540 repo.join("pitchfork.toml"),
1541 "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1542 [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1543 [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1544 [daemons.worker]\nrun = \"server\"\n",
1545 )
1546 .unwrap();
1547
1548 let host = |name: &str| {
1549 let (id, config) = daemon_config(&repo, name);
1550 auto_host_for_daemon(&id, &config)
1551 };
1552 assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1553 assert_eq!(host("admin"), None);
1554 assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1555 assert_eq!(host("worker"), None);
1556 }
1557
1558 #[test]
1561 fn test_daemon_label_collision_drops_both() {
1562 let temp = tempfile::tempdir().unwrap();
1563 let repo = temp.path().join("dupe-repo");
1564 std::fs::create_dir_all(&repo).unwrap();
1565 std::fs::write(
1566 repo.join("pitchfork.toml"),
1567 "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1568 [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1569 [daemons.other]\nrun = \"server\"\nport = 3002\n",
1570 )
1571 .unwrap();
1572
1573 let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1574 assert_eq!(hosts.labels(), vec!["other".to_string()]);
1575 assert_eq!(errors.len(), 1);
1576 assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1577
1578 for name in ["foo_bar", "foo-bar"] {
1579 let (id, config) = daemon_config(&repo, name);
1580 assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1581 }
1582 let (id, config) = daemon_config(&repo, "other");
1583 assert_eq!(
1584 auto_host_for_daemon(&id, &config).as_deref(),
1585 Some("other.dupe-repo")
1586 );
1587 }
1588
1589 #[test]
1592 fn test_hostname_fits() {
1593 assert!(hostname_fits(&format!(
1595 "{}.{}.{}",
1596 "a".repeat(63),
1597 "b".repeat(63),
1598 "c".repeat(63)
1599 )));
1600 assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1601 }
1602
1603 #[test]
1606 fn test_auto_host_none_for_colliding_worktrees() {
1607 let temp = tempfile::tempdir().unwrap();
1608 let repo = temp.path().join("wt-repo");
1609 std::fs::create_dir_all(repo.join(".git")).unwrap();
1610 write_config(&repo, &[("api", "")]);
1611
1612 let mut worktrees = Vec::new();
1613 for dir_name in ["fix-1", "fix.1"] {
1614 let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1615 std::fs::create_dir_all(&admin).unwrap();
1616 let wt = temp.path().join(dir_name);
1617 std::fs::create_dir_all(&wt).unwrap();
1618 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1619 std::fs::write(
1620 admin.join("gitdir"),
1621 format!("{}\n", wt.join(".git").display()),
1622 )
1623 .unwrap();
1624 write_config(&wt, &[("api", "")]);
1625 worktrees.push(wt);
1626 }
1627
1628 for wt in &worktrees {
1629 let (id, config) = daemon_config(wt, "api");
1630 assert_eq!(
1631 auto_host_for_daemon(&id, &config),
1632 None,
1633 "{} must not advertise a hostname",
1634 wt.display()
1635 );
1636 }
1637 let (id, config) = daemon_config(&repo, "api");
1639 assert_eq!(
1640 auto_host_for_daemon(&id, &config).as_deref(),
1641 Some("api.wt-repo")
1642 );
1643 }
1644
1645 #[test]
1649 fn test_shares_daemon_id() {
1650 let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1651 (
1652 label.to_string(),
1653 ProjectHosts {
1654 label: label.to_string(),
1655 primary,
1656 worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1657 },
1658 )
1659 };
1660
1661 let mut registry = HostRegistry {
1663 projects: HashMap::from([project(
1664 "myproj",
1665 checkout("/repos/myproj", "myproj", &[("api", "api")]),
1666 vec![(
1667 "fix-1",
1668 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1669 )],
1670 )]),
1671 errors: vec![],
1672 };
1673 assert!(!registry.shares_daemon_id("myproj", "api"));
1674 assert!(!registry.shares_daemon_id("fix-1", "api"));
1675
1676 registry
1679 .projects
1680 .get_mut("myproj")
1681 .unwrap()
1682 .worktrees
1683 .insert(
1684 "fix-2".to_string(),
1685 checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1686 );
1687 assert!(registry.shares_daemon_id("myproj", "api"));
1688
1689 let (label, other) = project(
1692 "other",
1693 checkout("/repos/other", "other", &[("api", "api")]),
1694 vec![(
1695 "fix-1",
1696 checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1697 )],
1698 );
1699 registry.projects.insert(label, other);
1700 assert!(registry.shares_daemon_id("fix-1", "api"));
1701
1702 assert!(!registry.shares_daemon_id("myproj", "worker"));
1704 }
1705
1706 #[test]
1709 fn test_worktree_label_collision_drops_both() {
1710 let found = vec![
1711 (
1712 "fix-1".to_string(),
1713 checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1714 ),
1715 (
1716 "fix-1".to_string(),
1717 checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1718 ),
1719 (
1720 "fix-2".to_string(),
1721 checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1722 ),
1723 ];
1724 let (kept, errors) = group_worktrees("myproj", found);
1725 assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1726 assert_eq!(errors.len(), 1);
1727 assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1728 assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1729 }
1730
1731 #[test]
1734 fn test_project_label_collision_message_depends_on_source() {
1735 let a = Path::new("/repos/a");
1736 let b = Path::new("/repos/b");
1737 let plain = project_label_collision_message("shop", a, b, false);
1738 assert!(plain.contains("`namespace`"), "{plain}");
1739 assert!(!plain.contains("--label"), "{plain}");
1740 let registered = project_label_collision_message("shop", a, b, true);
1741 assert!(
1742 registered.contains("pitchfork config add <file> --label <other>"),
1743 "{registered}"
1744 );
1745 assert!(!registered.contains("`namespace`"), "{registered}");
1746 for msg in [&plain, ®istered] {
1747 assert!(msg.contains("'shop'") && msg.contains("/repos/a") && msg.contains("/repos/b"));
1748 }
1749 }
1750
1751 #[test]
1754 fn test_project_label_collision_routes_neither() {
1755 let temp = tempfile::tempdir().unwrap();
1756 let a = temp.path().join("a/shop");
1757 let b = temp.path().join("b/shop");
1758 std::fs::create_dir_all(&a).unwrap();
1759 std::fs::create_dir_all(&b).unwrap();
1760 write_config(&a, &[("api", "")]);
1761 write_config(&b, &[("api", "")]);
1762
1763 let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1764 assert!(registry.project_labels().is_empty());
1765 assert_eq!(registry.errors.len(), 1);
1766 assert!(
1767 registry.errors[0].contains("shop"),
1768 "{}",
1769 registry.errors[0]
1770 );
1771 assert!(
1774 registry.errors[0].contains(&canonical(&b).display().to_string()),
1775 "{}",
1776 registry.errors[0]
1777 );
1778 assert_eq!(
1779 registry.resolve("api.shop", true),
1780 HostTarget::UnknownProject { known: vec![] }
1781 );
1782 }
1783}