mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 01:27:03 +01:00
add .pdfsignore selective sync for mirror folders
Mirror folders walked and uploaded every path under their root, so syncing a project directory pushed node_modules/, target/, and .git/ to Drive -- quota spent on files nobody wants there, and enough API traffic to invite rate limiting. Rules come from a .pdfsignore (or .protonignore) at the folder root, unioned with an ignore_patterns list in config.json that defaults to the usual build, VCS, and editor-leftover globs. Both are re-read at the start of every pass, so edits apply without restarting the daemon. The filter deliberately sits on the classification union in classification_order(), not on walk_local alone. do_reconcile classifies local | remote | baseline, so a file synced before a rule started matching it is absent from local but present in the other two -- the "local deleted, remote untouched" arm, which calls trash_nodes. Filtering only the walk would have meant that adding a line to .pdfsignore silently trashed files on Drive on the next pass. Ignored baseline rows are left in place so un-ignoring re-adopts the existing remote file instead of re-uploading it. guard_local_wipe takes a filtered baseline for the same reason: an ignored path is absent from the walk by rule rather than by loss, and counting those as missing would trip the guard on every pass once a rule covered the tree, wedging that folder's sync permanently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
This commit is contained in:
1 parent
661ae3cf7a
commit
3ade754ea2
5 files changed
+637
-16
No files matched your search
@@ -6,6 +6,7 @@ use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
||||
|
||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||
@@ -34,6 +35,12 @@ pub struct AppConfig {
|
||||
/// path here. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub mountpoint: Option<String>,
|
||||
/// Ignore patterns (gitignore syntax) applied to every mirror sync folder,
|
||||
/// on top of each folder's own `.pdfsignore`. `None` means "use
|
||||
/// [`DEFAULT_IGNORE_PATTERNS`]"; an explicit empty list disables the
|
||||
/// defaults. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub ignore_patterns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -43,6 +50,7 @@ impl Default for AppConfig {
|
||||
user_agent: USER_AGENT.to_string(),
|
||||
cache_budget: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +61,20 @@ impl AppConfig {
|
||||
pub fn resolved_cache_budget(&self) -> u64 {
|
||||
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
||||
}
|
||||
|
||||
/// The effective global sync ignore patterns: the user's explicit list, or
|
||||
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
|
||||
/// honoured as "no global patterns", which is why this is not a
|
||||
/// `unwrap_or_default`.
|
||||
pub fn resolved_ignore_patterns(&self) -> Vec<String> {
|
||||
match &self.ignore_patterns {
|
||||
Some(patterns) => patterns.clone(),
|
||||
None => DEFAULT_IGNORE_PATTERNS
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
||||
@@ -271,6 +293,7 @@ mod tests {
|
||||
user_agent: "test-agent/1.0".to_string(),
|
||||
cache_budget: Some(1234),
|
||||
mountpoint: Some("/tmp/x".to_string()),
|
||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
@@ -284,4 +307,30 @@ mod tests {
|
||||
assert_eq!(default_config.app_version, APP_VERSION);
|
||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_predating_ignore_patterns_still_loads() {
|
||||
// Every installed config on disk looks like this; the field must not be
|
||||
// required or the daemon would fall back to a default config on upgrade.
|
||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(decoded.ignore_patterns.is_none());
|
||||
assert_eq!(
|
||||
decoded.resolved_ignore_patterns().len(),
|
||||
DEFAULT_IGNORE_PATTERNS.len(),
|
||||
"an absent list means the defaults apply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_empty_ignore_list_disables_the_defaults() {
|
||||
let config = AppConfig {
|
||||
ignore_patterns: Some(Vec::new()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
assert!(
|
||||
config.resolved_ignore_patterns().is_empty(),
|
||||
"opting out must be expressible, so this cannot be unwrap_or_default"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod error;
|
||||
pub mod localindex;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
pub mod syncignore;
|
||||
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
|
||||
//!
|
||||
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
||||
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
||||
//! spent on files nobody wants on Drive, and enough API traffic to invite rate
|
||||
//! limiting.
|
||||
//!
|
||||
//! Rules come from two places, unioned:
|
||||
//!
|
||||
//! * `.pdfsignore` (or `.protonignore`) at the root of the synced folder,
|
||||
//! gitignore syntax.
|
||||
//! * A global pattern list from the config, applied to every synced folder.
|
||||
//!
|
||||
//! Both are read fresh at the start of each reconcile pass, so editing the file
|
||||
//! takes effect on the next pass without restarting the daemon.
|
||||
//!
|
||||
//! # Ignoring is not deleting
|
||||
//!
|
||||
//! A path that becomes ignored after it was already synced keeps its remote
|
||||
//! copy and its baseline row. The sync engine drops ignored paths from
|
||||
//! classification entirely rather than treating them as locally deleted —
|
||||
//! see the union filter in `pdfs-fuse`'s `do_reconcile`. Editing a text file
|
||||
//! must never trash data on Drive.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
|
||||
/// Patterns applied to every synced folder when the config does not override
|
||||
/// them. Version-control metadata, dependency and build trees, editor
|
||||
/// leftovers, and OS turds — none of which belong on Drive, all of which are
|
||||
/// large or rewritten constantly.
|
||||
pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
||||
".git/",
|
||||
".hg/",
|
||||
".svn/",
|
||||
"node_modules/",
|
||||
"target/",
|
||||
".venv/",
|
||||
"__pycache__/",
|
||||
"*~",
|
||||
"*.swp",
|
||||
"*.tmp",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
];
|
||||
|
||||
/// Filenames accepted as a folder's ignore file, in precedence order.
|
||||
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
||||
|
||||
/// Compiled ignore rules for one synced folder.
|
||||
pub struct IgnoreRules {
|
||||
matcher: Option<Gitignore>,
|
||||
}
|
||||
|
||||
impl IgnoreRules {
|
||||
/// Build the rules for a folder rooted at `root`: `globals` first, then any
|
||||
/// ignore file found at the root, so a folder-local rule can negate a
|
||||
/// global one with `!pattern`.
|
||||
///
|
||||
/// Malformed patterns are skipped individually rather than failing the
|
||||
/// load — a typo in `.pdfsignore` must not take the folder's sync down.
|
||||
pub fn load(root: &Path, globals: &[String]) -> Self {
|
||||
let mut builder = GitignoreBuilder::new(root);
|
||||
let mut any = false;
|
||||
|
||||
for pattern in globals {
|
||||
if builder.add_line(None, pattern).is_ok() {
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
|
||||
for name in IGNORE_FILE_NAMES {
|
||||
let path = root.join(name);
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
// `add` returns the first parse error but still takes the lines it
|
||||
// could read, so a bad line costs that line and nothing more.
|
||||
let _ = builder.add(&path);
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if !any {
|
||||
return Self { matcher: None };
|
||||
}
|
||||
Self {
|
||||
matcher: builder.build().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rules matching nothing, for callers with no folder context.
|
||||
pub fn empty() -> Self {
|
||||
Self { matcher: None }
|
||||
}
|
||||
|
||||
/// Whether any rule is loaded. When false, [`is_ignored`](Self::is_ignored)
|
||||
/// is always false and callers can skip filtering entirely.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.matcher.is_none()
|
||||
}
|
||||
|
||||
/// Whether `rel` — a `/`-joined path relative to the folder root — is
|
||||
/// ignored.
|
||||
///
|
||||
/// A path under an ignored directory is ignored too. `Gitignore` matches
|
||||
/// one path against one rule set and does not walk parents itself, so each
|
||||
/// ancestor is tested as a directory. This is what makes filtering the
|
||||
/// classification union sound: the engine can ask about any path in
|
||||
/// isolation, in any order, without having walked its parents first.
|
||||
pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool {
|
||||
let Some(matcher) = &self.matcher else {
|
||||
return false;
|
||||
};
|
||||
if rel.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut prefix = String::new();
|
||||
let mut parts = rel.split('/').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if !prefix.is_empty() {
|
||||
prefix.push('/');
|
||||
}
|
||||
prefix.push_str(part);
|
||||
let last = parts.peek().is_none();
|
||||
// Every ancestor is a directory; only the final component's kind
|
||||
// is the caller's to say.
|
||||
let as_dir = if last { is_dir } else { true };
|
||||
if matcher.matched(&prefix, as_dir).is_ignore() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IgnoreRules {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("IgnoreRules")
|
||||
.field("active", &!self.is_empty())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
|
||||
/// [`crate::cache`]'s tests.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pdfs-ignore-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn globals() -> Vec<String> {
|
||||
DEFAULT_IGNORE_PATTERNS
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, body: &str) {
|
||||
std::fs::write(root.join(name), body).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_rules_at_all_ignores_nothing() {
|
||||
let dir = TempDir::new();
|
||||
let rules = IgnoreRules::load(dir.path(), &[]);
|
||||
assert!(rules.is_empty());
|
||||
assert!(!rules.is_ignored("anything", false));
|
||||
assert!(!rules.is_ignored("deep/nested/path.rs", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_defaults_match_without_an_ignore_file() {
|
||||
let dir = TempDir::new();
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
assert!(rules.is_ignored("node_modules", true));
|
||||
assert!(rules.is_ignored("target", true));
|
||||
assert!(rules.is_ignored("notes.txt~", false));
|
||||
assert!(!rules.is_ignored("src/main.rs", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_under_an_ignored_directory_is_ignored() {
|
||||
let dir = TempDir::new();
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
// The engine asks about deep paths in isolation, so each must answer
|
||||
// correctly without its parents having been tested first.
|
||||
assert!(rules.is_ignored("node_modules/left-pad/index.js", false));
|
||||
assert!(rules.is_ignored("node_modules/left-pad", true));
|
||||
assert!(rules.is_ignored("target/debug/build/x/out", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_ignore_file_adds_to_the_globals() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".pdfsignore", "secrets/\n*.log\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
assert!(rules.is_ignored("secrets/key.pem", false));
|
||||
assert!(rules.is_ignored("run.log", false));
|
||||
// Globals still apply alongside the file.
|
||||
assert!(rules.is_ignored("node_modules", true));
|
||||
assert!(!rules.is_ignored("run.txt", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_rule_can_negate_a_global_one() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".pdfsignore", "!keep.tmp\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
assert!(rules.is_ignored("scratch.tmp", false));
|
||||
assert!(!rules.is_ignored("keep.tmp", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protonignore_is_accepted_as_an_alias() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".protonignore", "build/\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &[]);
|
||||
assert!(rules.is_ignored("build/out.o", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfsignore_wins_when_both_files_exist() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".pdfsignore", "from-pdfs/\n");
|
||||
write(dir.path(), ".protonignore", "from-proton/\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &[]);
|
||||
assert!(rules.is_ignored("from-pdfs/x", false));
|
||||
assert!(!rules.is_ignored("from-proton/x", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_pattern_does_not_take_the_rest_down() {
|
||||
let dir = TempDir::new();
|
||||
// An unclosed character class is not a valid glob.
|
||||
write(dir.path(), ".pdfsignore", "[unclosed\nvalid/\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
assert!(rules.is_ignored("valid/x", false));
|
||||
assert!(rules.is_ignored("node_modules", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_root_itself_is_never_ignored() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".pdfsignore", "*\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &[]);
|
||||
// A blanket rule must not classify the folder root, or the pass would
|
||||
// have nothing to reconcile against.
|
||||
assert!(!rules.is_ignored("", true));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user