Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
139a751
Refresh the watcher's ignore rules when a `.ignore` file changes
shengyfu Aug 27, 2026
64ec4c3
Stop the watcher from subscribing to ignored directories
shengyfu Aug 27, 2026
31825bb
Subscribe to new subtrees additively instead of rebuilding the set
shengyfu Aug 27, 2026
ecec6e8
Bump the workspace version to 1.0.3
shengyfu Aug 27, 2026
6ddba29
Close three watcher races found in review
shengyfu Aug 27, 2026
2907e1b
Re-subscribe to a directory that is removed and recreated
shengyfu Aug 27, 2026
329ec66
Address review round 3: recovery scans, event gating, eligibility
shengyfu Aug 27, 2026
371bc77
Close five watcher correctness gaps found in review
shengyfu Aug 27, 2026
700e64e
Close the remaining watcher recovery and symlink gaps
shengyfu Aug 28, 2026
d564fe5
Format the round 5 tests
shengyfu Aug 28, 2026
f9b9579
Contain the read to the served root, and serialize reindexing
shengyfu Aug 28, 2026
bf94c68
Keep the watcher's recovery honest about what it can and cannot see
shengyfu Aug 28, 2026
f8bedb0
Take the slice by reference in the new recovery test
shengyfu Aug 28, 2026
e284cab
Stop concluding absence from an incomplete listing, and from a path t…
shengyfu Aug 28, 2026
03adea4
Name the sweep test after the invariant it actually pins
shengyfu Aug 28, 2026
c88e345
Recheck sweep candidates under the reindex lock, and bound the read a…
shengyfu Aug 28, 2026
04a9af2
Name the size-gate test for the growth it actually pins
shengyfu Aug 28, 2026
1ab0504
Serialise removals with reindex, and stop missing ignore files
shengyfu Aug 28, 2026
8eebd99
Ask every directory for ignore rules before indexing any file
shengyfu Aug 28, 2026
d9bbd72
Recognise a rules file by what was read, not by its name
shengyfu Aug 28, 2026
6b34531
Sweep what can answer a search, and allow for a coarse clock
shengyfu Aug 28, 2026
80a8d6e
Compare ignore sources by their bytes, not their metadata
shengyfu Aug 28, 2026
de056e4
Backdate both writes so the restored mtime is exactly reproducible
shengyfu Aug 28, 2026
16d0081
Round 14: containment, stamp truth, and rules read under a race
shengyfu Aug 28, 2026
6afe2b0
Sweep vanished directories, order subscriptions, distrust raced stamps
shengyfu Aug 28, 2026
e585f37
Recheck a swept path under the same containment contract as indexing
shengyfu Aug 28, 2026
c07eb81
Round 17: align the watcher with the walk, and stop trusting lost events
shengyfu Aug 28, 2026
a784a01
Round 18: stop reading I/O failures, and a frozen cache, as facts
shengyfu Aug 28, 2026
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: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["tgrep-core", "tgrep-cli"]
resolver = "2"

[workspace.package]
version = "1.0.2"
version = "1.0.3"
edition = "2024"
license = "MIT"
repository = "https://github.com/microsoft/tgrep"
Expand Down
2 changes: 1 addition & 1 deletion tgrep-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ memmap2 = "0.9"
memchr = "2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_System_ProcessStatus", "Win32_System_SystemInformation", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_ProcessStatus", "Win32_System_SystemInformation", "Win32_System_Threading"] }

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand Down
8,132 changes: 6,162 additions & 1,970 deletions tgrep-cli/src/serve.rs

Large diffs are not rendered by default.

121 changes: 105 additions & 16 deletions tgrep-cli/tests/watcher_dot_ignore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,22 +101,21 @@ fn wait_for_match(port: u16, pattern: &str, timeout: Duration) -> bool {
}
}

#[test]
fn watcher_honors_dot_ignore_and_still_indexes_new_files() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let index_dir = root.join(".tgrep_test_index");

// No `.git` here: `.ignore` must apply on its own.
fs::write(root.join(".ignore"), "secret/\n").unwrap();
fs::create_dir_all(root.join("secret")).unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("src").join("lib.rs"),
"fn seeded() { let normal_source_marker = 1; }\n",
)
.unwrap();
/// Poll until `pattern` stops being searchable, returning whether it went away.
fn wait_for_no_match(port: u16, pattern: &str, timeout: Duration) -> bool {
let start = Instant::now();
loop {
if search_matches(port, pattern) == 0 {
return true;
}
if start.elapsed() > timeout {
return false;
}
thread::sleep(Duration::from_millis(100));
}
}

fn build_index(root: &Path, index_dir: &Path) {
let status = Command::new(tgrep_bin())
.args([
"index",
Expand All @@ -127,7 +126,9 @@ fn watcher_honors_dot_ignore_and_still_indexes_new_files() {
.status()
.expect("failed to run tgrep index");
assert!(status.success(), "initial index build failed");
}

fn spawn_server(root: &Path, index_dir: &Path) -> ServerGuard {
let child = Command::new(tgrep_bin())
.args([
"serve",
Expand All @@ -139,7 +140,27 @@ fn watcher_honors_dot_ignore_and_still_indexes_new_files() {
.stdout(std::process::Stdio::null())
.spawn()
.expect("failed to start tgrep serve");
let _server = ServerGuard { child };
ServerGuard { child }
}

#[test]
fn watcher_honors_dot_ignore_and_still_indexes_new_files() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let index_dir = root.join(".tgrep_test_index");

// No `.git` here: `.ignore` must apply on its own.
fs::write(root.join(".ignore"), "secret/\n").unwrap();
fs::create_dir_all(root.join("secret")).unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("src").join("lib.rs"),
"fn seeded() { let normal_source_marker = 1; }\n",
)
.unwrap();

build_index(root, &index_dir);
let _server = spawn_server(root, &index_dir);

let port = wait_for_port(&index_dir);

Expand Down Expand Up @@ -176,3 +197,71 @@ fn watcher_honors_dot_ignore_and_still_indexes_new_files() {
"watcher indexed a file under a directory excluded by .ignore"
);
}

/// A `.ignore` written *after* the server is live must refresh the ignore
/// rules, exactly as a `.gitignore` write does.
///
/// The startup matcher is built from the walk that the initial index used, so
/// a `.ignore` that already exists is honored for free — which is what the
/// test above covers. Rules that appear later only take effect if the watcher
/// recognizes the `.ignore` write as an ignore-rules change and schedules the
/// refresh; when it does not, the stale matcher stays published and the
/// already-indexed content under the newly excluded directory stays
/// searchable indefinitely (the periodic reconcile is on an hourly timer).
///
/// The fixture seeds the ignored file *before* indexing, so the assertion is a
/// transition — searchable, then not — rather than a fixed sleep racing the
/// refresh.
#[test]
fn late_dot_ignore_refreshes_the_watchers_ignore_rules() {
let dir = TempDir::new().unwrap();
let root = dir.path();
let index_dir = root.join(".tgrep_test_index");

// No `.git` here either: `.ignore` is not git-gated, so this pins the
// refresh path for the one ignore source that works outside a repo.
fs::create_dir_all(root.join("secret")).unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("secret").join("creds.txt"),
"late_ignored_leak_marker\n",
)
.unwrap();
fs::write(
root.join("src").join("lib.rs"),
"fn seeded() { let normal_source_marker = 1; }\n",
)
.unwrap();

build_index(root, &index_dir);
let _server = spawn_server(root, &index_dir);

let port = wait_for_port(&index_dir);

assert!(
wait_for_match(port, "normal_source_marker", Duration::from_secs(30)),
"expected the seeded source file to be searchable"
);
// Positive control: with no `.ignore` yet, the seeded file under `secret/`
// is legitimately indexed. Without this the assertion below could pass
// simply because the file was never indexed in the first place.
assert!(
wait_for_match(port, "late_ignored_leak_marker", Duration::from_secs(30)),
"expected the file under secret/ to be indexed before any .ignore exists"
);
thread::sleep(Duration::from_secs(2));

fs::write(root.join(".ignore"), "secret/\n").unwrap();

assert!(
wait_for_no_match(port, "late_ignored_leak_marker", Duration::from_secs(60)),
"a .ignore written while the server was live never refreshed the ignore \
rules; content under the newly excluded directory is still searchable"
);

// The refresh must not take the rest of the index with it.
assert!(
search_matches(port, "normal_source_marker") > 0,
"the ignore-rules refresh dropped a file that is not ignored"
);
}
Loading