use crate::utils::canonicalize_for_subprocess;
use colored::Colorize;
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const PRIMARY_WORKTREE_LINK_PATHS: &[&str] = &["apps/web/.dev.vars", "apps/web/wrangler.dev.jsonc"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreePathReport {
pub repo_root: String,
pub primary_worktree_root: String,
pub is_worktree_checkout: bool,
pub shared_wrangler_state_path: String,
}
pub fn collect_worktree_path_report(invocation_dir: &Path) -> Result<WorktreePathReport, String> {
let repo_root = get_repo_root(invocation_dir)?;
let primary_root = get_primary_worktree_root(invocation_dir)?;
let is_worktree = !path_eq(&repo_root, &primary_root);
let primary_rel = path_relative_to(&repo_root, &primary_root);
let shared_rel = if primary_rel == "." {
".wrangler/state".to_string()
} else {
format!(
"{}/.wrangler/state",
primary_rel.trim_end_matches('/')
)
};
Ok(WorktreePathReport {
repo_root: ".".to_string(),
primary_worktree_root: primary_rel,
is_worktree_checkout: is_worktree,
shared_wrangler_state_path: shared_rel,
})
}
pub fn print_worktree_paths(invocation_dir: &Path) -> Result<(), String> {
let report = collect_worktree_path_report(invocation_dir)?;
print_worktree_path_report(&report)
}
fn print_worktree_path_report(report: &WorktreePathReport) -> Result<(), String> {
let linked = if report.is_worktree_checkout {
"yes".yellow().to_string()
} else {
"no".green().to_string()
};
println!(
"{} {}",
"◆".bright_blue().bold(),
"Worktree paths".bright_blue().bold()
);
println!("{}", "─".repeat(48).bright_black());
print_kv("checkout (repo root)", &report.repo_root);
print_kv("primary worktree", &report.primary_worktree_root);
print_kv("linked worktree checkout", &linked);
print_kv("shared wrangler state", &report.shared_wrangler_state_path);
println!("{}", "─".repeat(48).bright_black());
println!(
"{}",
"Paths are relative to the current git toplevel (portable across Linux / WSL / Windows)."
.dimmed()
);
println!();
println!(
"{}",
serde_json::to_string_pretty(&json!({
"repo_root": report.repo_root,
"primary_worktree_root": report.primary_worktree_root,
"is_worktree_checkout": report.is_worktree_checkout,
"shared_wrangler_state_path": report.shared_wrangler_state_path,
}))
.map_err(|error| format!("Failed to encode JSON output: {}", error))?
);
Ok(())
}
fn print_kv(label: &str, value: &str) {
println!(
" {:<26} {}",
label.bright_white(),
value.cyan()
);
}
pub fn path_relative_to(base: &Path, target: &Path) -> String {
if path_eq(base, target) {
return ".".to_string();
}
let base_key = path_compare_key(base);
let target_key = path_compare_key(target);
let base_parts = path_components(&base_key);
let target_parts_cmp = path_components(&target_key);
let target_disp = path_components(&portable_path_string(target));
let mut common = 0usize;
while common < base_parts.len()
&& common < target_parts_cmp.len()
&& base_parts[common] == target_parts_cmp[common]
{
common += 1;
}
if common == 0 {
return portable_path_string(target);
}
let mut rel: Vec<String> = Vec::new();
for _ in common..base_parts.len() {
rel.push("..".to_string());
}
for part in target_disp.iter().skip(common) {
rel.push(part.clone());
}
if rel.is_empty() {
".".to_string()
} else {
rel.join("/")
}
}
fn path_components(path: &str) -> Vec<String> {
path.split('/')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
pub fn link_dev_vars_from_primary_worktree(invocation_dir: &Path) -> Result<(), String> {
let repo_root = get_repo_root(invocation_dir)?;
let primary_root = get_primary_worktree_root(invocation_dir)?;
if path_eq(&repo_root, &primary_root) {
println!("Current checkout is the primary worktree; nothing to link.");
return Ok(());
}
let mut linked = Vec::new();
let mut skipped = Vec::new();
for rel_path in PRIMARY_WORKTREE_LINK_PATHS {
match link_from_primary_worktree(rel_path, &primary_root, &repo_root)? {
LinkOutcome::Linked(path) => linked.push(path),
LinkOutcome::Unchanged(path) => skipped.push(format!("{path} (already linked)")),
LinkOutcome::Skipped(path) => skipped.push(path),
}
}
if !linked.is_empty() {
println!("Linked from primary worktree:");
for file in linked {
println!(" - {}", file);
}
}
if !skipped.is_empty() {
println!("Skipped:");
for file in skipped {
println!(" - {}", file);
}
}
Ok(())
}
pub fn get_repo_root(invocation_dir: &Path) -> Result<PathBuf, String> {
let root = PathBuf::from(run_git(invocation_dir, ["rev-parse", "--show-toplevel"])?);
Ok(normalize_resolved_path(&root))
}
pub fn get_primary_worktree_root(invocation_dir: &Path) -> Result<PathBuf, String> {
let common_dir = get_git_common_dir(invocation_dir)?;
let primary = common_dir
.parent()
.map(Path::to_path_buf)
.unwrap_or(common_dir);
Ok(normalize_resolved_path(&primary))
}
pub fn get_shared_wrangler_state_path(invocation_dir: &Path) -> Result<PathBuf, String> {
let primary = get_primary_worktree_root(invocation_dir)?;
Ok(PathBuf::from(format!(
"{}/.wrangler/state",
portable_path_string(&primary).trim_end_matches('/')
)))
}
pub fn is_worktree_checkout(invocation_dir: &Path) -> Result<bool, String> {
let repo_root = get_repo_root(invocation_dir)?;
let primary_root = get_primary_worktree_root(invocation_dir)?;
Ok(!path_eq(&repo_root, &primary_root))
}
fn get_git_common_dir(invocation_dir: &Path) -> Result<PathBuf, String> {
match run_git(
invocation_dir,
["rev-parse", "--path-format=absolute", "--git-common-dir"],
) {
Ok(path) => Ok(normalize_resolved_path(Path::new(&path))),
Err(_) => {
let common_dir_raw = run_git(invocation_dir, ["rev-parse", "--git-common-dir"])?;
let common_dir = PathBuf::from(&common_dir_raw);
if common_dir.is_absolute() {
Ok(normalize_resolved_path(&common_dir))
} else {
Ok(normalize_resolved_path(&invocation_dir.join(common_dir)))
}
}
}
}
fn normalize_resolved_path(path: &Path) -> PathBuf {
let cleaned = PathBuf::from(portable_path_string(path));
canonicalize_for_subprocess(&cleaned)
}
fn run_git<const N: usize>(invocation_dir: &Path, args: [&str; N]) -> Result<String, String> {
let output = Command::new("git")
.args(args)
.current_dir(invocation_dir)
.output()
.map_err(|error| format!("Failed to run git {}: {}", args.join(" "), error))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = if stderr.is_empty() {
format!(
"git {} exited with status {}",
args.join(" "),
output.status
)
} else {
stderr
};
return Err(message);
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
return Err(format!("git {} returned an empty response", args.join(" ")));
}
Ok(stdout)
}
enum LinkOutcome {
Linked(String),
Unchanged(String),
Skipped(String),
}
fn link_from_primary_worktree(
rel_path: &str,
primary_root: &Path,
repo_root: &Path,
) -> Result<LinkOutcome, String> {
let source = primary_root.join(rel_path);
let target = repo_root.join(rel_path);
let Some(target_dir) = target.parent() else {
return Err(format!(
"Could not resolve parent directory for {}",
target.display()
));
};
if !source.exists() {
return Ok(LinkOutcome::Skipped(format!(
"{} (missing in primary worktree)",
rel_path
)));
}
fs::create_dir_all(target_dir)
.map_err(|error| format!("Failed to create {}: {}", target_dir.display(), error))?;
if let Ok(metadata) = fs::symlink_metadata(&target) {
if metadata.file_type().is_symlink() {
let current_target = fs::read_link(&target)
.map_err(|error| format!("Failed to inspect {}: {}", target.display(), error))?;
let resolved_target = if current_target.is_absolute() {
current_target
} else {
target_dir.join(current_target)
};
if path_eq(&resolved_target, &source) {
return Ok(LinkOutcome::Unchanged(rel_path.to_string()));
}
fs::remove_file(&target)
.map_err(|error| format!("Failed to replace {}: {}", target.display(), error))?;
} else {
return Ok(LinkOutcome::Skipped(format!(
"{} (real file exists)",
rel_path
)));
}
}
create_file_symlink(&source, &target)?;
Ok(LinkOutcome::Linked(rel_path.to_string()))
}
#[cfg(windows)]
fn create_file_symlink(source: &Path, target: &Path) -> Result<(), String> {
std::os::windows::fs::symlink_file(source, target).map_err(|error| {
format!(
"Failed to create symlink {} -> {}: {}",
target.display(),
source.display(),
error
)
})
}
#[cfg(not(windows))]
fn create_file_symlink(source: &Path, target: &Path) -> Result<(), String> {
std::os::unix::fs::symlink(source, target).map_err(|error| {
format!(
"Failed to create symlink {} -> {}: {}",
target.display(),
source.display(),
error
)
})
}
fn path_eq(left: &Path, right: &Path) -> bool {
path_compare_key(left) == path_compare_key(right)
}
pub fn portable_path_string(path: impl AsRef<Path>) -> String {
let raw = path.as_ref().to_string_lossy();
let mut value = raw.replace('\\', "/");
if let Some(rest) = value.strip_prefix("//?/UNC/") {
value = format!("//{rest}");
} else if let Some(rest) = value.strip_prefix("//./UNC/") {
value = format!("//{rest}");
} else if let Some(rest) = value.strip_prefix("//?/") {
value = rest.to_string();
} else if let Some(rest) = value.strip_prefix("//./") {
value = rest.to_string();
}
value = collapse_duplicate_slashes(&value);
if looks_like_windows_drive(&value) {
let mut chars = value.chars();
let drive = chars.next().unwrap().to_ascii_uppercase();
let rest: String = chars.collect();
value = format!("{drive}{rest}");
}
trim_trailing_slash(&value)
}
fn path_compare_key(path: &Path) -> String {
let mut value = portable_path_string(path).to_ascii_lowercase();
value = wsl_mnt_to_windows_drive(&value);
value
}
fn wsl_mnt_to_windows_drive(path: &str) -> String {
let Some(rest) = path.strip_prefix("/mnt/") else {
return path.to_string();
};
let mut chars = rest.chars();
let Some(drive) = chars.next() else {
return path.to_string();
};
if !drive.is_ascii_alphabetic() {
return path.to_string();
}
let after: String = chars.collect();
if after.is_empty() {
return format!("{drive}:/");
}
if let Some(stripped) = after.strip_prefix('/') {
return format!("{drive}:/{stripped}");
}
path.to_string()
}
fn looks_like_windows_drive(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
fn collapse_duplicate_slashes(path: &str) -> String {
if path.starts_with("//") && !path.starts_with("//?/") && !path.starts_with("//./") {
let rest = path.trim_start_matches('/');
return format!("//{}", rest.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("/"));
}
let mut out = String::with_capacity(path.len());
let mut prev_slash = false;
for ch in path.chars() {
if ch == '/' {
if !prev_slash {
out.push('/');
}
prev_slash = true;
} else {
out.push(ch);
prev_slash = false;
}
}
out
}
fn trim_trailing_slash(path: &str) -> String {
if path == "/" {
return path.to_string();
}
if looks_like_windows_drive(path) {
let stripped = path.trim_end_matches('/');
if stripped.len() == 2 {
return format!("{stripped}/");
}
return stripped.to_string();
}
path.trim_end_matches('/').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn print_worktree_paths_succeeds_from_repo_root() {
let invocation_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("workspace root");
print_worktree_paths(&invocation_dir).expect("paths should resolve from monorepo root");
let report =
collect_worktree_path_report(&invocation_dir).expect("relative path report");
assert_eq!(report.repo_root, ".");
assert_eq!(report.primary_worktree_root, ".");
assert!(!report.is_worktree_checkout);
assert_eq!(report.shared_wrangler_state_path, ".wrangler/state");
assert!(!report.shared_wrangler_state_path.contains('\\'));
assert!(!report.shared_wrangler_state_path.contains('?'));
}
#[test]
fn report_from_nested_app_dir_is_still_repo_relative() {
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("workspace root");
let nested = repo.join("apps").join("web");
let report = collect_worktree_path_report(&nested).expect("report from apps/web");
assert_eq!(report.repo_root, ".");
assert_eq!(report.primary_worktree_root, ".");
assert!(!report.is_worktree_checkout);
assert_eq!(report.shared_wrangler_state_path, ".wrangler/state");
}
#[test]
fn path_relative_to_handles_sibling_and_equal() {
let base = Path::new(r"C:\Users\floris\Documents\GitHub\xbp");
let same = Path::new(r"\\?\C:\Users\floris\Documents\GitHub\xbp");
let sibling = Path::new(r"C:\Users\floris\Documents\GitHub\xbp-worktree");
assert_eq!(path_relative_to(base, same), ".");
assert_eq!(path_relative_to(base, sibling), "../xbp-worktree");
}
#[test]
fn path_eq_ignores_windows_verbatim_prefix() {
let left = Path::new(r"\\?\C:\Users\floris\Documents\GitHub\xbp");
let right = Path::new(r"C:/Users/floris/Documents/GitHub/xbp");
assert!(path_eq(left, right));
}
#[test]
fn path_eq_matches_wsl_mnt_and_windows_drive() {
let wsl = Path::new("/mnt/c/Users/floris/Documents/GitHub/xbp");
let win = Path::new(r"C:\Users\floris\Documents\GitHub\xbp");
assert!(path_eq(wsl, win));
}
#[test]
fn portable_path_string_is_consistent_across_styles() {
assert_eq!(
portable_path_string(r"\\?\C:\Users\floris\xbp"),
"C:/Users/floris/xbp"
);
assert_eq!(
portable_path_string(r"c:\Users\floris\xbp\"),
"C:/Users/floris/xbp"
);
assert_eq!(
portable_path_string(r"\\?\UNC\server\share\repo"),
"//server/share/repo"
);
assert_eq!(
portable_path_string("/home/floris/xbp/"),
"/home/floris/xbp"
);
assert_eq!(portable_path_string("/"), "/");
assert_eq!(portable_path_string("C:"), "C:/");
}
#[test]
fn portable_paths_have_no_mixed_separators() {
let mixed = portable_path_string(r"C:\Users/floris/Documents/GitHub\xbp\.wrangler\state");
assert!(!mixed.contains('\\'));
assert!(mixed.contains('/'));
assert_eq!(mixed, "C:/Users/floris/Documents/GitHub/xbp/.wrangler/state");
}
}