diff --git a/nix/hashes.json b/nix/hashes.json index 47e3e240bb..326cc98a66 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-v83hWzYVg/g4zJiBpGsQ71wTdndPk3BQVZ2mjMApUIQ=", - "aarch64-linux": "sha256-inpMwkQqwBFP2wL8w/pTOP7q3fg1aOqvE0wgzVd3/B8=", - "aarch64-darwin": "sha256-r42LGrQWqDyIy62mBSU5Nf3M22dJ3NNo7mjN/1h8d8Y=", - "x86_64-darwin": "sha256-J6XrrdK5qBK3sQBQOO/B3ZluOnsAf5f65l4q/K1nDTI=" + "x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=", + "aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=", + "aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=", + "x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE=" } } diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index a7ccba6175..fbb13008b2 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 d4918da8e7..7a51ca36c7 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -419,7 +419,7 @@ export namespace File { if (project.vcs !== "git") return [] const diffOutput = ( - await git(["-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { cwd: Instance.directory, }) ).text() @@ -440,9 +440,12 @@ export namespace File { } const untrackedOutput = ( - await git(["-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"], { - cwd: Instance.directory, - }) + await git( + ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"], + { + cwd: Instance.directory, + }, + ) ).text() if (untrackedOutput.trim()) { @@ -465,9 +468,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, - }) + await git( + ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"], + { + cwd: Instance.directory, + }, + ) ).text() if (deletedOutput.trim()) { @@ -539,8 +545,12 @@ export namespace File { const content = (await Filesystem.readText(full).catch(() => "")).trim() if (project.vcs === "git") { - let diff = (await git(["diff", "--", file], { cwd: Instance.directory })).text() - if (!diff.trim()) diff = (await git(["diff", "--staged", "--", file], { cwd: Instance.directory })).text() + let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text() + if (!diff.trim()) { + diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory }) + ).text() + } if (diff.trim()) { const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).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 a8b0b73604..8b5accc333 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -476,6 +476,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"], { cwd: target }) + } + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) @@ -486,11 +491,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], { cwd: Instance.worktree, }) @@ -646,7 +653,7 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) } - const status = await git(["status", "--porcelain=v1"], { cwd: worktreePath }) + const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { 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 0000000000..8cdde014db --- /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 0000000000..153276a283 --- /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 ed8c5e344a..63f93bcafe 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 e17a5392bc..a6b5bb7c34 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) + }) })