gitpane 0.5.2

Multi-repo Git workspace dashboard TUI
use notify_debouncer_full::{
    DebounceEventResult, Debouncer, NoCache, new_debouncer_opt,
    notify::{Config, RecommendedWatcher, RecursiveMode},
};
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc::UnboundedSender;

use crate::event::Event;

pub(crate) struct RepoWatcher {
    _debouncer: Debouncer<RecommendedWatcher, NoCache>,
}

impl RepoWatcher {
    pub fn new(
        repo_paths: &[PathBuf],
        debounce_ms: u64,
        event_tx: UnboundedSender<Event>,
        watch_exclude_dirs: &[String],
    ) -> color_eyre::Result<Self> {
        let owned_paths: Vec<PathBuf> = repo_paths.to_vec();

        // Bridge channel: notify callback (OS thread) -> tokio task
        let (bridge_tx, mut bridge_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<PathBuf>>();

        // Spawn tokio task to route changed paths to repo paths.
        // Filters out .git/ internals to prevent feedback loops (git2 reads
        // trigger watcher events which would re-trigger git2 queries).
        let paths_for_routing = owned_paths.clone();
        let exclude_set: HashSet<String> = watch_exclude_dirs.iter().cloned().collect();
        tokio::spawn(async move {
            while let Some(changed_paths) = bridge_rx.recv().await {
                let mut affected_repos: HashSet<PathBuf> = HashSet::new();

                for changed_path in &changed_paths {
                    // Skip events from excluded directories (node_modules, target, etc.)
                    if changed_path
                        .components()
                        .any(|c| exclude_set.contains(c.as_os_str().to_string_lossy().as_ref()))
                    {
                        continue;
                    }

                    // Allow key .git/ files that change on commit/pull/checkout,
                    // but skip noisy internals that cause feedback loops with git2.
                    if changed_path.components().any(|c| c.as_os_str() == ".git") {
                        let name = changed_path
                            .file_name()
                            .map(|n| n.to_string_lossy())
                            .unwrap_or_default();
                        let path_str = changed_path.to_string_lossy();
                        let is_meaningful = name == "HEAD"
                            || name == "index"
                            || name == "MERGE_HEAD"
                            || name == "REBASE_HEAD"
                            || name == "COMMIT_EDITMSG"
                            || name == "packed-refs"
                            || path_str.contains(".git/refs/");
                        if !is_meaningful {
                            continue;
                        }
                    }

                    for repo_path in &paths_for_routing {
                        if changed_path.starts_with(repo_path) {
                            affected_repos.insert(repo_path.clone());
                            break;
                        }
                    }
                }

                for path in affected_repos {
                    let _ = event_tx.send(Event::RepoChanged(path));
                }
            }
        });

        let config = Config::default().with_poll_interval(Duration::from_secs(2));

        let mut debouncer = new_debouncer_opt::<_, RecommendedWatcher, NoCache>(
            Duration::from_millis(debounce_ms),
            None,
            move |result: DebounceEventResult| {
                if let Ok(events) = result {
                    let paths: Vec<PathBuf> =
                        events.into_iter().flat_map(|e| e.event.paths).collect();
                    if !paths.is_empty() {
                        let _ = bridge_tx.send(paths);
                    }
                }
            },
            NoCache,
            config,
        )?;

        // Watch each repo root recursively
        for path in &owned_paths {
            if path.exists()
                && let Err(e) = debouncer.watch(path, RecursiveMode::Recursive)
            {
                tracing::warn!("Failed to watch {}: {}", path.display(), e);
            }
        }

        Ok(Self {
            _debouncer: debouncer,
        })
    }
}