#![warn(
clippy::unwrap_used, // Require .expect() over .unwrap()
clippy::redundant_clone, // Catch unnecessary clones
clippy::too_many_lines, // Flag long functions (configured in clippy.toml)
clippy::excessive_nesting, // Flag deeply nested code
)]
mod html;
mod print;
use branchdiff::app::{self, App, FrameContext};
use branchdiff::cli::{Cli, OutputMode};
use clap::Parser;
use branchdiff::file_events::VcsLockState;
#[cfg(target_os = "linux")]
use branchdiff::gitignore::GitignoreFilter;
use branchdiff::input::{handle_event, AppAction};
use branchdiff::limits;
use branchdiff::message::{
FetchResult, LoopAction, Message, RefreshOutcome, RefreshTrigger, FALLBACK_REFRESH_SECS,
};
use branchdiff::update::{update, RefreshState, Timers, UpdateConfig};
use branchdiff::vcs::{self, ComparisonContext, Vcs};
use branchdiff::ui;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
#[cfg(target_os = "linux")]
use ignore::WalkBuilder;
use notify::RecursiveMode::{NonRecursive, Recursive};
use notify::{PollWatcher, RecommendedWatcher};
use notify_debouncer_mini::{new_debouncer_opt, Config as DebouncerConfig, Debouncer};
use ratatui::prelude::*;
enum AnyDebouncer {
Recommended(Debouncer<RecommendedWatcher>),
Poll(Debouncer<PollWatcher>),
}
impl AnyDebouncer {
fn watcher(&mut self) -> &mut dyn notify::Watcher {
match self {
Self::Recommended(d) => d.watcher(),
Self::Poll(d) => d.watcher(),
}
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
let repo_path = cli
.path
.canonicalize()
.context("Failed to resolve repository path")?;
let detected = match vcs::detect(&repo_path) {
Ok(vcs) => Some(vcs),
Err(_) => {
if cli.output.mode() != OutputMode::Tui {
anyhow::bail!("Not a git or jj repository");
}
None
}
};
if let Some(vcs) = &detected {
let mode = cli.output.mode();
if mode != OutputMode::Tui {
let repo_root = vcs.repo_path().to_path_buf();
let comparison = vcs.comparison_context()?;
let cancel_flag = Arc::new(AtomicBool::new(false));
let initial = vcs.refresh(&cancel_flag)?;
let mut app = app::App::new(repo_root, comparison, initial);
match mode {
OutputMode::Diff => {
let patch = branchdiff::patch::generate_patch(&app.lines);
print!("{}", patch);
}
OutputMode::Print | OutputMode::Html => {
app.view.view_mode = match mode {
OutputMode::Print => app::ViewMode::Full,
_ => app::ViewMode::Context,
};
let data = branchdiff::output::prepare(&mut app);
if mode == OutputMode::Html {
for file in &data.files {
for line in &file.lines {
if line.is_image_marker()
&& let Some(ref path) = line.file_path
&& !app.image_cache.contains(path)
&& let Some(state) = branchdiff::image_diff::load_image_diff(vcs.as_ref(), path)
{
app.image_cache.insert(path.clone(), state);
}
}
}
}
match mode {
OutputMode::Print => print::print_diff(&data)?,
OutputMode::Html => html::render_html(&data, &app.image_cache)?,
_ => unreachable!(),
}
}
OutputMode::Tui => unreachable!(),
}
return Ok(());
}
}
match detected {
Some(vcs) => {
let repo_root = vcs.repo_path().to_path_buf();
if let Some(frames) = cli.benchmark {
return run_benchmark(vcs, repo_root, frames);
}
run_main_app(vcs, repo_root, !cli.no_auto_fetch)
}
None => run_waiting_for_vcs(&repo_path, !cli.no_auto_fetch),
}
}
fn run_waiting_for_vcs(path: &Path, auto_fetch: bool) -> Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::layout::Alignment;
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let (watch_tx, watch_rx) = mpsc::channel();
let _watcher = {
use notify::{Watcher, RecursiveMode};
let mut watcher = notify::recommended_watcher(move |_res: notify::Result<notify::Event>| {
let _ = watch_tx.send(());
})?;
watcher.watch(path, RecursiveMode::NonRecursive)?;
watcher
};
let poll_interval = Duration::from_secs(2);
let mut last_poll = Instant::now();
let mut repo_found: Option<&str> = None; let mut last_error: Option<String> = None;
let mut retry_delay = Duration::from_millis(500);
let mut last_detect_attempt = Instant::now();
let max_retry_delay = Duration::from_secs(2);
if let Some((vcs_type, _)) = vcs::detect_repo_dir(path) {
repo_found = Some(vcs_type);
}
loop {
let display_msg = match (&repo_found, &last_error) {
(None, _) => "Not a repository.\n\nWaiting for git init or jj init...".to_string(),
(Some(vcs_type), None) => format!("Repository found (.{vcs_type} detected)\n\nInitializing..."),
(Some(vcs_type), Some(err)) => format!("Repository found (.{vcs_type} detected)\n\nInitializing...\n\n{err}"),
};
terminal.draw(|f| {
let area = f.area();
let message = Paragraph::new(display_msg.as_str())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
let y = area.height / 2;
let line_count: u16 = display_msg.lines().count().try_into().unwrap_or(4);
let box_height = (line_count + 2).min(area.height);
let centered_area = ratatui::layout::Rect {
x: 0,
y: y.saturating_sub(line_count / 2),
width: area.width,
height: box_height,
};
f.render_widget(message, centered_area);
})?;
if event::poll(Duration::from_millis(100))?
&& let crossterm::event::Event::Key(KeyEvent { code, modifiers, .. }) = event::read()?
{
match (code, modifiers) {
(KeyCode::Char('q'), _)
| (KeyCode::Char('c'), KeyModifiers::CONTROL)
| (KeyCode::Esc, _) => {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
return Ok(());
}
_ => {}
}
}
let mut watcher_fired = false;
while watch_rx.try_recv().is_ok() {
watcher_fired = true;
}
if repo_found.is_none()
&& (watcher_fired || last_poll.elapsed() >= poll_interval)
{
last_poll = Instant::now();
if let Some((vcs_type, _)) = vcs::detect_repo_dir(path) {
repo_found = Some(vcs_type);
last_detect_attempt = Instant::now() - retry_delay; }
}
if repo_found.is_some()
&& last_detect_attempt.elapsed() >= retry_delay
{
last_detect_attempt = Instant::now();
match vcs::detect(path) {
Ok(detected) => {
let repo_root = detected.repo_path().to_path_buf();
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
return run_main_app(detected, repo_root, auto_fetch);
}
Err(e) => {
last_error = Some(format!("{e:#}"));
retry_delay = (retry_delay * 2).min(max_retry_delay);
}
}
}
}
}
fn run_main_app(
mut detected: Box<dyn Vcs>,
mut repo_root: PathBuf,
auto_fetch: bool,
) -> Result<()> {
let in_multiplexer = std::env::var("ZELLIJ").is_ok()
|| std::env::var("TMUX").is_ok()
|| std::env::var("STY").is_ok();
let mut image_picker = if in_multiplexer {
ratatui_image::picker::Picker::halfblocks()
} else {
ratatui_image::picker::Picker::from_query_stdio()
.unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks())
};
image_picker.set_background_color(image::Rgba([30, 30, 30, 255]));
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let watch_limit = limits::get_watch_limit();
loop {
let vcs: Arc<dyn Vcs> = Arc::from(detected);
let comparison = vcs.comparison_context().unwrap_or_else(|_| ComparisonContext {
from_label: "base".to_string(),
to_label: "working copy".to_string(),
stack_position: None,
vcs_backend: vcs.backend(),
bookmark_name: None,
divergence: None,
});
let cancel_flag = Arc::new(AtomicBool::new(false));
let initial = vcs.refresh(&cancel_flag)?;
let mut app = App::new(repo_root.clone(), comparison, initial);
app.load_images_for_markers(&*vcs);
app.set_image_picker(image_picker.clone());
let (file_tx, file_rx) = mpsc::channel();
let debouncer_config = DebouncerConfig::default()
.with_timeout(Duration::from_millis(100))
.with_notify_config(
notify::Config::default().with_poll_interval(Duration::from_millis(500)),
);
let mut debouncer = if limits::is_wsl() {
AnyDebouncer::Poll(new_debouncer_opt::<_, PollWatcher>(debouncer_config, file_tx)?)
} else {
AnyDebouncer::Recommended(new_debouncer_opt::<_, RecommendedWatcher>(
debouncer_config,
file_tx,
)?)
};
let watcher_metrics = setup_watcher(debouncer.watcher(), &*vcs, watch_limit)?;
let needs_fallback_refresh =
limits::check_watch_warning(&watcher_metrics, watch_limit).is_some();
if needs_fallback_refresh {
app.performance_warning = Some(format!(
"Large repo: refreshing every {}s",
FALLBACK_REFRESH_SECS
));
}
let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshOutcome>();
let config = UpdateConfig {
auto_fetch,
needs_fallback_refresh,
repo_path: repo_root.clone(),
..Default::default()
};
let loop_action = run_app(
&mut terminal,
&mut app,
debouncer.watcher(),
file_rx,
refresh_tx,
refresh_rx,
vcs,
config,
watch_limit,
)?;
match loop_action {
LoopAction::Quit => break,
LoopAction::RestartVcs => {
match vcs::detect(&repo_root) {
Ok(new_vcs) => {
repo_root = new_vcs.repo_path().to_path_buf();
detected = new_vcs;
continue;
}
Err(_) => break,
}
}
LoopAction::Continue => unreachable!("run_app should not return Continue"),
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
fn run_benchmark(detected: Box<dyn Vcs>, repo_root: PathBuf, frames: usize) -> Result<()> {
use ratatui::backend::TestBackend;
eprintln!("Loading diff from {}...", repo_root.display());
let load_start = Instant::now();
let comparison = detected.comparison_context()?;
let cancel_flag = Arc::new(AtomicBool::new(false));
let initial = detected.refresh(&cancel_flag)?;
let mut app = App::new(repo_root, comparison, initial);
let load_time = load_start.elapsed();
eprintln!(
"Loaded {} lines across {} files in {:?}",
app.lines.len(),
app.files.len(),
load_time
);
if app.lines.is_empty() {
eprintln!("No changes to benchmark. Try running in a repo with uncommitted changes.");
return Ok(());
}
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend)?;
let visible_height = 40_usize;
app.set_viewport_height(visible_height);
app.view.collapsed_files.clear();
eprintln!("Running {} frames...", frames);
let bench_start = Instant::now();
let ctx = FrameContext::new(&app);
let max_scroll = ctx.max_scroll(&app);
for frame_num in 0..frames {
let action = match frame_num % 20 {
0..=4 => AppAction::ScrollDown(3),
5..=9 => AppAction::ScrollUp(2),
10 => AppAction::NextFile,
11 => AppAction::PrevFile,
12 => AppAction::CycleViewMode,
13 => AppAction::GoToBottom,
14 => AppAction::GoToTop,
15 => AppAction::PageDown,
16 => AppAction::PageUp,
_ => AppAction::ScrollDown(1),
};
match action {
AppAction::ScrollDown(n) => {
let new_offset = (app.view.scroll_offset + n).min(max_scroll);
app.view.scroll_offset = new_offset;
}
AppAction::ScrollUp(n) => {
app.view.scroll_offset = app.view.scroll_offset.saturating_sub(n);
}
AppAction::NextFile => app.next_file(),
AppAction::PrevFile => app.prev_file(),
AppAction::CycleViewMode => app.cycle_view_mode(),
AppAction::GoToBottom => app.go_to_bottom(),
AppAction::GoToTop => app.go_to_top(),
AppAction::PageDown => app.page_down(),
AppAction::PageUp => app.page_up(),
_ => {}
}
let items = if app.needs_inline_spans() {
let items = app.ensure_inline_spans_for_visible(visible_height);
app.clear_needs_inline_spans();
Some(items)
} else {
None
};
terminal.draw(|f| {
let frame_ctx = match items {
Some(items) => FrameContext::with_items(items, &app),
None => FrameContext::new(&app),
};
ui::draw_with_frame(f, &mut app, &frame_ctx)
})?;
}
let bench_time = bench_start.elapsed();
let avg_frame = bench_time.as_micros() as f64 / frames as f64;
eprintln!("\nResults:");
eprintln!(" Total time: {:?}", bench_time);
eprintln!(" Frames: {}", frames);
eprintln!(" Avg frame: {:.1} µs", avg_frame);
eprintln!(" Throughput: {:.0} fps", 1_000_000.0 / avg_frame);
Ok(())
}
fn spawn_single_file_refresh(
vcs: Arc<dyn Vcs>,
file_path: String,
refresh_tx: mpsc::Sender<RefreshOutcome>,
) {
thread::spawn(move || {
let diff = vcs.single_file_diff(&file_path);
let revision_id = vcs.current_revision_id().ok();
let _ = refresh_tx.send(RefreshOutcome::SingleFile { path: file_path, diff, revision_id });
});
}
fn spawn_refresh(
vcs: Arc<dyn Vcs>,
refresh_tx: mpsc::Sender<RefreshOutcome>,
cancel_flag: Arc<AtomicBool>,
) {
thread::spawn(move || {
match vcs.refresh(&cancel_flag) {
Ok(mut result) => {
result.revision_id = vcs.current_revision_id().ok();
let _ = refresh_tx.send(RefreshOutcome::success(result));
}
Err(e) => {
let outcome = if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
RefreshOutcome::Cancelled
} else {
RefreshOutcome::Error(format!("{e:#}"))
};
let _ = refresh_tx.send(outcome);
}
}
});
}
fn spawn_fetch(vcs: Arc<dyn Vcs>, fetch_tx: mpsc::Sender<FetchResult>) {
thread::spawn(move || {
if vcs.fetch().is_ok() {
let has_conflicts = vcs.has_conflicts().unwrap_or(false);
let new_merge_base = vcs.base_identifier().ok();
let _ = fetch_tx.send(FetchResult {
has_conflicts,
new_merge_base,
});
}
});
}
#[allow(unused_variables)] fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
watcher: &mut (impl notify::Watcher + ?Sized),
file_events: mpsc::Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
refresh_tx: mpsc::Sender<RefreshOutcome>,
refresh_rx: mpsc::Receiver<RefreshOutcome>,
vcs: Arc<dyn Vcs>,
config: UpdateConfig,
watch_limit: Option<usize>,
) -> Result<LoopAction>
where
B::Error: Send + Sync + 'static,
{
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::new(config.repo_path.join(".jj").is_dir());
let (fetch_tx, fetch_rx) = mpsc::channel::<FetchResult>();
let terminal_size = terminal.size()?;
let status_height = ui::status_bar_height(app, terminal_size.width);
let content_height = (terminal_size.height - status_height).saturating_sub(2) as usize;
app.set_viewport_height(content_height);
app.estimate_content_width(terminal_size.width);
let items = app.ensure_inline_spans_for_visible(content_height);
app.clear_needs_inline_spans();
terminal.draw(|f| {
let frame_ctx = FrameContext::with_items(items, app);
ui::draw_with_frame(f, app, &frame_ctx)
})?;
loop {
let messages = collect_messages(
&file_events,
&refresh_rx,
&fetch_rx,
app.is_search_input_active(),
)?;
#[cfg(target_os = "linux")]
for msg in &messages {
if let Message::FileChanged(events) = msg {
watch_new_directories(watcher, vcs.repo_path(), events);
let gitignore_changed = events
.iter()
.any(|e| GitignoreFilter::is_gitignore_file(&e.path));
if gitignore_changed {
add_watches_for_visible_directories(watcher, vcs.repo_path(), watch_limit);
}
}
}
let mut needs_redraw = false;
for msg in messages {
let result = update(
msg,
app,
&mut refresh_state,
&mut vcs_lock,
&mut timers,
&config,
&*vcs,
);
needs_redraw |= result.needs_redraw;
if result.loop_action == LoopAction::Quit
|| result.loop_action == LoopAction::RestartVcs
{
return Ok(result.loop_action);
}
match result.refresh {
RefreshTrigger::Full => {
vcs.set_diff_base(app.diff_base);
let cancel_flag = refresh_state.start();
spawn_refresh(
vcs.clone(),
refresh_tx.clone(),
cancel_flag,
);
}
RefreshTrigger::SingleFile(file_path) => {
refresh_state.start_single_file();
spawn_single_file_refresh(
vcs.clone(),
file_path.to_string_lossy().to_string(),
refresh_tx.clone(),
);
}
RefreshTrigger::None => {}
}
if result.trigger_fetch {
spawn_fetch(vcs.clone(), fetch_tx.clone());
}
}
if needs_redraw {
let visible_height = terminal.size()?.height as usize;
let items = if app.needs_inline_spans() {
let items = app.ensure_inline_spans_for_visible(visible_height);
app.clear_needs_inline_spans();
Some(items)
} else {
None
};
terminal.draw(|f| {
let frame_ctx = match items {
Some(items) => FrameContext::with_items(items, app),
None => FrameContext::new(app),
};
ui::draw_with_frame(f, app, &frame_ctx)
})?;
}
}
}
fn collect_messages(
file_events: &mpsc::Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
refresh_rx: &mpsc::Receiver<RefreshOutcome>,
fetch_rx: &mpsc::Receiver<FetchResult>,
search_input_active: bool,
) -> Result<Vec<Message>> {
let mut messages = Vec::new();
if event::poll(Duration::from_millis(10))? {
let event = event::read()?;
if search_input_active {
messages.push(Message::SearchInput(event));
} else {
let action = handle_event(event);
if action != AppAction::None {
messages.push(Message::Input(action));
}
}
}
if let Ok(outcome) = refresh_rx.try_recv() {
messages.push(Message::RefreshCompleted(Box::new(outcome)));
}
if let Ok(Ok(events)) = file_events.try_recv()
&& !events.is_empty()
{
messages.push(Message::FileChanged(events));
}
if let Ok(result) = fetch_rx.try_recv() {
messages.push(Message::FetchCompleted(result));
}
messages.push(Message::Tick);
Ok(messages)
}
fn setup_watcher(
watcher: &mut (impl notify::Watcher + ?Sized),
vcs: &dyn Vcs,
watch_limit: Option<usize>,
) -> Result<limits::WatcherMetrics> {
setup_vcs_watches(watcher, vcs)?;
let repo_root = vcs.repo_path();
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
let _ = watch_limit; watcher.watch(repo_root, Recursive)?;
Ok(limits::WatcherMetrics::default())
}
#[cfg(target_os = "linux")]
{
setup_linux_watches(watcher, repo_root, watch_limit)
}
#[cfg(all(unix, not(target_os = "macos"), not(target_os = "linux")))]
{
let _ = watch_limit;
watcher.watch(repo_root, Recursive)?;
Ok(limits::WatcherMetrics::default())
}
}
fn setup_vcs_watches(
watcher: &mut (impl notify::Watcher + ?Sized),
vcs: &dyn Vcs,
) -> Result<()> {
let watch_paths = vcs.watch_paths();
for file in &watch_paths.files {
if file.exists() {
watcher.watch(file, NonRecursive)?;
}
}
for dir in &watch_paths.recursive_dirs {
if dir.exists() {
watcher.watch(dir, Recursive)?;
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn setup_linux_watches(
watcher: &mut (impl notify::Watcher + ?Sized),
repo_root: &Path,
watch_limit: Option<usize>,
) -> Result<limits::WatcherMetrics> {
let mut metrics = limits::WatcherMetrics::default();
let mut watches_added = 0;
let limit = watch_limit.unwrap_or(usize::MAX);
for entry in WalkBuilder::new(repo_root)
.hidden(false) .git_ignore(true)
.git_global(true)
.git_exclude(true)
.filter_entry(|e| {
e.file_name() != ".git"
})
.build()
.flatten()
{
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
metrics.directory_count += 1;
if watches_added >= limit {
metrics.skipped_count += 1;
continue;
}
if watcher.watch(entry.path(), NonRecursive).is_ok() {
watches_added += 1;
} else {
metrics.skipped_count += 1;
}
}
}
Ok(metrics)
}
#[cfg(target_os = "linux")]
fn watch_new_directories(
watcher: &mut (impl notify::Watcher + ?Sized),
repo_root: &Path,
events: &[notify_debouncer_mini::DebouncedEvent],
) {
for event in events {
let path = &event.path;
if !path.is_dir() {
continue;
}
if !path.starts_with(repo_root) {
continue;
}
if let Ok(relative) = path.strip_prefix(repo_root)
&& relative.components().any(|c| c.as_os_str() == ".git")
{
continue;
}
if is_directory_watchable(path) {
let _ = watcher.watch(path, NonRecursive);
}
}
}
#[cfg(target_os = "linux")]
fn is_directory_watchable(dir_path: &Path) -> bool {
let parent = match dir_path.parent() {
Some(p) => p,
None => return false,
};
WalkBuilder::new(parent)
.max_depth(Some(1))
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build()
.flatten()
.any(|entry| entry.path() == dir_path)
}
#[cfg(target_os = "linux")]
fn add_watches_for_visible_directories(
watcher: &mut (impl notify::Watcher + ?Sized),
repo_root: &Path,
watch_limit: Option<usize>,
) {
let limit = watch_limit.unwrap_or(usize::MAX);
let mut watches_added = 0;
for entry in WalkBuilder::new(repo_root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.filter_entry(|e| e.file_name() != ".git")
.build()
.flatten()
{
if watches_added >= limit {
break;
}
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if watcher.watch(entry.path(), NonRecursive).is_ok() {
watches_added += 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use tempfile::TempDir;
#[test]
#[cfg(target_os = "linux")]
fn test_setup_linux_watches_respects_limit() {
use std::fs;
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
fs::create_dir(repo_root.join(".git")).unwrap();
for i in 0..10 {
fs::create_dir(repo_root.join(format!("dir{}", i))).unwrap();
}
let (tx, _rx) = mpsc::channel();
let config = DebouncerConfig::default()
.with_timeout(Duration::from_millis(100))
.with_notify_config(
notify::Config::default().with_poll_interval(Duration::from_millis(500)),
);
let mut debouncer =
AnyDebouncer::Recommended(new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap());
let metrics = setup_linux_watches(debouncer.watcher(), repo_root, Some(5)).unwrap();
assert!(metrics.directory_count >= 10);
assert!(metrics.skipped_count >= 5, "Expected at least 5 skipped, got {}", metrics.skipped_count);
}
#[test]
#[cfg(target_os = "linux")]
fn test_add_watches_for_visible_directories_respects_limit() {
use std::fs;
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
fs::create_dir(repo_root.join(".git")).unwrap();
for i in 0..10 {
fs::create_dir(repo_root.join(format!("dir{}", i))).unwrap();
}
let (tx, _rx) = mpsc::channel();
let config = DebouncerConfig::default()
.with_timeout(Duration::from_millis(100))
.with_notify_config(
notify::Config::default().with_poll_interval(Duration::from_millis(500)),
);
let mut debouncer =
AnyDebouncer::Recommended(new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap());
add_watches_for_visible_directories(debouncer.watcher(), repo_root, Some(3));
}
#[test]
#[cfg(target_os = "linux")]
fn test_add_watches_for_visible_directories_no_limit() {
use std::fs;
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
fs::create_dir(repo_root.join(".git")).unwrap();
for i in 0..5 {
fs::create_dir(repo_root.join(format!("dir{}", i))).unwrap();
}
let (tx, _rx) = mpsc::channel();
let config = DebouncerConfig::default()
.with_timeout(Duration::from_millis(100))
.with_notify_config(
notify::Config::default().with_poll_interval(Duration::from_millis(500)),
);
let mut debouncer =
AnyDebouncer::Recommended(new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap());
add_watches_for_visible_directories(debouncer.watcher(), repo_root, None);
}
#[test]
fn test_any_debouncer_poll_variant_creates_working_watcher() {
let (tx, _rx) = mpsc::channel();
let temp_dir = TempDir::new().unwrap();
let config = DebouncerConfig::default()
.with_timeout(Duration::from_millis(100))
.with_notify_config(
notify::Config::default().with_poll_interval(Duration::from_millis(500)),
);
let mut debouncer =
AnyDebouncer::Poll(new_debouncer_opt::<_, PollWatcher>(config, tx).unwrap());
let result = debouncer.watcher().watch(temp_dir.path(), NonRecursive);
assert!(result.is_ok());
}
#[test]
fn test_any_debouncer_recommended_variant_creates_working_watcher() {
let (tx, _rx) = mpsc::channel();
let temp_dir = TempDir::new().unwrap();
let config = DebouncerConfig::default()
.with_timeout(Duration::from_millis(100))
.with_notify_config(
notify::Config::default().with_poll_interval(Duration::from_millis(500)),
);
let mut debouncer = AnyDebouncer::Recommended(
new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap(),
);
let result = debouncer.watcher().watch(temp_dir.path(), NonRecursive);
assert!(result.is_ok());
}
}