Add ignorable glob support (#1256)

This commit is contained in:
EgoMoose
2026-06-02 23:40:12 -04:00
committed by GitHub
parent 0afb26e143
commit 15939b1647
6 changed files with 222 additions and 20 deletions

View File

@@ -39,6 +39,7 @@ Making a new release? Simply add the new header with the version and date undern
* Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211]) * Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211])
* Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215]) * Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215])
* Improves relative path calculation for sourcemap generation to avoid issues with Windows UNC paths. ([#1217]) * Improves relative path calculation for sourcemap generation to avoid issues with Windows UNC paths. ([#1217])
* Add support for gitignore-style negation in `globIgnorePaths` and syncback's `ignorePaths` ([#1256])
* Fixed the sync fallback scrambling sibling order; replacements are now re-parented ancestors-first and in their original child order. ([#1265]) * Fixed the sync fallback scrambling sibling order; replacements are now re-parented ancestors-first and in their original child order. ([#1265])
* Instances that share a name and class are now robustly matched on resync by comparing their properties, instead of relying on child order alone. ([#1266]) * Instances that share a name and class are now robustly matched on resync by comparing their properties, instead of relying on child order alone. ([#1266])
* Rojo now reports a clear error instead of panicking in several cases, including when the `serve` port is already in use, when a synced file is read-only or locked, when the filesystem watcher can't be created, and when the working directory is inaccessible. ([#1267]) * Rojo now reports a clear error instead of panicking in several cases, including when the `serve` port is already in use, when a synced file is read-only or locked, when the filesystem watcher can't be created, and when the working directory is inaccessible. ([#1267])
@@ -51,6 +52,7 @@ Making a new release? Simply add the new header with the version and date undern
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211 [#1211]: https://github.com/rojo-rbx/rojo/pull/1211
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215 [#1215]: https://github.com/rojo-rbx/rojo/pull/1215
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217 [#1217]: https://github.com/rojo-rbx/rojo/pull/1217
[#1256]: https://github.com/rojo-rbx/rojo/pull/1256
[#1265]: https://github.com/rojo-rbx/rojo/pull/1265 [#1265]: https://github.com/rojo-rbx/rojo/pull/1265
[#1266]: https://github.com/rojo-rbx/rojo/pull/1266 [#1266]: https://github.com/rojo-rbx/rojo/pull/1266
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267 [#1267]: https://github.com/rojo-rbx/rojo/pull/1267

View File

@@ -48,3 +48,61 @@ impl<'de> Deserialize<'de> for Glob {
Glob::new(&glob).map_err(D::Error::custom) Glob::new(&glob).map_err(D::Error::custom)
} }
} }
/// A glob with optional gitignore-style negation. A leading `!` marks the
/// pattern as a negation (re-includes paths that an earlier rule excluded).
/// To match a literal `!` at the start of a pattern, escape it with `\!`.
#[derive(Debug, Clone)]
pub struct IgnorableGlob {
glob: Glob,
negated: bool,
raw: String,
}
impl IgnorableGlob {
pub fn new(pattern: &str) -> Result<Self, Error> {
let (negated, body) = if let Some(rest) = pattern.strip_prefix('!') {
(true, rest)
} else if pattern.starts_with(r"\!") {
(false, &pattern[1..])
} else {
(false, pattern)
};
Ok(IgnorableGlob {
glob: Glob::new(body)?,
negated,
raw: pattern.to_owned(),
})
}
pub fn is_match<P: AsRef<Path>>(&self, path: P) -> bool {
self.glob.is_match(path)
}
pub fn is_negation(&self) -> bool {
self.negated
}
}
impl PartialEq for IgnorableGlob {
fn eq(&self, other: &Self) -> bool {
self.negated == other.negated && self.glob == other.glob
}
}
impl Eq for IgnorableGlob {}
impl Serialize for IgnorableGlob {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.raw)
}
}
impl<'de> Deserialize<'de> for IgnorableGlob {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let pattern = String::deserialize(deserializer)?;
IgnorableGlob::new(&pattern).map_err(D::Error::custom)
}
}

View File

@@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
glob::Glob, json, resolution::UnresolvedValue, snapshot::SyncRule, syncback::SyncbackRules, glob::IgnorableGlob, json, resolution::UnresolvedValue, snapshot::SyncRule,
syncback::SyncbackRules,
}; };
/// Represents 'default' project names that act as `init` files /// Represents 'default' project names that act as `init` files
@@ -114,7 +115,7 @@ pub struct Project {
/// A list of globs, relative to the folder the project file is in, that /// A list of globs, relative to the folder the project file is in, that
/// match files that should be excluded if Rojo encounters them. /// match files that should be excluded if Rojo encounters them.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub glob_ignore_paths: Vec<Glob>, pub glob_ignore_paths: Vec<IgnorableGlob>,
/// A list of rules for syncback with this project file. /// A list of rules for syncback with this project file.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -593,4 +594,55 @@ mod test {
assert!(project.sync_rules[0].include.is_match("data.data.json")); assert!(project.sync_rules[0].include.is_match("data.data.json"));
assert!(project.sync_rules[1].include.is_match("init.module.lua")); assert!(project.sync_rules[1].include.is_match("init.module.lua"));
} }
#[test]
fn glob_ignore_paths_negation() {
let project_json = r#"{
"name": "TestProject",
"tree": { "$path": "src" },
"globIgnorePaths": [
"**/*.spec.lua",
"!keep.spec.lua",
"\\!literal.lua"
]
}"#;
let project = Project::load_from_slice(
project_json.as_bytes(),
PathBuf::from("/test/default.project.json"),
None,
)
.expect("project should parse");
let paths = &project.glob_ignore_paths;
assert_eq!(paths.len(), 3);
assert!(!paths[0].is_negation());
assert!(paths[0].is_match("foo.spec.lua"));
assert!(paths[1].is_negation());
assert!(paths[1].is_match("keep.spec.lua"));
// `\!literal.lua` should match a file literally named `!literal.lua`,
// not be parsed as a negation.
assert!(!paths[2].is_negation());
assert!(paths[2].is_match("!literal.lua"));
let rules: Vec<_> = paths
.iter()
.map(|g| crate::snapshot::PathIgnoreRule {
base_path: PathBuf::from("/test"),
glob: g.clone(),
})
.collect();
assert!(crate::snapshot::is_path_ignored(
&rules,
"/test/foo.spec.lua"
));
assert!(!crate::snapshot::is_path_ignored(
&rules,
"/test/keep.spec.lua"
));
assert!(!crate::snapshot::is_path_ignored(&rules, "/test/plain.lua"));
}
} }

View File

@@ -8,7 +8,7 @@ use anyhow::Context;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
glob::Glob, glob::{Glob, IgnorableGlob},
path_serializer, path_serializer,
project::ProjectNode, project::ProjectNode,
snapshot_middleware::{emit_legacy_scripts_default, Middleware}, snapshot_middleware::{emit_legacy_scripts_default, Middleware},
@@ -222,18 +222,37 @@ pub struct PathIgnoreRule {
pub base_path: PathBuf, pub base_path: PathBuf,
/// The actual glob that can be matched against the input path. /// The actual glob that can be matched against the input path.
pub glob: Glob, pub glob: IgnorableGlob,
} }
impl PathIgnoreRule { impl PathIgnoreRule {
pub fn passes<P: AsRef<Path>>(&self, path: P) -> bool { pub fn matches<P: AsRef<Path>>(&self, path: P) -> bool {
let path = path.as_ref(); let path = path.as_ref();
match path.strip_prefix(&self.base_path) { match path.strip_prefix(&self.base_path) {
Ok(suffix) => !self.glob.is_match(suffix), Ok(suffix) => self.glob.is_match(suffix),
Err(_) => true, Err(_) => false,
} }
} }
pub fn is_negation(&self) -> bool {
self.glob.is_negation()
}
}
/// Evaluates an ordered list of [`PathIgnoreRule`]s against a path using
/// gitignore-style "last match wins" semantics: a path is ignored if the last
/// rule whose pattern matches it is non-negated. Paths matched by no rule are
/// not ignored.
pub fn is_path_ignored<P: AsRef<Path>>(rules: &[PathIgnoreRule], path: P) -> bool {
let path = path.as_ref();
let mut ignored = false;
for rule in rules {
if rule.matches(path) {
ignored = !rule.is_negation();
}
}
ignored
} }
/// Represents where a particular Instance or InstanceSnapshot came from. /// Represents where a particular Instance or InstanceSnapshot came from.

View File

@@ -7,7 +7,9 @@ use anyhow::Context;
use memofs::{DirEntry, Vfs}; use memofs::{DirEntry, Vfs};
use crate::{ use crate::{
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource}, snapshot::{
is_path_ignored, InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource,
},
syncback::{hash_instance, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, syncback::{hash_instance, FsSnapshot, SyncbackReturn, SyncbackSnapshot},
}; };
@@ -41,12 +43,8 @@ pub fn snapshot_dir_no_meta(
path: &Path, path: &Path,
name: &str, name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> { ) -> anyhow::Result<Option<InstanceSnapshot>> {
let passes_filter_rules = |child: &DirEntry| { let passes_filter_rules =
context |child: &DirEntry| !is_path_ignored(&context.path_ignore_rules, child.path());
.path_ignore_rules
.iter()
.all(|rule| rule.passes(child.path()))
};
let mut snapshot_children = Vec::new(); let mut snapshot_children = Vec::new();

View File

@@ -21,7 +21,7 @@ use std::{
}; };
use crate::{ use crate::{
glob::Glob, glob::{Glob, IgnorableGlob},
snapshot::{InstanceWithMeta, RojoTree}, snapshot::{InstanceWithMeta, RojoTree},
snapshot_middleware::Middleware, snapshot_middleware::Middleware,
syncback::ref_properties::{collect_referents, link_referents}, syncback::ref_properties::{collect_referents, link_referents},
@@ -414,18 +414,18 @@ pub struct SyncbackRules {
} }
impl SyncbackRules { impl SyncbackRules {
pub fn compile_globs(&self) -> anyhow::Result<Vec<Glob>> { pub fn compile_globs(&self) -> anyhow::Result<Vec<IgnorableGlob>> {
let mut globs = Vec::with_capacity(self.ignore_paths.len()); let mut globs = Vec::with_capacity(self.ignore_paths.len());
let dir_ignore_paths = self.create_ignore_dir_paths.unwrap_or(true); let dir_ignore_paths = self.create_ignore_dir_paths.unwrap_or(true);
for pattern in &self.ignore_paths { for pattern in &self.ignore_paths {
let glob = Glob::new(pattern) let glob = IgnorableGlob::new(pattern)
.with_context(|| format!("the pattern '{pattern}' is not a valid glob"))?; .with_context(|| format!("the pattern '{pattern}' is not a valid glob"))?;
globs.push(glob); globs.push(glob);
if dir_ignore_paths { if dir_ignore_paths {
if let Some(dir_pattern) = pattern.strip_suffix("/**") { if let Some(dir_pattern) = pattern.strip_suffix("/**") {
if let Ok(glob) = Glob::new(dir_pattern) { if let Ok(glob) = IgnorableGlob::new(dir_pattern) {
globs.push(glob) globs.push(glob)
} }
} }
@@ -436,7 +436,7 @@ impl SyncbackRules {
} }
} }
fn is_valid_path(globs: &Option<Vec<Glob>>, base_path: &Path, path: &Path) -> bool { fn is_valid_path(globs: &Option<Vec<IgnorableGlob>>, base_path: &Path, path: &Path) -> bool {
let git_glob = GIT_IGNORE_GLOB.get_or_init(|| Glob::new(".git/**").unwrap()); let git_glob = GIT_IGNORE_GLOB.get_or_init(|| Glob::new(".git/**").unwrap());
let test_path = match path.strip_prefix(base_path) { let test_path = match path.strip_prefix(base_path) {
Ok(suffix) => suffix, Ok(suffix) => suffix,
@@ -446,11 +446,16 @@ fn is_valid_path(globs: &Option<Vec<Glob>>, base_path: &Path, path: &Path) -> bo
return false; return false;
} }
if let Some(ref ignore_paths) = globs { if let Some(ref ignore_paths) = globs {
// Gitignore-style "last match wins"
let mut ignored = false;
for glob in ignore_paths { for glob in ignore_paths {
if glob.is_match(test_path) { if glob.is_match(test_path) {
return false; ignored = !glob.is_negation();
} }
} }
if ignored {
return false;
}
} }
true true
} }
@@ -538,3 +543,71 @@ fn strip_unknown_root_children(new: &mut WeakDom, old: &RojoTree) {
new.destroy(child_ref); new.destroy(child_ref);
} }
} }
#[cfg(test)]
mod test {
use super::*;
fn rules(ignore_paths: &[&str], create_ignore_dir_paths: Option<bool>) -> SyncbackRules {
SyncbackRules {
ignore_trees: Vec::new(),
ignore_paths: ignore_paths.iter().map(|s| s.to_string()).collect(),
ignore_properties: IndexMap::new(),
sync_current_camera: None,
sync_unscriptable: None,
ignore_referents: None,
create_ignore_dir_paths,
}
}
#[test]
fn ignore_paths_negation() {
let globs = Some(
rules(&["**/*.lua", "!keep.lua"], Some(false))
.compile_globs()
.unwrap(),
);
let base = Path::new("/test");
// A later negation re-includes a path matched by an earlier pattern.
assert!(!is_valid_path(&globs, base, Path::new("/test/foo.lua")));
assert!(is_valid_path(&globs, base, Path::new("/test/keep.lua")));
// Paths matched by no rule are valid.
assert!(is_valid_path(&globs, base, Path::new("/test/plain.txt")));
}
#[test]
fn ignore_paths_negation_with_dir_expansion() {
// With `create_ignore_dir_paths`, a negated `foo/**` pattern should also
// re-include the `foo` directory itself, mirroring the file rule.
let globs = Some(
rules(&["**/*", "!keep/**"], Some(true))
.compile_globs()
.unwrap(),
);
let base = Path::new("/test");
assert!(!is_valid_path(&globs, base, Path::new("/test/drop/a.lua")));
assert!(is_valid_path(&globs, base, Path::new("/test/keep")));
assert!(is_valid_path(&globs, base, Path::new("/test/keep/a.lua")));
}
#[test]
fn ignore_paths_escaped_bang_is_literal() {
// `\!literal.lua` should ignore a file literally named `!literal.lua`
// rather than being parsed as a negation.
let globs = Some(
rules(&[r"\!literal.lua"], Some(false))
.compile_globs()
.unwrap(),
);
let base = Path::new("/test");
assert!(!globs.as_ref().unwrap()[0].is_negation());
assert!(!is_valid_path(
&globs,
base,
Path::new("/test/!literal.lua")
));
}
}