Merge pull request #412 from kossoy/main
fix: vault watcher reload loop on iCloud .git symlink vaults
This commit is contained in:
@@ -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']
|
||||
|
||||
|
||||
@@ -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: <path>` (worktrees, submodules)
|
||||
fn resolve_git_dir(vault_path: &Path) -> Option<PathBuf> {
|
||||
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<String> {
|
||||
fn changed_paths(event: Event, git_dir: Option<&Path>) -> Vec<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user