diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index a7ccba617..fbb13008b 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -197,6 +197,7 @@ export async function createTestProject() { await fs.writeFile(path.join(root, "README.md"), "# e2e\n") execSync("git init", { cwd: root, stdio: "ignore" }) + execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { cwd: root, @@ -207,7 +208,10 @@ export async function createTestProject() { } export async function cleanupTestProject(directory: string) { - await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined) + try { + execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" }) + } catch {} + await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined) } export function sessionIDFromUrl(url: string) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index b7daddc5f..01f07c9af 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -418,7 +418,7 @@ export namespace File { const project = Instance.project if (project.vcs !== "git") return [] - const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD` + const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD` .cwd(Instance.directory) .quiet() .nothrow() @@ -439,11 +439,12 @@ export namespace File { } } - const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard` - .cwd(Instance.directory) - .quiet() - .nothrow() - .text() + const untrackedOutput = + await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard` + .cwd(Instance.directory) + .quiet() + .nothrow() + .text() if (untrackedOutput.trim()) { const untrackedFiles = untrackedOutput.trim().split("\n") @@ -464,11 +465,12 @@ export namespace File { } // Get deleted files - const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD` - .cwd(Instance.directory) - .quiet() - .nothrow() - .text() + const deletedOutput = + await $`git -c core.fsmonitor=false -c core.quotepath=false diff --name-only --diff-filter=D HEAD` + .cwd(Instance.directory) + .quiet() + .nothrow() + .text() if (deletedOutput.trim()) { const deletedFiles = deletedOutput.trim().split("\n") @@ -539,8 +541,14 @@ export namespace File { const content = (await Filesystem.readText(full).catch(() => "")).trim() if (project.vcs === "git") { - let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text() - if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text() + let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text() + if (!diff.trim()) { + diff = await $`git -c core.fsmonitor=false diff --staged ${file}` + .cwd(Instance.directory) + .quiet() + .nothrow() + .text() + } if (diff.trim()) { const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text() const patch = structuredPatch(file, file, original, content, "old", "new", { diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 226732249..15efcea38 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -474,6 +474,11 @@ export namespace Worktree { throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) }) + const stop = async (target: string) => { + if (!(await exists(target))) return + await $`git fsmonitor--daemon stop`.quiet().nothrow().cwd(target) + } + const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) @@ -484,11 +489,13 @@ export namespace Worktree { if (!entry?.path) { const directoryExists = await exists(directory) if (directoryExists) { + await stop(directory) await clean(directory) } return true } + await stop(entry.path) const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree) if (removed.exitCode !== 0) { const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) @@ -637,7 +644,7 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) } - const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath) + const status = await $`git -c core.fsmonitor=false status --porcelain=v1`.quiet().nothrow().cwd(worktreePath) if (status.exitCode !== 0) { throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) } diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts new file mode 100644 index 000000000..8cdde014d --- /dev/null +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -0,0 +1,62 @@ +import { $ } from "bun" +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { File } from "../../src/file" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +const wintest = process.platform === "win32" ? test : test.skip + +describe("file fsmonitor", () => { + wintest("status does not start fsmonitor for readonly git checks", async () => { + await using tmp = await tmpdir({ git: true }) + const target = path.join(tmp.path, "tracked.txt") + + await fs.writeFile(target, "base\n") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit -m init`.cwd(tmp.path).quiet() + await $`git config core.fsmonitor true`.cwd(tmp.path).quiet() + await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow() + await fs.writeFile(target, "next\n") + await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n") + + const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() + expect(before.exitCode).not.toBe(0) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.status() + }, + }) + + const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() + expect(after.exitCode).not.toBe(0) + }) + + wintest("read does not start fsmonitor for git diffs", async () => { + await using tmp = await tmpdir({ git: true }) + const target = path.join(tmp.path, "tracked.txt") + + await fs.writeFile(target, "base\n") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit -m init`.cwd(tmp.path).quiet() + await $`git config core.fsmonitor true`.cwd(tmp.path).quiet() + await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow() + await fs.writeFile(target, "next\n") + + const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() + expect(before.exitCode).not.toBe(0) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.read("tracked.txt") + }, + }) + + const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() + expect(after.exitCode).not.toBe(0) + }) +}) diff --git a/packages/opencode/test/fixture/fixture.test.ts b/packages/opencode/test/fixture/fixture.test.ts new file mode 100644 index 000000000..153276a28 --- /dev/null +++ b/packages/opencode/test/fixture/fixture.test.ts @@ -0,0 +1,26 @@ +import { $ } from "bun" +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import { tmpdir } from "./fixture" + +describe("tmpdir", () => { + test("disables fsmonitor for git fixtures", async () => { + await using tmp = await tmpdir({ git: true }) + + const value = (await $`git config core.fsmonitor`.cwd(tmp.path).quiet().text()).trim() + expect(value).toBe("false") + }) + + test("removes directories on dispose", async () => { + const tmp = await tmpdir({ git: true }) + const dir = tmp.path + + await tmp[Symbol.asyncDispose]() + + const exists = await fs + .stat(dir) + .then(() => true) + .catch(() => false) + expect(exists).toBe(false) + }) +}) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ed8c5e344..63f93bcaf 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -9,6 +9,27 @@ function sanitizePath(p: string): string { return p.replace(/\0/g, "") } +function exists(dir: string) { + return fs + .stat(dir) + .then(() => true) + .catch(() => false) +} + +function clean(dir: string) { + return fs.rm(dir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }) +} + +async function stop(dir: string) { + if (!(await exists(dir))) return + await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow() +} + type TmpDirOptions = { git?: boolean config?: Partial @@ -20,6 +41,7 @@ export async function tmpdir(options?: TmpDirOptions) { await fs.mkdir(dirpath, { recursive: true }) if (options?.git) { await $`git init`.cwd(dirpath).quiet() + await $`git config core.fsmonitor false`.cwd(dirpath).quiet() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() } if (options?.config) { @@ -31,12 +53,16 @@ export async function tmpdir(options?: TmpDirOptions) { }), ) } - const extra = await options?.init?.(dirpath) const realpath = sanitizePath(await fs.realpath(dirpath)) + const extra = await options?.init?.(realpath) const result = { [Symbol.asyncDispose]: async () => { - await options?.dispose?.(dirpath) - // await fs.rm(dirpath, { recursive: true, force: true }) + try { + await options?.dispose?.(realpath) + } finally { + if (options?.git) await stop(realpath).catch(() => undefined) + await clean(realpath).catch(() => undefined) + } }, path: realpath, extra: extra as T, diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index e17a5392b..a6b5bb7c3 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -7,6 +7,8 @@ import { Worktree } from "../../src/worktree" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" +const wintest = process.platform === "win32" ? test : test.skip + describe("Worktree.remove", () => { test("continues when git remove exits non-zero after detaching", async () => { await using tmp = await tmpdir({ git: true }) @@ -62,4 +64,33 @@ describe("Worktree.remove", () => { const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow() expect(ref.exitCode).not.toBe(0) }) + + wintest("stops fsmonitor before removing a worktree", async () => { + await using tmp = await tmpdir({ git: true }) + const root = tmp.path + const name = `remove-fsmonitor-${Date.now().toString(36)}` + const branch = `opencode/${name}` + const dir = path.join(root, "..", name) + + await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet() + await $`git reset --hard`.cwd(dir).quiet() + await $`git config core.fsmonitor true`.cwd(dir).quiet() + await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow() + await Bun.write(path.join(dir, "tracked.txt"), "next\n") + await $`git diff`.cwd(dir).quiet() + + const before = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow() + expect(before.exitCode).toBe(0) + + const ok = await Instance.provide({ + directory: root, + fn: () => Worktree.remove({ directory: dir }), + }) + + expect(ok).toBe(true) + expect(await Filesystem.exists(dir)).toBe(false) + + const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow() + expect(ref.exitCode).not.toBe(0) + }) })