Files
yaak-mountain-loop/crates-tauri/yaak-app-client/src/git_watcher.rs
T

186 lines
5.8 KiB
Rust

use crate::error::{Error, Result};
use chrono::Utc;
use log::{debug, error, warn};
use notify::Watcher;
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use tauri::{AppHandle, Listener, Runtime};
use tokio::select;
use tokio::sync::watch;
use tokio::time::sleep;
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
use yaak_rpc_schema::GitWatchResult;
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
pub(crate) async fn watch_git_worktree_status<R, F>(
app_handle: AppHandle<R>,
dir: &Path,
on_status: F,
) -> Result<GitWatchResult>
where
R: Runtime,
F: Fn(GitWorktreeStatus) + Send + Sync + 'static,
{
let paths = git_repository_paths(dir)?;
let repo_dir = dir.to_path_buf();
let workdir = paths.workdir;
let gitdir = paths.gitdir;
let commondir = paths.commondir;
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = notify::recommended_watcher(tx)
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?;
// Watch only the directory Yaak syncs to, not the whole worktree — the
// containing repo may be huge and busy (e.g. a sync dir inside a monorepo),
// and watching it all burns CPU re-checking status for unrelated changes
watcher
.watch(&repo_dir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git sync directory: {e}")))?;
// Watch the git metadata that affects branch/status info: the top-level
// gitdir files (HEAD, index) and refs. Not the whole gitdir, since
// .git/objects churns constantly during fetches and gc. Refs and
// packed-refs live in the common dir, which only differs from the gitdir
// for linked worktrees
watcher
.watch(&gitdir, notify::RecursiveMode::NonRecursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
if commondir != gitdir {
watcher
.watch(&commondir, notify::RecursiveMode::NonRecursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git common dir: {e}")))?;
}
let refs_dir = commondir.join("refs");
if refs_dir.exists() {
watcher
.watch(&refs_dir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git refs: {e}")))?;
}
let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);
std::thread::spawn(move || {
for res in rx {
if async_tx.blocking_send(res).is_err() {
break;
}
}
});
let (cancel_tx, cancel_rx) = watch::channel(());
let mut cancel_rx = cancel_rx;
send_worktree_status(&repo_dir, &on_status);
tauri::async_runtime::spawn(async move {
let _watcher = watcher;
loop {
select! {
Some(event_res) = async_rx.recv() => {
handle_git_watch_event(
event_res,
&mut async_rx,
&repo_dir,
&workdir,
&gitdir,
&commondir,
&on_status,
).await;
}
_ = cancel_rx.changed() => {
break;
}
}
}
});
let app_handle_inner = app_handle.clone();
let unlisten_event = format!("git-watch-unlisten-{}", Utc::now().timestamp_millis());
app_handle.listen_any(unlisten_event.clone(), move |event| {
app_handle_inner.unlisten(event.id());
if let Err(e) = cancel_tx.send(()) {
warn!("Failed to send git watch cancel signal {e:?}");
}
});
Ok(GitWatchResult { unlisten_event })
}
async fn handle_git_watch_event(
event_res: notify::Result<notify::Event>,
async_rx: &mut tokio::sync::mpsc::Receiver<notify::Result<notify::Event>>,
repo_dir: &Path,
workdir: &Path,
gitdir: &Path,
commondir: &Path,
on_status: &impl Fn(GitWorktreeStatus),
) {
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir) {
return;
}
send_worktree_status(repo_dir, on_status);
let settle_window = sleep(GIT_STATUS_COALESCE_WINDOW);
tokio::pin!(settle_window);
loop {
select! {
Some(event_res) = async_rx.recv() => {
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir);
}
_ = &mut settle_window => {
break;
}
}
}
send_worktree_status(repo_dir, on_status);
}
fn is_relevant_git_watch_event(
event_res: notify::Result<notify::Event>,
repo_dir: &Path,
workdir: &Path,
gitdir: &Path,
commondir: &Path,
) -> bool {
let event = match event_res {
Ok(event) => event,
Err(e) => {
error!("Git watch error: {:?}", e);
return false;
}
};
for path in event.paths {
if path.strip_prefix(gitdir).is_ok() || path.strip_prefix(commondir).is_ok() {
return true;
}
let Ok(rela_path) = path.strip_prefix(workdir) else {
continue;
};
match git_path_is_ignored(repo_dir, rela_path) {
Ok(true) => {}
Ok(false) => return true,
Err(e) => {
debug!("Failed to check Git ignore status for {:?}: {e}", rela_path);
return true;
}
}
}
false
}
fn send_worktree_status(repo_dir: &Path, on_status: &impl Fn(GitWorktreeStatus)) {
match git_worktree_status(repo_dir) {
Ok(status) => on_status(status),
Err(e) => {
warn!("Failed to get git worktree status: {e}");
}
}
}