#![cfg(stress)]
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{
FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker,
SharedFrecency,
};
use git2::{Repository, Status, StatusOptions};
use proptest::prelude::*;
use proptest::strategy::ValueTree;
use proptest::test_runner::{
Config as ProptestConfig, FileFailurePersistence, RngAlgorithm, TestRng, TestRunner,
};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tempfile::TempDir;
const CONVERGE_TIMEOUT: Duration = Duration::from_secs(15);
const CONVERGE_POLL: Duration = Duration::from_millis(50);
const PER_OP_SETTLE: Duration = Duration::from_millis(10);
fn stress_cases() -> u32 {
std::env::var("FFF_STRESS_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2)
}
fn stress_max_ops() -> usize {
std::env::var("FFF_STRESS_MAX_OPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(40)
}
fn stress_min_ops() -> usize {
std::env::var("FFF_STRESS_MIN_OPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20)
}
#[derive(Debug, Clone)]
enum AbstractOp {
CreateFile {
seed: u32,
content_seed: u32,
},
EditFile {
idx: usize,
content_seed: u32,
},
DeleteFile {
idx: usize,
},
RenameFile {
idx: usize,
new_seed: u32,
},
CreateSubdirFile {
dir_seed: u16,
file_seed: u16,
content_seed: u32,
},
GitignoreAppend {
pattern_seed: u16,
},
GitAddAll,
GitCommit {
msg_seed: u16,
},
GitResetHard,
GitStashThenPop,
Touch {
idx: usize,
}, Noop,
}
fn op_strategy() -> impl Strategy<Value = AbstractOp> {
prop_oneof![
40 => (any::<usize>(), any::<u32>()).prop_map(|(i, c)| AbstractOp::EditFile {
idx: i, content_seed: c,
}),
5 => any::<usize>().prop_map(|i| AbstractOp::Touch { idx: i }),
8 => (any::<u32>(), any::<u32>()).prop_map(|(a, b)| AbstractOp::CreateFile {
seed: a, content_seed: b,
}),
3 => (any::<u16>(), any::<u16>(), any::<u32>()).prop_map(
|(d, f, c)| AbstractOp::CreateSubdirFile {
dir_seed: d, file_seed: f, content_seed: c,
}
),
4 => any::<usize>().prop_map(|i| AbstractOp::DeleteFile { idx: i }),
3 => (any::<usize>(), any::<u32>()).prop_map(|(i, s)| AbstractOp::RenameFile {
idx: i, new_seed: s,
}),
1 => Just(AbstractOp::GitAddAll),
1 => any::<u16>().prop_map(|m| AbstractOp::GitCommit { msg_seed: m }),
1 => Just(AbstractOp::GitStashThenPop),
1 => Just(AbstractOp::GitResetHard),
1 => any::<u16>().prop_map(|p| AbstractOp::GitignoreAppend { pattern_seed: p }),
2 => Just(AbstractOp::Noop),
]
}
fn ops_strategy() -> impl Strategy<Value = Vec<AbstractOp>> {
let min = stress_min_ops();
let max = stress_max_ops();
prop::collection::vec(op_strategy(), min..=max)
}
const DEFAULT_STRESS_SEED: u64 = 0xDEAD_BEEF_CAFE_BABE;
fn proptest_config() -> ProptestConfig {
ProptestConfig {
cases: stress_cases(),
max_shrink_iters: 16,
fork: false,
failure_persistence: Some(Box::new(FileFailurePersistence::Direct(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fuzz_git_watcher_stress.proptest-regressions",
)))),
..ProptestConfig::default()
}
}
proptest! {
#![proptest_config(proptest_config())]
#[test]
fn stress_random(ops in ops_strategy()) {
run_stress_scenario(&ops);
}
}
#[test]
fn stress_seeded() {
let seed = parse_stress_seed();
let seed_bytes = expand_u64_seed(seed);
eprintln!("stress_seeded: using deterministic seed {seed:#018x}");
let mut config = proptest_config();
config.failure_persistence = Some(Box::new(FileFailurePersistence::Off));
let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &seed_bytes);
let mut runner = TestRunner::new_with_rng(config, rng);
let strategy = ops_strategy();
for case_idx in 0..runner.config().cases {
let tree = strategy
.new_tree(&mut runner)
.expect("ops_strategy::new_tree");
let ops = tree.current();
eprintln!(
" case {}/{}: {} ops",
case_idx + 1,
runner.config().cases,
ops.len()
);
run_stress_scenario(&ops);
}
}
fn parse_stress_seed() -> u64 {
match std::env::var("FFF_STRESS_SEED") {
Ok(raw) => {
let trimmed = raw.trim();
if let Some(hex) = trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
{
u64::from_str_radix(hex, 16)
.unwrap_or_else(|e| panic!("FFF_STRESS_SEED={raw:?} is not valid hex: {e}"))
} else {
trimmed
.parse::<u64>()
.unwrap_or_else(|e| panic!("FFF_STRESS_SEED={raw:?} is not a valid u64: {e}"))
}
}
Err(_) => DEFAULT_STRESS_SEED,
}
}
fn expand_u64_seed(seed: u64) -> [u8; 32] {
let le = seed.to_le_bytes();
let mut out = [0u8; 32];
for i in 0..4 {
out[i * 8..(i + 1) * 8].copy_from_slice(&le);
}
out
}
#[derive(Debug)]
struct Live {
relative: String,
abs: PathBuf,
}
fn run_stress_scenario(ops: &[AbstractOp]) {
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new(
"warn,fff_search=debug,notify=debug,notify_debouncer_full=debug",
)
}),
)
.with_test_writer()
.try_init();
let tmp = TempDir::new().unwrap();
let base = tmp.path().canonicalize().unwrap();
seed_repo(&base);
let (shared_picker, _frecency) = start_watched_picker(&base);
wait_ready(&shared_picker);
let mut live: Vec<Live> = get_baseline_status_from_git(&base);
std::thread::sleep(Duration::from_millis(1100));
for (step, op) in ops.iter().enumerate() {
apply_op(op, &base, &mut live);
live = get_baseline_status_from_git(&base);
std::thread::sleep(PER_OP_SETTLE);
if let Err(err) = converge_git_status(&shared_picker, &base, &live) {
panic!(
"\n──────────────────────────────────────────────────────────\n\
❌ Picker git_status diverged from repository truth.\n\
──────────────────────────────────────────────────────────\n\
step: {step}\n\
op: {op:?}\n\
scenario ops:\n{trace}\n\
──────────────────────────────────────────────────────────\n\
{err}\n",
trace = format_ops_trace(ops, step),
);
}
}
}
fn format_ops_trace(ops: &[AbstractOp], up_to_and_including: usize) -> String {
let mut s = String::new();
for (i, op) in ops.iter().enumerate().take(up_to_and_including + 1) {
s.push_str(&format!(" [{i:>3}] {op:?}\n"));
}
s
}
fn seed_repo(base: &Path) {
fs::create_dir_all(base.join("src")).unwrap();
fs::write(base.join("README.md"), "# seed\n").unwrap();
fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(base.join("src/lib.rs"), "// lib\n").unwrap();
fs::write(base.join(".gitignore"), "*.log\ntmp/\n").unwrap();
git(base, &["init", "-b", "main"]);
git(base, &["config", "user.email", "fuzz@fff.test"]);
git(base, &["config", "user.name", "fuzz"]);
git(base, &["config", "status.renames", "false"]);
git(base, &["add", "-A"]);
git(base, &["commit", "-m", "seed", "--no-gpg-sign"]);
}
fn apply_op(op: &AbstractOp, base: &Path, live: &mut [Live]) {
use AbstractOp::*;
match op {
CreateFile { seed, content_seed } => {
let rel = format!("f_{seed:08x}.rs");
let abs = base.join(&rel);
if abs.exists() {
return;
}
fs::write(&abs, content_for(*content_seed, &rel)).unwrap();
}
EditFile { idx, content_seed } => {
if live.is_empty() {
return;
}
let i = idx % live.len();
let abs = &live[i].abs;
if !abs.is_file() {
return;
}
let body = format!(
"// edited seed={content_seed:08x}\n{}",
content_for(*content_seed, &live[i].relative)
);
fs::write(abs, body).unwrap();
}
Touch { idx } => {
if live.is_empty() {
return;
}
let i = idx % live.len();
let abs = &live[i].abs;
if let Ok(contents) = fs::read(abs) {
let _ = fs::write(abs, contents);
}
}
DeleteFile { idx } => {
if live.is_empty() {
return;
}
let i = idx % live.len();
let _ = fs::remove_file(&live[i].abs);
}
RenameFile { idx, new_seed } => {
if live.is_empty() {
return;
}
let i = idx % live.len();
let old = &live[i].abs;
if !old.is_file() {
return;
}
let new_rel = format!("r_{new_seed:08x}.rs");
let new_abs = base.join(&new_rel);
if new_abs.exists() {
return;
}
let _ = fs::rename(old, &new_abs);
}
CreateSubdirFile {
dir_seed,
file_seed,
content_seed,
} => {
let rel = format!("d_{dir_seed:04x}/inside_{file_seed:04x}.rs");
let abs = base.join(&rel);
if abs.exists() {
return;
}
fs::create_dir_all(abs.parent().unwrap()).unwrap();
fs::write(&abs, content_for(*content_seed, &rel)).unwrap();
}
GitignoreAppend { pattern_seed } => {
let pattern = format!("__ignored_{pattern_seed:x}/\n");
let gi = base.join(".gitignore");
let mut cur = fs::read_to_string(&gi).unwrap_or_default();
cur.push_str(&pattern);
fs::write(&gi, cur).unwrap();
}
GitAddAll => {
git_allow_fail(base, &["add", "-A"]);
}
GitCommit { msg_seed } => {
let _ = git_output(
base,
&[
"commit",
"-m",
&format!("fuzz-{msg_seed:x}"),
"--allow-empty",
"--allow-empty-message",
"--no-gpg-sign",
],
);
}
GitResetHard => {
git_allow_fail(base, &["reset", "--hard"]);
}
GitStashThenPop => {
git_allow_fail(base, &["add", "-A"]);
let stash = git_output(base, &["stash", "push", "-u", "-m", "fuzz"]);
let had_stash = stash
.as_ref()
.map(|o| {
o.status.success()
&& !String::from_utf8_lossy(&o.stdout).contains("No local changes")
})
.unwrap_or(false);
if had_stash {
git_allow_fail(base, &["stash", "pop"]);
}
}
Noop => {}
}
}
fn converge_git_status(
shared_picker: &SharedFilePicker,
base: &Path,
live: &[Live],
) -> Result<(), String> {
let deadline = Instant::now() + CONVERGE_TIMEOUT;
let mut last_mismatches: Vec<Mismatch>;
let mut last_probe_err: Option<String> = None;
loop {
let truth = read_truth_status(base);
let picker_view = read_picker_status(shared_picker);
last_mismatches = diff_statuses(&truth, &picker_view);
let probe = if last_mismatches.is_empty() {
probe_real_queries(shared_picker, live)
} else {
None
};
match (last_mismatches.is_empty(), probe) {
(true, None) | (true, Some(Ok(()))) => return Ok(()),
(true, Some(Err(msg))) => last_probe_err = Some(msg),
_ => {}
}
if Instant::now() >= deadline {
let mut report = if !last_mismatches.is_empty() {
format_mismatches(&last_mismatches, shared_picker)
} else {
String::from("git_status enumeration converged, but real-query probe failed:\n")
};
if let Some(probe_msg) = last_probe_err {
report.push_str("\n── real-query probe ──\n");
report.push_str(&probe_msg);
report.push('\n');
}
report.push_str(&debug_dump_environment(
base,
shared_picker,
&last_mismatches,
));
return Err(report);
}
std::thread::sleep(CONVERGE_POLL);
}
}
fn debug_dump_environment(
base: &Path,
shared_picker: &SharedFilePicker,
mismatches: &[Mismatch],
) -> String {
let mut s = String::new();
s.push_str("\n── diagnostic dump ──\n");
s.push_str(&format!(
"test base path (display) : {}\n",
base.display()
));
s.push_str(&format!("test base path (debug) : {:?}\n", base));
s.push_str(&format!(
"test base path (os bytes) : {:?}\n",
base.as_os_str()
));
if let Ok(guard) = shared_picker.read()
&& let Some(picker) = guard.as_ref()
{
s.push_str(&format!(
"picker base_path (display) : {}\n",
picker.base_path().display()
));
s.push_str(&format!(
"picker base_path (debug) : {:?}\n",
picker.base_path()
));
}
if let Ok(repo) = Repository::open(base) {
s.push_str("\nlibgit2 status_file probes (per mismatch path):\n");
for m in mismatches {
let path = match m {
Mismatch::Disagree { path, .. } => path,
Mismatch::ExtraInPicker { path, .. } => path,
};
match repo.status_file(std::path::Path::new(path)) {
Ok(st) => s.push_str(&format!(" • {path} -> {st:?}\n")),
Err(e) => {
s.push_str(&format!(
" • {path} -> ERROR {} (class={:?}, code={:?})\n",
e.message(),
e.class(),
e.code()
));
}
}
}
} else {
s.push_str("\nlibgit2: could not open repo at base path\n");
}
s.push_str("\npicker enumeration (first 20 entries, with byte-repr of each relative path):\n");
if let Ok(guard) = shared_picker.read()
&& let Some(picker) = guard.as_ref()
{
let parser = QueryParser::default();
let parsed = parser.parse("");
let result = picker.fuzzy_search(
&parsed,
None,
FuzzySearchOptions {
max_threads: 1,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
..Default::default()
},
);
for (i, f) in result.items.iter().take(20).enumerate() {
let raw = f.relative_path(picker);
let norm = normalize(raw.clone());
s.push_str(&format!(
" [{i:>2}] raw={raw:?} norm={norm:?} status={:?}\n",
f.git_status
));
}
s.push_str(&format!(" (total: {} items)\n", result.items.len()));
}
s
}
#[derive(Debug)]
enum Mismatch {
Disagree {
path: String,
truth: Status,
picker: Option<Option<Status>>,
},
ExtraInPicker { path: String, picker: Status },
}
fn read_truth_status(base: &Path) -> BTreeMap<String, Status> {
let repo = Repository::open(base).expect("open repo for truth");
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.include_unmodified(true)
.exclude_submodules(true);
let statuses = repo.statuses(Some(&mut opts)).expect("read statuses");
let mut out = BTreeMap::new();
for entry in statuses.iter() {
if let Some(p) = entry.path() {
out.insert(p.to_string(), entry.status());
}
}
out
}
fn read_picker_status(shared: &SharedFilePicker) -> BTreeMap<String, Option<Status>> {
let guard = shared.read().expect("picker read lock");
let picker = guard.as_ref().expect("picker initialized");
let parser = QueryParser::default();
let parsed = parser.parse("");
let result = picker.fuzzy_search(
&parsed,
None,
FuzzySearchOptions {
max_threads: 1,
pagination: PaginationArgs {
offset: 0,
limit: 100_000,
},
..Default::default()
},
);
let mut out = BTreeMap::new();
for f in &result.items {
out.insert(normalize(f.relative_path(picker)), f.git_status);
}
out
}
fn normalize(s: String) -> String {
#[cfg(windows)]
{
if s.contains('\\') {
return s.replace('\\', "/");
}
}
s
}
fn probe_single_file_status(shared: &SharedFilePicker, relative: &str) -> Option<Option<Status>> {
let guard = shared.read().ok()?;
let picker = guard.as_ref()?;
let parser = QueryParser::default();
let parsed = parser.parse(relative);
let result = picker.fuzzy_search(
&parsed,
None,
FuzzySearchOptions {
max_threads: 1,
pagination: PaginationArgs {
offset: 0,
limit: 200,
},
..Default::default()
},
);
result
.items
.iter()
.find(|f| normalize(f.relative_path(picker)) == relative)
.map(|f| f.git_status)
}
static PROBE_COUNTER: AtomicU64 = AtomicU64::new(0);
fn extract_marker(abs: &Path) -> Option<String> {
let content = fs::read_to_string(abs).ok()?;
let start = content.find("FFF_STRESS_MARKER_")?;
const MARKER_LEN: usize = "FFF_STRESS_MARKER_".len() + 8;
if start + MARKER_LEN > content.len() {
return None;
}
let marker = &content[start..start + MARKER_LEN];
if !marker.as_bytes()[MARKER_LEN - 8..]
.iter()
.all(|b| b.is_ascii_hexdigit())
{
return None;
}
Some(marker.to_string())
}
fn stem_for_query(relative: &str) -> String {
PathBuf::from(relative)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
}
fn fuzzy_search_items(shared: &SharedFilePicker, query: &str) -> Vec<(String, Option<Status>)> {
let guard = match shared.read() {
Ok(g) => g,
Err(_) => return Vec::new(),
};
let Some(picker) = guard.as_ref() else {
return Vec::new();
};
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result = picker.fuzzy_search(
&parsed,
None,
FuzzySearchOptions {
max_threads: 1,
pagination: PaginationArgs {
offset: 0,
limit: 500,
},
..Default::default()
},
);
result
.items
.iter()
.map(|f| (normalize(f.relative_path(picker)), f.git_status))
.collect()
}
fn grep_plain_matches(shared: &SharedFilePicker, query: &str) -> Vec<String> {
let guard = match shared.read() {
Ok(g) => g,
Err(_) => return Vec::new(),
};
let Some(picker) = guard.as_ref() else {
return Vec::new();
};
let parsed = parse_grep_query(query);
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 500,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
let result = picker.grep(&parsed, &opts);
result
.files
.iter()
.map(|f| normalize(f.relative_path(picker)))
.collect()
}
fn grep_fuzzy_matches(shared: &SharedFilePicker, query: &str) -> Vec<String> {
let guard = match shared.read() {
Ok(g) => g,
Err(_) => return Vec::new(),
};
let Some(picker) = guard.as_ref() else {
return Vec::new();
};
let parsed = parse_grep_query(query);
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 500,
mode: GrepMode::Fuzzy,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
let result = picker.grep(&parsed, &opts);
result
.files
.iter()
.map(|f| normalize(f.relative_path(picker)))
.collect()
}
fn grep_regex_matches(shared: &SharedFilePicker, query: &str) -> Vec<String> {
let guard = match shared.read() {
Ok(g) => g,
Err(_) => return Vec::new(),
};
let Some(picker) = guard.as_ref() else {
return Vec::new();
};
let parsed = parse_grep_query(query);
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 500,
mode: GrepMode::Regex,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
};
let result = picker.grep(&parsed, &opts);
result
.files
.iter()
.map(|f| normalize(f.relative_path(picker)))
.collect()
}
type ProbeOutcome = Option<Result<(), String>>;
fn probe_real_queries(shared: &SharedFilePicker, live: &[Live]) -> ProbeOutcome {
if live.is_empty() {
return None;
}
let idx = (PROBE_COUNTER.fetch_add(1, Ordering::Relaxed) as usize) % live.len();
let target = &live[idx];
let stem = stem_for_query(&target.relative);
if stem.len() >= 2 {
let fuzzy_hits = fuzzy_search_items(shared, &stem);
let found = fuzzy_hits.iter().find(|(p, _)| p == &target.relative);
if found.is_none() {
return Some(Err(format!(
"fuzzy_search({stem:?}) did not return expected live file {:?}\n\
got {} results; first few: {:?}",
target.relative,
fuzzy_hits.len(),
fuzzy_hits.iter().take(5).collect::<Vec<_>>(),
)));
}
}
if let Some(marker) = extract_marker(&target.abs) {
let probe_round = PROBE_COUNTER.load(Ordering::Relaxed);
let (mode_name, matches) = match probe_round % 3 {
0 => ("plain", grep_plain_matches(shared, &marker)),
1 => ("fuzzy", grep_fuzzy_matches(shared, &marker)),
_ => ("regex", grep_regex_matches(shared, &marker)),
};
if !matches.contains(&target.relative) {
return Some(Err(format!(
"grep[{mode_name}]({marker:?}) did not return expected live file {:?}\n\
got {} matched files; first few: {:?}",
target.relative,
matches.len(),
matches.iter().take(5).collect::<Vec<_>>(),
)));
}
}
Some(Ok(()))
}
fn diff_statuses(
truth: &BTreeMap<String, Status>,
picker: &BTreeMap<String, Option<Status>>,
) -> Vec<Mismatch> {
let mut out = Vec::new();
for (path, &truth_status) in truth {
if path.starts_with(".git/") || path == ".git" {
continue;
}
match picker.get(path) {
Some(&p) => {
if !status_equivalent(truth_status, p) {
out.push(Mismatch::Disagree {
path: path.clone(),
truth: truth_status,
picker: Some(p),
});
}
}
None => {
let only_absence_reasons =
Status::WT_DELETED | Status::INDEX_DELETED | Status::IGNORED;
if !truth_status.intersects(only_absence_reasons) {
out.push(Mismatch::Disagree {
path: path.clone(),
truth: truth_status,
picker: None,
});
}
}
}
}
for (path, &p) in picker {
if path.starts_with(".git/") || path == ".git" {
continue;
}
if !truth.contains_key(path) {
let non_clean = match p {
None => false,
Some(s) => !(s.is_empty() || s == Status::CURRENT),
};
if non_clean {
out.push(Mismatch::ExtraInPicker {
path: path.clone(),
picker: p.unwrap_or(Status::CURRENT),
});
}
}
}
out
}
fn status_equivalent(truth: Status, picker: Option<Status>) -> bool {
let picker_bits = picker.unwrap_or(Status::CURRENT);
let truth_clean = truth.is_empty() || truth == Status::CURRENT;
let picker_clean = picker_bits.is_empty() || picker_bits == Status::CURRENT;
if truth_clean && picker_clean {
return true;
}
truth == picker_bits
}
fn format_mismatches(mismatches: &[Mismatch], shared: &SharedFilePicker) -> String {
let mut s = String::new();
s.push_str(&format!(
"{} mismatch(es) after {} of wait:\n",
mismatches.len(),
humantime(CONVERGE_TIMEOUT),
));
for m in mismatches {
match m {
Mismatch::Disagree {
path,
truth,
picker,
} => {
let probe = probe_single_file_status(shared, path);
s.push_str(&format!(
" • {path}\n truth : {}\n picker(enum): {}\n picker(probe): {}\n",
format_status(Some(*truth)),
match picker {
Some(p) => format_status(*p),
None => "<missing from enumeration>".into(),
},
match probe {
Some(p) => format_status(p),
None => "<not returned by fuzzy_search>".into(),
}
));
}
Mismatch::ExtraInPicker { path, picker } => {
let probe = probe_single_file_status(shared, path);
s.push_str(&format!(
" • {path}\n truth : <missing from repo>\n picker(enum) : {}\n picker(probe): {}\n",
format_status(Some(*picker)),
match probe {
Some(p) => format_status(p),
None => "<not returned by fuzzy_search>".into(),
}
));
}
}
}
s
}
fn format_status(s: Option<Status>) -> String {
match s {
None => "None (= clean)".into(),
Some(st) if st.is_empty() || st == Status::CURRENT => "CURRENT (= clean)".into(),
Some(st) => format!("{st:?}"),
}
}
fn humantime(d: Duration) -> String {
format!("{:.1}s", d.as_secs_f64())
}
fn get_baseline_status_from_git(base: &Path) -> Vec<Live> {
let mut out = Vec::new();
let repo = match Repository::open(base) {
Ok(r) => r,
Err(_) => return out,
};
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.include_unmodified(true)
.exclude_submodules(true);
let statuses = match repo.statuses(Some(&mut opts)) {
Ok(s) => s,
Err(_) => return out,
};
for entry in statuses.iter() {
if let Some(p) = entry.path() {
let abs = base.join(p);
if abs.is_file() {
out.push(Live {
relative: p.to_string(),
abs,
});
}
}
}
out
}
fn marker_for(seed: u32) -> String {
format!("FFF_STRESS_MARKER_{seed:08x}")
}
fn content_for(seed: u32, name: &str) -> String {
let marker = marker_for(seed);
format!(
"// file: {name}\n\
// seed: {seed:08x}\n\
// anchor: {marker}\n\
pub fn anchor_{seed:x}() {{ let _ = \"{marker}\"; }}\n"
)
}
fn start_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::noop();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: false,
mode: FFFMode::Neovim,
watch: true,
..Default::default()
},
)
.expect("FilePicker::new_with_shared_state");
(shared_picker, shared_frecency)
}
fn wait_ready(p: &SharedFilePicker) {
assert!(
p.wait_for_scan(Duration::from_secs(15)),
"timed out waiting for initial scan"
);
assert!(
p.wait_for_watcher(Duration::from_secs(15)),
"timed out waiting for watcher"
);
std::thread::sleep(Duration::from_millis(200));
}
fn git_env() -> [(&'static str, &'static str); 4] {
[
("GIT_AUTHOR_NAME", "fuzz"),
("GIT_AUTHOR_EMAIL", "fuzz@fff.test"),
("GIT_COMMITTER_NAME", "fuzz"),
("GIT_COMMITTER_EMAIL", "fuzz@fff.test"),
]
}
fn git(base: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(base)
.envs(git_env())
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(
out.status.success(),
"git {args:?} failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
fn git_allow_fail(base: &Path, args: &[&str]) {
let _ = Command::new("git")
.args(args)
.current_dir(base)
.envs(git_env())
.output();
}
fn git_output(base: &Path, args: &[&str]) -> Option<std::process::Output> {
Command::new("git")
.args(args)
.current_dir(base)
.envs(git_env())
.output()
.ok()
}
const CONFLICT_CONVERGE_TIMEOUT: Duration = Duration::from_secs(30);
#[test]
fn stress_merge_conflict_convergence() {
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new(
"warn,fff_search=debug,notify=debug,notify_debouncer_full=debug",
)
}),
)
.with_test_writer()
.try_init();
let tmp = TempDir::new().expect("mktemp");
let base = tmp.path().canonicalize().expect("canonicalize tmp");
seed_conflict_repo(&base);
let (shared_picker, _frecency) = start_watched_picker(&base);
wait_ready(&shared_picker);
std::thread::sleep(Duration::from_millis(1100));
git(&base, &["checkout", "-b", "feature"]);
fs::write(
base.join("conflict.rs"),
"fn flavour() {\n \"FEATURE_VARIANT\"\n}\n",
)
.unwrap();
git(&base, &["add", "conflict.rs"]);
git(
&base,
&["commit", "-m", "feature: rewrite", "--no-gpg-sign"],
);
std::thread::sleep(Duration::from_millis(1100));
git(&base, &["checkout", "main"]);
fs::write(
base.join("conflict.rs"),
"fn flavour() {\n \"MAIN_VARIANT\"\n}\n",
)
.unwrap();
git(&base, &["add", "conflict.rs"]);
git(&base, &["commit", "-m", "main: rewrite", "--no-gpg-sign"]);
std::thread::sleep(Duration::from_millis(1100));
expect_file_status(
&shared_picker,
&base,
"conflict.rs",
|s| {
s.is_none() || s.unwrap().is_empty() || s.unwrap().contains(Status::CURRENT)
},
Duration::from_secs(10),
"pre-merge clean",
)
.expect("pre-merge state should be clean");
let merge = git_output(&base, &["merge", "feature", "--no-edit", "--no-gpg-sign"])
.expect("git merge didn't launch");
assert!(
!merge.status.success(),
"expected `git merge feature` to conflict, but it succeeded:\nstdout:{}\nstderr:{}",
String::from_utf8_lossy(&merge.stdout),
String::from_utf8_lossy(&merge.stderr)
);
expect_file_status(
&shared_picker,
&base,
"conflict.rs",
|s| s.is_some_and(|st| st.contains(Status::CONFLICTED)),
CONFLICT_CONVERGE_TIMEOUT,
"CONFLICTED after merge",
)
.expect("picker must surface CONFLICTED after merge");
let fuzzy_hits = fuzzy_search_items(&shared_picker, "conflict");
assert!(
fuzzy_hits.iter().any(|(p, _)| p == "conflict.rs"),
"fuzzy_search(\"conflict\") during conflict state returned: {:?}",
fuzzy_hits
);
let grep_hits = grep_plain_matches(&shared_picker, "<<<<<<< ");
assert!(
grep_hits.contains(&"conflict.rs".to_string()),
"grep(\"<<<<<<< \") during conflict state returned: {:?}",
grep_hits
);
std::thread::sleep(Duration::from_millis(1100));
fs::write(
base.join("conflict.rs"),
"fn flavour() {\n \"RESOLVED_VARIANT\"\n}\n",
)
.unwrap();
git(&base, &["add", "conflict.rs"]);
git(
&base,
&[
"commit",
"-m",
"resolve merge",
"--no-gpg-sign",
"--no-edit",
],
);
expect_file_status(
&shared_picker,
&base,
"conflict.rs",
|s| s.is_none() || s.unwrap().is_empty() || s.unwrap().contains(Status::CURRENT),
CONFLICT_CONVERGE_TIMEOUT,
"CURRENT after resolve",
)
.expect("picker must converge back to CURRENT after conflict resolution");
let on_disk = fs::read_to_string(base.join("conflict.rs")).unwrap();
assert!(
!on_disk.contains("<<<<<<<"),
"worktree still has conflict markers after resolve — test harness bug\n\
on-disk content:\n{on_disk}"
);
let deadline = Instant::now() + CONFLICT_CONVERGE_TIMEOUT;
loop {
let grep_hits = grep_plain_matches(&shared_picker, "<<<<<<< ");
if !grep_hits.contains(&"conflict.rs".to_string()) {
break;
}
if Instant::now() >= deadline {
panic!(
"picker grep(\"<<<<<<< \") after resolve still returns conflict.rs \
after {} — mmap/overlay cache is stale\n\
(worktree on disk has no conflict markers — this is a real \
content-invalidation bug)\n\
last grep hits: {:?}",
humantime(CONFLICT_CONVERGE_TIMEOUT),
grep_hits,
);
}
std::thread::sleep(CONVERGE_POLL);
}
}
fn seed_conflict_repo(base: &Path) {
fs::write(base.join("README.md"), "# merge conflict test\n").unwrap();
fs::write(
base.join("conflict.rs"),
"fn flavour() {\n \"BASE\"\n}\n",
)
.unwrap();
git(base, &["init", "-b", "main"]);
git(base, &["config", "user.email", "fuzz@fff.test"]);
git(base, &["config", "user.name", "fuzz"]);
git(base, &["config", "merge.conflictstyle", "merge"]);
git(base, &["add", "-A"]);
git(base, &["commit", "-m", "seed", "--no-gpg-sign"]);
}
fn expect_file_status(
shared: &SharedFilePicker,
base: &Path,
relative: &str,
predicate: impl Fn(Option<Status>) -> bool,
timeout: Duration,
what: &str,
) -> Result<(), String> {
let deadline = Instant::now() + timeout;
let mut last_picker;
let mut last_truth;
loop {
let picker_status = probe_single_file_status(shared, relative).flatten();
let truth_status = read_truth_status(base).get(relative).copied();
last_picker = picker_status;
last_truth = truth_status;
if predicate(picker_status) {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"timed out after {} waiting for `{relative}` to satisfy `{what}`\n\
last picker status: {:?}\n\
last truth status : {:?}",
humantime(timeout),
last_picker,
last_truth,
));
}
std::thread::sleep(CONVERGE_POLL);
}
}