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.
29const 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(String::as_str) != Some(id.name()) {
312        return None;
313    }
314
315    let host = join_labels(&daemon, worktree.as_deref(), &project_label);
316    if !hostname_fits(&host) {
317        log::warn!(
318            "'{host}' plus the configured proxy.tld is over the {MAX_HOSTNAME_LEN}-byte DNS \
319             limit, so no hostname is assigned. Shorten the project, worktree or daemon name, \
320             or use a shorter proxy.tld."
321        );
322        return None;
323    }
324    Some(host)
325}
326
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 → daemon name.
362    pub daemons: HashMap<String, String>,
363}
364
365impl CheckoutHosts {
366    /// Load the routable daemons of one checkout, along with any collisions
367    /// found while doing so.
368    ///
369    /// Two daemons whose labels reduce to the same string are both dropped:
370    /// keeping either one would route half the requests to a daemon the user
371    /// did not name, and which of the two won would depend on config order.
372    fn load(dir: &Path) -> Option<(Self, Vec<String>)> {
373        let namespace = PitchforkToml::namespace_for_dir(dir).ok()?;
374        let pt = PitchforkToml::all_merged_from(dir).ok()?;
375        let mut daemons: HashMap<String, String> = HashMap::new();
376        let mut errors = Vec::new();
377        let mut colliding: Vec<String> = Vec::new();
378        for (id, config) in &pt.daemons {
379            if id.namespace() != namespace || config.port.is_none() {
380                continue;
381            }
382            let Some(label) = daemon_label(id.name(), config.proxy.as_ref()) else {
383                continue;
384            };
385            match daemons.get(&label) {
386                Some(other) => {
387                    errors.push(format!(
388                        "daemon hostname label '{label}' in {} is claimed by both '{other}' and \
389                         '{name}'. Rename one of them, or set `proxy = \"<label>\"` on one.",
390                        dir.display(),
391                        name = id.name(),
392                    ));
393                    colliding.push(label);
394                }
395                None => {
396                    daemons.insert(label, id.name().to_string());
397                }
398            }
399        }
400        for label in colliding {
401            daemons.remove(&label);
402        }
403        Some((
404            Self {
405                dir: dir.to_path_buf(),
406                namespace,
407                daemons,
408            },
409            errors,
410        ))
411    }
412
413    /// Sorted daemon labels, for listings and error pages.
414    pub fn labels(&self) -> Vec<String> {
415        let mut labels: Vec<String> = self.daemons.keys().cloned().collect();
416        labels.sort();
417        labels
418    }
419}
420
421/// A project and its checkouts, keyed by hostname label.
422#[derive(Debug, Clone)]
423pub struct ProjectHosts {
424    pub label: String,
425    pub primary: CheckoutHosts,
426    /// Worktree label → checkout.
427    pub worktrees: HashMap<String, CheckoutHosts>,
428}
429
430impl ProjectHosts {
431    /// Every checkout of this project: its primary and each linked worktree.
432    pub fn checkouts(&self) -> impl Iterator<Item = &CheckoutHosts> {
433        std::iter::once(&self.primary).chain(self.worktrees.values())
434    }
435
436    /// Sorted worktree labels.
437    pub fn worktree_labels(&self) -> Vec<String> {
438        let mut labels: Vec<String> = self.worktrees.keys().cloned().collect();
439        labels.sort();
440        labels
441    }
442}
443
444/// All projects the proxy can route to, keyed by project label.
445#[derive(Debug, Clone, Default)]
446pub struct HostRegistry {
447    pub projects: HashMap<String, ProjectHosts>,
448    /// Label collisions found while loading, reported to the user as-is.
449    pub errors: Vec<String>,
450}
451
452/// What a hostname resolves to.
453#[derive(Debug, Clone, PartialEq, Eq)]
454pub enum HostTarget {
455    /// A daemon in one of the project's checkouts.
456    Daemon {
457        project: String,
458        worktree: Option<String>,
459        dir: PathBuf,
460        namespace: String,
461        daemon: String,
462    },
463    /// `<project>.<tld>` — reserved for the project page.
464    ProjectPage { project: String },
465    /// `<worktree>.<project>.<tld>` — reserved for the stack page.
466    WorktreePage { project: String, worktree: String },
467    /// The rightmost label is not a known project.
468    UnknownProject { known: Vec<String> },
469    /// The project is known but the daemon label is not.
470    UnknownDaemon {
471        project: String,
472        worktree: Option<String>,
473        known: Vec<String>,
474    },
475}
476
477/// Group a project's worktree checkouts by label.
478///
479/// Two worktrees that reduce to the same label are both dropped and reported:
480/// routing one of them would answer half the requests with the other's content,
481/// which is worse than not routing the label at all.
482fn group_worktrees(
483    project: &str,
484    found: Vec<(String, CheckoutHosts)>,
485) -> (HashMap<String, CheckoutHosts>, Vec<String>) {
486    let mut kept: HashMap<String, CheckoutHosts> = HashMap::new();
487    let mut errors = Vec::new();
488    let mut colliding: Vec<String> = Vec::new();
489    for (label, hosts) in found {
490        match kept.get(&label) {
491            Some(existing) if existing.dir == hosts.dir => continue,
492            Some(existing) => {
493                errors.push(format!(
494                    "worktree label '{label}' in project '{project}' is claimed by two \
495                     directories: {} and {}. Set `worktree_label` in one of them.",
496                    existing.dir.display(),
497                    hosts.dir.display(),
498                ));
499                colliding.push(label);
500            }
501            None => {
502                kept.insert(label, hosts);
503            }
504        }
505    }
506    for label in colliding {
507        kept.remove(&label);
508    }
509    (kept, errors)
510}
511
512/// How long a project's worktree discovery stays cached.
513///
514/// `pitchfork list` derives a hostname for every daemon it prints, and each one
515/// would otherwise re-enumerate the project's worktrees. Only the enumeration
516/// is cached: configuration is re-read every time, so a label that starts
517/// colliding stops being advertised at once, and only a brand-new worktree
518/// directory can take up to this long to appear.
519const WORKTREE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
520
521struct WorktreeCache {
522    entries: HashMap<PathBuf, (std::time::Instant, std::sync::Arc<Vec<PathBuf>>)>,
523}
524
525static WORKTREE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<WorktreeCache>> =
526    once_cell::sync::Lazy::new(|| {
527        std::sync::Mutex::new(WorktreeCache {
528            entries: HashMap::new(),
529        })
530    });
531
532/// The linked worktree roots of a project, cached briefly.
533fn cached_worktree_dirs(primary: &Path) -> std::sync::Arc<Vec<PathBuf>> {
534    let now = std::time::Instant::now();
535    {
536        let cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
537        if let Some((expires_at, dirs)) = cache.entries.get(primary)
538            && now < *expires_at
539        {
540            return std::sync::Arc::clone(dirs);
541        }
542    } // lock released before any I/O
543
544    let dirs = std::sync::Arc::new(worktree_dirs(primary));
545
546    let mut cache = WORKTREE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
547    cache.entries.retain(|_, (expires_at, _)| now < *expires_at);
548    cache.entries.insert(
549        primary.to_path_buf(),
550        (now + WORKTREE_CACHE_TTL, std::sync::Arc::clone(&dirs)),
551    );
552    dirs
553}
554
555/// Report a configuration problem once per distinct message.
556///
557/// A label collision persists until someone renames something, while the
558/// registry behind it is rebuilt every couple of seconds and on every CLI
559/// invocation. Logging each rebuild would fill the supervisor log with the same
560/// line; the user needs to read it once.
561pub fn warn_once(message: &str) {
562    static SEEN: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashSet<String>>> =
563        once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
564
565    let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
566    // Distinct messages are bounded by the configuration, but a pathological
567    // one should not grow the set forever.
568    if seen.len() > 256 {
569        seen.clear();
570    }
571    if seen.insert(message.to_string()) {
572        log::warn!("{message}");
573    }
574}
575
576/// Directories that may contain a project pitchfork knows about.
577///
578/// Only persisted knowledge counts: the namespace registry, the legacy slug
579/// registry, and the directories of daemons in the state file. The current
580/// directory is deliberately absent, because it differs between the supervisor
581/// and each CLI invocation, and a registry that depended on it would let the
582/// CLI advertise a hostname the proxy does not route — or refuse one it does.
583fn candidate_dirs() -> Vec<PathBuf> {
584    let mut candidates: Vec<PathBuf> = Vec::new();
585    for (_, entry) in PitchforkToml::read_global_namespaces() {
586        candidates.push(entry.dir);
587    }
588    for (_, entry) in PitchforkToml::read_global_slugs() {
589        if let Some(dir) = entry.resolve_dir() {
590            candidates.push(dir);
591        }
592    }
593    if let Ok(state) = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE) {
594        for daemon in state.daemons.values() {
595            if let Some(dir) = &daemon.dir {
596                candidates.push(dir.clone());
597            }
598        }
599    }
600    candidates
601}
602
603/// Whether another known project claims the same project label.
604///
605/// Such a label routes to neither project, so nothing may advertise a URL
606/// under it. This reads configuration files but never enumerates worktrees,
607/// which keeps it cheap enough for the per-daemon display paths.
608fn project_label_is_ambiguous(label: &str, primary: &Path) -> bool {
609    let mut seen: Vec<PathBuf> = Vec::new();
610    for dir in candidate_dirs() {
611        if !dir.exists() {
612            continue;
613        }
614        let other = detect_checkout(&dir).primary;
615        if other == primary || seen.contains(&other) {
616            continue;
617        }
618        seen.push(other.clone());
619        if project_label(&other).as_deref() == Some(label) {
620            return true;
621        }
622    }
623    false
624}
625
626/// Linked worktree roots recorded in a checkout's git common directory.
627///
628/// Each `<common>/worktrees/<name>/gitdir` holds the path of that worktree's
629/// own `.git` file, whose parent is the worktree root. Reading them directly
630/// costs no subprocess and still works when `git` is unavailable, so it
631/// complements the `git worktree list` discovery that also covers jj.
632fn linked_worktree_dirs(primary: &Path) -> Vec<PathBuf> {
633    let Ok(entries) = std::fs::read_dir(primary.join(".git/worktrees")) else {
634        return vec![];
635    };
636    entries
637        .filter_map(|entry| {
638            let gitdir = std::fs::read_to_string(entry.ok()?.path().join("gitdir")).ok()?;
639            let git_file = PathBuf::from(gitdir.trim());
640            git_file.parent().map(Path::to_path_buf)
641        })
642        .collect()
643}
644
645/// Every linked worktree root of a project, from both discovery sources.
646///
647/// `git worktree list` also covers jj workspaces, and the `gitdir` pointers
648/// cover repositories where `git` is unavailable or errors.
649fn worktree_dirs(primary: &Path) -> Vec<PathBuf> {
650    if !crate::settings::settings().general.worktree {
651        return vec![];
652    }
653    let mut dirs: Vec<PathBuf> = Vec::new();
654    let found = crate::proxy::worktree::discover_worktrees(primary)
655        .into_iter()
656        .map(|entry| entry.path)
657        .chain(linked_worktree_dirs(primary));
658    for path in found {
659        let Some(wt_dir) = detect_checkout(&path).worktree else {
660            continue; // the primary checkout itself
661        };
662        if !dirs.contains(&wt_dir) {
663            dirs.push(wt_dir);
664        }
665    }
666    dirs
667}
668
669/// Build one project's checkouts: its primary and every linked worktree.
670///
671/// Returns the project together with the label collisions found inside it.
672/// Configuration is read fresh on every call, so a collision introduced by an
673/// edit takes effect immediately; only `worktree_dirs` may be cached.
674fn build_project_hosts(
675    primary: &Path,
676    label: &str,
677    worktrees: &[PathBuf],
678) -> Option<(ProjectHosts, Vec<String>)> {
679    let (primary_hosts, mut errors) = CheckoutHosts::load(primary)?;
680    let mut project = ProjectHosts {
681        label: label.to_string(),
682        primary: primary_hosts,
683        worktrees: HashMap::new(),
684    };
685
686    let mut found: Vec<(String, CheckoutHosts)> = Vec::new();
687    for wt_dir in worktrees {
688        let (Some(wt_label), Some((hosts, wt_errors))) =
689            (worktree_label(wt_dir), CheckoutHosts::load(wt_dir))
690        else {
691            continue;
692        };
693        errors.extend(wt_errors);
694        found.push((wt_label, hosts));
695    }
696    let (worktrees, wt_errors) = group_worktrees(label, found);
697    project.worktrees = worktrees;
698    errors.extend(wt_errors);
699
700    Some((project, errors))
701}
702
703/// Build a project the way the proxy routes it, reusing a brief cache of its
704/// worktree enumeration.
705fn project_hosts_for(primary: &Path, label: &str) -> Option<ProjectHosts> {
706    let worktrees = cached_worktree_dirs(primary);
707    let (project, errors) = build_project_hosts(primary, label, &worktrees)?;
708    for err in errors {
709        warn_once(&err);
710    }
711    Some(project)
712}
713
714impl HostRegistry {
715    /// Build the registry from every project pitchfork knows about.
716    ///
717    /// Candidate directories come from the current directory, the namespace
718    /// registry, the legacy slug registry, and the directories of daemons in
719    /// the state file. Each is mapped to its primary checkout, whose linked
720    /// worktrees are then discovered.
721    ///
722    /// A project the supervisor has never seen — never started, never
723    /// registered — is therefore not routable from another directory yet.
724    pub fn build() -> Self {
725        Self::from_dirs(&candidate_dirs())
726    }
727
728    /// Build the registry from an explicit list of project directories.
729    pub fn from_dirs(dirs: &[PathBuf]) -> Self {
730        // Collapse the candidates to distinct primary checkouts.
731        let mut primaries: Vec<PathBuf> = Vec::new();
732        for dir in dirs {
733            if !dir.exists() {
734                continue;
735            }
736            let primary = detect_checkout(dir).primary;
737            if !primaries.contains(&primary) {
738                primaries.push(primary);
739            }
740        }
741
742        let mut registry = Self::default();
743        let mut colliding: Vec<String> = Vec::new();
744        for primary in primaries {
745            let Some(label) = project_label(&primary) else {
746                continue;
747            };
748
749            if let Some(existing) = registry.projects.get(&label) {
750                if existing.primary.dir != primary {
751                    registry.errors.push(format!(
752                        "project label '{label}' is claimed by two directories: {} and {}. \
753                         Set a distinct top-level `namespace` in one of them.",
754                        existing.primary.dir.display(),
755                        primary.display(),
756                    ));
757                    // Neither is routed: answering for one of them would serve
758                    // the wrong project's daemons under a URL the other
759                    // checkout advertises for itself.
760                    colliding.push(label);
761                }
762                continue;
763            }
764
765            let worktrees = worktree_dirs(&primary);
766            let Some((project, errors)) = build_project_hosts(&primary, &label, &worktrees) else {
767                continue;
768            };
769            registry.errors.extend(errors);
770            // A directory with no routable daemon anywhere in it is not a
771            // project worth a hostname; an ad-hoc daemon's working directory
772            // would otherwise become an empty one.
773            if project.checkouts().all(|c| c.daemons.is_empty()) {
774                continue;
775            }
776            registry.projects.insert(label, project);
777        }
778        for label in colliding {
779            registry.projects.remove(&label);
780        }
781
782        registry
783    }
784
785    /// Whether more than one checkout across all known projects would run this
786    /// daemon ID.
787    ///
788    /// A namespace comes from the checkout's own directory name or config, not
789    /// from the project above it, so two unrelated projects that each have a
790    /// `fix-1` worktree with an `api` daemon both produce `fix-1/api`. The
791    /// state file holds one record per ID, so in that case a request has to be
792    /// matched to the checkout it names, exactly as for two checkouts of one
793    /// project.
794    pub fn shares_daemon_id(&self, namespace: &str, daemon: &str) -> bool {
795        self.projects
796            .values()
797            .flat_map(ProjectHosts::checkouts)
798            .filter(|c| c.namespace == namespace && c.daemons.values().any(|n| n == daemon))
799            .count()
800            > 1
801    }
802
803    /// Sorted project labels.
804    pub fn project_labels(&self) -> Vec<String> {
805        let mut labels: Vec<String> = self.projects.keys().cloned().collect();
806        labels.sort();
807        labels
808    }
809
810    /// Resolve a hostname's labels (the host with the TLD already stripped).
811    ///
812    /// Resolution runs right to left: the last label must name a project, an
813    /// optional worktree label follows, and the label before the daemon's is
814    /// where extra leading labels become wildcard subdomains of the same
815    /// daemon. A label that names both a worktree and a daemon is read as the
816    /// worktree.
817    pub fn resolve(&self, subdomain: &str, wildcard: bool) -> HostTarget {
818        let labels: Vec<String> = subdomain
819            .split('.')
820            .map(|l| l.to_ascii_lowercase())
821            .collect();
822        let Some((project_label, rest)) = labels.split_last() else {
823            return HostTarget::UnknownProject {
824                known: self.project_labels(),
825            };
826        };
827        let Some(project) = self.projects.get(project_label) else {
828            return HostTarget::UnknownProject {
829                known: self.project_labels(),
830            };
831        };
832        if rest.is_empty() {
833            return HostTarget::ProjectPage {
834                project: project.label.clone(),
835            };
836        }
837
838        // A worktree label directly left of the project consumes one label.
839        let (checkout, worktree, rest) = match rest.split_last() {
840            Some((maybe_worktree, head)) => match project.worktrees.get(maybe_worktree) {
841                Some(checkout) => (checkout, Some(maybe_worktree.clone()), head),
842                None => (&project.primary, None, rest),
843            },
844            None => (&project.primary, None, rest),
845        };
846
847        let Some((daemon_label, extra)) = rest.split_last() else {
848            return HostTarget::WorktreePage {
849                project: project.label.clone(),
850                worktree: worktree.unwrap_or_default(),
851            };
852        };
853        if !extra.is_empty() && !wildcard {
854            return HostTarget::UnknownDaemon {
855                project: project.label.clone(),
856                worktree,
857                known: checkout.labels(),
858            };
859        }
860
861        match checkout.daemons.get(daemon_label) {
862            Some(name) => HostTarget::Daemon {
863                project: project.label.clone(),
864                worktree,
865                dir: checkout.dir.clone(),
866                namespace: checkout.namespace.clone(),
867                daemon: name.clone(),
868            },
869            None => HostTarget::UnknownDaemon {
870                project: project.label.clone(),
871                worktree,
872                known: checkout.labels(),
873            },
874        }
875    }
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881
882    /// The path as `detect_checkout` reports it: canonical, and without the
883    /// `\\?\` verbatim prefix Windows canonicalization adds.
884    fn canonical(path: &Path) -> PathBuf {
885        dunce::simplified(&path.canonicalize().unwrap()).to_path_buf()
886    }
887
888    fn checkout(dir: &str, namespace: &str, daemons: &[(&str, &str)]) -> CheckoutHosts {
889        CheckoutHosts {
890            dir: PathBuf::from(dir),
891            namespace: namespace.to_string(),
892            daemons: daemons
893                .iter()
894                .map(|(l, n)| (l.to_string(), n.to_string()))
895                .collect(),
896        }
897    }
898
899    #[test]
900    fn test_sanitize_label() {
901        assert_eq!(sanitize_label("api").as_deref(), Some("api"));
902        assert_eq!(sanitize_label("My App").as_deref(), Some("my-app"));
903        assert_eq!(
904            sanitize_label("feature/my_branch").as_deref(),
905            Some("feature-my-branch")
906        );
907        assert_eq!(sanitize_label("--weird--").as_deref(), Some("weird"));
908        assert_eq!(sanitize_label("café").as_deref(), Some("caf"));
909        assert_eq!(sanitize_label("---"), None);
910        assert_eq!(sanitize_label(""), None);
911        assert_eq!(sanitize_label(&"a".repeat(80)).unwrap().len(), 63);
912    }
913
914    #[test]
915    fn test_daemon_label_override_and_opt_out() {
916        assert_eq!(daemon_label("api", None).as_deref(), Some("api"));
917        assert_eq!(
918            daemon_label("api", Some(&ProxyConfig::Enabled)).as_deref(),
919            Some("api")
920        );
921        assert_eq!(
922            daemon_label("api", Some(&ProxyConfig::Name("Web UI".into()))).as_deref(),
923            Some("web-ui")
924        );
925        assert_eq!(daemon_label("api", Some(&ProxyConfig::Disabled)), None);
926    }
927
928    /// A primary checkout has a `.git` directory and no worktree label.
929    #[test]
930    fn test_detect_checkout_primary() {
931        let temp = tempfile::tempdir().unwrap();
932        let repo = temp.path().join("my-repo");
933        std::fs::create_dir_all(repo.join(".git")).unwrap();
934        std::fs::create_dir_all(repo.join("sub/dir")).unwrap();
935
936        let found = detect_checkout(&repo.join("sub/dir"));
937        assert_eq!(found.primary, canonical(&repo));
938        assert_eq!(found.worktree, None);
939    }
940
941    /// A linked worktree's `.git` file points into `<common>/worktrees/<name>`.
942    #[test]
943    fn test_detect_checkout_linked_worktree() {
944        let temp = tempfile::tempdir().unwrap();
945        let repo = temp.path().join("my-repo");
946        std::fs::create_dir_all(repo.join(".git/worktrees/fix-1")).unwrap();
947        let wt = temp.path().join("fix-1");
948        std::fs::create_dir_all(&wt).unwrap();
949        std::fs::write(
950            wt.join(".git"),
951            format!("gitdir: {}\n", repo.join(".git/worktrees/fix-1").display()),
952        )
953        .unwrap();
954
955        let found = detect_checkout(&wt);
956        assert_eq!(found.primary, canonical(&repo));
957        assert_eq!(found.worktree, Some(canonical(&wt)));
958        assert_eq!(found.root(), canonical(&wt));
959    }
960
961    /// A working directory that no longer exists still belongs to the checkout
962    /// above it, so a daemon whose directory was deleted keeps its hostname.
963    #[test]
964    fn test_checkout_root_of_missing_directory() {
965        let temp = tempfile::tempdir().unwrap();
966        let repo = temp.path().join("my-repo");
967        std::fs::create_dir_all(repo.join(".git")).unwrap();
968
969        assert_eq!(
970            checkout_root_of(&repo.join("gone/deeper")),
971            canonical(&repo)
972        );
973    }
974
975    /// The primary checkout read from a worktree's `gitdir:` pointer is spelled
976    /// the same as the one found by walking into the primary directly, whatever
977    /// alias the pointer took.
978    #[cfg(unix)]
979    #[test]
980    fn test_detect_checkout_primary_spelling_matches_through_symlink() {
981        use std::os::unix::fs::symlink;
982
983        let temp = tempfile::tempdir().unwrap();
984        let repo = temp.path().join("my-repo");
985        let admin = repo.join(".git/worktrees/fix-1");
986        std::fs::create_dir_all(&admin).unwrap();
987        let wt = temp.path().join("fix-1");
988        std::fs::create_dir_all(&wt).unwrap();
989
990        // Point the worktree at the repository through a symlinked alias, the
991        // way a checkout under a symlinked home directory would.
992        let alias = temp.path().join("alias");
993        symlink(&repo, &alias).unwrap();
994        std::fs::write(
995            wt.join(".git"),
996            format!("gitdir: {}\n", alias.join(".git/worktrees/fix-1").display()),
997        )
998        .unwrap();
999
1000        assert_eq!(detect_checkout(&wt).primary, detect_checkout(&repo).primary);
1001        assert_eq!(detect_checkout(&wt).primary, canonical(&repo));
1002    }
1003
1004    /// A bare repository has no working tree to be the project, so its linked
1005    /// worktrees stand alone rather than being grouped under the directory that
1006    /// happens to hold the bare repositories.
1007    #[test]
1008    fn test_detect_checkout_bare_repository_worktree() {
1009        let temp = tempfile::tempdir().unwrap();
1010        let bare = temp.path().join("my-repo.git");
1011        let admin = bare.join("worktrees/fix-1");
1012        std::fs::create_dir_all(&admin).unwrap();
1013        let wt = temp.path().join("fix-1");
1014        std::fs::create_dir_all(&wt).unwrap();
1015        std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1016
1017        let found = detect_checkout(&wt);
1018        assert_eq!(found.primary, canonical(&wt));
1019        assert_eq!(found.worktree, None);
1020    }
1021
1022    /// A daemon declared in a global config has no project, so it gets no
1023    /// automatic hostname; the config directory is not one.
1024    #[test]
1025    fn test_auto_host_skips_global_config_daemons() {
1026        let config = PitchforkTomlDaemon {
1027            run: "server".to_string(),
1028            port: Some(crate::config_types::PortConfig {
1029                expect: vec![3000],
1030                ..Default::default()
1031            }),
1032            path: Some(crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone()),
1033            ..PitchforkTomlDaemon::default()
1034        };
1035        let id = DaemonId::try_new("global", "api").unwrap();
1036        assert_eq!(auto_host_for_daemon(&id, &config), None);
1037    }
1038
1039    /// A registered slug wins over the automatic hostname, because the proxy
1040    /// resolves slugs first.
1041    #[test]
1042    fn test_host_for_daemon_prefers_a_registered_slug() {
1043        let temp = tempfile::tempdir().unwrap();
1044        let repo = temp.path().join("slug-repo");
1045        std::fs::create_dir_all(&repo).unwrap();
1046        write_config(&repo, &[("api", "")]);
1047        let (id, config) = daemon_config(&repo, "api");
1048
1049        let empty = indexmap::IndexMap::new();
1050        assert_eq!(
1051            host_for_daemon(&id, Some(&config), &empty).as_deref(),
1052            Some("api.slug-repo")
1053        );
1054
1055        // A slug registered for the same daemon replaces it, dots and all.
1056        let mut slugs = indexmap::IndexMap::new();
1057        slugs.insert(
1058            "myapp".to_string(),
1059            crate::pitchfork_toml::SlugEntry {
1060                dir: Some(repo.clone()),
1061                namespace: Some(id.namespace().to_string()),
1062                daemon: Some("api".to_string()),
1063            },
1064        );
1065        assert_eq!(
1066            host_for_daemon(&id, Some(&config), &slugs).as_deref(),
1067            Some("myapp")
1068        );
1069    }
1070
1071    /// A `.git` file that is not a worktree pointer (a submodule) is its own
1072    /// checkout rather than a worktree of something else.
1073    #[test]
1074    fn test_detect_checkout_submodule_pointer() {
1075        let temp = tempfile::tempdir().unwrap();
1076        let repo = temp.path().join("my-repo");
1077        std::fs::create_dir_all(repo.join(".git/modules/sub")).unwrap();
1078        let sub = repo.join("sub");
1079        std::fs::create_dir_all(&sub).unwrap();
1080        std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/sub\n").unwrap();
1081
1082        let found = detect_checkout(&sub);
1083        assert_eq!(found.primary, canonical(&sub));
1084        assert_eq!(found.worktree, None);
1085    }
1086
1087    /// Without a `.git` anywhere above it, a directory is its own project.
1088    #[test]
1089    fn test_detect_checkout_without_git() {
1090        let temp = tempfile::tempdir().unwrap();
1091        let dir = temp.path().join("plain");
1092        std::fs::create_dir_all(&dir).unwrap();
1093
1094        let found = detect_checkout(&dir);
1095        assert_eq!(found.primary, canonical(&dir));
1096        assert_eq!(found.worktree, None);
1097    }
1098
1099    fn registry() -> HostRegistry {
1100        let mut projects = HashMap::new();
1101        let mut worktrees = HashMap::new();
1102        worktrees.insert(
1103            "fix-1".to_string(),
1104            checkout("/repos/fix-1", "fix-1", &[("api", "api"), ("web", "web")]),
1105        );
1106        projects.insert(
1107            "myproj".to_string(),
1108            ProjectHosts {
1109                label: "myproj".to_string(),
1110                primary: checkout("/repos/myproj", "myproj", &[("api", "api")]),
1111                worktrees,
1112            },
1113        );
1114        HostRegistry {
1115            projects,
1116            errors: vec![],
1117        }
1118    }
1119
1120    #[test]
1121    fn test_resolve_primary_checkout_daemon() {
1122        let target = registry().resolve("api.myproj", true);
1123        assert_eq!(
1124            target,
1125            HostTarget::Daemon {
1126                project: "myproj".into(),
1127                worktree: None,
1128                dir: PathBuf::from("/repos/myproj"),
1129                namespace: "myproj".into(),
1130                daemon: "api".into(),
1131            }
1132        );
1133    }
1134
1135    #[test]
1136    fn test_resolve_worktree_daemon() {
1137        let target = registry().resolve("web.fix-1.myproj", true);
1138        assert_eq!(
1139            target,
1140            HostTarget::Daemon {
1141                project: "myproj".into(),
1142                worktree: Some("fix-1".into()),
1143                dir: PathBuf::from("/repos/fix-1"),
1144                namespace: "fix-1".into(),
1145                daemon: "web".into(),
1146            }
1147        );
1148    }
1149
1150    #[test]
1151    fn test_resolve_is_case_insensitive() {
1152        assert!(matches!(
1153            registry().resolve("API.MyProj", true),
1154            HostTarget::Daemon { .. }
1155        ));
1156    }
1157
1158    /// Project and stack pages are reserved: they never resolve to a daemon.
1159    #[test]
1160    fn test_resolve_reserved_pages() {
1161        assert_eq!(
1162            registry().resolve("myproj", true),
1163            HostTarget::ProjectPage {
1164                project: "myproj".into()
1165            }
1166        );
1167        assert_eq!(
1168            registry().resolve("fix-1.myproj", true),
1169            HostTarget::WorktreePage {
1170                project: "myproj".into(),
1171                worktree: "fix-1".into(),
1172            }
1173        );
1174    }
1175
1176    /// When a worktree label equals a daemon name, the worktree wins: the
1177    /// worktree is consumed first, so `api.myproj` is that stack's page and
1178    /// the primary checkout's `api` daemon is unreachable under that spelling.
1179    #[test]
1180    fn test_resolve_worktree_beats_daemon_of_same_name() {
1181        let mut reg = registry();
1182        // A worktree whose label is also a daemon name of the primary checkout.
1183        reg.projects.get_mut("myproj").unwrap().worktrees.insert(
1184            "api".to_string(),
1185            checkout("/repos/api-wt", "api-wt", &[("api", "api")]),
1186        );
1187        assert_eq!(
1188            reg.resolve("api.myproj", true),
1189            HostTarget::WorktreePage {
1190                project: "myproj".into(),
1191                worktree: "api".into(),
1192            }
1193        );
1194        // The daemon inside that worktree is still reachable.
1195        assert!(matches!(
1196            reg.resolve("api.api.myproj", true),
1197            HostTarget::Daemon { .. }
1198        ));
1199    }
1200
1201    #[test]
1202    fn test_resolve_wildcard_subdomain() {
1203        let target = registry().resolve("tenant.api.myproj", true);
1204        assert!(matches!(target, HostTarget::Daemon { daemon, .. } if daemon == "api"));
1205        // Wildcards off: the extra label is not a daemon of its own.
1206        assert!(matches!(
1207            registry().resolve("tenant.api.myproj", false),
1208            HostTarget::UnknownDaemon { .. }
1209        ));
1210    }
1211
1212    #[test]
1213    fn test_resolve_unknown_names() {
1214        assert_eq!(
1215            registry().resolve("api.other", true),
1216            HostTarget::UnknownProject {
1217                known: vec!["myproj".to_string()]
1218            }
1219        );
1220        assert_eq!(
1221            registry().resolve("nope.myproj", true),
1222            HostTarget::UnknownDaemon {
1223                project: "myproj".into(),
1224                worktree: None,
1225                known: vec!["api".to_string()],
1226            }
1227        );
1228    }
1229
1230    /// Build a primary checkout and a linked worktree of it on disk, with the
1231    /// same pair of pointers real `git worktree add` writes.
1232    fn git_project(temp: &Path, name: &str, worktree: &str) -> (PathBuf, PathBuf) {
1233        let repo = temp.join(name);
1234        let admin = repo.join(format!(".git/worktrees/{worktree}"));
1235        std::fs::create_dir_all(&admin).unwrap();
1236        let wt = temp.join(worktree);
1237        std::fs::create_dir_all(&wt).unwrap();
1238        std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1239        std::fs::write(
1240            admin.join("gitdir"),
1241            format!("{}\n", wt.join(".git").display()),
1242        )
1243        .unwrap();
1244        (repo, wt)
1245    }
1246
1247    /// Without an explicit namespace the project label is the primary
1248    /// checkout's directory name, sanitized.
1249    #[test]
1250    fn test_project_label_from_directory_name() {
1251        let temp = tempfile::tempdir().unwrap();
1252        let repo = temp.path().join("My App");
1253        std::fs::create_dir_all(&repo).unwrap();
1254        assert_eq!(project_label(&repo).as_deref(), Some("my-app"));
1255    }
1256
1257    /// A project that declares a namespace uses that name instead.
1258    #[test]
1259    fn test_project_label_from_explicit_namespace() {
1260        let temp = tempfile::tempdir().unwrap();
1261        let repo = temp.path().join("checkout-dir");
1262        std::fs::create_dir_all(&repo).unwrap();
1263        std::fs::write(repo.join("pitchfork.toml"), "namespace = \"storefront\"\n").unwrap();
1264        assert_eq!(project_label(&repo).as_deref(), Some("storefront"));
1265    }
1266
1267    /// Write a `pitchfork.toml` with one daemon per entry, each with a port.
1268    fn write_config(dir: &Path, daemons: &[(&str, &str)]) {
1269        let body: String = daemons
1270            .iter()
1271            .enumerate()
1272            .map(|(i, (name, extra))| {
1273                format!(
1274                    "[daemons.{name}]\nrun = \"server\"\nport = {}\n{extra}\n",
1275                    3000 + i
1276                )
1277            })
1278            .collect();
1279        std::fs::write(dir.join("pitchfork.toml"), body).unwrap();
1280    }
1281
1282    /// The daemon config as the merged config holds it, anchored at `dir`.
1283    fn daemon_config(dir: &Path, name: &str) -> (DaemonId, PitchforkTomlDaemon) {
1284        let pt = PitchforkToml::all_merged_from(dir).unwrap();
1285        let (id, config) = pt
1286            .daemons
1287            .iter()
1288            .find(|(id, _)| id.name() == name)
1289            .unwrap_or_else(|| panic!("no daemon '{name}' in {}", dir.display()));
1290        (id.clone(), config.clone())
1291    }
1292
1293    /// A daemon in the primary checkout omits the worktree label; the same
1294    /// daemon in a linked worktree carries it.
1295    #[test]
1296    fn test_auto_host_primary_and_worktree() {
1297        let temp = tempfile::tempdir().unwrap();
1298        let (repo, wt) = git_project(temp.path(), "my-repo", "fix-1");
1299        write_config(&repo, &[("api", "")]);
1300        write_config(&wt, &[("api", "")]);
1301
1302        let (id, config) = daemon_config(&repo, "api");
1303        assert_eq!(
1304            auto_host_for_daemon(&id, &config).as_deref(),
1305            Some("api.my-repo")
1306        );
1307
1308        let (wt_id, wt_config) = daemon_config(&wt, "api");
1309        assert_eq!(
1310            auto_host_for_daemon(&wt_id, &wt_config).as_deref(),
1311            Some("api.fix-1.my-repo")
1312        );
1313    }
1314
1315    /// `worktree_label` in the worktree's own config replaces its directory name.
1316    #[test]
1317    fn test_worktree_label_override() {
1318        let temp = tempfile::tempdir().unwrap();
1319        let (_repo, wt) = git_project(temp.path(), "my-repo", "sleepy-kapitsa-9f02fc");
1320        assert_eq!(
1321            worktree_label(&wt).as_deref(),
1322            Some("sleepy-kapitsa-9f02fc")
1323        );
1324
1325        std::fs::write(wt.join("pitchfork.toml"), "worktree_label = \"Fix 1\"\n").unwrap();
1326        assert_eq!(worktree_label(&wt).as_deref(), Some("fix-1"));
1327    }
1328
1329    /// A daemon opts out with `proxy = false` and gets no hostname; one without
1330    /// a port never had one to begin with; a string renames its label.
1331    #[test]
1332    fn test_auto_host_requires_port_and_opt_in() {
1333        let temp = tempfile::tempdir().unwrap();
1334        let repo = temp.path().join("opt-in-repo");
1335        std::fs::create_dir_all(&repo).unwrap();
1336        std::fs::write(
1337            repo.join("pitchfork.toml"),
1338            "[daemons.api]\nrun = \"server\"\nport = 3000\n\n\
1339             [daemons.admin]\nrun = \"server\"\nport = 3001\nproxy = false\n\n\
1340             [daemons.web-frontend]\nrun = \"server\"\nport = 3002\nproxy = \"web\"\n\n\
1341             [daemons.worker]\nrun = \"server\"\n",
1342        )
1343        .unwrap();
1344
1345        let host = |name: &str| {
1346            let (id, config) = daemon_config(&repo, name);
1347            auto_host_for_daemon(&id, &config)
1348        };
1349        assert_eq!(host("api").as_deref(), Some("api.opt-in-repo"));
1350        assert_eq!(host("admin"), None);
1351        assert_eq!(host("web-frontend").as_deref(), Some("web.opt-in-repo"));
1352        assert_eq!(host("worker"), None);
1353    }
1354
1355    /// Two daemons whose labels reduce to the same string are both dropped,
1356    /// and neither is advertised as a URL.
1357    #[test]
1358    fn test_daemon_label_collision_drops_both() {
1359        let temp = tempfile::tempdir().unwrap();
1360        let repo = temp.path().join("dupe-repo");
1361        std::fs::create_dir_all(&repo).unwrap();
1362        std::fs::write(
1363            repo.join("pitchfork.toml"),
1364            "[daemons.foo_bar]\nrun = \"server\"\nport = 3000\n\n\
1365             [daemons.foo-bar]\nrun = \"server\"\nport = 3001\n\n\
1366             [daemons.other]\nrun = \"server\"\nport = 3002\n",
1367        )
1368        .unwrap();
1369
1370        let (hosts, errors) = CheckoutHosts::load(&repo).unwrap();
1371        assert_eq!(hosts.labels(), vec!["other".to_string()]);
1372        assert_eq!(errors.len(), 1);
1373        assert!(errors[0].contains("foo-bar"), "{}", errors[0]);
1374
1375        for name in ["foo_bar", "foo-bar"] {
1376            let (id, config) = daemon_config(&repo, name);
1377            assert_eq!(auto_host_for_daemon(&id, &config), None, "{name}");
1378        }
1379        let (id, config) = daemon_config(&repo, "other");
1380        assert_eq!(
1381            auto_host_for_daemon(&id, &config).as_deref(),
1382            Some("other.dupe-repo")
1383        );
1384    }
1385
1386    /// A name that cannot fit alongside the configured TLD is refused by every
1387    /// surface, so nothing advertises an address DNS would reject.
1388    #[test]
1389    fn test_hostname_fits() {
1390        // The default TLD leaves room for three maximum-length labels.
1391        assert!(hostname_fits(&format!(
1392            "{}.{}.{}",
1393            "a".repeat(63),
1394            "b".repeat(63),
1395            "c".repeat(63)
1396        )));
1397        assert!(!hostname_fits(&"a".repeat(MAX_HOSTNAME_LEN)));
1398    }
1399
1400    /// Two worktrees whose labels collide are dropped from the project, and
1401    /// neither advertises a URL the proxy would refuse to route.
1402    #[test]
1403    fn test_auto_host_none_for_colliding_worktrees() {
1404        let temp = tempfile::tempdir().unwrap();
1405        let repo = temp.path().join("wt-repo");
1406        std::fs::create_dir_all(repo.join(".git")).unwrap();
1407        write_config(&repo, &[("api", "")]);
1408
1409        let mut worktrees = Vec::new();
1410        for dir_name in ["fix-1", "fix.1"] {
1411            let admin = repo.join(format!(".git/worktrees/{dir_name}"));
1412            std::fs::create_dir_all(&admin).unwrap();
1413            let wt = temp.path().join(dir_name);
1414            std::fs::create_dir_all(&wt).unwrap();
1415            std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
1416            std::fs::write(
1417                admin.join("gitdir"),
1418                format!("{}\n", wt.join(".git").display()),
1419            )
1420            .unwrap();
1421            write_config(&wt, &[("api", "")]);
1422            worktrees.push(wt);
1423        }
1424
1425        for wt in &worktrees {
1426            let (id, config) = daemon_config(wt, "api");
1427            assert_eq!(
1428                auto_host_for_daemon(&id, &config),
1429                None,
1430                "{} must not advertise a hostname",
1431                wt.display()
1432            );
1433        }
1434        // The primary checkout is unaffected by its worktrees' collision.
1435        let (id, config) = daemon_config(&repo, "api");
1436        assert_eq!(
1437            auto_host_for_daemon(&id, &config).as_deref(),
1438            Some("api.wt-repo")
1439        );
1440    }
1441
1442    /// Checkouts that share a namespace cannot be told apart by daemon ID,
1443    /// which the proxy has to know before trusting a state record. Namespaces
1444    /// come from directory names, so the clash can span unrelated projects.
1445    #[test]
1446    fn test_shares_daemon_id() {
1447        let project = |label: &str, primary: CheckoutHosts, wts: Vec<(&str, CheckoutHosts)>| {
1448            (
1449                label.to_string(),
1450                ProjectHosts {
1451                    label: label.to_string(),
1452                    primary,
1453                    worktrees: wts.into_iter().map(|(l, c)| (l.to_string(), c)).collect(),
1454                },
1455            )
1456        };
1457
1458        // One project, one worktree, each with its own namespace.
1459        let mut registry = HostRegistry {
1460            projects: HashMap::from([project(
1461                "myproj",
1462                checkout("/repos/myproj", "myproj", &[("api", "api")]),
1463                vec![(
1464                    "fix-1",
1465                    checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1466                )],
1467            )]),
1468            errors: vec![],
1469        };
1470        assert!(!registry.shares_daemon_id("myproj", "api"));
1471        assert!(!registry.shares_daemon_id("fix-1", "api"));
1472
1473        // A worktree inheriting the project's explicit namespace clashes with
1474        // the primary checkout.
1475        registry
1476            .projects
1477            .get_mut("myproj")
1478            .unwrap()
1479            .worktrees
1480            .insert(
1481                "fix-2".to_string(),
1482                checkout("/repos/fix-2", "myproj", &[("api", "api")]),
1483            );
1484        assert!(registry.shares_daemon_id("myproj", "api"));
1485
1486        // So does an identically named worktree of an unrelated project, whose
1487        // namespace is its directory name.
1488        let (label, other) = project(
1489            "other",
1490            checkout("/repos/other", "other", &[("api", "api")]),
1491            vec![(
1492                "fix-1",
1493                checkout("/repos/other/fix-1", "fix-1", &[("api", "api")]),
1494            )],
1495        );
1496        registry.projects.insert(label, other);
1497        assert!(registry.shares_daemon_id("fix-1", "api"));
1498
1499        // A daemon only one checkout defines stays unambiguous.
1500        assert!(!registry.shares_daemon_id("myproj", "worker"));
1501    }
1502
1503    /// Two worktrees reducing to one label are both dropped, and the error
1504    /// names both directories.
1505    #[test]
1506    fn test_worktree_label_collision_drops_both() {
1507        let found = vec![
1508            (
1509                "fix-1".to_string(),
1510                checkout("/repos/fix-1", "fix-1", &[("api", "api")]),
1511            ),
1512            (
1513                "fix-1".to_string(),
1514                checkout("/repos/fix.1", "fix-1b", &[("api", "api")]),
1515            ),
1516            (
1517                "fix-2".to_string(),
1518                checkout("/repos/fix-2", "fix-2", &[("api", "api")]),
1519            ),
1520        ];
1521        let (kept, errors) = group_worktrees("myproj", found);
1522        assert_eq!(kept.keys().collect::<Vec<_>>(), vec!["fix-2"]);
1523        assert_eq!(errors.len(), 1);
1524        assert!(errors[0].contains("/repos/fix-1"), "{}", errors[0]);
1525        assert!(errors[0].contains("/repos/fix.1"), "{}", errors[0]);
1526    }
1527
1528    /// Two projects that reduce to one label are reported, and neither is
1529    /// routed: answering for one would serve the wrong project's daemons.
1530    #[test]
1531    fn test_project_label_collision_routes_neither() {
1532        let temp = tempfile::tempdir().unwrap();
1533        let a = temp.path().join("a/shop");
1534        let b = temp.path().join("b/shop");
1535        std::fs::create_dir_all(&a).unwrap();
1536        std::fs::create_dir_all(&b).unwrap();
1537        write_config(&a, &[("api", "")]);
1538        write_config(&b, &[("api", "")]);
1539
1540        let registry = HostRegistry::from_dirs(&[a.clone(), b.clone()]);
1541        assert!(registry.project_labels().is_empty());
1542        assert_eq!(registry.errors.len(), 1);
1543        assert!(
1544            registry.errors[0].contains("shop"),
1545            "{}",
1546            registry.errors[0]
1547        );
1548        // The error names the checkout as the registry resolved it, which is
1549        // not always how the test spelled the path.
1550        assert!(
1551            registry.errors[0].contains(&canonical(&b).display().to_string()),
1552            "{}",
1553            registry.errors[0]
1554        );
1555        assert_eq!(
1556            registry.resolve("api.shop", true),
1557            HostTarget::UnknownProject { known: vec![] }
1558        );
1559    }
1560}