diff --git a/scripts/run-vitest-coverage.mjs b/scripts/run-vitest-coverage.mjs index d9fb88d0..0fc3fcad 100644 --- a/scripts/run-vitest-coverage.mjs +++ b/scripts/run-vitest-coverage.mjs @@ -17,12 +17,16 @@ const hasMaxWorkersOverride = forwardedArgs.some((arg) => ) const maxAttempts = 2 +// Standalone pnpm installs ship a native binary, so npm_execpath points at +// a Mach-O/ELF executable. Only reuse process.execPath (node) when the path +// is something node can actually load as a module. const packageManagerExec = process.env.npm_execpath -const command = packageManagerExec ? process.execPath : 'pnpm' -const baseCommandArgs = packageManagerExec +const isJsExecpath = packageManagerExec && /\.[mc]?js$/i.test(packageManagerExec) +const command = isJsExecpath ? process.execPath : 'pnpm' +const baseCommandArgs = isJsExecpath ? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage'] : ['exec', 'vitest', 'run', '--coverage'] -const clearCacheCommandArgs = packageManagerExec +const clearCacheCommandArgs = isJsExecpath ? [packageManagerExec, 'exec', 'vitest', '--clearCache'] : ['exec', 'vitest', '--clearCache'] diff --git a/src-tauri/src/vault_watcher.rs b/src-tauri/src/vault_watcher.rs index e2293937..ceccf335 100644 --- a/src-tauri/src/vault_watcher.rs +++ b/src-tauri/src/vault_watcher.rs @@ -26,16 +26,50 @@ fn is_temp_file_name(name: &OsStr) -> bool { name == ".DS_Store" || name == ".tolaria-rename-txn" || name.starts_with(".#") + || name.starts_with(".gitstatus.") || name.ends_with('~') || name.ends_with(".tmp") || name.ends_with(".swp") || name.ends_with(".swx") + || name.ends_with(".icloud") } -fn is_watchable_path(path: &Path) -> bool { +/// Resolve the real git directory for `vault_path`. Handles three cases: +/// - regular `.git/` directory +/// - `.git` symlink (e.g. the iCloud `.git -> .git.nosync` workaround) +/// - `.git` file containing `gitdir: ` (worktrees, submodules) +fn resolve_git_dir(vault_path: &Path) -> Option { + let git_path = vault_path.join(".git"); + if let Ok(target) = std::fs::read_link(&git_path) { + let resolved = if target.is_absolute() { + target + } else { + vault_path.join(target) + }; + return Some(resolved); + } + if git_path.is_dir() { + return Some(git_path); + } + let content = std::fs::read_to_string(&git_path).ok()?; + let rest = content.lines().next()?.strip_prefix("gitdir:")?.trim(); + let target = Path::new(rest); + Some(if target.is_absolute() { + target.to_path_buf() + } else { + vault_path.join(target) + }) +} + +fn is_watchable_path(path: &Path, git_dir: Option<&Path>) -> bool { if has_ignored_component(path) { return false; } + if let Some(git_dir) = git_dir { + if path.starts_with(git_dir) { + return false; + } + } match path.file_name() { Some(name) => !is_temp_file_name(name), None => true, @@ -51,7 +85,9 @@ mod desktop { }; use tauri::Emitter; - use super::{is_watchable_path, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT}; + use super::{ + is_watchable_path, resolve_git_dir, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT, + }; struct ActiveVaultWatcher { path: PathBuf, @@ -93,20 +129,25 @@ mod desktop { !matches!(event.kind, EventKind::Access(_)) } - fn changed_paths(event: Event) -> Vec { + fn changed_paths(event: Event, git_dir: Option<&Path>) -> Vec { if !should_emit_event(&event) { return Vec::new(); } event .paths .into_iter() - .filter(|path| is_watchable_path(path)) + .filter(|path| is_watchable_path(path, git_dir)) .map(|path| path.to_string_lossy().to_string()) .collect() } - fn emit_vault_change(app: &tauri::AppHandle, vault_path: &Path, event: Event) { - let paths = changed_paths(event); + fn emit_vault_change( + app: &tauri::AppHandle, + vault_path: &Path, + git_dir: Option<&Path>, + event: Event, + ) { + let paths = changed_paths(event, git_dir); if paths.is_empty() { return; } @@ -134,9 +175,15 @@ mod desktop { } let event_vault_path = vault_path.clone(); + let event_git_dir = resolve_git_dir(&vault_path); let event_app = app.clone(); let mut watcher = recommended_watcher(move |event| match event { - Ok(event) => emit_vault_change(&event_app, &event_vault_path, event), + Ok(event) => emit_vault_change( + &event_app, + &event_vault_path, + event_git_dir.as_deref(), + event, + ), Err(err) => log::warn!("Vault watcher event failed: {}", err), }) .map_err(|err| format!("Failed to create vault watcher: {err}"))?; @@ -299,30 +346,86 @@ pub fn stop_vault_watcher() -> Result<(), String> { #[cfg(test)] mod tests { - use super::is_watchable_path; - use std::path::Path; + use super::{is_watchable_path, resolve_git_dir}; + use std::path::{Path, PathBuf}; #[test] fn ignores_git_and_dependency_directory_changes() { - assert!(!is_watchable_path(Path::new(".git/index.lock"))); - assert!(!is_watchable_path(Path::new( - "node_modules/package/index.js" - ))); + assert!(!is_watchable_path(Path::new(".git/index.lock"), None)); + assert!(!is_watchable_path( + Path::new("node_modules/package/index.js"), + None + )); } #[test] fn ignores_common_temporary_files() { - assert!(!is_watchable_path(Path::new("note.md.tmp"))); - assert!(!is_watchable_path(Path::new("note.md.swp"))); - assert!(!is_watchable_path(Path::new("draft.md~"))); - assert!(!is_watchable_path(Path::new(".DS_Store"))); - assert!(!is_watchable_path(Path::new(".tolaria-rename-txn"))); + assert!(!is_watchable_path(Path::new("note.md.tmp"), None)); + assert!(!is_watchable_path(Path::new("note.md.swp"), None)); + assert!(!is_watchable_path(Path::new("draft.md~"), None)); + assert!(!is_watchable_path(Path::new(".DS_Store"), None)); + assert!(!is_watchable_path(Path::new(".tolaria-rename-txn"), None)); + assert!(!is_watchable_path(Path::new(".gitstatus.KASSUJ"), None)); + assert!(!is_watchable_path(Path::new("notes/draft.md.icloud"), None)); } #[test] fn keeps_notes_assets_and_saved_views_watchable() { - assert!(is_watchable_path(Path::new("notes/day.md"))); - assert!(is_watchable_path(Path::new("attachments/image.png"))); - assert!(is_watchable_path(Path::new(".laputa/views/work.yml"))); + assert!(is_watchable_path(Path::new("notes/day.md"), None)); + assert!(is_watchable_path(Path::new("attachments/image.png"), None)); + assert!(is_watchable_path(Path::new(".laputa/views/work.yml"), None)); + } + + #[test] + fn ignores_paths_inside_resolved_git_dir() { + // .git -> .git.nosync symlink trick used for iCloud/Dropbox vaults + let git_dir = PathBuf::from("/vault/.git.nosync"); + assert!(!is_watchable_path( + Path::new("/vault/.git.nosync/index.lock"), + Some(&git_dir) + )); + assert!(!is_watchable_path( + Path::new("/vault/.git.nosync/refs/remotes/origin/HEAD"), + Some(&git_dir) + )); + assert!(is_watchable_path( + Path::new("/vault/notes/day.md"), + Some(&git_dir) + )); + } + + #[test] + fn resolves_real_git_dir_through_symlink() { + let dir = tempfile::tempdir().unwrap(); + let real_git = dir.path().join(".git.nosync"); + std::fs::create_dir(&real_git).unwrap(); + std::os::unix::fs::symlink(".git.nosync", dir.path().join(".git")).unwrap(); + + let resolved = resolve_git_dir(dir.path()).unwrap(); + assert_eq!(resolved, dir.path().join(".git.nosync")); + } + + #[test] + fn resolves_real_git_dir_for_regular_directory() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + + let resolved = resolve_git_dir(dir.path()).unwrap(); + assert_eq!(resolved, dir.path().join(".git")); + } + + #[test] + fn resolves_real_git_dir_for_worktree_pointer_file() { + let dir = tempfile::tempdir().unwrap(); + let worktree_target = dir.path().join("main/.git/worktrees/foo"); + std::fs::create_dir_all(&worktree_target).unwrap(); + std::fs::write( + dir.path().join(".git"), + format!("gitdir: {}\n", worktree_target.display()), + ) + .unwrap(); + + let resolved = resolve_git_dir(dir.path()).unwrap(); + assert_eq!(resolved, worktree_target); } }