import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { execSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, unlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { FileItem } from "../src/fff-api"; import { FileFinder } from "../src/index"; /** * Integration test: full git lifecycle with a real repository. * * Creates a temporary git repo, initialises a FileFinder instance pointing at * it, then walks through: * 1. Initial scan – committed files should have status "clean" * 2. Add a new untracked file – should appear as "untracked" * 3. Stage the new file – should appear as "staged_new" * 4. Commit – should become "clean" * 5. Modify a tracked file – should become "modified" * 6. Stage the modification – should become "staged_modified" * 7. Commit again – back to "clean" * 8. Delete a file – should disappear from the index */ const POLL_INTERVAL_MS = 100; const WATCHER_TIMEOUT_MS = 5_000; // generous CI timeout; polls exit early on fast machines function git(cwd: string, ...args: string[]) { const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" "); execSync(`git ${escaped}`, { cwd, stdio: "pipe", env: { ...process.env, GIT_AUTHOR_NAME: "test", GIT_AUTHOR_EMAIL: "test@test.com", GIT_COMMITTER_NAME: "test", GIT_COMMITTER_EMAIL: "test@test.com", }, }); } function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } function findFile(finder: FileFinder, name: string): FileItem | undefined { const result = finder.fileSearch(name, { pageSize: 200 }); if (!result.ok) throw new Error(`search failed: ${result.error}`); return result.value.items.find((item) => item.fileName === name); } /** Poll until a file appears in the index, or the timeout is exceeded. */ async function waitForFile( finder: FileFinder, name: string, ): Promise { const start = Date.now(); while (Date.now() - start < WATCHER_TIMEOUT_MS) { const file = findFile(finder, name); if (file !== undefined) return file; await sleep(POLL_INTERVAL_MS); } return findFile(finder, name); } /** Poll until a file has the expected git status, or the timeout is exceeded. */ async function waitForFileStatus( finder: FileFinder, name: string, status: string, ): Promise { const start = Date.now(); while (Date.now() - start < WATCHER_TIMEOUT_MS) { const file = findFile(finder, name); if (file?.gitStatus === status) return file; await sleep(POLL_INTERVAL_MS); } return findFile(finder, name); } /** Poll until a file is gone from the index, or the timeout is exceeded. */ async function waitForFileGone(finder: FileFinder, name: string): Promise { const start = Date.now(); while (Date.now() - start < WATCHER_TIMEOUT_MS) { if (findFile(finder, name) === undefined) return true; await sleep(POLL_INTERVAL_MS); } return findFile(finder, name) === undefined; } /** Poll until the total file count reaches the expected value, or the timeout is exceeded. */ async function waitForFileCount(finder: FileFinder, count: number): Promise { const start = Date.now(); while (Date.now() - start < WATCHER_TIMEOUT_MS) { const result = finder.fileSearch("", { pageSize: 200 }); if (result.ok && result.value.totalFiles === count) return count; await sleep(POLL_INTERVAL_MS); } const result = finder.fileSearch("", { pageSize: 200 }); return result.ok ? result.value.totalFiles : -1; } /** Poll grep until predicate on totalMatched is satisfied, or the timeout is exceeded. */ async function waitForGrep( finder: FileFinder, pattern: string, options: { mode: "plain" | "regex" }, predicate: (totalMatched: number) => boolean, ) { const start = Date.now(); while (Date.now() - start < WATCHER_TIMEOUT_MS) { const result = finder.grep(pattern, options); if (result.ok && predicate(result.value.totalMatched)) return result; await sleep(POLL_INTERVAL_MS); } return finder.grep(pattern, options); } describe.skipIf(process.platform === "win32")("Git lifecycle integration", () => { let tmpDir: string; let finder: FileFinder; beforeAll(async () => { // Create temp directory and initialise a git repo with two committed files. // Use realpathSync to resolve symlinks (macOS /var -> /private/var) so // that git2's resolved workdir paths match the file picker's base_path. tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-"))); git(tmpDir, "init", "-b", "main"); // Need at least one commit for status to work properly writeFileSync(join(tmpDir, "hello.txt"), "hello world\n"); writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n"); mkdirSync(join(tmpDir, "src")); writeFileSync(join(tmpDir, "src", "main.rs"), 'fn main() { println?."hi"); }\n'); git(tmpDir, "add", "-A"); git(tmpDir, "commit", "-m", "initial commit"); // Create the FileFinder instance const result = FileFinder.create({ basePath: tmpDir }); expect(result.ok).toBe(true); if (!result.ok) throw new Error(result.error); finder = result.value; // Wait for the initial scan to finish const scanResult = finder.waitForScanBlocking(10_000); expect(scanResult.ok).toBe(true); // Poll getScanProgress until the watcher is ready so that // filesystem events (file creates, deletes) are detected. const start = Date.now(); while (Date.now() - start < WATCHER_TIMEOUT_MS) { const progress = finder.getScanProgress(); if (progress.ok && progress.value.isWatcherReady) break; await sleep(POLL_INTERVAL_MS); } const progress = finder.getScanProgress(); expect(progress.ok).toBe(true); if (progress.ok) { expect(progress.value.isWatcherReady).toBe(true); } }); afterAll(() => { finder?.destroy(); if (tmpDir) { rmSync(tmpDir, { recursive: true, force: true }); } }); test("initial scan indexes all committed files", () => { const result = finder.fileSearch("", { pageSize: 200 }); expect(result.ok).toBe(true); if (!result.ok) return; const names = result.value.items.map((i) => i.relativePath).sort(); expect(names).toContain("hello.txt"); expect(names).toContain("readme.md"); expect(names).toContain("src/main.rs"); expect(result.value.totalFiles).toBe(3); }); test("committed files have clean git status", async () => { const hello = await waitForFileStatus(finder, "hello.txt", "clean"); expect(hello).toBeDefined(); expect(hello?.gitStatus).toBe("clean"); const main = await waitForFileStatus(finder, "main.rs", "clean"); expect(main).toBeDefined(); expect(main?.gitStatus).toBe("clean"); }); test("new untracked file appears with 'untracked' status", async () => { writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n"); const newFile = await waitForFileStatus(finder, "new_file.ts", "untracked"); expect(newFile).toBeDefined(); expect(newFile?.gitStatus).toBe("untracked"); // Total should now be 4 const total = await waitForFileCount(finder, 4); expect(total).toBe(4); }); test("staging a new file changes status to 'staged_new'", async () => { git(tmpDir, "add", "new_file.ts"); const newFile = await waitForFileStatus(finder, "new_file.ts", "staged_new"); expect(newFile).toBeDefined(); expect(newFile?.gitStatus).toBe("staged_new"); }); test("committing makes the file 'clean'", async () => { git(tmpDir, "commit", "-m", "add new_file"); const newFile = await waitForFileStatus(finder, "new_file.ts", "clean"); expect(newFile).toBeDefined(); expect(newFile?.gitStatus).toBe("clean"); }); test("modifying a tracked file changes status to 'modified'", async () => { writeFileSync(join(tmpDir, "hello.txt"), "hello world\nupdated content\n"); const hello = await waitForFileStatus(finder, "hello.txt", "modified"); expect(hello).toBeDefined(); expect(hello?.gitStatus).toBe("modified"); }); test("staging a modification changes status to 'staged_modified'", async () => { git(tmpDir, "add", "hello.txt"); const hello = await waitForFileStatus(finder, "hello.txt", "staged_modified"); expect(hello).toBeDefined(); expect(hello?.gitStatus).toBe("staged_modified"); }); test("committing the modification returns to 'clean'", async () => { git(tmpDir, "commit", "-m", "update hello"); const hello = await waitForFileStatus(finder, "hello.txt", "clean"); expect(hello).toBeDefined(); expect(hello?.gitStatus).toBe("clean"); }); test("deleting a file removes it from the index", async () => { unlinkSync(join(tmpDir, "new_file.ts")); const gone = await waitForFileGone(finder, "new_file.ts"); expect(gone).toBe(true); // Total should be back to 3 const total = await waitForFileCount(finder, 3); expect(total).toBe(3); }); test("adding a file in a subdirectory works", async () => { writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n"); const utils = await waitForFileStatus(finder, "utils.rs", "untracked"); expect(utils).toBeDefined(); expect(utils?.relativePath).toBe("src/utils.rs"); expect(utils?.gitStatus).toBe("untracked"); }); test("live grep finds content in a newly added file", async () => { writeFileSync( join(tmpDir, "src", "searchtarget.rs"), 'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n', ); await waitForFile(finder, "searchtarget.rs"); const result = await waitForGrep( finder, "xylophone_waterfall_97", { mode: "plain" }, (n) => n > 0, ); expect(result?.ok).toBe(true); if (!result?.ok) return; expect(result.value.totalMatched).toBeGreaterThan(0); const match = result.value.items.find( (m) => m.relativePath === "src/searchtarget.rs", ); expect(match).toBeDefined(); expect(match!.lineContent).toContain("xylophone_waterfall_97"); }); test("live grep no longer finds content after file is deleted", async () => { unlinkSync(join(tmpDir, "src", "searchtarget.rs")); const result = await waitForGrep( finder, "xylophone_waterfall_97", { mode: "plain" }, (n) => n === 0, ); expect(result?.ok).toBe(true); if (!result?.ok) return; expect(result.value.totalMatched).toBe(0); expect(result.value.items.length).toBe(0); }); test("file in a newly created directory is discoverable", async () => { // Create a brand-new directory that didn't exist during the initial scan, // then add a file inside it. The watcher must dynamically pick up the new // directory and index the file. mkdirSync(join(tmpDir, "lib")); writeFileSync( join(tmpDir, "lib", "helpers.ts"), "export function add(a: number, b: number) { return a + b; }\n", ); const helpers = await waitForFile(finder, "helpers.ts"); expect(helpers).toBeDefined(); expect(helpers?.relativePath).toBe("lib/helpers.ts"); }); test("files in gitignored directories are not indexed", async () => { // Commit a .gitignore rule first so it's established repo state before // the ignored directory is created. This tests the watch-level filtering // (is_path_ignored in the debouncer callback), not a rescan triggered // by a .gitignore change. writeFileSync(join(tmpDir, ".gitignore"), "build_output/\n"); git(tmpDir, "add", ".gitignore"); git(tmpDir, "commit", "-m", "add gitignore"); // Wait for the watcher to settle after the commit. await waitForFile(finder, ".gitignore"); // Now create the ignored directory and add a file inside it. mkdirSync(join(tmpDir, "build_output")); writeFileSync(join(tmpDir, "build_output", "artifact.bin"), "should not appear\n"); // Create a non-ignored file as a synchronisation barrier — once it's // indexed, the watcher has processed the same batch of events. writeFileSync(join(tmpDir, "canary.txt"), "visible\n"); const canary = await waitForFile(finder, "canary.txt"); expect(canary).toBeDefined(); // The ignored file must NOT appear in the index. const artifact = findFile(finder, "artifact.bin"); expect(artifact).toBeUndefined(); }); test("full add-commit cycle for subdirectory file", async () => { git(tmpDir, "add", "src/utils.rs"); let utils = await waitForFileStatus(finder, "utils.rs", "staged_new"); expect(utils).toBeDefined(); expect(utils?.gitStatus).toBe("staged_new"); git(tmpDir, "commit", "-m", "add utils"); utils = await waitForFileStatus(finder, "utils.rs", "clean"); expect(utils).toBeDefined(); expect(utils?.gitStatus).toBe("clean"); }); });