feat(desktop): .cloudreveignore/.ignore files in sync root (#157)

Gitignore-style pattern files placed at the sync root are merged with
the per-drive settings patterns: loaded at mount, re-merged on settings
update, and hot-reloaded when fs events touch an ignore file — which
also covers rules arriving via remote sync.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
Tomas Dvorak 2 weeks ago
parent 1ae122dfd7
commit eaa1ba3d60

@ -4,6 +4,7 @@ use crate::{
placeholder::{LocalFileInfo, OpenOptions, PinState}, placeholder::{LocalFileInfo, OpenOptions, PinState},
}, },
drive::{ drive::{
ignore,
mounts::Mount, mounts::Mount,
placeholder::CrPlaceholder, placeholder::CrPlaceholder,
sync::{GroupedFsEvents, SyncMode, local_snapshot_differs}, sync::{GroupedFsEvents, SyncMode, local_snapshot_differs},
@ -613,6 +614,25 @@ impl Mount {
} }
pub async fn process_fs_events(&self, events: GroupedFsEvents) -> Result<()> { pub async fn process_fs_events(&self, events: GroupedFsEvents) -> Result<()> {
// Hot-reload ignore rules when an ignore file was touched in this
// batch, so the new rules apply to the events that follow.
let ignore_file_touched = events.values().flatten().any(|event| {
event.paths.iter().any(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(ignore::is_ignore_file_name)
})
});
if ignore_file_touched {
if let Err(err) = self.reload_ignore_patterns().await {
tracing::warn!(
target: "drive::commands",
error = %err,
"Failed to reload ignore patterns"
);
}
}
// Process groups in a deterministic order: renames/moves first, then // Process groups in a deterministic order: renames/moves first, then
// creates and modifications, removes last. A directory move can emit // creates and modifications, removes last. A directory move can emit
// per-descendant remove events in the same batch; committing the move // per-descendant remove events in the same batch; committing the move

@ -8,6 +8,27 @@ use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder}; use globset::{Glob, GlobSet, GlobSetBuilder};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Filenames in the sync root that hold additional ignore patterns,
/// one gitignore-style pattern per line.
pub const IGNORE_FILE_NAMES: &[&str] = &[".cloudreveignore", ".ignore"];
/// Returns true if `filename` is an ignore-pattern filename.
pub fn is_ignore_file_name(filename: &str) -> bool {
IGNORE_FILE_NAMES.contains(&filename)
}
/// Read ignore patterns from ignore files at the sync root.
/// Missing or unreadable files are skipped silently.
pub fn read_ignore_files(sync_root: &Path) -> Vec<String> {
let mut patterns = Vec::new();
for name in IGNORE_FILE_NAMES {
if let Ok(content) = std::fs::read_to_string(sync_root.join(name)) {
patterns.extend(content.lines().map(str::to_string));
}
}
patterns
}
/// A wrapper around `GlobSet` for matching ignore patterns (gitignore-style). /// A wrapper around `GlobSet` for matching ignore patterns (gitignore-style).
/// ///
/// The matcher stores the sync root path and automatically strips it from /// The matcher stores the sync root path and automatically strips it from
@ -269,6 +290,26 @@ mod tests {
assert!(!matcher.is_match(outside_sync_root_path())); assert!(!matcher.is_match(outside_sync_root_path()));
} }
#[test]
fn test_read_ignore_files() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// No files -> no patterns
assert!(read_ignore_files(root).is_empty());
std::fs::write(root.join(".cloudreveignore"), "*.log\n# comment\nbuild/\n").unwrap();
std::fs::write(root.join(".ignore"), "secret.txt\n").unwrap();
let patterns = read_ignore_files(root);
assert_eq!(patterns, vec!["*.log", "# comment", "build/", "secret.txt"]);
// Patterns feed into a working matcher
let matcher = IgnoreMatcher::new(&patterns, root.to_path_buf()).unwrap();
assert!(matcher.is_match(root.join("debug.log")));
assert!(matcher.is_match(root.join("secret.txt")));
assert!(!matcher.is_match(root.join("readme.md")));
}
#[test] #[test]
fn test_relative_path_matching() { fn test_relative_path_matching() {
let sync_root = test_sync_root(); let sync_root = test_sync_root();

@ -8,7 +8,7 @@ use crate::drive::callback::CallbackHandler;
use crate::drive::commands::ManagerCommand; use crate::drive::commands::ManagerCommand;
use crate::drive::commands::MountCommand; use crate::drive::commands::MountCommand;
use crate::drive::event_blocker::EventBlocker; use crate::drive::event_blocker::EventBlocker;
use crate::drive::ignore::IgnoreMatcher; use crate::drive::ignore::{self, IgnoreMatcher};
use crate::drive::sync::{SyncMode, group_fs_events}; use crate::drive::sync::{SyncMode, group_fs_events};
#[cfg(windows)] #[cfg(windows)]
use crate::drive::utils::recycle_bin_url; use crate::drive::utils::recycle_bin_url;
@ -226,9 +226,9 @@ impl Mount {
) )
.await; .await;
// Parse ignore patterns from config // Parse ignore patterns from config and sync-root ignore files
let sync_path = config.sync_path.clone(); let sync_path = config.sync_path.clone();
let ignore_matcher = match IgnoreMatcher::new(&config.ignore_patterns, sync_path.clone()) { let ignore_matcher = match build_ignore_matcher(&config.ignore_patterns, &sync_path) {
Ok(matcher) => { Ok(matcher) => {
if !matcher.is_empty() { if !matcher.is_empty() {
tracing::info!( tracing::info!(
@ -327,12 +327,23 @@ impl Mount {
/// Returns an error if any pattern is invalid /// Returns an error if any pattern is invalid
pub async fn update_ignore_patterns(&self, patterns: Vec<String>) -> Result<()> { pub async fn update_ignore_patterns(&self, patterns: Vec<String>) -> Result<()> {
let sync_path = self.config.read().await.sync_path.clone(); let sync_path = self.config.read().await.sync_path.clone();
let new_matcher = IgnoreMatcher::new(&patterns, sync_path)?; let new_matcher = build_ignore_matcher(&patterns, &sync_path)?;
self.config.write().await.ignore_patterns = patterns; self.config.write().await.ignore_patterns = patterns;
*self.ignore_matcher.write().await = new_matcher; *self.ignore_matcher.write().await = new_matcher;
Ok(()) Ok(())
} }
/// Rebuild the ignore matcher from config patterns plus any ignore files
/// present at the sync root (`.cloudreveignore` / `.ignore`).
pub async fn reload_ignore_patterns(&self) -> Result<()> {
let (patterns, sync_path) = {
let config = self.config.read().await;
(config.ignore_patterns.clone(), config.sync_path.clone())
};
*self.ignore_matcher.write().await = build_ignore_matcher(&patterns, &sync_path)?;
Ok(())
}
/// Get a copy of the current status flags /// Get a copy of the current status flags
pub async fn get_status_flags(&self) -> MountStatusFlags { pub async fn get_status_flags(&self) -> MountStatusFlags {
*self.status_flags.lock().await *self.status_flags.lock().await
@ -906,6 +917,14 @@ fn resolve_task_queue_config(config: &DriveConfig) -> TaskQueueConfig {
} }
} }
/// Build an ignore matcher from config patterns merged with any ignore files
/// (`.cloudreveignore` / `.ignore`) present at the sync root.
fn build_ignore_matcher(patterns: &[String], sync_root: &Path) -> Result<IgnoreMatcher> {
let mut all = patterns.to_vec();
all.extend(ignore::read_ignore_files(sync_root));
IgnoreMatcher::new(&all, sync_root.to_path_buf())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

Loading…
Cancel
Save