use std::fs;
use std::path::{Path, PathBuf};
use super::strip_utf8_bom;
pub const GLOBAL_WORKTREE_IGNORE_FILENAME: &str = ".xbp-worktree-ignore";
pub const DEFAULT_NOISE_DIR_NAMES: &[&str] = &[
".git",
".github",
".hg",
".svn",
".agents",
".cargo",
".codex",
".cursor",
".idea",
".postman",
".next",
".nx",
".open-next",
".turbo",
".vercel",
".xbp",
".dist",
"node_modules",
"target",
"target-publish",
"dist",
"build",
"out",
"coverage",
"tmp",
"vendor",
"venv",
".venv",
"__pycache__",
"terminals",
"mcps",
"agent-tools",
];
pub const DEFAULT_DISCOVERY_SKIP_DIRS: &[&str] = DEFAULT_NOISE_DIR_NAMES;
pub const DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS: &[&str] = &[
".agents/",
".cargo/",
".codex/",
".cursor/",
".git/",
".github/",
".idea/",
".postman/",
".xbp/",
".next/",
".nx/",
".open-next/",
".turbo/",
".vercel/",
".dist/",
"target/",
"target-publish/",
"/target-publish/",
"dist/",
"build/",
"out/",
"coverage/",
"node_modules/",
"tmp/",
"vendor/",
"venv/",
".venv/",
"__pycache__/",
"terminals/",
"/terminals/",
"mcps/",
"/mcps/",
"agent-tools/",
"/agent-tools/",
];
#[derive(Debug, Clone, PartialEq, Eq)]
struct IgnorePattern {
raw: String,
negated: bool,
dir_only: bool,
body: String,
anchored: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct XbpIgnoreSet {
patterns: Vec<IgnorePattern>,
}
impl XbpIgnoreSet {
pub fn empty() -> Self {
Self {
patterns: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
pub fn patterns(&self) -> impl Iterator<Item = &str> {
self.patterns.iter().map(|pattern| pattern.raw.as_str())
}
pub fn from_patterns<I, S>(patterns: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut set = Self::empty();
set.extend_patterns(patterns);
set
}
pub fn from_file_content(content: &str) -> Self {
let mut set = Self::empty();
set.extend_file_content(content);
set
}
pub fn extend_patterns<I, S>(&mut self, patterns: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for pattern in patterns {
if let Some(parsed) = parse_ignore_pattern(pattern.as_ref()) {
self.patterns.push(parsed);
}
}
}
pub fn extend_file_content(&mut self, content: &str) {
let stripped = strip_utf8_bom(content);
for line in stripped.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(parsed) = parse_ignore_pattern(trimmed) {
self.patterns.push(parsed);
}
}
}
pub fn merge(&mut self, other: &XbpIgnoreSet) {
self.patterns.extend(other.patterns.iter().cloned());
}
pub fn is_ignored_relative(&self, relative: &str, is_dir: bool) -> bool {
let normalized = normalize_relative_path(relative);
if normalized.is_empty() || normalized == "." {
return false;
}
let mut ignored = false;
for pattern in &self.patterns {
if pattern_matches(pattern, &normalized, is_dir) {
ignored = !pattern.negated;
}
}
ignored
}
pub fn is_ignored_path(&self, project_root: &Path, path: &Path) -> bool {
let relative = match path.strip_prefix(project_root) {
Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
Err(_) => path.to_string_lossy().replace('\\', "/"),
};
let is_dir = path.is_dir();
self.is_ignored_relative(&relative, is_dir)
}
pub fn skips_dir_name(&self, dir_name: &str) -> bool {
let name = dir_name.trim().trim_matches(|ch| ch == '/' || ch == '\\');
if name.is_empty() || name == "." || name == ".." {
return false;
}
self.is_ignored_relative(name, true)
}
pub fn ignores_service_root(&self, root_directory: &str) -> bool {
let normalized = normalize_relative_path(root_directory);
if normalized.is_empty() || normalized == "." {
return false;
}
self.is_ignored_relative(&normalized, true)
}
pub fn ignores_version_target(&self, target: &str) -> bool {
let normalized = normalize_relative_path(target);
if normalized.is_empty() {
return false;
}
if self.is_ignored_relative(&normalized, false) {
return true;
}
if let Some(parent) = Path::new(&normalized).parent() {
let parent_rel = parent.to_string_lossy().replace('\\', "/");
if !parent_rel.is_empty() && parent_rel != "." {
return self.is_ignored_relative(&parent_rel, true);
}
}
false
}
}
pub fn load_project_xbp_ignore(project_root: &Path, extra_patterns: &[String]) -> XbpIgnoreSet {
let mut set = XbpIgnoreSet::from_patterns(
DEFAULT_DISCOVERY_SKIP_DIRS
.iter()
.map(|name| (*name).to_string()),
);
for candidate in xbp_ignore_file_candidates(project_root) {
if let Ok(content) = fs::read_to_string(&candidate) {
set.extend_file_content(&content);
}
}
if !extra_patterns.is_empty() {
set.extend_patterns(extra_patterns.iter().map(String::as_str));
}
set
}
pub fn xbp_ignore_file_candidates(project_root: &Path) -> Vec<PathBuf> {
vec![
project_root.join(".xbpignore"),
project_root.join(".xbp").join(".xbpignore"),
]
}
pub fn default_xbp_ignore_path(project_root: &Path) -> PathBuf {
project_root.join(".xbp").join(".xbpignore")
}
pub fn default_global_worktree_ignore_content() -> String {
let mut lines = vec![
"# Global worktree-watch ignore patterns (gitignore-style syntax).".to_string(),
"# Managed by XBP at ~/.xbp/.xbp-worktree-ignore (platform equivalent).".to_string(),
"# Edit to add machine-wide paths excluded from worktree-watch activity.".to_string(),
"# Built-in noise dirs (target, .next, .xbp, mcps, …) are also hard-coded in the CLI.".to_string(),
String::new(),
];
for pattern in DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS {
lines.push((*pattern).to_string());
}
lines.push(String::new());
lines.join("\n")
}
pub fn load_global_worktree_ignore_from_path(path: &Path) -> XbpIgnoreSet {
match fs::read_to_string(path) {
Ok(content) => XbpIgnoreSet::from_file_content(&content),
Err(_) => XbpIgnoreSet::empty(),
}
}
pub fn sync_global_worktree_ignore_file(path: &Path) -> Result<(), String> {
if !path.exists() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create global worktree-ignore directory {}: {error}",
parent.display()
)
})?;
}
fs::write(path, default_global_worktree_ignore_content()).map_err(|error| {
format!(
"Failed to write global worktree-ignore file {}: {error}",
path.display()
)
})?;
return Ok(());
}
let content = fs::read_to_string(path).map_err(|error| {
format!(
"Failed to read global worktree-ignore file {}: {error}",
path.display()
)
})?;
let existing_patterns = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.trim_end_matches('/').to_string())
.collect::<Vec<_>>();
let missing = DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS
.iter()
.copied()
.filter(|pattern| {
let normalized = pattern.trim_end_matches('/');
!existing_patterns.iter().any(|existing| {
existing == normalized || existing == *pattern
})
})
.collect::<Vec<_>>();
if missing.is_empty() {
return Ok(());
}
let mut updated = content;
if !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str("\n# Added by XBP (default worktree-watch exclusions)\n");
for pattern in missing {
updated.push_str(pattern);
updated.push('\n');
}
fs::write(path, updated).map_err(|error| {
format!(
"Failed to update global worktree-ignore file {}: {error}",
path.display()
)
})
}
fn parse_ignore_pattern(raw: &str) -> Option<IgnorePattern> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let (negated, rest) = if let Some(rest) = trimmed.strip_prefix('!') {
(true, rest.trim())
} else {
(false, trimmed)
};
if rest.is_empty() {
return None;
}
let dir_only = rest.ends_with('/');
let without_slash = rest.trim_end_matches(|ch| ch == '/' || ch == '\\');
let normalized = without_slash.replace('\\', "/");
let anchored = normalized.starts_with('/');
let body = normalized
.trim_start_matches('/')
.trim_start_matches("./")
.trim_matches('/')
.to_string();
if body.is_empty() {
return None;
}
Some(IgnorePattern {
raw: trimmed.to_string(),
negated,
dir_only,
body,
anchored,
})
}
fn normalize_relative_path(raw: &str) -> String {
raw.trim()
.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string()
}
fn pattern_matches(pattern: &IgnorePattern, path: &str, is_dir: bool) -> bool {
if pattern.dir_only && !is_dir {
let prefix = &pattern.body;
if path == *prefix || path.starts_with(&format!("{prefix}/")) {
return path != *prefix || is_dir;
}
if path == *prefix {
return false;
}
return path_has_directory_match(path, pattern);
}
if pattern.anchored {
return path_glob_match(&pattern.body, path)
|| (!pattern.dir_only && path_is_under_glob(&pattern.body, path));
}
if path_glob_match(&pattern.body, path) {
return true;
}
if path_is_under_glob(&pattern.body, path) {
return true;
}
if !pattern.body.contains('/') && !pattern.body.contains("**") {
return path
.split('/')
.any(|component| path_glob_match(&pattern.body, component));
}
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
for start in 0..segments.len() {
let suffix = segments[start..].join("/");
if path_glob_match(&pattern.body, &suffix)
|| path_is_under_glob(&pattern.body, &suffix)
{
return true;
}
}
false
}
fn path_has_directory_match(path: &str, pattern: &IgnorePattern) -> bool {
if pattern.anchored {
return path == pattern.body || path.starts_with(&format!("{}/", pattern.body));
}
if !pattern.body.contains('/') && !pattern.body.contains('*') {
return path
.split('/')
.any(|component| component.eq_ignore_ascii_case(&pattern.body));
}
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
for start in 0..segments.len() {
let suffix = segments[start..].join("/");
if path_glob_match(&pattern.body, &suffix)
|| suffix.starts_with(&format!("{}/", pattern.body))
|| path_is_under_glob(&pattern.body, &suffix)
{
return true;
}
}
false
}
fn path_is_under_glob(pattern: &str, path: &str) -> bool {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
for end in 1..segments.len() {
let parent = segments[..end].join("/");
if path_glob_match(pattern, &parent) {
return true;
}
}
false
}
fn path_glob_match(pattern: &str, path: &str) -> bool {
let pattern = pattern.trim_matches('/');
let path = path.trim_matches('/');
if pattern == "**" {
return true;
}
glob_match_segments(
&split_glob_segments(pattern),
&path.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
)
}
fn split_glob_segments(pattern: &str) -> Vec<&str> {
pattern.split('/').filter(|s| !s.is_empty()).collect()
}
fn glob_match_segments(pattern: &[&str], path: &[&str]) -> bool {
fn rec(p: &[&str], s: &[&str]) -> bool {
match (p.first().copied(), s.first().copied()) {
(None, None) => true,
(Some("**"), _) => {
if rec(&p[1..], s) {
return true;
}
if s.is_empty() {
return false;
}
rec(p, &s[1..])
}
(Some(pat), Some(seg)) => {
if segment_glob_match(pat, seg) {
rec(&p[1..], &s[1..])
} else {
false
}
}
(Some(_), None) | (None, Some(_)) => false,
}
}
rec(pattern, path)
}
fn segment_glob_match(pattern: &str, segment: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') {
return pattern == segment;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == segment;
}
let mut rest = segment;
for (index, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if index == 0 {
if !rest.starts_with(part) {
return false;
}
rest = &rest[part.len()..];
continue;
}
if index == parts.len() - 1 {
return rest.ends_with(part);
}
if let Some(pos) = rest.find(part) {
rest = &rest[pos + part.len()..];
} else {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_root(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let path = std::env::temp_dir().join(format!("xbp-ignore-{name}-{nanos}"));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(path.join(".xbp")).expect("mkdir");
path
}
#[test]
fn matches_unanchored_folder_anywhere() {
let set = XbpIgnoreSet::from_patterns(["node_modules"]);
assert!(set.is_ignored_relative("node_modules", true));
assert!(set.is_ignored_relative("apps/web/node_modules", true));
assert!(set.is_ignored_relative("apps/web/node_modules/pkg/index.js", false));
assert!(!set.is_ignored_relative("apps/web/src/main.ts", false));
}
#[test]
fn matches_anchored_and_directory_only_patterns() {
let set = XbpIgnoreSet::from_patterns(["/docs/", "packages/icons"]);
assert!(set.is_ignored_relative("docs", true));
assert!(set.is_ignored_relative("docs/guide.md", false));
assert!(!set.is_ignored_relative("apps/docs/guide.md", false));
assert!(set.is_ignored_relative("packages/icons", true));
assert!(set.is_ignored_relative("packages/icons/package.json", false));
assert!(!set.is_ignored_relative("packages/web/package.json", false));
}
#[test]
fn supports_negation() {
let set = XbpIgnoreSet::from_patterns(["secrets/", "!secrets/public.env"]);
assert!(set.is_ignored_relative("secrets/private.key", false));
assert!(!set.is_ignored_relative("secrets/public.env", false));
}
#[test]
fn loads_dot_xbp_ignore_and_yaml_patterns() {
let root = temp_root("load");
fs::write(
root.join(".xbp").join(".xbpignore"),
"# project ignores\npackages/icons/\n*.snap\n",
)
.expect("write ignore");
fs::write(root.join(".xbpignore"), "legacy-root-ignore/\n").expect("write root ignore");
let set = load_project_xbp_ignore(
&root,
&["e2e/".to_string(), "packages/icons/README.md".to_string()],
);
assert!(set.ignores_service_root("packages/icons"));
assert!(set.ignores_version_target("packages/icons/package.json"));
assert!(set.is_ignored_relative("legacy-root-ignore/foo", false));
assert!(set.is_ignored_relative("e2e/test.spec.ts", false));
assert!(set.is_ignored_relative("apps/web/__snapshots__/a.snap", false));
assert!(set.skips_dir_name("node_modules"));
assert!(set.skips_dir_name("target"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn global_mcps_patterns_ignore_nested_paths() {
let set = XbpIgnoreSet::from_patterns(DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS);
assert!(set.is_ignored_relative("mcps", true));
assert!(set.is_ignored_relative("mcps/github/tools", false));
assert!(set.is_ignored_relative("apps/api/mcps/local", true));
assert!(set.is_ignored_relative("mcps/catalog.json", false));
assert!(!set.is_ignored_relative("apps/web/src/main.ts", false));
}
#[test]
fn sync_global_worktree_ignore_seeds_defaults() {
let root = temp_root("global-watch-ignore");
let path = root.join(GLOBAL_WORKTREE_IGNORE_FILENAME);
sync_global_worktree_ignore_file(&path).expect("seed");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("mcps/"));
let set = load_global_worktree_ignore_from_path(&path);
assert!(set.is_ignored_relative("packages/foo/mcps/bar.json", false));
let _ = fs::remove_dir_all(root);
}
#[test]
fn glob_double_star_and_star_segment() {
let set = XbpIgnoreSet::from_patterns(["**/dist", "apps/*/tmp"]);
assert!(set.is_ignored_relative("packages/foo/dist/index.js", false));
assert!(set.is_ignored_relative("dist", true));
assert!(set.is_ignored_relative("apps/web/tmp", true));
assert!(set.is_ignored_relative("apps/web/tmp/cache", false));
assert!(!set.is_ignored_relative("apps/web/src/tmp-file.ts", false));
}
}