use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use notify_debouncer_mini::{DebouncedEvent, DebouncedEventKind};
use crate::app::App;
use crate::file_events::VcsLockState;
use crate::gitignore::GitignoreFilter;
use crate::message::{RefreshTrigger, UpdateResult};
use crate::vcs::{Vcs, VcsEventType};
use super::{RefreshState, Timers};
const POST_REFRESH_COOLDOWN_MS: u64 = 300;
fn is_noisy_path(path_str: &str) -> bool {
path_str.contains("/tmp/")
|| path_str.contains("/node_modules/")
|| path_str.contains("/vendor/bundle/")
|| path_str.contains("/.bundle/")
|| path_str.contains("/log/")
|| path_str.ends_with(".lock")
}
fn is_vcs_path(path: &Path, repo_root: &Path) -> bool {
let relative = path.strip_prefix(repo_root).unwrap_or(path);
relative
.components()
.next()
.is_some_and(|c| c.as_os_str() == ".jj" || c.as_os_str() == ".git")
}
pub(super) fn handle_file_change(
events: Vec<DebouncedEvent>,
app: &mut App,
refresh_state: &mut RefreshState,
vcs_lock: &mut VcsLockState,
timers: &mut Timers,
vcs: &dyn Vcs,
) -> UpdateResult {
let mut result = UpdateResult::default();
let unique_paths: HashSet<_> = events
.iter()
.filter(|e| e.kind == DebouncedEventKind::Any)
.map(|e| &e.path)
.collect();
let has_lock_event = unique_paths
.iter()
.any(|p| vcs.classify_event(p) == VcsEventType::Lock);
if has_lock_event {
let currently_locked = vcs.is_locked();
let was_locked = vcs_lock.is_locked();
if was_locked && !currently_locked && vcs_lock.take_pending() {
vcs_lock.set_locked(false);
result.refresh = RefreshTrigger::Full;
return result;
}
vcs_lock.set_locked(currently_locked);
}
let gitignore_changed = unique_paths
.iter()
.any(|p| GitignoreFilter::is_gitignore_file(p));
if gitignore_changed {
app.gitignore_filter.rebuild();
}
let repo_root = vcs.repo_path();
let filtered_paths: Vec<_> = unique_paths
.into_iter()
.filter(|p| {
let relative = p.strip_prefix(repo_root).unwrap_or(p);
!is_noisy_path(&relative.to_string_lossy())
})
.filter(|p| is_vcs_path(p, repo_root) || !app.gitignore_filter.is_ignored(p))
.collect();
let mut should_refresh = false;
let mut has_vcs_change = false;
let mut source_files = Vec::new();
for path in &filtered_paths {
match vcs.classify_event(path) {
VcsEventType::RevisionChange => {
should_refresh = true;
has_vcs_change = true;
}
VcsEventType::Internal => {
should_refresh = true;
}
VcsEventType::Lock => {
}
VcsEventType::Source => {
should_refresh = true;
source_files.push(path);
}
}
}
if should_refresh {
if vcs_lock.is_locked() {
vcs_lock.set_pending();
return result;
}
let has_only_vcs_changes = source_files.is_empty();
if has_only_vcs_changes {
if has_vcs_change {
if !refresh_state.is_idle() {
return result;
}
if let Some(completed) = timers.last_refresh_completed
&& completed.elapsed() < Duration::from_millis(POST_REFRESH_COOLDOWN_MS)
{
return result;
}
result.refresh = RefreshTrigger::Full;
return result;
}
if !refresh_state.is_idle() {
return result;
}
if let Some(completed) = timers.last_refresh_completed
&& completed.elapsed() < Duration::from_millis(POST_REFRESH_COOLDOWN_MS)
{
return result;
}
timers.pending_vcs_event = Some(Instant::now());
return result;
}
let had_pending_vcs = timers.pending_vcs_event.take().is_some();
if !refresh_state.is_idle() {
refresh_state.mark_pending();
} else {
let can_use_single_file =
!has_vcs_change && !had_pending_vcs && source_files.len() == 1 && !app.files.is_empty();
if can_use_single_file {
let file_path = source_files[0]
.strip_prefix(vcs.repo_path())
.unwrap_or(source_files[0])
.to_string_lossy()
.to_string();
result.refresh = RefreshTrigger::SingleFile(PathBuf::from(file_path));
} else {
result.refresh = RefreshTrigger::Full;
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use crate::test_support::{StubVcs, TestAppBuilder};
#[test]
fn test_is_noisy_path() {
assert!(is_noisy_path("/tmp/file.txt"));
assert!(is_noisy_path("/project/node_modules/pkg/file.js"));
assert!(is_noisy_path("/project/file.lock"));
assert!(!is_noisy_path("/project/src/main.rs"));
}
#[test]
fn test_duplicate_file_events_are_deduplicated() {
let events = vec![
DebouncedEvent::new(PathBuf::from("/repo/src/main.rs"), DebouncedEventKind::Any),
DebouncedEvent::new(PathBuf::from("/repo/src/main.rs"), DebouncedEventKind::Any),
DebouncedEvent::new(PathBuf::from("/repo/src/lib.rs"), DebouncedEventKind::Any),
DebouncedEvent::new(PathBuf::from("/repo/src/main.rs"), DebouncedEventKind::Any),
DebouncedEvent::new(PathBuf::from("/repo/src/lib.rs"), DebouncedEventKind::Any),
];
let unique_paths: HashSet<_> = events
.iter()
.filter(|e| e.kind == DebouncedEventKind::Any)
.map(|e| &e.path)
.collect();
assert_eq!(unique_paths.len(), 2);
assert!(unique_paths.contains(&PathBuf::from("/repo/src/main.rs")));
assert!(unique_paths.contains(&PathBuf::from("/repo/src/lib.rs")));
}
#[test]
fn test_handle_file_change_no_redraw() {
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(PathBuf::from("/repo"));
let events = vec![DebouncedEvent::new(
PathBuf::from("/repo/src/main.rs"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert!(!result.needs_redraw);
}
#[test]
fn test_handle_file_change_skips_refresh_when_locked() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(git_dir.join("index.lock"), "").unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let lock_events = vec![DebouncedEvent::new(
git_dir.join("index.lock"),
DebouncedEventKind::Any,
)];
let _ = handle_file_change(lock_events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert!(vcs_lock.is_locked());
let events = vec![DebouncedEvent::new(
temp.path().join("src/main.rs"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert_eq!(result.refresh, RefreshTrigger::None);
}
#[test]
fn test_handle_file_change_triggers_refresh_on_unlock() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let lock_path = git_dir.join("index.lock");
std::fs::write(&lock_path, "").unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let lock_events = vec![DebouncedEvent::new(
lock_path.clone(),
DebouncedEventKind::Any,
)];
let _ = handle_file_change(lock_events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert!(vcs_lock.is_locked());
let source_events = vec![DebouncedEvent::new(
temp.path().join("src/main.rs"),
DebouncedEventKind::Any,
)];
let _ = handle_file_change(source_events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
std::fs::remove_file(&lock_path).unwrap();
let unlock_events = vec![DebouncedEvent::new(
lock_path,
DebouncedEventKind::Any,
)];
let result = handle_file_change(unlock_events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert!(!vcs_lock.is_locked());
assert_eq!(result.refresh, RefreshTrigger::Full);
}
#[test]
fn test_vcs_events_deferred_for_differentiated_debouncing() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
git_dir.join("index"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert_eq!(result.refresh, RefreshTrigger::None);
assert!(timers.pending_vcs_event.is_some());
}
#[test]
fn test_source_file_events_trigger_immediate_refresh() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join(".git")).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
temp.path().join("src/main.rs"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert_eq!(result.refresh, RefreshTrigger::Full);
}
#[test]
fn test_mixed_vcs_and_source_events_trigger_immediate_refresh() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![
DebouncedEvent::new(git_dir.join("index"), DebouncedEventKind::Any),
DebouncedEvent::new(temp.path().join("src/main.rs"), DebouncedEventKind::Any),
];
let result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert_eq!(result.refresh, RefreshTrigger::Full);
assert!(timers.pending_vcs_event.is_none());
}
#[test]
fn test_source_file_event_clears_stale_pending_vcs_timer() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join(".git")).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::InProgress {
cancel_flag: Arc::new(AtomicBool::new(false)),
started_at: Instant::now(),
};
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.pending_vcs_event = Some(Instant::now() - Duration::from_millis(300));
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![
DebouncedEvent::new(temp.path().join("src/main.rs"), DebouncedEventKind::Any),
];
let _result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert!(
timers.pending_vcs_event.is_none(),
"source file event should clear stale pending_vcs_event"
);
}
#[test]
fn test_revision_change_during_refresh_marks_pending_without_cancel() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let cancel_flag = Arc::new(AtomicBool::new(false));
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::InProgress {
cancel_flag: cancel_flag.clone(),
started_at: Instant::now(),
};
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![
DebouncedEvent::new(git_dir.join("HEAD"), DebouncedEventKind::Any),
DebouncedEvent::new(temp.path().join("src/main.rs"), DebouncedEventKind::Any),
];
let _result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert!(matches!(refresh_state, RefreshState::InProgressPending { .. }));
assert!(
!cancel_flag.load(Ordering::Relaxed),
"cancel flag should not be set during active refresh"
);
}
#[test]
fn test_revision_change_only_triggers_immediate_full() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.last_refresh_completed = Some(Instant::now() - Duration::from_secs(5));
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![
DebouncedEvent::new(git_dir.join("HEAD"), DebouncedEventKind::Any),
];
let result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert_eq!(result.refresh, RefreshTrigger::Full);
assert!(timers.pending_vcs_event.is_none());
}
#[test]
fn test_vcs_only_event_during_in_progress_refresh_does_not_cancel() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let cancel_flag = Arc::new(AtomicBool::new(false));
let mut refresh_state = RefreshState::InProgress {
cancel_flag: cancel_flag.clone(),
started_at: Instant::now(),
};
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![
DebouncedEvent::new(git_dir.join("index"), DebouncedEventKind::Any),
];
let result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert_eq!(result.refresh, RefreshTrigger::None);
assert!(timers.pending_vcs_event.is_none());
assert!(!cancel_flag.load(Ordering::Relaxed), "cancel flag should not be set");
assert!(
matches!(refresh_state, RefreshState::InProgress { .. }),
"should remain InProgress, not InProgressPending"
);
}
#[test]
fn test_vcs_only_events_ignored_during_active_refresh() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::InProgress {
cancel_flag: Arc::new(AtomicBool::new(false)),
started_at: Instant::now(),
};
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
git_dir.join("index"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs);
assert_eq!(result.refresh, RefreshTrigger::None);
assert!(
timers.pending_vcs_event.is_none(),
"VCS-only events during active refresh should be suppressed entirely"
);
}
#[test]
fn test_vcs_event_suppressed_during_post_refresh_cooldown() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.last_refresh_completed = Some(Instant::now() - Duration::from_millis(100));
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
git_dir.join("index"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(result.refresh, RefreshTrigger::None);
assert!(
timers.pending_vcs_event.is_none(),
"VCS event during cooldown should be suppressed"
);
}
#[test]
fn test_vcs_event_accepted_after_cooldown_expires() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.last_refresh_completed = Some(Instant::now() - Duration::from_millis(500));
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
git_dir.join("index"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(result.refresh, RefreshTrigger::None);
assert!(
timers.pending_vcs_event.is_some(),
"VCS event after cooldown should be accepted as pending"
);
}
#[test]
fn test_revision_change_alone_triggers_immediate_full_refresh() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.last_refresh_completed = Some(Instant::now() - Duration::from_secs(5));
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
git_dir.join("HEAD"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(result.refresh, RefreshTrigger::Full);
assert!(timers.pending_vcs_event.is_none());
}
#[test]
fn test_revision_change_during_refresh_suppressed() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let cancel_flag = Arc::new(AtomicBool::new(false));
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::InProgress {
cancel_flag: cancel_flag.clone(),
started_at: Instant::now(),
};
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
git_dir.join("HEAD"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(result.refresh, RefreshTrigger::None);
assert!(
matches!(refresh_state, RefreshState::InProgress { .. }),
"should remain InProgress — VCS events during refresh are suppressed"
);
assert!(
!cancel_flag.load(Ordering::Relaxed),
"cancel flag should not be set"
);
}
#[test]
fn test_revision_change_within_cooldown_suppressed() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.last_refresh_completed = Some(Instant::now() - Duration::from_millis(100));
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
git_dir.join("HEAD"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(result.refresh, RefreshTrigger::None);
}
#[test]
fn test_source_events_with_pending_vcs_force_full_refresh() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let dummy = crate::diff::FileDiff::new(vec![crate::diff::DiffLine::file_header("src/lib.rs")]);
let mut app = TestAppBuilder::new().with_files(vec![dummy]).build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.pending_vcs_event = Some(Instant::now());
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
temp.path().join("src/main.rs"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(result.refresh, RefreshTrigger::Full);
assert!(timers.pending_vcs_event.is_none());
}
#[test]
fn test_single_source_event_without_pending_vcs_uses_single_file() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let dummy = crate::diff::FileDiff::new(vec![crate::diff::DiffLine::file_header("src/lib.rs")]);
let mut app = TestAppBuilder::new().with_files(vec![dummy]).build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
temp.path().join("src/main.rs"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert!(
matches!(result.refresh, RefreshTrigger::SingleFile(_)),
"single source event without pending VCS should use SingleFile, got {:?}",
result.refresh,
);
}
#[test]
fn test_internal_only_events_still_delayed() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.last_refresh_completed = Some(Instant::now() - Duration::from_secs(5));
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
git_dir.join("index"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(result.refresh, RefreshTrigger::None);
assert!(
timers.pending_vcs_event.is_some(),
"Internal-only events should be delayed via pending_vcs_event"
);
}
#[test]
fn test_vcs_paths_bypass_gitignore_filter() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let jj_dir = temp.path().join(".jj");
std::fs::create_dir_all(jj_dir.join("working_copy")).unwrap();
std::fs::create_dir_all(temp.path().join(".git")).unwrap();
std::fs::create_dir_all(temp.path().join(".git/info")).unwrap();
std::fs::write(temp.path().join(".gitignore"), ".jj/\n").unwrap();
let mut app = TestAppBuilder::new().build();
app.gitignore_filter = crate::gitignore::GitignoreFilter::new(temp.path());
let mut refresh_state = RefreshState::Idle;
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
timers.last_refresh_completed = Some(Instant::now() - Duration::from_secs(5));
let vcs = StubVcs::new(temp.path().to_path_buf());
let events = vec![DebouncedEvent::new(
temp.path().join(".git/HEAD"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(
result.refresh,
RefreshTrigger::Full,
".git/HEAD should bypass gitignore filter and trigger refresh"
);
}
#[test]
fn test_single_file_cooldown_suppresses_post_completion_vcs_event() {
use tempfile::TempDir;
use crate::message::RefreshOutcome;
use crate::update::refresh::handle_refresh;
use crate::update::UpdateConfig;
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut app = TestAppBuilder::new().build();
let mut refresh_state = RefreshState::InProgress {
cancel_flag: Arc::new(AtomicBool::new(false)),
started_at: Instant::now(),
};
let mut vcs_lock = VcsLockState::default();
let mut timers = Timers::default();
let vcs = StubVcs::new(temp.path().to_path_buf());
let config = UpdateConfig::default();
let outcome = RefreshOutcome::SingleFile {
path: "src/main.rs".to_string(),
diff: None,
revision_id: None,
};
let refresh_result = handle_refresh(
outcome, &mut app, &mut refresh_state, &mut timers, &config, &vcs,
);
assert_eq!(refresh_result.refresh, RefreshTrigger::None);
assert!(timers.last_refresh_completed.is_some());
let events = vec![DebouncedEvent::new(
git_dir.join("HEAD"),
DebouncedEventKind::Any,
)];
let result = handle_file_change(
events, &mut app, &mut refresh_state, &mut vcs_lock, &mut timers, &vcs,
);
assert_eq!(
result.refresh,
RefreshTrigger::None,
"VCS event within cooldown after SingleFile should be suppressed"
);
}
}