use super::DaemonSettings;
use eyre::{Result, bail};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
pub(crate) const REQUIRED_PITCHFORK: &str = "2.26.0";
const MAX_LABEL_LEN: usize = 63;
const MAX_HOSTNAME_LEN: usize = 253;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct RootLabels {
pub project: Option<String>,
pub worktree: Option<String>,
}
impl RootLabels {
fn suffix(&self, tld: &str) -> Option<String> {
let project = self.project.as_deref()?;
Some(match self.worktree.as_deref() {
Some(worktree) => format!("{worktree}.{project}.{tld}"),
None => format!("{project}.{tld}"),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Proxy {
Disabled,
Label(String),
Derived,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProxySettings {
pub https: bool,
pub port: u16,
pub tld: String,
}
impl Default for ProxySettings {
fn default() -> Self {
Self {
https: true,
port: 443,
tld: "localhost".into(),
}
}
}
impl ProxySettings {
pub(crate) fn url(&self, host: &str) -> String {
let scheme = if self.https { "https" } else { "http" };
let standard = if self.https { 443 } else { 80 };
if self.port == standard {
format!("{scheme}://{host}")
} else {
format!("{scheme}://{host}:{}", self.port)
}
}
pub(crate) fn stack_url(&self, labels: &RootLabels) -> Option<String> {
let project = labels.project.as_deref()?;
let worktree = labels.worktree.as_deref()?;
self.page_url(&format!("{worktree}.{project}.{}", self.tld))
}
pub(crate) fn project_url(&self, labels: &RootLabels) -> Option<String> {
let project = labels.project.as_deref()?;
self.page_url(&format!("{project}.{}", self.tld))
}
fn page_url(&self, host: &str) -> Option<String> {
hostname_fits(host).then(|| self.url(host))
}
}
pub(crate) fn proxy_settings() -> &'static ProxySettings {
static SETTINGS: LazyLock<ProxySettings> = LazyLock::new(read_proxy_settings);
&SETTINGS
}
fn user_config_dir() -> PathBuf {
crate::env::var_path("PITCHFORK_CONFIG_DIR")
.unwrap_or_else(|| crate::dirs::HOME.join(".config").join("pitchfork"))
}
fn env_flag(value: &str) -> Option<bool> {
let value = value.trim();
match value {
"1" => Some(true),
"0" | "" => Some(false),
_ if ["true", "yes", "y", "on"]
.iter()
.any(|known| value.eq_ignore_ascii_case(known)) =>
{
Some(true)
}
_ if ["false", "no", "n", "off"]
.iter()
.any(|known| value.eq_ignore_ascii_case(known)) =>
{
Some(false)
}
_ => None,
}
}
fn read_proxy_settings() -> ProxySettings {
let mut settings = ProxySettings::default();
let mut lan = Lan::default();
for path in [
Path::new("/etc/pitchfork/config.toml").to_path_buf(),
user_config_dir().join("config.toml"),
] {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
debug!("ignoring unparseable {}", crate::file::display_path(&path));
continue;
};
let Some(proxy) = doc.get("settings").and_then(|s| s.get("proxy")) else {
continue;
};
if let Some(https) = proxy.get("https").and_then(toml::Value::as_bool) {
settings.https = https;
}
if let Some(port) = proxy
.get("port")
.and_then(toml::Value::as_integer)
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p > 0)
{
settings.port = port;
}
if let Some(tld) = proxy.get("tld").and_then(toml::Value::as_str) {
settings.tld = tld.to_string();
}
if let Some(value) = proxy.get("lan").and_then(toml::Value::as_bool) {
lan.enabled = value;
}
if let Some(ip) = proxy.get("lan_ip").and_then(toml::Value::as_str) {
lan.ip = ip.to_string();
}
}
let lan = apply_proxy_env(&mut settings, lan, |key| crate::env::var(key).ok());
if lan.on() {
settings.tld = "local".into();
}
settings
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Lan {
enabled: bool,
ip: String,
}
impl Lan {
fn on(&self) -> bool {
self.enabled || !self.ip.is_empty()
}
}
fn apply_proxy_env(
settings: &mut ProxySettings,
mut lan: Lan,
var: impl Fn(&str) -> Option<String>,
) -> Lan {
if let Some(https) = var("PITCHFORK_PROXY_HTTPS").as_deref().and_then(env_flag) {
settings.https = https;
}
if let Some(port) = var("PITCHFORK_PROXY_PORT")
.and_then(|p| p.trim().parse::<u16>().ok())
.filter(|p| *p > 0)
{
settings.port = port;
}
if let Some(tld) = var("PITCHFORK_PROXY_TLD").filter(|t| !t.trim().is_empty()) {
settings.tld = tld.trim().to_string();
}
if let Some(value) = var("PITCHFORK_PROXY_LAN").as_deref().and_then(env_flag) {
lan.enabled = value;
}
if let Some(ip) = var("PITCHFORK_PROXY_LAN_IP") {
lan.ip = ip.trim().to_string();
}
lan
}
pub(crate) fn validate_label(kind: &str, value: &str) -> Result<()> {
if value.is_empty()
|| value.len() > 63
|| value.starts_with('-')
|| value.ends_with('-')
|| !value
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
bail!(
"invalid {kind} {value:?}; use lowercase letters, numbers and '-', \
up to 63 characters, not starting or ending with '-'"
);
}
Ok(())
}
pub(crate) fn sanitize_label(value: &str) -> Option<String> {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
let trimmed = if trimmed.len() > MAX_LABEL_LEN {
trimmed[..MAX_LABEL_LEN].trim_end_matches('-')
} else {
trimmed
};
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn hostname_fits(host: &str) -> bool {
host.len() <= MAX_HOSTNAME_LEN
}
fn declared_worktree_label(dir: &Path) -> Option<String> {
for name in [
"pitchfork.local.toml",
"pitchfork.toml",
".config/pitchfork.local.toml",
".config/pitchfork.toml",
] {
let path = dir.join(name);
if !path.exists() {
continue;
}
let Ok(doc) = std::fs::read_to_string(&path).map(|t| toml::from_str::<toml::Value>(&t))
else {
continue;
};
if let Ok(doc) = doc
&& let Some(label) = doc.get("worktree_label").and_then(toml::Value::as_str)
{
return Some(label.to_string());
}
}
None
}
fn worktree_label(dir: &Path) -> Option<String> {
match declared_worktree_label(dir) {
Some(label) => sanitize_label(&label),
None => sanitize_label(&dir.file_name()?.to_string_lossy()),
}
}
fn namespace_reaches_siblings<'a>(
root: &'a Path,
checkout: &'a crate::git::Checkout,
worktree: Option<&str>,
namespace: Option<&str>,
) -> Option<&'a Path> {
let checkout_dir = checkout.repository.as_deref().unwrap_or(root);
(worktree.is_none() && namespace.is_some() && crate::git::has_sibling_worktrees(checkout_dir))
.then_some(checkout_dir)
}
pub(crate) fn labels(root: &Path, settings: &DaemonSettings) -> Result<RootLabels> {
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let checkout = crate::git::checkout_of(&root);
let project = match settings.namespace.as_deref() {
Some(explicit) => sanitize_label(explicit),
None => {
let dir = checkout.repository.as_deref().unwrap_or(root.as_path());
dir.file_name()
.and_then(|name| sanitize_label(&name.to_string_lossy()))
}
};
let worktree = checkout.worktree.as_deref().and_then(worktree_label);
if checkout.worktree.is_some() && worktree.is_none() {
return Ok(RootLabels::default());
}
if let Some(checkout_dir) = namespace_reaches_siblings(
&root,
&checkout,
worktree.as_deref(),
settings.namespace.as_deref(),
) {
warn_once!(
"[daemons] {} is a worktree of a repository with no main checkout, so its hostnames carry no worktree component and every sibling worktree inheriting namespace {} resolves to the same one, which pitchfork cannot route. Give each checkout its own [daemons_settings] namespace in a gitignored mise.local.toml.",
checkout_dir.display(),
settings.namespace.as_deref().unwrap_or_default()
);
}
Ok(RootLabels { project, worktree })
}
pub(crate) struct Applied {
pub host: Option<String>,
}
pub(crate) fn apply(
name: &str,
table: &mut toml::Table,
labels: &RootLabels,
tld: &str,
) -> Result<Applied> {
let routable = table.contains_key("port");
let tls = match table.get("proxy_tls") {
None => None,
Some(toml::Value::String(mode)) if matches!(mode.as_str(), "terminate" | "passthrough") => {
Some(mode.clone())
}
Some(other) => bail!(
"[daemons.{name}].proxy_tls must be \"terminate\" or \"passthrough\"; got {other}"
),
};
let proxy = match table.get("proxy") {
None | Some(toml::Value::Boolean(true)) => Proxy::Derived,
Some(toml::Value::Boolean(false)) => Proxy::Disabled,
Some(toml::Value::String(label)) => {
validate_label(&format!("[daemons.{name}].proxy label"), label)?;
Proxy::Label(label.clone())
}
Some(other) => {
bail!("[daemons.{name}].proxy must be a hostname label, true, or false; got {other}")
}
};
if tls.is_some() {
if matches!(proxy, Proxy::Disabled) {
bail!("[daemons.{name}] sets proxy_tls but proxy = false, so nothing is proxied");
}
if !routable {
bail!(
"[daemons.{name}] sets proxy_tls but configures no port, so pitchfork never routes it; give it a port or drop proxy_tls"
);
}
}
let label = match &proxy {
Proxy::Disabled => None,
_ if !routable => None,
Proxy::Label(label) => Some(label.clone()),
Proxy::Derived => sanitize_label(name),
};
let Some(label) = label else {
withdraw(table);
return Ok(Applied { host: None });
};
table.insert("proxy".into(), toml::Value::String(label.clone()));
let host = labels
.suffix(tld)
.map(|suffix| format!("{label}.{suffix}"))
.filter(|host| hostname_fits(host));
if host.is_none() {
withdraw(table);
}
Ok(Applied { host })
}
pub(crate) fn withdraw(table: &mut toml::Table) {
table.insert("proxy".into(), toml::Value::Boolean(false));
table.remove("proxy_tls");
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(namespace: Option<&str>) -> DaemonSettings {
DaemonSettings {
namespace: namespace.map(str::to_string),
namespace_per_worktree: None,
}
}
fn checkout_pair(tmp: &Path) -> (PathBuf, PathBuf) {
let primary = tmp.join("shop");
let private = primary.join(".git").join("worktrees").join("pr-42");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(primary.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
let linked = tmp.join("shop-pr-42");
std::fs::create_dir_all(&linked).unwrap();
std::fs::write(
linked.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.unwrap();
(primary, linked)
}
#[test]
fn a_label_is_repaired_or_rejected_depending_on_who_wrote_it() {
assert_eq!(sanitize_label("My_Api.v2").unwrap(), "my-api-v2");
assert_eq!(sanitize_label("--a__b--").unwrap(), "a-b");
assert!(sanitize_label("--").is_none(), "nothing usable is no label");
assert_eq!(
sanitize_label(&"a".repeat(80)).unwrap().len(),
MAX_LABEL_LEN
);
let cut = sanitize_label(&format!("{}-{}", "a".repeat(62), "b".repeat(5))).unwrap();
assert_eq!(cut, "a".repeat(62));
validate_label("label", &cut).unwrap();
assert!(validate_label("label", "api-2").is_ok());
for invalid in ["", "-api", "api-", "API", "my_api", &"a".repeat(64)] {
assert!(validate_label("label", invalid).is_err(), "{invalid:?}");
}
}
#[test]
fn a_url_omits_the_port_only_on_the_standard_one() {
let host = "api.shop.localhost";
assert_eq!(
ProxySettings::default().url(host),
"https://api.shop.localhost"
);
let plain = ProxySettings {
https: false,
port: 80,
tld: "localhost".into(),
};
assert_eq!(plain.url(host), "http://api.shop.localhost");
let custom = ProxySettings {
https: true,
port: 8443,
tld: "localhost".into(),
};
assert_eq!(custom.url(host), "https://api.shop.localhost:8443");
let http_custom = ProxySettings {
https: false,
port: 8088,
tld: "test".into(),
};
assert_eq!(http_custom.url(host), "http://api.shop.localhost:8088");
}
#[test]
fn a_page_url_is_withheld_when_it_would_not_resolve() {
let labels = RootLabels {
project: Some("shop".into()),
worktree: Some("feature".into()),
};
let settings = ProxySettings::default();
assert_eq!(
settings.stack_url(&labels).as_deref(),
Some("https://feature.shop.localhost")
);
assert_eq!(
settings.project_url(&labels).as_deref(),
Some("https://shop.localhost")
);
let long = ProxySettings {
tld: "t".repeat(MAX_HOSTNAME_LEN),
..ProxySettings::default()
};
assert_eq!(long.stack_url(&labels), None);
assert_eq!(long.project_url(&labels), None);
}
#[test]
fn the_environment_wins_over_the_files_pitchfork_reads() {
let mut settings = ProxySettings::default();
let env = |key: &str| match key {
"PITCHFORK_PROXY_HTTPS" => Some("false".to_string()),
"PITCHFORK_PROXY_PORT" => Some("8088".to_string()),
"PITCHFORK_PROXY_TLD" => Some("test".to_string()),
_ => None,
};
apply_proxy_env(&mut settings, Lan::default(), env);
assert_eq!(
settings,
ProxySettings {
https: false,
port: 8088,
tld: "test".into()
}
);
let mut settings = ProxySettings {
tld: "test".into(),
..ProxySettings::default()
};
let one = |key: &str, value: &str| {
let key = key.to_string();
let value = value.to_string();
move |asked: &str| (asked == key).then(|| value.clone())
};
let lan = apply_proxy_env(
&mut settings,
Lan::default(),
one("PITCHFORK_PROXY_LAN", "1"),
);
assert!(lan.on());
assert_eq!(
settings.tld, "test",
"the caller settles the mDNS TLD, not this"
);
let pinned = Lan {
enabled: false,
ip: "192.168.1.42".into(),
};
let after = apply_proxy_env(
&mut settings,
pinned.clone(),
one("PITCHFORK_PROXY_LAN", "off"),
);
assert!(after.on(), "a pinned address keeps LAN mode on: {after:?}");
let cleared = apply_proxy_env(&mut settings, pinned, one("PITCHFORK_PROXY_LAN_IP", ""));
assert!(
!cleared.on(),
"an empty address clears the pin: {cleared:?}"
);
let ip = apply_proxy_env(
&mut settings,
Lan::default(),
one("PITCHFORK_PROXY_LAN_IP", "192.168.1.42"),
);
assert!(ip.on());
for spelling in ["FALSE", "False", "no", "N", "Off", "0", ""] {
let mut cased = ProxySettings::default();
apply_proxy_env(&mut cased, Lan::default(), |key| {
matches!(key, "PITCHFORK_PROXY_HTTPS").then(|| spelling.to_string())
});
assert!(!cased.https, "{spelling:?} must turn HTTPS off");
}
for spelling in ["TRUE", "Yes", "y", "On", "1"] {
let mut cased = ProxySettings {
https: false,
..ProxySettings::default()
};
apply_proxy_env(&mut cased, Lan::default(), |key| {
matches!(key, "PITCHFORK_PROXY_HTTPS").then(|| spelling.to_string())
});
assert!(cased.https, "{spelling:?} must turn HTTPS on");
}
let mut unknown = ProxySettings::default();
apply_proxy_env(&mut unknown, Lan::default(), |key| {
matches!(key, "PITCHFORK_PROXY_HTTPS").then(|| "maybe".to_string())
});
assert!(unknown.https);
}
#[test]
fn proxy_declarations_are_validated_and_normalized() {
let labels = RootLabels {
project: Some("shop".into()),
worktree: None,
};
let parse = |text: &str| -> Result<(Option<String>, toml::Table)> {
let mut table: toml::Table = toml::from_str(text)?;
let applied = apply("api", &mut table, &labels, "localhost")?;
Ok((applied.host, table))
};
let (host, table) = parse("port = 3000").unwrap();
assert_eq!(host.unwrap(), "api.shop.localhost");
assert_eq!(table["proxy"].as_str().unwrap(), "api");
let (host, table) = parse("port = 3000\nproxy = 'web'").unwrap();
assert_eq!(host.unwrap(), "web.shop.localhost");
assert_eq!(table["proxy"].as_str().unwrap(), "web");
let (host, table) = parse("port = 3000\nproxy = false").unwrap();
assert!(host.is_none(), "an opted-out daemon has no hostname");
assert_eq!(table["proxy"].as_bool(), Some(false));
let (host, table) = parse("port = 3000\nproxy = true").unwrap();
assert_eq!(host.unwrap(), "api.shop.localhost");
assert_eq!(table["proxy"].as_str().unwrap(), "api");
let (host, table) = parse("").unwrap();
assert!(host.is_none(), "a portless daemon is never routed");
assert_eq!(table["proxy"].as_bool(), Some(false));
let (host, table) = parse("proxy = 'web'").unwrap();
assert!(host.is_none());
assert_eq!(table["proxy"].as_bool(), Some(false));
assert!(parse("proxy_tls = 'terminate'").is_err());
assert!(parse("proxy = true\nproxy_tls = 'terminate'").is_err());
for mode in ["terminate", "passthrough"] {
let (_, table) = parse(&format!("port = 3000\nproxy_tls = '{mode}'")).unwrap();
assert_eq!(table["proxy_tls"].as_str().unwrap(), mode);
}
for invalid in [
"port = 3000\nproxy = 3000",
"port = 3000\nproxy = 'Web'",
"port = 3000\nproxy = 'my_web'",
"port = 3000\nproxy_tls = 'reencrypt'",
"port = 3000\nproxy_tls = true",
"port = 3000\nproxy = false\nproxy_tls = 'terminate'",
] {
assert!(parse(invalid).is_err(), "{invalid:?}");
}
}
#[test]
fn a_hostname_that_cannot_fit_is_not_offered() {
let labels = RootLabels {
project: Some("a".repeat(MAX_LABEL_LEN)),
worktree: Some("b".repeat(MAX_LABEL_LEN)),
};
let mut table: toml::Table = toml::from_str("port = 3000").unwrap();
let long = "c".repeat(MAX_LABEL_LEN);
let applied = apply(&long, &mut table, &labels, &"d".repeat(MAX_LABEL_LEN)).unwrap();
assert!(applied.host.is_none(), "{:?}", applied.host);
let mut table: toml::Table =
toml::from_str("port = 3000\nproxy_tls = 'passthrough'").unwrap();
let applied = apply(&long, &mut table, &labels, &"d".repeat(MAX_LABEL_LEN)).unwrap();
assert!(applied.host.is_none());
assert_eq!(table["proxy"].as_bool(), Some(false));
assert!(!table.contains_key("proxy_tls"), "{table:?}");
let mut table: toml::Table = toml::from_str("port = 3000").unwrap();
assert!(
apply(&long, &mut table, &labels, "localhost")
.unwrap()
.host
.is_some()
);
}
#[test]
fn endpoint_variables_share_one_stem() {
assert_eq!(super::super::env_var_base("api").unwrap(), "API");
assert_eq!(
super::super::env_var_base("my-api.v2").unwrap(),
"MY_API_V2"
);
assert!(super::super::env_var_base("9api").is_none());
}
#[test]
fn a_primary_checkout_has_no_worktree_label() {
let tmp = tempfile::tempdir().unwrap();
let (primary, linked) = checkout_pair(tmp.path());
let resolved = labels(&primary, &settings(Some("shop"))).unwrap();
assert_eq!(resolved.project.as_deref(), Some("shop"));
assert_eq!(resolved.worktree, None);
assert_eq!(resolved.suffix("localhost").unwrap(), "shop.localhost");
let resolved = labels(&linked, &settings(Some("shop"))).unwrap();
assert_eq!(resolved.worktree.as_deref(), Some("shop-pr-42"));
assert_eq!(
resolved.suffix("localhost").unwrap(),
"shop-pr-42.shop.localhost"
);
assert_eq!(
labels(&primary, &settings(None))
.unwrap()
.project
.as_deref(),
Some("shop")
);
assert_eq!(
labels(&linked, &settings(None)).unwrap().project.as_deref(),
Some("shop")
);
let nested = linked.join("packages").join("api");
std::fs::create_dir_all(&nested).unwrap();
let resolved = labels(&nested, &settings(None)).unwrap();
assert_eq!(resolved.project.as_deref(), Some("shop"));
assert_eq!(resolved.worktree.as_deref(), Some("shop-pr-42"));
}
#[test]
fn a_worktree_that_cannot_be_named_gets_no_hostname() {
let tmp = tempfile::tempdir().unwrap();
let primary = tmp.path().join("shop");
let private = primary.join(".git").join("worktrees").join("odd");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(primary.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
let linked = tmp.path().join("---");
std::fs::create_dir_all(&linked).unwrap();
std::fs::write(
linked.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.unwrap();
let resolved = labels(&linked, &settings(Some("shop"))).unwrap();
assert_eq!(resolved, RootLabels::default());
assert!(resolved.suffix("localhost").is_none());
assert_eq!(
labels(&primary, &settings(Some("shop")))
.unwrap()
.suffix("localhost")
.unwrap(),
"shop.localhost"
);
}
#[test]
fn a_worktree_of_a_bare_repository_names_itself() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("shop");
let bare = project.join("repo.git");
std::fs::create_dir_all(&bare).unwrap();
std::fs::write(bare.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let worktree = |name: &str| {
let private = bare.join("worktrees").join(name);
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
let root = project.join(name);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.unwrap();
root
};
let main = worktree("main");
let feature = worktree("feature");
let resolved = labels(&main, &settings(None)).unwrap();
assert_eq!(resolved.project.as_deref(), Some("main"));
assert_eq!(resolved.worktree, None);
assert_eq!(resolved.suffix("localhost").unwrap(), "main.localhost");
assert_eq!(
labels(&feature, &settings(None))
.unwrap()
.project
.as_deref(),
Some("feature"),
"each worktree of a bare repository names itself"
);
let shared = labels(&main, &settings(Some("shop"))).unwrap();
assert_eq!(shared.project.as_deref(), Some("shop"));
assert_eq!(shared.worktree, None);
std::fs::write(
main.join("pitchfork.local.toml"),
"worktree_label = 'one'\n",
)
.unwrap();
assert_eq!(labels(&main, &settings(Some("shop"))).unwrap(), shared);
let api = main.join("packages").join("api");
let web = main.join("packages").join("web");
std::fs::create_dir_all(&api).unwrap();
std::fs::create_dir_all(&web).unwrap();
assert_eq!(
labels(&api, &settings(None)).unwrap(),
labels(&web, &settings(None)).unwrap()
);
}
#[test]
fn a_submodule_takes_the_worktree_that_contains_it() {
let tmp = tempfile::tempdir().unwrap();
let (primary, linked) = checkout_pair(tmp.path());
let module = primary
.join(".git")
.join("modules")
.join("vendor")
.join("shared-lib");
std::fs::create_dir_all(&module).unwrap();
let submodule = linked.join("vendor").join("shared-lib");
std::fs::create_dir_all(&submodule).unwrap();
std::fs::write(
submodule.join(".git"),
format!("gitdir: {}\n", module.display()),
)
.unwrap();
let resolved = labels(&submodule, &settings(Some("shop"))).unwrap();
assert_eq!(resolved.worktree.as_deref(), Some("shop-pr-42"));
assert_eq!(resolved.project.as_deref(), Some("shop"));
}
#[test]
fn siblings_are_counted_from_the_worktree_registry() {
let tmp = tempfile::tempdir().unwrap();
let bare = tmp.path().join("shop.git");
std::fs::create_dir_all(&bare).unwrap();
std::fs::write(bare.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let add = |name: &str| {
let private = bare.join("worktrees").join(name);
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
let root = tmp.path().join(name);
std::fs::create_dir_all(&root).unwrap();
let dotgit = root.join(".git");
std::fs::write(&dotgit, format!("gitdir: {}\n", private.display())).unwrap();
std::fs::write(private.join("gitdir"), format!("{}\n", dotgit.display())).unwrap();
root
};
let relative = |name: &str| {
let private = bare.join("worktrees").join(name);
std::fs::write(private.join("gitdir"), format!("../../../{name}/.git\n")).unwrap();
};
let only = add("main");
assert!(
!crate::git::has_sibling_worktrees(&only),
"one worktree has no sibling to collide with"
);
let second = add("feature");
assert!(crate::git::has_sibling_worktrees(&only));
assert!(crate::git::has_sibling_worktrees(&second));
let nested = second.join("packages").join("api");
std::fs::create_dir_all(&nested).unwrap();
assert!(!crate::git::has_sibling_worktrees(&nested));
let reaches = |dir: &Path| {
namespace_reaches_siblings(dir, &crate::git::checkout_of(dir), None, Some("shop"))
.map(Path::to_path_buf)
};
assert_eq!(
reaches(&nested),
Some(second.clone()),
"the nested root must be answered for by its checkout"
);
assert_eq!(reaches(&second), Some(second.clone()));
assert_eq!(
namespace_reaches_siblings(&second, &crate::git::checkout_of(&second), None, None),
None
);
assert_eq!(
namespace_reaches_siblings(
&second,
&crate::git::checkout_of(&second),
Some("feature"),
Some("shop")
),
None
);
std::fs::remove_dir_all(&second).unwrap();
assert!(
!crate::git::has_sibling_worktrees(&only),
"a stale registry entry is not a sibling"
);
let second = add("feature");
relative("main");
relative("feature");
assert!(crate::git::has_sibling_worktrees(&only));
std::fs::remove_dir_all(&second).unwrap();
assert!(
!crate::git::has_sibling_worktrees(&only),
"a relative pointer to a removed worktree is still stale"
);
let ordinary = tmp.path().join("plain");
std::fs::create_dir_all(ordinary.join(".git")).unwrap();
assert!(!crate::git::has_sibling_worktrees(&ordinary));
}
#[test]
fn a_worktree_of_a_separate_git_dir_clone_names_itself() {
let tmp = tempfile::tempdir().unwrap();
let common = tmp.path().join("gitdirs").join("shop");
let private = common.join("worktrees").join("pr-42");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(common.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
let linked = tmp.path().join("shop-pr-42");
std::fs::create_dir_all(&linked).unwrap();
std::fs::write(
linked.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.unwrap();
let resolved = labels(&linked, &settings(None)).unwrap();
assert_eq!(resolved.project.as_deref(), Some("shop-pr-42"));
assert_eq!(resolved.worktree, None);
}
#[test]
fn a_checkouts_own_config_names_the_worktree() {
let tmp = tempfile::tempdir().unwrap();
let (_, linked) = checkout_pair(tmp.path());
std::fs::write(linked.join("pitchfork.toml"), "worktree_label = 'pr-42'\n").unwrap();
let resolved = labels(&linked, &settings(Some("shop"))).unwrap();
assert_eq!(resolved.worktree.as_deref(), Some("pr-42"));
std::fs::write(
linked.join("pitchfork.local.toml"),
"worktree_label = 'mine'\n",
)
.unwrap();
assert_eq!(
labels(&linked, &settings(Some("shop")))
.unwrap()
.worktree
.as_deref(),
Some("mine")
);
std::fs::write(
linked.join("pitchfork.local.toml"),
"worktree_label = 'PR 42'\n",
)
.unwrap();
assert_eq!(
labels(&linked, &settings(Some("shop")))
.unwrap()
.worktree
.as_deref(),
Some("pr-42")
);
}
}