diff --git a/.agents/skills/verify-mcode/references/features/workspace-file-invalidation.md b/.agents/skills/verify-mcode/references/features/workspace-file-invalidation.md index 36e326d08..f05c060cc 100644 --- a/.agents/skills/verify-mcode/references/features/workspace-file-invalidation.md +++ b/.agents/skills/verify-mcode/references/features/workspace-file-invalidation.md @@ -5,7 +5,7 @@ - Files, selected Review file content, and `@` file autocomplete refresh after an external local change is detected at an attention boundary. - Review compares the live filesystem again. Last turn remains the recorded agent comparison, so an external edit never gains agent attribution. - An open Mcode Browser preview on a local loopback address reloads. Remote pages do not reload. -- The server tracks one dirty-set fingerprint per workspace scope. A `file.refresh` RPC diffs the current `git status` against that baseline and broadcasts `files.changed` only when the set moved. +- The server tracks one dirty-set fingerprint per workspace scope. A `file.refresh` RPC diffs the current `git status` against that baseline and broadcasts `files.changed` only when the set moved. Untracked directories are fingerprinted per file (`git status --untracked-files=all`), so adding or removing a file inside an already-untracked `?? dir/` still moves the fingerprint. - Clients call `file.refresh` on attention boundaries: window focus, panel mount, thread scope change, and `@` picker open. There is no ambient filesystem watch. ## How to get to it (user or client POV) diff --git a/apps/server/src/features/projects/files/__tests__/audit-untracked-file-refresh.test.ts b/apps/server/src/features/projects/files/__tests__/audit-untracked-file-refresh.test.ts new file mode 100644 index 000000000..de63d9fe1 --- /dev/null +++ b/apps/server/src/features/projects/files/__tests__/audit-untracked-file-refresh.test.ts @@ -0,0 +1,107 @@ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import { afterEach, expect, it } from "vitest"; +import "reflect-metadata"; +import { FileService } from "../file-service.js"; +import { RealGitExecutor } from "../../git/execution/real-git-executor.js"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) NodeFS.rmSync(dir, { recursive: true, force: true }); +}); + +function fixture() { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mcode-server-audit-8384-")); + dirs.push(root); + const git = (...args: string[]) => + NodeChildProcess.execFileSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 10000 }).trim(); + git("init", "-b", "main"); + git("config", "commit.gpgSign", "false"); + git("config", "core.hooksPath", root); + git("config", "user.name", "Audit Fixture"); + git("config", "user.email", "audit@example.invalid"); + const repo = { findById: () => ({ path: root }) }; + const executor = new RealGitExecutor(); + const files = new FileService( + repo as never, + { findById: () => null } as never, + { resolveWorkingDir: () => root } as never, + executor, + { platform: process.platform } as never, + ); + return { root, git, files }; +} + +it("refresh invalidates files added inside an already untracked directory", async () => { + const { root, files } = fixture(); + NodeFS.mkdirSync(NodePath.join(root, "new-folder")); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first"); + await files.refresh("fixture"); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "second.ts"), "second"); + const delta = await files.refresh("fixture"); + expect(delta).toEqual({ changedPaths: ["new-folder/second.ts"], wholeWorkspace: false }); +}); + +it("refresh invalidates files removed from inside an untracked directory", async () => { + const { root, files } = fixture(); + NodeFS.mkdirSync(NodePath.join(root, "new-folder")); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first"); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "second.ts"), "second"); + await files.refresh("fixture"); + NodeFS.unlinkSync(NodePath.join(root, "new-folder", "second.ts")); + const delta = await files.refresh("fixture"); + expect(delta).toEqual({ changedPaths: ["new-folder/second.ts"], wholeWorkspace: false }); +}); + +it("refresh invalidates files added inside nested untracked directories", async () => { + const { root, files } = fixture(); + NodeFS.mkdirSync(NodePath.join(root, "outer", "inner"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(root, "outer", "inner", "first.ts"), "first"); + await files.refresh("fixture"); + NodeFS.mkdirSync(NodePath.join(root, "outer", "inner", "deep"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(root, "outer", "inner", "deep", "second.ts"), "second"); + const delta = await files.refresh("fixture"); + expect(delta).toEqual({ changedPaths: ["outer/inner/deep/second.ts"], wholeWorkspace: false }); +}); + +it("refresh stays silent when the untracked file set is unchanged", async () => { + const { root, files } = fixture(); + NodeFS.mkdirSync(NodePath.join(root, "new-folder")); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first"); + await files.refresh("fixture"); + expect(await files.refresh("fixture")).toBeNull(); +}); + +it("refresh stays silent for ignored files inside an untracked directory", async () => { + const { root, files } = fixture(); + NodeFS.writeFileSync(NodePath.join(root, ".gitignore"), "*.log\n"); + NodeFS.mkdirSync(NodePath.join(root, "new-folder")); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first"); + await files.refresh("fixture"); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "debug.log"), "x"); + expect(await files.refresh("fixture")).toBeNull(); +}); + +it("refresh stays silent when an untracked directory is staged", async () => { + const { root, files, git } = fixture(); + NodeFS.mkdirSync(NodePath.join(root, "new-folder")); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first"); + await files.refresh("fixture"); + // The fingerprint tracks the dirty path set, not index state: `?? f` -> `A f` + // keeps the same path, matching single-file staging which never invalidated. + git("add", "new-folder"); + expect(await files.refresh("fixture")).toBeNull(); +}); + +it("refresh expands untracked directories even when repo config disables them", async () => { + const { root, files, git } = fixture(); + git("config", "status.showUntrackedFiles", "no"); + NodeFS.mkdirSync(NodePath.join(root, "new-folder")); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first"); + await files.refresh("fixture"); + NodeFS.writeFileSync(NodePath.join(root, "new-folder", "second.ts"), "second"); + const delta = await files.refresh("fixture"); + expect(delta).toEqual({ changedPaths: ["new-folder/second.ts"], wholeWorkspace: false }); +}); diff --git a/apps/server/src/features/projects/files/__tests__/file-service.test.ts b/apps/server/src/features/projects/files/__tests__/file-service.test.ts index 80bbb3916..a073762b1 100644 --- a/apps/server/src/features/projects/files/__tests__/file-service.test.ts +++ b/apps/server/src/features/projects/files/__tests__/file-service.test.ts @@ -40,7 +40,10 @@ describe("FileService.refresh", () => { wholeWorkspace: false, }); expect(exec).toHaveBeenCalledTimes(3); - expect(exec).toHaveBeenLastCalledWith(["status", "--porcelain"], { cwd: "C:/workspace" }); + expect(exec).toHaveBeenLastCalledWith( + ["status", "--porcelain", "--untracked-files=all"], + { cwd: "C:/workspace" }, + ); }); it("tracks thread scopes independently", async () => { @@ -55,6 +58,22 @@ describe("FileService.refresh", () => { await expect(service.refresh("workspace-1")).resolves.toBeNull(); }); + it("reports wholeWorkspace when a delta exceeds the changed-path cap", async () => { + const exec = vi + .fn() + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ + stdout: Array.from({ length: 101 }, (_, i) => `?? dir/file-${i}.ts`).join("\n"), + }); + const { service } = makeService({ exec }); + + await expect(service.refresh("workspace-1")).resolves.toBeNull(); + await expect(service.refresh("workspace-1")).resolves.toEqual({ + changedPaths: [], + wholeWorkspace: true, + }); + }); + it("fingerprints the bounded listing when the scope is not a git repository", async () => { const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "file-service-")); try { diff --git a/apps/server/src/features/projects/files/file-service.ts b/apps/server/src/features/projects/files/file-service.ts index 60bbc72f0..48c87e31c 100644 --- a/apps/server/src/features/projects/files/file-service.ts +++ b/apps/server/src/features/projects/files/file-service.ts @@ -62,6 +62,9 @@ export class FileService { * Runs one bounded `git status` for the scope and reports paths whose * dirty-set fingerprint moved since the previous refresh. The first call * only records the baseline; callers emit `files.changed` on real deltas. + * `--untracked-files=all` expands untracked directories to their files: + * default status output collapses them to `?? dir/`, which hides file + * additions and removals inside the directory from the fingerprint. * Non-git scopes fingerprint the bounded directory listing instead. * Returns null when the fingerprint is unchanged. */ @@ -74,7 +77,10 @@ export class FileService { let paths: string[]; try { - const { stdout } = await this.gitExecutor.exec(["status", "--porcelain"], { cwd }); + const { stdout } = await this.gitExecutor.exec( + ["status", "--porcelain", "--untracked-files=all"], + { cwd }, + ); paths = parsePorcelainPaths(stdout); } catch { // Non-git folders fingerprint the same bounded listing `list` falls back to. diff --git a/apps/web/src/transport/ws-events.test.ts b/apps/web/src/transport/ws-events.test.ts index d40eacf7c..c14c53a1a 100644 --- a/apps/web/src/transport/ws-events.test.ts +++ b/apps/web/src/transport/ws-events.test.ts @@ -6,6 +6,11 @@ vi.mock("@/transport", () => ({ getTransport: vi.fn(), })); +vi.mock("@/components/chat/useFileAutocomplete", async (importOriginal) => ({ + ...(await importOriginal()), + clearFileListCache: vi.fn(), +})); + import { pushEmitter } from "./ws-transport"; import { startPushListeners, stopPushListeners } from "./ws-events"; import { useWorkspaceStore } from "@/features/projects/state/workspaceStore"; @@ -18,6 +23,7 @@ import { onPtyExit } from "@/features/terminal/adapters/pty-data-registry"; import { useProjectActionStore } from "@/features/projects/environment/state/project-action-store"; import { useThreadStartupStore } from "@/features/thread-startup"; import { buildVolatileItems } from "@/features/conversation/messages/virtual-items"; +import { clearFileListCache } from "@/components/chat/useFileAutocomplete"; function makeThread(overrides: Partial = {}): Thread { return { @@ -380,6 +386,42 @@ describe("ws-events turn.persisted Review invalidation", () => { }); }); +describe("ws-events files.changed", () => { + beforeEach(() => { + useDiffStore.setState({ diffRevisionByScope: {} }); + vi.mocked(clearFileListCache).mockClear(); + }); + + afterEach(() => stopPushListeners()); + + it("invalidates the file autocomplete cache and bumps the diff revision for the pushed scope", () => { + startPushListeners(); + + pushEmitter.emit("files.changed", { + workspaceId: "ws-1", + threadId: "thread-1", + changedPaths: ["new-folder/second.ts"], + wholeWorkspace: false, + }); + + expect(clearFileListCache).toHaveBeenCalledWith("ws-1", "thread-1"); + expect(useDiffStore.getState().diffRevisionByScope["thread-1"]).toBe(1); + }); + + it("falls back to the workspace scope when the push carries no threadId", () => { + startPushListeners(); + + pushEmitter.emit("files.changed", { + workspaceId: "ws-1", + changedPaths: ["new-folder/second.ts"], + wholeWorkspace: false, + }); + + expect(clearFileListCache).toHaveBeenCalledWith("ws-1", undefined); + expect(useDiffStore.getState().diffRevisionByScope["ws-1"]).toBe(1); + }); +}); + describe("ws-events terminal.exit", () => { afterEach(() => { stopPushListeners();