Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src-tauri/crates/git/src/watch/event_emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ impl EventEmitter {
});

let payload = json!({
"type": "repo:changed",
"repo_id": repo_id,
"change_type": change_type_str,
"affected_count": affected_count,
"timestamp": Self::current_timestamp_ms(),
});

let _ = self.app_handle.emit("repo:changed", payload);
let _ = self.app_handle.emit("repo:changed", payload.clone());
crate::hooks::websocket_broadcast(payload.to_string());
}

/// Emit file changed event (for Filesync channel - individual file changes)
Expand Down
19 changes: 19 additions & 0 deletions src-tauri/crates/git/src/watch/state_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ impl RepoStateStore {
.collect()
}

/// Snapshot only the fields needed to enforce the watch-set capacity.
///
/// Like `get_unhealthy_watcher_ids`, this deliberately avoids cloning the
/// cached `GitStatus` each state may own.
pub fn get_watch_activity(&self) -> Vec<WatchActivity> {
self.states
.read()
.values()
.map(|state| WatchActivity {
repo_id: state.repo_id.clone(),
last_activity: state
.last_git_status_ts
.unwrap_or(state.last_fs_event_ts)
.max(state.last_fs_event_ts),
has_in_flight_jobs: !state.in_flight_jobs.is_empty(),
})
.collect()
}

// ============================================
// Dirty Flag Management
// ============================================
Expand Down
52 changes: 50 additions & 2 deletions src-tauri/crates/git/src/watch/tests/watcher_tests.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use notify::{event::ModifyKind, Event, EventKind};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use crate::watch::types::RepoChangeType;
use crate::watch::watcher::RepoWatcher;
use crate::watch::types::{RepoChangeType, WatchActivity};
use crate::watch::watcher::{select_watch_eviction_victims, RepoWatcher};

fn make_event(paths: Vec<PathBuf>) -> Event {
Event {
Expand All @@ -12,6 +13,53 @@ fn make_event(paths: Vec<PathBuf>) -> Event {
}
}

fn watch_activity(repo_id: &str, age: Duration, has_in_flight_jobs: bool) -> WatchActivity {
WatchActivity {
repo_id: repo_id.to_string(),
last_activity: Instant::now() - age,
has_in_flight_jobs,
}
}

// ============================================
// watch capacity eviction
// ============================================

#[test]
fn watch_capacity_evicts_oldest_idle_repositories() {
let activity = vec![
watch_activity("recent", Duration::from_secs(10), false),
watch_activity("oldest", Duration::from_secs(30), false),
watch_activity("older", Duration::from_secs(20), false),
];

assert_eq!(
select_watch_eviction_victims(&activity, &[], 2),
vec!["oldest".to_string(), "older".to_string()]
);
}

#[test]
fn watch_capacity_preserves_protected_and_busy_repositories() {
let activity = vec![
watch_activity("active-poll", Duration::from_secs(40), false),
watch_activity("busy", Duration::from_secs(30), true),
watch_activity("eligible", Duration::from_secs(20), false),
];

assert_eq!(
select_watch_eviction_victims(&activity, &["active-poll", "incoming"], 3),
vec!["eligible".to_string()]
);
}

#[test]
fn watch_capacity_does_nothing_without_overflow() {
let activity = vec![watch_activity("repo", Duration::from_secs(10), false)];

assert!(select_watch_eviction_victims(&activity, &[], 0).is_empty());
}

// ============================================
// classify_event: Windows backslash paths
// ============================================
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/crates/git/src/watch/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ impl RepoState {
}
}

/// Lightweight per-repo record used to pick watch-capacity eviction victims.
///
/// Cloning whole `RepoState` values would allocate in proportion to every
/// cached status file list; capacity checks only need identity, recency, and
/// whether work is still in flight.
#[derive(Debug, Clone)]
pub struct WatchActivity {
pub repo_id: String,
/// Most recent of the last filesystem event and the last status refresh.
pub last_activity: Instant,
pub has_in_flight_jobs: bool,
}

// ============================================
// Git Status
// ============================================
Expand Down
80 changes: 80 additions & 0 deletions src-tauri/crates/git/src/watch/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,44 @@ const DEBOUNCED_GIT_PATHS: &[&str] = &[
".git/index", // Staging area - changes on every git add/rm
];

// ============================================
// Watch Capacity
// ============================================

/// Pick the least-recently-active watched repos to evict so the watch set
/// honors `MAX_WATCHED_REPOS`.
///
/// Repos listed in `protected` (the incoming repo and the repo currently
/// driving working-directory polling) and repos with in-flight jobs are never
/// chosen — dropping their watcher would strand work the user can still see.
/// When everything is protected the set is allowed to exceed the cap rather
/// than break a live surface.
pub(crate) fn select_watch_eviction_victims(
activity: &[WatchActivity],
protected: &[&str],
overflow: usize,
) -> Vec<String> {
if overflow == 0 {
return Vec::new();
}

let mut candidates: Vec<&WatchActivity> = activity
.iter()
.filter(|entry| !entry.has_in_flight_jobs && !protected.contains(&entry.repo_id.as_str()))
.collect();
candidates.sort_by(|left, right| {
left.last_activity
.cmp(&right.last_activity)
.then_with(|| left.repo_id.cmp(&right.repo_id))
});

candidates
.into_iter()
.take(overflow)
.map(|entry| entry.repo_id.clone())
.collect()
}

// ============================================
// RepoWatcher
// ============================================
Expand Down Expand Up @@ -445,6 +483,8 @@ impl RepoWatcher {
return Err(format!("Not a git repository: {:?}", repo_path));
}

self.enforce_watch_capacity(&repo_info.repo_id);

// Add to state store and wake the poller if it was parked with no active repos.
self.state_store.add_repo(repo_info.clone());
{
Expand Down Expand Up @@ -525,6 +565,46 @@ impl RepoWatcher {
Ok(())
}

/// Drop the least-recently-active watchers so the watch set stays within
/// `MAX_WATCHED_REPOS`.
///
/// Every retained repo holds a native watcher (file descriptors) plus a
/// cached `GitStatus` whose file list scales with the working tree, and
/// nothing unwatches a repo the user simply navigated away from — so an
/// uncapped watch set grows for the whole app session as projects are
/// opened. An evicted repo keeps working through the existing
/// polling/on-demand paths and is re-watched on its next `watch_repo`.
fn enforce_watch_capacity(&self, incoming_repo_id: &str) {
let activity = self.state_store.get_watch_activity();
if activity
.iter()
.any(|entry| entry.repo_id == incoming_repo_id)
{
// Re-watching an already-tracked repo does not grow the set.
return;
}

let overflow = (activity.len() + 1).saturating_sub(MAX_WATCHED_REPOS);
if overflow == 0 {
return;
}

let active_poll_repo_id = self.active_poll_repo_id.read().clone();
let mut protected: Vec<&str> = vec![incoming_repo_id];
if let Some(active_repo_id) = active_poll_repo_id.as_deref() {
protected.push(active_repo_id);
}

for repo_id in select_watch_eviction_victims(&activity, &protected, overflow) {
log::info!(
"[RepoWatch] Watch capacity {} reached; evicting least-recently-active repo {}",
MAX_WATCHED_REPOS,
repo_id
);
let _ = self.unwatch_repo(&repo_id);
}
}

/// Stop watching a repository
pub fn unwatch_repo(&self, repo_id: &str) -> Result<(), String> {
// Remove watcher
Expand Down
Loading
Loading