Skip to main content

pitchfork_cli/proxy/
hostname.rs

1//! Automatic hostnames for daemons.
2//!
3//! Every daemon that configures a `port` gets a hostname derived from where its
4//! configuration lives, resolved right to left against a registry of projects:
5//!
6//! ```text
7//! <daemon>.<worktree>.<project>.<tld>   daemon in a linked git worktree
8//! <daemon>.<project>.<tld>              daemon in the primary checkout
9//! <worktree>.<project>.<tld>            stack page (not a daemon)
10//! <project>.<tld>                       project page (not a daemon)
11//! ```
12//!
13//! Labels are DNS-safe lowercase. The project label is the namespace's project
14//! name when the project declares one explicitly, otherwise the directory name
15//! of the primary checkout. The worktree label is the linked worktree's
16//! directory name unless the config sets `worktree_label`.
17
18use crate::config_types::ProxyConfig;
19use crate::daemon_id::DaemonId;
20use crate::pitchfork_toml::{PitchforkToml, PitchforkTomlDaemon};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24/// Maximum length of a single DNS label (RFC 1035).
25const MAX_LABEL_LEN: usize = 63;
26
27/// Maximum length of a full host name (RFC 1035), which the labels share with
28/// the configured TLD.
29pub(crate) const MAX_HOSTNAME_LEN: usize = 253;
30
31/// Whether a hostname's labels still leave room for the configured TLD.
32///
33/// Three maximum-length labels plus a long `proxy.tld` can exceed what DNS
34/// accepts, and a name nothing can resolve is worse than no name at all. Every
35/// surface asks this — generation, listing and routing — so they agree on
36/// whether such a hostname exists.
37pub fn hostname_fits(host: &str) -> bool {
38    let s = crate::settings::settings();
39    host.len() + 1 + crate::proxy::effective_tld(&s).len() <= MAX_HOSTNAME_LEN
40}
41
42/// Convert an arbitrary name into a DNS-safe lowercase label.
43///
44/// ASCII letters are lowercased, digits are kept, and every other character
45/// becomes `-`. Runs of `-` collapse, leading and trailing `-` are trimmed and
46/// the result is truncated to 63 characters. Returns `None` when nothing
47/// usable is left, in which case the caller has no hostname to offer.
48pub fn sanitize_label(input: &str) -> Option<String> {
49    let mut out = String::with_capacity(input.len());
50    for c in input.chars() {
51        if c.is_ascii_alphanumeric() {
52            out.push(c.to_ascii_lowercase());
53        } else if !out.ends_with('-') {
54            out.push('-');
55        }
56    }
57    let trimmed = out.trim_matches('-');
58    let trimmed = if trimmed.len() > MAX_LABEL_LEN {
59        trimmed[..MAX_LABEL_LEN].trim_end_matches('-')
60    } else {
61        trimmed
62    };
63    (!trimmed.is_empty()).then(|| trimmed.to_string())
64}
65
66// ─── checkout detection ──────────────────────────────────────────────────────
67
68/// Where a directory sits in a git repository.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Checkout {
71    /// The primary checkout: the parent of the git common directory.
72    pub primary: PathBuf,
73    /// The linked worktree's own root, when the directory is inside one.
74    pub worktree: Option<PathBuf>,
75}
76
77impl Checkout {
78    /// The root of the checkout the directory belongs to.
79    pub fn root(&self) -> &Path {
80        self.worktree.as_deref().unwrap_or(&self.primary)
81    }
82}
83
84/// Parse the `gitdir:` pointer of a linked worktree's `.git` file.
85///
86/// Returns the linked worktree's administrative directory
87/// (`<common>/worktrees/<name>`), resolved against `dir` when relative.
88fn parse_gitdir_pointer(dir: &Path, content: &str) -> Option<PathBuf> {
89    let target = content
90        .lines()
91        .find_map(|line| line.trim().strip_prefix("gitdir:"))?
92        .trim();
93    if target.is_empty() {
94        return None;
95    }
96    let path = PathBuf::from(target);
97    let path = if path.is_absolute() {
98        path
99    } else {
100        dir.join(path)
101    };
102    Some(normalize(&path))
103}
104
105/// Resolve `..` and `.` components without touching the filesystem, so the
106/// result is stable for directories git has already removed.
107fn normalize(path: &Path) -> PathBuf {
108    let mut out = PathBuf::new();
109    for part in path.components() {
110        match part {
111            std::path::Component::CurDir => {}
112            std::path::Component::ParentDir => {
113                out.pop();
114            }
115            part => out.push(part.as_os_str()),
116        }
117    }
118    out
119}
120
121/// Canonicalize as much of a path as exists, keeping the rest as written.
122///
123/// Every checkout path pitchfork compares goes through here, so the same
124/// directory has one spelling no matter how it was reached: through a symlink,
125/// as `/var` instead of `/private/var` on macOS, or with the `\\?\` prefix
126/// Windows adds. A leaf that no longer exists — a deleted working directory, a
127/// dangling symlink — still resolves against its nearest surviving ancestor, so
128/// a daemon whose directory disappeared keeps the checkout it started in.
129fn canonicalize_best_effort(path: &Path) -> PathBuf {
130    let path = normalize(path);
131    let mut suffix: Vec<std::ffi::OsString> = Vec::new();
132    let mut current = path.as_path();
133    loop {
134        if let Ok(resolved) = current.canonicalize() {
135            let mut out = dunce::simplified(&resolved).to_path_buf();
136            for part in suffix.iter().rev() {
137                out.push(part);
138            }
139            return out;
140        }
141        let Some(parent) = current.parent() else {
142            return path;
143        };
144        match current.file_name() {
145            Some(name) => suffix.push(name.to_os_string()),
146            // A path with no file name (a bare root) cannot be walked further.
147            None => return path,
148        }
149        current = parent;
150    }
151}
152
153/// Split `<common>/worktrees/<name>` into the primary checkout directory.
154///
155/// The common directory is the parent of `worktrees/`, and the primary
156/// checkout is its parent. Anything else (a submodule's `.git/modules/...`
157/// pointer, for instance) is not a linked worktree.
158fn primary_from_worktree_gitdir(gitdir: &Path) -> Option<PathBuf> {
159    let worktrees_dir = gitdir.parent()?;
160    if worktrees_dir.file_name()? != "worktrees" {
161        return None;
162    }
163    let common = worktrees_dir.parent()?;
164    // A bare repository has no primary checkout: its common directory is the
165    // repository itself (`repo.git`), not a `.git` inside a working tree.
166    // Treating its parent as the project would group every bare repository in
167    // that directory under one label, so such a worktree stands on its own.
168    if common.file_name()? != ".git" {
169        return None;
170    }
171    // The pointer is whatever git wrote, so it needs the same resolution as a
172    // path the caller supplied before it can be compared with one.
173    common.parent().map(canonicalize_best_effort)
174}
175
176/// The root of the checkout a directory belongs to, canonicalized.
177///
178/// Used to attribute a running daemon to one checkout. Comparing paths
179/// lexically would put a worktree nested inside its primary checkout (say
180/// `.worktrees/feature`) in the primary, and would miss a daemon whose
181/// directory reaches the same place through a symlink.
182pub fn checkout_root_of(dir: &Path) -> PathBuf {
183    let checkout = detect_checkout(dir);
184    checkout.root().to_path_buf()
185}
186
187/// Locate the checkout containing `dir` by walking up to the nearest `.git`.
188///
189/// A `.git` directory marks the primary checkout. A `.git` file whose
190/// `gitdir:` points into `<common>/worktrees/<name>` marks a linked worktree,
191/// whose primary checkout is the parent of the common directory. When no
192/// `.git` is found the directory is treated as its own primary checkout, so
193/// projects that are not git repositories still get a hostname.
194pub fn detect_checkout(dir: &Path) -> Checkout {
195    let start = canonicalize_best_effort(dir);
196    for current in start.ancestors() {
197        let git = current.join(".git");
198        if git.is_dir() {
199            return Checkout {
200                primary: current.to_path_buf(),
201                worktree: None,
202            };
203        }
204        if git.is_file() {
205            let primary = std::fs::read_to_string(&git)
206                .ok()
207                .and_then(|content| parse_gitdir_pointer(current, &content))
208                .and_then(|gitdir| primary_from_worktree_gitdir(&gitdir));
209            return match primary {
210                Some(primary) if primary != current => Checkout {
211                    primary,
212                    worktree: Some(current.to_path_buf()),
213                },
214                // A `.git` file that is not a linked worktree pointer (a
215                // submodule, say) still marks the root of its own checkout.
216                _ => Checkout {
217                    primary: current.to_path_buf(),
218                    worktree: None,
219                },
220            };
221        }
222    }
223    Checkout {
224        primary: start,
225        worktree: None,
226    }
227}
228
229// ─── labels ──────────────────────────────────────────────────────────────────
230
231/// The project label for a primary checkout.
232///
233/// A project registered or configured with an explicit namespace uses that
234/// name; otherwise the directory name of the primary checkout is used.
235pub fn project_label(primary: &Path) -> Option<String> {
236    let explicit = PitchforkToml::project_namespace_override(primary)
237        .ok()
238        .flatten()
239        .or_else(|| crate::extra_configs::namespace_for_dir(primary));
240    match explicit {
241        Some(ns) => sanitize_label(&ns),
242        None => sanitize_label(&primary.file_name()?.to_string_lossy()),
243    }
244}
245
246/// The worktree label for a linked worktree directory.
247///
248/// Defaults to the directory name, overridden by the `worktree_label` key in
249/// that worktree's own configuration files.
250pub fn worktree_label(worktree: &Path) -> Option<String> {
251    if let Some(label) = PitchforkToml::project_worktree_label(worktree) {
252        return sanitize_label(&label);
253    }
254    sanitize_label(&worktree.file_name()?.to_string_lossy())
255}
256
257/// The daemon label for a daemon config: its name, or the `proxy` override.
258pub fn daemon_label(name: &str, proxy: Option<&ProxyConfig>) -> Option<String> {
259    if proxy.is_some_and(ProxyConfig::is_disabled) {
260        return None;
261    }
262    match proxy.and_then(ProxyConfig::label) {
263        Some(label) => sanitize_label(label),
264        None => sanitize_label(name),
265    }
266}
267
268/// Join hostname labels left to right, omitting the worktree when absent.
269fn join_labels(daemon: &str, worktree: Option<&str>, project: &str) -> String {
270    match worktree {
271        Some(wt) => format!("{daemon}.{wt}.{project}"),
272        None => format!("{daemon}.{project}"),
273    }
274}
275
276/// The automatic hostname (without TLD) for a daemon, e.g. `api.fix-1.myproj`.
277///
278/// Every label is checked against the same project the proxy routes with, so a
279/// label the proxy refuses as ambiguous is never advertised as a URL. Returns
280/// `None` when the daemon configures no port, opted out with `proxy = false`,
281/// no label could be derived, or any of its labels collides with another
282/// daemon, worktree or project.
283pub fn auto_host_for_daemon(id: &DaemonId, config: &PitchforkTomlDaemon) -> Option<String> {
284    config.port.as_ref()?;
285    let daemon = daemon_label(id.name(), config.proxy.as_ref())?;
286    let path = config.path.as_deref()?;
287    // A daemon declared in a global config belongs to no project, and the
288    // config's own directory is not one: it would put daemons under a label
289    // like `pitchfork`. Those daemons are reachable through a legacy slug.
290    if crate::pitchfork_toml::is_global_config(path) {
291        return None;
292    }
293    let base = crate::pitchfork_toml::project_dir_for_config(path)?;
294    let checkout = detect_checkout(&base);
295    let project_label = project_label(&checkout.primary)?;
296    if project_label_is_ambiguous(&project_label, &checkout.primary) {
297        return None;
298    }
299
300    let project = project_hosts_for(&checkout.primary, &project_label)?;
301    let (hosts, worktree) = match &checkout.worktree {
302        Some(dir) => {
303            // A worktree missing from the project either collided with another
304            // worktree's label or failed to load; either way it is not routed.
305            let label = worktree_label(dir)?;
306            (project.worktrees.get(&label)?, Some(label))
307        }
308        None => (&project.primary, None),
309    };
310    // The checkout routes this label to this daemon, or to nothing at all.
311    if hosts.daemons.get(&daemon).map(|d| d.name.as_str()) != Some(id.name()) {
312        return None;
313    }
314
315    let host = join_labels(&daemon, worktree.as_deref(), &project_label);
316    if !hostname_fits(&host) {
317        log::warn!(
318            "'{host}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS \
319             limit, so no hostname is assigned. Shorten the project, worktree or daemon name, \
320             or use a shorter proxy.tld."
321        );
322        return None;
323    }
324    Some(host)
325}
326
327/// The hostname to advertise for a daemon: a legacy `[slugs]` entry when one
328/// exists, otherwise the automatic hostname.
329///
330/// Legacy slugs win because the proxy resolves them first, so this never
331/// advertises an address that routes somewhere else.
332pub fn host_for_daemon(
333    id: &DaemonId,
334    config: Option<&PitchforkTomlDaemon>,
335    global_slugs: &indexmap::IndexMap<String, crate::pitchfork_toml::SlugEntry>,
336) -> Option<String> {
337    // Slugs are registered without a length check, and one too long for the
338    // configured TLD is a name the proxy will not route, so it is not offered
339    // as a URL either.
340    if let Some(slug) = PitchforkToml::find_slug_for_daemon_in_registry(id, global_slugs) {
341        if hostname_fits(&slug) {
342            return Some(slug);
343        }
344        log::warn!(
345            "Slug '{slug}' plus the configured proxy.tld is over the \
346             {MAX_HOSTNAME_LEN}-byte DNS limit, so it is not advertised for {id}."
347        );
348    }
349    auto_host_for_daemon(id, config?)
350}
351
352// ─── registry ────────────────────────────────────────────────────────────────
353
354/// One checkout of a project and the daemons reachable within it.
355#[derive(Debug, Clone)]
356pub struct CheckoutHosts {
357    /// Root directory of this checkout.
358    pub dir: PathBuf,
359    /// Namespace the checkout's daemons belong to.
360    pub namespace: String,
361    /// Daemon label → the daemon it names.
362    pub daemons: HashMap<String, DaemonHost>,
363}
364
365/// A daemon reachable under a checkout's hostname, and how its TLS is served.
366///
367/// The TLS settings are captured here, while the checkout's config is being
368/// read anyway, rather than re-read per connection. That keeps the routing
369/// decision and the hostname that carries it from ever coming from different
370/// reads of the config: a `proxy_tls = "passthrough"` daemon cannot be listed
371/// with its mode missing and be terminated with the proxy's certificate
372/// because its config happened to be unreadable at that moment.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct DaemonHost {
375    /// The daemon's name within its namespace.
376    pub name: String,
377    /// `proxy_tls` as the checkout's config had it, `None` when unset.
378    pub proxy_tls: Option<crate::config_types::ProxyTlsMode>,
379    /// `proxy_tls_port`, or its shorter `proxy_port` spelling.
380    pub proxy_tls_port: Option<u16>,
381}
382
383impl CheckoutHosts {
384    /// Load the routable daemons of one checkout, along with any collisions
385    /// found while doing so.
386    ///
387    /// Two daemons whose labels reduce to the same string are both dropped:
388    /// keeping either one would route half the requests to a daemon the user
389    /// did not name, and which of the two won would depend on config order.
390    fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
391        let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
392        let pt = PitchforkToml::all_merged_from(dir).ok()?;
393        let mut daemons: HashMap<String, DaemonHost> = HashMap::new();
394        let mut errors = Vec::new();
395        let mut colliding: Vec<String> = Vec::new();
396        for (id, config) in &pt.daemons {
397            if id.namespace() != namespace || config.port.is_none() {
398                continue;
399            }
400            let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
401                continue;
402            };
403            match daemons.get(&label) {
404                Some(other) => {
405                    errors.push(format!(
406                        "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
407                         '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
408                        dir.display(),
409                        other = other.name,
410                        name = id.name(),
411                    ));
412                    colliding.push(label);
413                }
414                None => {
415                    daemons.insert(
416                        label,
417                        DaemonHost {
418                            name: id.name().to_string(),
419                            proxy_tls: config.proxy_tls,
420                            proxy_tls_port: config.effective_proxy_tls_port(),
421                        },
422                    );
423                }
424            }
425        }
426        for label in colliding {
427            daemons.remove(&label);
428        }
429        Some((
430            Self {
431                dir: dir.to_path_buf(),
432                namespace,
433                daemons,
434            },
435            errors,
436        ))
437    }
438
439    /// Sorted daemon labels, for listings and error pages.
440    pub fn labels(&self) -> Vec<String> {
441        let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
442        labels.sort();
443        labels
444    }
445}
446
447/// A project and its checkouts, keyed by hostname label.
448#[derive(Debug, Clone)]
449pub struct ProjectHosts {
450    pub label: String,
451    pub primary: CheckoutHosts,
452    /// Worktree label → checkout.
453    pub worktrees: HashMap<String, CheckoutHosts>,
454}
455
456impl ProjectHosts {
457    /// Every checkout of this project: its primary and each linked worktree.
458    pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
459        std::iter::once(&self.primary).chain(self.worktrees.values())
460    }
461
462    /// Sorted worktree labels.
463    pub fn worktree_labels(&self) -> Vec<String> {
464        let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
465        labels.sort();
466        labels
467    }
468}
469
470/// All projects the proxy can route to, keyed by project label.
471#[derive(Debug, Clone, Default)]
472pub struct HostRegistry {
473    pub projects: HashMap<String, ProjectHosts>,
474    /// Label collisions found while loading, reported to the user as-is.
475    pub errors: Vec<String>,
476}
477
478/// What a hostname resolves to.
479#[derive(Debug, Clone, PartialEq, Eq)]
480pub enum HostTarget {
481    /// A daemon in one of the project's checkouts.
482    Daemon {
483        project: String,
484        worktree: Option<String>,
485        dir: PathBuf,
486        namespace: String,
487        daemon: String,
488        /// `proxy_tls` as the checkout's config had it when the registry was
489        /// built, so routing and the hostname come from one read.
490        proxy_tls: Option<crate::config_types::ProxyTlsMode>,
491        /// `proxy_tls_port`, from the same read.
492        proxy_tls_port: Option<u16>,
493    },
494    /// `<project>.<tld>` — reserved for the project page.
495    ProjectPage { project: String },
496    /// `<worktree>.<project>.<tld>` — reserved for the stack page.
497    WorktreePage { project: String, worktree: String },
498    /// The rightmost label is not a known project.
499    UnknownProject { known: Vec<String> },
500    /// The project is known but the daemon label is not.
501    UnknownDaemon {
502        project: String,
503        worktree: Option<String>,
504        known: Vec<String>,
505    },
506}
507
508/// Group a project's worktree checkouts by label.
509///
510/// Two worktrees that reduce to the same label are both dropped and reported:
511/// routing one of them would answer half the requests with the other's content,
512/// which is worse than not routing the label at all.
513fn group_worktrees(
514    project: &str,
515    found: Vec<(String, CheckoutHosts)>,
516) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
517    let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
518    let mut errors = Vec::new();
519    let mut colliding: Vec<String> = Vec::new();
520    for (label, hosts) in found {
521        match kept.get(&label) {
522            Some(existing) if existing.dir == hosts.dir => continue,
523            Some(existing) => {
524                errors.push(format!(
525                    "worktree label '{label}' in project '{project}' is claimed by two \
526                     directories: {} and {}. Set `worktree_label` in one of them.",
527                    existing.dir.display(),
528                    hosts.dir.display(),
529                ));
530                colliding.push(label);
531            }
532            None => {
533                kept.insert(label, hosts);
534            }
535        }
536    }
537    for label in colliding {
538        kept.remove(&label);
539    }
540    (kept, errors)
541}
542
543/// How long a project's worktree discovery stays cached.
544///
545/// `pitchfork list` derives a hostname for every daemon it prints, and each one
546/// would otherwise re-enumerate the project's worktrees. Only the enumeration
547/// is cached: configuration is re-read every time, so a label that starts
548/// colliding stops being advertised at once, and only a brand-new worktree
549/// directory can take up to this long to appear.
550const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
551
552struct WorktreeCache {
553    entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
554}
555
556static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
557    once_cell::sync::Lazy::new(|| {
558        std::sync::Mutex::new(WorktreeCache {
559            entries: HashMap::new(),
560        })
561    });
562
563/// The linked worktree roots of a project, cached briefly.
564fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
565    let now = std::time::Instant::now();
566    {
567        let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
568        if let Some((expires_at, dirs)) = cache.entries.get(primary)
569            && now < *expires_at
570        {
571            return std::sync::Arc::clone(dirs);
572        }
573    } // lock released before any I/O
574
575    let dirs = std::sync::Arc::new(worktree_dirs(primary));
576
577    let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
578    cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
579    cache.entries.insert(
580        primary.to_path_buf(),
581        (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
582    );
583    dirs
584}
585
586/// Report a configuration problem once per distinct message.
587///
588/// A label collision persists until someone renames something, while the
589/// registry behind it is rebuilt every couple of seconds and on every CLI
590/// invocation. Logging each rebuild would fill the supervisor log with the same
591/// line; the user needs to read it once.
592pub fn warn_once(message: &str) {
593    static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
594        once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
595
596    let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
597    // Distinct messages are bounded by the configuration, but a pathological
598    // one should not grow the set forever.
599    if seen.len() > 256 {
600        seen.clear();
601    }
602    if seen.insert(message.to_string()) {
603        log::warn!("{message}");
604    }
605}
606
607/// Directories that may contain a project pitchfork knows about.
608///
609/// Only persisted knowledge counts: the namespace registry, the legacy slug
610/// registry, and the directories of daemons in the state file. The current
611/// directory is deliberately absent, because it differs between the supervisor
612/// and each CLI invocation, and a registry that depended on it would let the
613/// CLI advertise a hostname the proxy does not route — or refuse one it does.
614fn candidate_dirs() -> Vec<PathBuf> {
615    let mut candidates: Vec<PathBuf> = Vec::new();
616    for (_, entry) in PitchforkToml::read_global_namespaces() {
617        candidates.push(entry.dir);
618    }
619    for (_, entry) in PitchforkToml::read_global_slugs() {
620        if let Some(dir) = entry.resolve_dir() {
621            candidates.push(dir);
622        }
623    }
624    if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
625        for daemon in state.daemons.values() {
626            if let Some(dir) = &daemon.dir {
627                candidates.push(dir.clone());
628            }
629        }
630    }
631    candidates
632}
633
634/// Whether another known project claims the same project label.
635///
636/// Such a label routes to neither project, so nothing may advertise a URL
637/// under it. This reads configuration files but never enumerates worktrees,
638/// which keeps it cheap enough for the per-daemon display paths.
639fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
640    let mut seen: Vec<PathBuf> = Vec::new();
641    for dir in candidate_dirs() {
642        if !dir.exists() {
643            continue;
644        }
645        let other = detect_checkout(&dir).primary;
646        if other == primary || seen.contains(&other) {
647            continue;
648        }
649        seen.push(other.clone());
650        if project_label(&other).as_deref() == Some(label) {
651            return true;
652        }
653    }
654    false
655}
656
657/// Linked worktree roots recorded in a checkout's git common directory.
658///
659/// Each `<common>/worktrees/<name>/gitdir` holds the path of that worktree's
660/// own `.git` file, whose parent is the worktree root. Reading them directly
661/// costs no subprocess and still works when `git` is unavailable, so it
662/// complements the `git worktree list` discovery that also covers jj.
663fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
664    let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
665        return vec![];
666    };
667    entries
668        .filter_map(|entry| {
669            let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
670            let git_file = PathBuf::from(gitdir.trim());
671            git_file.parent().map(Path::to_path_buf)
672        })
673        .collect()
674}
675
676/// Every linked worktree root of a project, from both discovery sources.
677///
678/// `git worktree list` also covers jj workspaces, and the `gitdir` pointers
679/// cover repositories where `git` is unavailable or errors.
680fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
681    if !crate::settings::settings().general.worktree {
682        return vec![];
683    }
684    let mut dirs: Vec<PathBuf> = Vec::new();
685    let found = crate::proxy::worktree::discover_worktrees(primary)
686        .into_iter()
687        .map(|entry| entry.path)
688        .chain(linked_worktree_dirs(primary));
689    for path in found {
690        let Some(wt_dir) = detect_checkout(&path).worktree else {
691            continue; // the primary checkout itself
692        };
693        if !dirs.contains(&wt_dir) {
694            dirs.push(wt_dir);
695        }
696    }
697    dirs
698}
699
700/// Build one project's checkouts: its primary and every linked worktree.
701///
702/// Returns the project together with the label collisions found inside it.
703/// Configuration is read fresh on every call, so a collision introduced by an
704/// edit takes effect immediately; only `worktree_dirs` may be cached.
705fn build_project_hosts(
706    primary: &Path,
707    label: &str,
708    worktrees: &[PathBuf],
709) -> Option<(ProjectHosts, Vec<String>)> {
710    let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
711    let mut project = ProjectHosts {
712        label: label.to_string(),
713        primary: primary_hosts,
714        worktrees: HashMap::new(),
715    };
716
717    let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
718    for wt_dir in worktrees {
719        let (Some(wt_label), Some((hosts, wt_errors))) =
720            (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
721        else {
722            continue;
723        };
724        errors.extend(wt_errors);
725        found.push((wt_label, hosts));
726    }
727    let (worktrees, wt_errors) = group_worktrees(label, found);
728    project.worktrees = worktrees;
729    errors.extend(wt_errors);
730
731    Some((project, errors))
732}
733
734/// Build a project the way the proxy routes it, reusing a brief cache of its
735/// worktree enumeration.
736fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
737    let worktrees = cached_worktree_dirs(primary);
738    let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
739    for err in errors {
740        warn_once(&err);
741    }
742    Some(project)
743}
744
745impl HostRegistry {
746    /// Build the registry from every project pitchfork knows about.
747    ///
748    /// Candidate directories come from the current directory, the namespace
749    /// registry, the legacy slug registry, and the directories of daemons in
750    /// the state file. Each is mapped to its primary checkout, whose linked
751    /// worktrees are then discovered.
752    ///
753    /// A project the supervisor has never seen — never started, never
754    /// registered — is therefore not routable from another directory yet.
755    pub fn build() -> Self {
756        Self::from_dirs(&candidate_dirs())
757    }
758
759    /// Build the registry from an explicit list of project directories.
760    pub fn from_dirs(dirs: &[PathBuf]) -> Self {
761        // Collapse the candidates to distinct primary checkouts.
762        let mut primaries: Vec<PathBuf> = Vec::new();
763        for dir in dirs {
764            if !dir.exists() {
765                continue;
766            }
767            let primary = detect_checkout(dir).primary;
768            if !primaries.contains(&primary) {
769                primaries.push(primary);
770            }
771        }
772
773        let mut registry = Self::default();
774        let mut colliding: Vec<String> = Vec::new();
775        for primary in primaries {
776            let Some(label) = project_label(&primary) else {
777                continue;
778            };
779
780            if let Some(existing) = registry.projects.get(&label) {
781                if existing.primary.dir != primary {
782                    registry.errors.push(format!(
783                        "project label '{label}' is claimed by two directories: {} and {}. \
784                         Set a distinct top-level `namespace` in one of them.",
785                        existing.primary.dir.display(),
786                        primary.display(),
787                    ));
788                    // Neither is routed: answering for one of them would serve
789                    // the wrong project's daemons under a URL the other
790                    // checkout advertises for itself.
791                    colliding.push(label);
792                }
793                continue;
794            }
795
796            let worktrees = worktree_dirs(&primary);
797            let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
798                continue;
799            };
800            registry.errors.extend(errors);
801            // A directory with no routable daemon anywhere in it is not a
802            // project worth a hostname; an ad-hoc daemon's working directory
803            // would otherwise become an empty one.
804            if project.checkouts().all(|c| c.daemons.is_empty()) {
805                continue;
806            }
807            registry.projects.insert(label, project);
808        }
809        for label in colliding {
810            registry.projects.remove(&label);
811        }
812
813        registry
814    }
815
816    /// Whether more than one checkout across all known projects would run this
817    /// daemon ID.
818    ///
819    /// A namespace comes from the checkout's own directory name or config, not
820    /// from the project above it, so two unrelated projects that each have a
821    /// `fix-1` worktree with an `api` daemon both produce `fix-1/api`. The
822    /// state file holds one record per ID, so in that case a request has to be
823    /// matched to the checkout it names, exactly as for two checkouts of one
824    /// project.
825    pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
826        self.projects
827            .values()
828            .flat_map(ProjectHosts::checkouts)
829            .filter(|c| c.namespace == namespace && c.daemons.values().any(|d| d.name == daemon))
830            .count()
831            > 1
832    }
833
834    /// Sorted project labels.
835    pub fn project_labels(&self) -> Vec<String> {
836        let mut labels: Vec<String> = self.projects.keys().cloned().collect();
837        labels.sort();
838        labels
839    }
840
841    /// Resolve a hostname's labels (the host with the TLD already stripped).
842    ///
843    /// Resolution runs right to left: the last label must name a project, an
844    /// optional worktree label follows, and the label before the daemon's is
845    /// where extra leading labels become wildcard subdomains of the same
846    /// daemon. A label that names both a worktree and a daemon is read as the
847    /// worktree.
848    pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
849        let labels: Vec<String> = subdomain
850            .split('.')
851            .map(|l| l.to_ascii_lowercase())
852            .collect();
853        let Some((project_label, rest)) = labels.split_last() else {
854            return HostTarget::UnknownProject {
855                known: self.project_labels(),
856            };
857        };
858        let Some(project) = self.projects.get(project_label) else {
859            return HostTarget::UnknownProject {
860                known: self.project_labels(),
861            };
862        };
863        if rest.is_empty() {
864            return HostTarget::ProjectPage {
865                project: project.label.clone(),
866            };
867        }
868
869        // A worktree label directly left of the project consumes one label.
870        let (checkout, worktree, rest) = match rest.split_last() {
871            Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
872                Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
873                None => (&project.primary, None, rest),
874            },
875            None => (&project.primary, None, rest),
876        };
877
878        let Some((daemon_label, extra)) = rest.split_last() else {
879            return HostTarget::WorktreePage {
880                project: project.label.clone(),
881                worktree: worktree.unwrap_or_default(),
882            };
883        };
884        if !extra.is_empty() && !wildcard {
885            return HostTarget::UnknownDaemon {
886                project: project.label.clone(),
887                worktree,
888                known: checkout.labels(),
889            };
890        }
891
892        match checkout.daemons.get(daemon_label) {
893            Some(daemon) => HostTarget::Daemon {
894                project: project.label.clone(),
895                worktree,
896                dir: checkout.dir.clone(),
897                namespace: checkout.namespace.clone(),
898                daemon: daemon.name.clone(),
899                proxy_tls: daemon.proxy_tls,
900                proxy_tls_port: daemon.proxy_tls_port,
901            },
902            None => HostTarget::UnknownDaemon {
903                project: project.label.clone(),
904                worktree,
905                known: checkout.labels(),
906            },
907        }
908    }
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    /// The path as `detect_checkout` reports it: canonical, and without the
916    /// `\\?\` verbatim prefix Windows canonicalization adds.
917    fn canonical(path: &Path) -> PathBuf {
918        dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
919    }
920
921    fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
922        CheckoutHosts {
923            dir: PathBuf::from(dir),
924            namespace: namespace.to_string(),
925            daemons: daemons
926                .iter()
927                .map(|(l, n)| {
928                    (
929                        l.to_string(),
930                        DaemonHost {
931                            name: n.to_string(),
932                            proxy_tls: None,
933                            proxy_tls_port: None,
934                        },
935                    )
936                })
937                .collect(),
938        }
939    }
940
941    #[test]
942    fn test_sanitize_label() {
943        assert_eq!(sanitize_label("api").as_deref(), Some("api"));
944        assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
945        assert_eq!(
946            sanitize_label("feature/my_branch").as_deref(),
947            Some("feature-my-branch")
948        );
949        assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
950        assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
951        assert_eq!(sanitize_label("---"), None);
952        assert_eq!(sanitize_label(""), None);
953        assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
954    }
955
956    #[test]
957    fn test_daemon_label_override_and_opt_out() {
958        assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
959        assert_eq!(
960            daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
961            Some("api")
962        );
963        assert_eq!(
964            daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
965            Some("web-ui")
966        );
967        assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
968    }
969
970    /// A primary checkout has a `.git` directory and no worktree label.
971    #[test]
972    fn test_detect_checkout_primary() {
973        let temp = tempfile::tempdir().unwrap();
974        let repo = temp.path().join("my-repo");
975        std::fs::create_dir_all(repo.join(".git")).unwrap();
976        std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
977
978        let found = detect_checkout(&repo.join("sub/dir"));
979        assert_eq!(found.primary, canonical(&repo));
980        assert_eq!(found.worktree, None);
981    }
982
983    /// A linked worktree's `.git` file points into `<common>/worktrees/<name>`.
984    #[test]
985    fn test_detect_checkout_linked_worktree() {
986        let temp = tempfile::tempdir().unwrap();
987        let repo = temp.path().join("my-repo");
988        std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
989        let wt = temp.path().join("fix-1");
990        std::fs::create_dir_all(&wt).unwrap();
991        std::fs::write(
992            wt.join(".git"),
993            format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
994        )
995        .unwrap();
996
997        let found = detect_checkout(&wt);
998        assert_eq!(found.primary, canonical(&repo));
999        assert_eq!(found.worktree, Some(canonical(&wt)));
1000        assert_eq!(found.root(), canonical(&wt));
1001    }
1002
1003    /// A working directory that no longer exists still belongs to the checkout
1004    /// above it, so a daemon whose directory was deleted keeps its hostname.
1005    #[test]
1006    fn test_checkout_root_of_missing_directory() {
1007        let temp = tempfile::tempdir().unwrap();
1008        let repo = temp.path().join("my-repo");
1009        std::fs::create_dir_all(repo.join(".git")).unwrap();
1010
1011        assert_eq!(
1012            checkout_root_of(&repo.join("gone/deeper")),
1013            canonical(&repo)
1014        );
1015    }
1016
1017    /// The primary checkout read from a worktree's `gitdir:` pointer is spelled
1018    /// the same as the one found by walking into the primary directly, whatever
1019    /// alias the pointer took.
1020    #[cfg(unix)]
1021    #[test]
1022    fn test_detect_checkout_primary_spelling_matches_through_symlink() {
1023        use std::os::unix::fs::symlink;
1024
1025        let temp = tempfile::tempdir().unwrap();
1026        let repo = temp.path().join("my-repo");
1027        let admin = repo.join(".git/worktrees/fix-1");
1028        std::fs::create_dir_all(&admin).unwrap();
1029        let wt = temp.path().join("fix-1");
1030        std::fs::create_dir_all(&wt).unwrap();
1031
1032        // Point the worktree at the repository through a symlinked alias, the
1033        // way a checkout under a symlinked home directory would.
1034        let alias = temp.path().join("alias");
1035        symlink(&repo, &alias).unwrap();
1036        std::fs::write(
1037            wt.join(".git"),
1038            format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
1039        )
1040        .unwrap();
1041
1042        assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1043        assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1044    }
1045
1046    /// A bare repository has no working tree to be the project, so its linked
1047    /// worktrees stand alone rather than being grouped under the directory that
1048    /// happens to hold the bare repositories.
1049    #[test]
1050    fn test_detect_checkout_bare_repository_worktree() {
1051        let temp = tempfile::tempdir().unwrap();
1052        let bare = temp.path().join("my-repo.git");
1053        let admin = bare.join("worktrees/fix-1");
1054        std::fs::create_dir_all(&admin).unwrap();
1055        let wt = temp.path().join("fix-1");
1056        std::fs::create_dir_all(&wt).unwrap();
1057        std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1058
1059        let found = detect_checkout(&wt);
1060        assert_eq!(found.primary, canonical(&wt));
1061        assert_eq!(found.worktree, None);
1062    }
1063
1064    /// A daemon declared in a global config has no project, so it gets no
1065    /// automatic hostname; the config directory is not one.
1066    #[test]
1067    fn test_auto_host_skips_global_config_daemons() {
1068        let config = PitchforkTomlDaemon {
1069            run: "server".into(),
1070            port: Some(crate::config_types::PortConfig {
1071                expect: vec![3000],
1072                ..Default::default()
1073            }),
1074            path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1075            ..PitchforkTomlDaemon::default()
1076        };
1077        let id = DaemonId::try_new("global", "api").unwrap();
1078        assert_eq!(auto_host_for_daemon(&id, &config), None);
1079    }
1080
1081    /// A registered slug wins over the automatic hostname, because the proxy
1082    /// resolves slugs first.
1083    #[test]
1084    fn test_host_for_daemon_prefers_a_registered_slug() {
1085        let temp = tempfile::tempdir().unwrap();
1086        let repo = temp.path().join("slug-repo");
1087        std::fs::create_dir_all(&repo).unwrap();
1088        write_config(&repo, &[("api", "")]);
1089        let (id, config) = daemon_config(&repo, "api");
1090
1091        let empty = indexmap::IndexMap::new();
1092        assert_eq!(
1093            host_for_daemon(&id, Some(&config), &empty).as_deref(),
1094            Some("api.slug-repo")
1095        );
1096
1097        // A slug registered for the same daemon replaces it, dots and all.
1098        let mut slugs = indexmap::IndexMap::new();
1099        slugs.insert(
1100            "myapp".to_string(),
1101            crate::pitchfork_toml::SlugEntry {
1102                dir: Some(repo.clone()),
1103                namespace: Some(id.namespace().to_string()),
1104                daemon: Some("api".to_string()),
1105            },
1106        );
1107        assert_eq!(
1108            host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1109            Some("myapp")
1110        );
1111    }
1112
1113    /// A `.git` file that is not a worktree pointer (a submodule) is its own
1114    /// checkout rather than a worktree of something else.
1115    #[test]
1116    fn test_detect_checkout_submodule_pointer() {
1117        let temp = tempfile::tempdir().unwrap();
1118        let repo = temp.path().join("my-repo");
1119        std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1120        let sub = repo.join("sub");
1121        std::fs::create_dir_all(&sub).unwrap();
1122        std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1123
1124        let found = detect_checkout(&sub);
1125        assert_eq!(found.primary, canonical(&sub));
1126        assert_eq!(found.worktree, None);
1127    }
1128
1129    /// Without a `.git` anywhere above it, a directory is its own project.
1130    #[test]
1131    fn test_detect_checkout_without_git() {
1132        let temp = tempfile::tempdir().unwrap();
1133        let dir = temp.path().join("plain");
1134        std::fs::create_dir_all(&dir).unwrap();
1135
1136        let found = detect_checkout(&dir);
1137        assert_eq!(found.primary, canonical(&dir));
1138        assert_eq!(found.worktree, None);
1139    }
1140
1141    fn registry() -> HostRegistry {
1142        let mut projects = HashMap::new();
1143        let mut worktrees = HashMap::new();
1144        worktrees.insert(
1145            "fix-1".to_string(),
1146            checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1147        );
1148        projects.insert(
1149            "myproj".to_string(),
1150            ProjectHosts {
1151                label: "myproj".to_string(),
1152                primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1153                worktrees,
1154            },
1155        );
1156        HostRegistry {
1157            projects,
1158            errors: vec![],
1159        }
1160    }
1161
1162    /// The registry captures each daemon's TLS settings while it reads the
1163    /// checkout's config, so routing never has to read it again and cannot
1164    /// disagree with the hostname it built.
1165    #[test]
1166    fn test_checkout_hosts_capture_proxy_tls() {
1167        let dir = tempfile::tempdir().unwrap();
1168        let project = dir.path().join("tlsproj");
1169        std::fs::create_dir_all(&project).unwrap();
1170        std::fs::write(
1171            project.join("pitchfork.toml"),
1172            "[daemons.secure]\nrun = \"serve\"\nport = [8443, 9443]\n\
1173             proxy_tls = \"passthrough\"\nproxy_tls_port = 9443\n\
1174             [daemons.plain]\nrun = \"serve\"\nport = 8080\n",
1175        )
1176        .unwrap();
1177
1178        let (hosts, errors) = CheckoutHosts::load(&project).expect("checkout loads");
1179        assert!(errors.is_empty(), "{errors:?}");
1180
1181        let secure = hosts.daemons.get("secure").expect("secure is routable");
1182        assert_eq!(secure.name, "secure");
1183        assert_eq!(
1184            secure.proxy_tls,
1185            Some(crate::config_types::ProxyTlsMode::Passthrough)
1186        );
1187        assert_eq!(secure.proxy_tls_port, Some(9443));
1188
1189        let plain = hosts.daemons.get("plain").expect("plain is routable");
1190        assert_eq!(plain.proxy_tls, None);
1191        assert_eq!(plain.proxy_tls_port, None);
1192    }
1193
1194    #[test]
1195    fn test_resolve_primary_checkout_daemon() {
1196        let target = registry().resolve("api.myproj", true);
1197        assert_eq!(
1198            target,
1199            HostTarget::Daemon {
1200                project: "myproj".into(),
1201                worktree: None,
1202                dir: PathBuf::from("/repos/myproj"),
1203                namespace: "myproj".into(),
1204                daemon: "api".into(),
1205                proxy_tls: None,
1206                proxy_tls_port: None,
1207            }
1208        );
1209    }
1210
1211    #[test]
1212    fn test_resolve_worktree_daemon() {
1213        let target = registry().resolve("web.fix-1.myproj", true);
1214        assert_eq!(
1215            target,
1216            HostTarget::Daemon {
1217                project: "myproj".into(),
1218                worktree: Some("fix-1".into()),
1219                dir: PathBuf::from("/repos/fix-1"),
1220                namespace: "fix-1".into(),
1221                daemon: "web".into(),
1222                proxy_tls: None,
1223                proxy_tls_port: None,
1224            }
1225        );
1226    }
1227
1228    #[test]
1229    fn test_resolve_is_case_insensitive() {
1230        assert!(matches!(
1231            registry().resolve("API.MyProj", true),
1232            HostTarget::Daemon { .. }
1233        ));
1234    }
1235
1236    /// Project and stack pages are reserved: they never resolve to a daemon.
1237    #[test]
1238    fn test_resolve_reserved_pages() {
1239        assert_eq!(
1240            registry().resolve("myproj", true),
1241            HostTarget::ProjectPage {
1242                project: "myproj".into()
1243            }
1244        );
1245        assert_eq!(
1246            registry().resolve("fix-1.myproj", true),
1247            HostTarget::WorktreePage {
1248                project: "myproj".into(),
1249                worktree: "fix-1".into(),
1250            }
1251        );
1252    }
1253
1254    /// When a worktree label equals a daemon name, the worktree wins: the
1255    /// worktree is consumed first, so `api.myproj` is that stack's page and
1256    /// the primary checkout's `api` daemon is unreachable under that spelling.
1257    #[test]
1258    fn test_resolve_worktree_beats_daemon_of_same_name() {
1259        let mut reg = registry();
1260        // A worktree whose label is also a daemon name of the primary checkout.
1261        reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1262            "api".to_string(),
1263            checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1264        );
1265        assert_eq!(
1266            reg.resolve("api.myproj", true),
1267            HostTarget::WorktreePage {
1268                project: "myproj".into(),
1269                worktree: "api".into(),
1270            }
1271        );
1272        // The daemon inside that worktree is still reachable.
1273        assert!(matches!(
1274            reg.resolve("api.api.myproj", true),
1275            HostTarget::Daemon { .. }
1276        ));
1277    }
1278
1279    #[test]
1280    fn test_resolve_wildcard_subdomain() {
1281        let target = registry().resolve("tenant.api.myproj", true);
1282        assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1283        // Wildcards off: the extra label is not a daemon of its own.
1284        assert!(matches!(
1285            registry().resolve("tenant.api.myproj", false),
1286            HostTarget::UnknownDaemon { .. }
1287        ));
1288    }
1289
1290    #[test]
1291    fn test_resolve_unknown_names() {
1292        assert_eq!(
1293            registry().resolve("api.other", true),
1294            HostTarget::UnknownProject {
1295                known: vec!["myproj".to_string()]
1296            }
1297        );
1298        assert_eq!(
1299            registry().resolve("nope.myproj", true),
1300            HostTarget::UnknownDaemon {
1301                project: "myproj".into(),
1302                worktree: None,
1303                known: vec!["api".to_string()],
1304            }
1305        );
1306    }
1307
1308    /// Build a primary checkout and a linked worktree of it on disk, with the
1309    /// same pair of pointers real `git worktree add` writes.
1310    fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1311        let repo = temp.join(name);
1312        let admin = repo.join(format!(".git/worktrees/{worktree}"));
1313        std::fs::create_dir_all(&admin).unwrap();
1314        let wt = temp.join(worktree);
1315        std::fs::create_dir_all(&wt).unwrap();
1316        std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1317        std::fs::write(
1318            admin.join("gitdir"),
1319            format!("{}\n", wt.join(".git").display()),
1320        )
1321        .unwrap();
1322        (repo, wt)
1323    }
1324
1325    /// Without an explicit namespace the project label is the primary
1326    /// checkout's directory name, sanitized.
1327    #[test]
1328    fn test_project_label_from_directory_name() {
1329        let temp = tempfile::tempdir().unwrap();
1330        let repo = temp.path().join("My App");
1331        std::fs::create_dir_all(&repo).unwrap();
1332        assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1333    }
1334
1335    /// A project that declares a namespace uses that name instead.
1336    #[test]
1337    fn test_project_label_from_explicit_namespace() {
1338        let temp = tempfile::tempdir().unwrap();
1339        let repo = temp.path().join("checkout-dir");
1340        std::fs::create_dir_all(&repo).unwrap();
1341        std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1342        assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1343    }
1344
1345    /// Write a `pitchfork.toml` with one daemon per entry, each with a port.
1346    fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1347        let body: String = daemons
1348            .iter()
1349            .enumerate()
1350            .map(|(i, (name, extra))| {
1351                format!(
1352                    "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1353                    3000 + i
1354                )
1355            })
1356            .collect();
1357        std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1358    }
1359
1360    /// The daemon config as the merged config holds it, anchored at `dir`.
1361    fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1362        let pt = PitchforkToml::all_merged_from(dir).unwrap();
1363        let (id, config) = pt
1364            .daemons
1365            .iter()
1366            .find(|(id, _)| id.name() == name)
1367            .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1368        (id.clone(), config.clone())
1369    }
1370
1371    /// A daemon in the primary checkout omits the worktree label; the same
1372    /// daemon in a linked worktree carries it.
1373    #[test]
1374    fn test_auto_host_primary_and_worktree() {
1375        let temp = tempfile::tempdir().unwrap();
1376        let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1377        write_config(&repo, &[("api", "")]);
1378        write_config(&wt, &[("api", "")]);
1379
1380        let (id, config) = daemon_config(&repo, "api");
1381        assert_eq!(
1382            auto_host_for_daemon(&id, &config).as_deref(),
1383            Some("api.my-repo")
1384        );
1385
1386        let (wt_id, wt_config) = daemon_config(&wt, "api");
1387        assert_eq!(
1388            auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1389            Some("api.fix-1.my-repo")
1390        );
1391    }
1392
1393    /// `worktree_label` in the worktree's own config replaces its directory name.
1394    #[test]
1395    fn test_worktree_label_override() {
1396        let temp = tempfile::tempdir().unwrap();
1397        let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1398        assert_eq!(
1399            worktree_label(&wt).as_deref(),
1400            Some("sleepy-kapitsa-9f02fc")
1401        );
1402
1403        std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1404        assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1405    }
1406
1407    /// A daemon opts out with `proxy = false` and gets no hostname; one without
1408    /// a port never had one to begin with; a string renames its label.
1409    #[test]
1410    fn test_auto_host_requires_port_and_opt_in() {
1411        let temp = tempfile::tempdir().unwrap();
1412        let repo = temp.path().join("opt-in-repo");
1413        std::fs::create_dir_all(&repo).unwrap();
1414        std::fs::write(
1415            repo.join("pitchfork.toml"),
1416            "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1417             [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1418             [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1419             [daemons.worker]\nrun = \"server\"\n",
1420        )
1421        .unwrap();
1422
1423        let host = |name: &str| {
1424            let (id, config) = daemon_config(&repo, name);
1425            auto_host_for_daemon(&id, &config)
1426        };
1427        assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1428        assert_eq!(host("admin"), None);
1429        assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1430        assert_eq!(host("worker"), None);
1431    }
1432
1433    /// Two daemons whose labels reduce to the same string are both dropped,
1434    /// and neither is advertised as a URL.
1435    #[test]
1436    fn test_daemon_label_collision_drops_both() {
1437        let temp = tempfile::tempdir().unwrap();
1438        let repo = temp.path().join("dupe-repo");
1439        std::fs::create_dir_all(&repo).unwrap();
1440        std::fs::write(
1441            repo.join("pitchfork.toml"),
1442            "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1443             [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1444             [daemons.other]\nrun = \"server\"\nport = 3002\n",
1445        )
1446        .unwrap();
1447
1448        let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1449        assert_eq!(hosts.labels(), vec!["other".to_string()]);
1450        assert_eq!(errors.len(), 1);
1451        assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1452
1453        for name in ["foo_bar", "foo-bar"] {
1454            let (id, config) = daemon_config(&repo, name);
1455            assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1456        }
1457        let (id, config) = daemon_config(&repo, "other");
1458        assert_eq!(
1459            auto_host_for_daemon(&id, &config).as_deref(),
1460            Some("other.dupe-repo")
1461        );
1462    }
1463
1464    /// A name that cannot fit alongside the configured TLD is refused by every
1465    /// surface, so nothing advertises an address DNS would reject.
1466    #[test]
1467    fn test_hostname_fits() {
1468        // The default TLD leaves room for three maximum-length labels.
1469        assert!(hostname_fits(&format!(
1470            "{}.{}.{}",
1471            "a".repeat(63),
1472            "b".repeat(63),
1473            "c".repeat(63)
1474        )));
1475        assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1476    }
1477
1478    /// Two worktrees whose labels collide are dropped from the project, and
1479    /// neither advertises a URL the proxy would refuse to route.
1480    #[test]
1481    fn test_auto_host_none_for_colliding_worktrees() {
1482        let temp = tempfile::tempdir().unwrap();
1483        let repo = temp.path().join("wt-repo");
1484        std::fs::create_dir_all(repo.join(".git")).unwrap();
1485        write_config(&repo, &[("api", "")]);
1486
1487        let mut worktrees = Vec::new();
1488        for dir_name in ["fix-1", "fix.1"] {
1489            let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1490            std::fs::create_dir_all(&admin).unwrap();
1491            let wt = temp.path().join(dir_name);
1492            std::fs::create_dir_all(&wt).unwrap();
1493            std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1494            std::fs::write(
1495                admin.join("gitdir"),
1496                format!("{}\n", wt.join(".git").display()),
1497            )
1498            .unwrap();
1499            write_config(&wt, &[("api", "")]);
1500            worktrees.push(wt);
1501        }
1502
1503        for wt in &worktrees {
1504            let (id, config) = daemon_config(wt, "api");
1505            assert_eq!(
1506                auto_host_for_daemon(&id, &config),
1507                None,
1508                "{} must not advertise a hostname",
1509                wt.display()
1510            );
1511        }
1512        // The primary checkout is unaffected by its worktrees' collision.
1513        let (id, config) = daemon_config(&repo, "api");
1514        assert_eq!(
1515            auto_host_for_daemon(&id, &config).as_deref(),
1516            Some("api.wt-repo")
1517        );
1518    }
1519
1520    /// Checkouts that share a namespace cannot be told apart by daemon ID,
1521    /// which the proxy has to know before trusting a state record. Namespaces
1522    /// come from directory names, so the clash can span unrelated projects.
1523    #[test]
1524    fn test_shares_daemon_id() {
1525        let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1526            (
1527                label.to_string(),
1528                ProjectHosts {
1529                    label: label.to_string(),
1530                    primary,
1531                    worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1532                },
1533            )
1534        };
1535
1536        // One project, one worktree, each with its own namespace.
1537        let mut registry = HostRegistry {
1538            projects: HashMap::from([project(
1539                "myproj",
1540                checkout("/repos/myproj", "myproj", &[("api", "api")]),
1541                vec![(
1542                    "fix-1",
1543                    checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1544                )],
1545            )]),
1546            errors: vec![],
1547        };
1548        assert!(!registry.shares_daemon_id("myproj", "api"));
1549        assert!(!registry.shares_daemon_id("fix-1", "api"));
1550
1551        // A worktree inheriting the project's explicit namespace clashes with
1552        // the primary checkout.
1553        registry
1554            .projects
1555            .get_mut("myproj")
1556            .unwrap()
1557            .worktrees
1558            .insert(
1559                "fix-2".to_string(),
1560                checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1561            );
1562        assert!(registry.shares_daemon_id("myproj", "api"));
1563
1564        // So does an identically named worktree of an unrelated project, whose
1565        // namespace is its directory name.
1566        let (label, other) = project(
1567            "other",
1568            checkout("/repos/other", "other", &[("api", "api")]),
1569            vec![(
1570                "fix-1",
1571                checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1572            )],
1573        );
1574        registry.projects.insert(label, other);
1575        assert!(registry.shares_daemon_id("fix-1", "api"));
1576
1577        // A daemon only one checkout defines stays unambiguous.
1578        assert!(!registry.shares_daemon_id("myproj", "worker"));
1579    }
1580
1581    /// Two worktrees reducing to one label are both dropped, and the error
1582    /// names both directories.
1583    #[test]
1584    fn test_worktree_label_collision_drops_both() {
1585        let found = vec![
1586            (
1587                "fix-1".to_string(),
1588                checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1589            ),
1590            (
1591                "fix-1".to_string(),
1592                checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1593            ),
1594            (
1595                "fix-2".to_string(),
1596                checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1597            ),
1598        ];
1599        let (kept, errors) = group_worktrees("myproj", found);
1600        assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1601        assert_eq!(errors.len(), 1);
1602        assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1603        assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1604    }
1605
1606    /// Two projects that reduce to one label are reported, and neither is
1607    /// routed: answering for one would serve the wrong project's daemons.
1608    #[test]
1609    fn test_project_label_collision_routes_neither() {
1610        let temp = tempfile::tempdir().unwrap();
1611        let a = temp.path().join("a/shop");
1612        let b = temp.path().join("b/shop");
1613        std::fs::create_dir_all(&a).unwrap();
1614        std::fs::create_dir_all(&b).unwrap();
1615        write_config(&a, &[("api", "")]);
1616        write_config(&b, &[("api", "")]);
1617
1618        let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1619        assert!(registry.project_labels().is_empty());
1620        assert_eq!(registry.errors.len(), 1);
1621        assert!(
1622            registry.errors[0].contains("shop"),
1623            "{}",
1624            registry.errors[0]
1625        );
1626        // The error names the checkout as the registry resolved it, which is
1627        // not always how the test spelled the path.
1628        assert!(
1629            registry.errors[0].contains(&canonical(&b).display().to_string()),
1630            "{}",
1631            registry.errors[0]
1632        );
1633        assert_eq!(
1634            registry.resolve("api.shop", true),
1635            HostTarget::UnknownProject { known: vec![] }
1636        );
1637    }
1638}