From dcf7b4e7924b51d8092684e6f24b2c997e14eb71 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 23 Jun 2026 20:51:56 +0200 Subject: [PATCH] fix(opencode): handle snapshot paths from subdirectories (#33506) --- packages/opencode/src/snapshot/index.ts | 29 ++++-- .../opencode/test/snapshot/snapshot.test.ts | 93 +++++++++++++++++++ 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index fd25437bb..604e046d9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -82,7 +82,9 @@ export const layer: Layer.Layer ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] - const feed = (list: string[]) => list.join("\0") + "\0" + const encodeNulTerminatedPaths = (files: string[]) => files.join("\0") + "\0" + const encodeTopLevelLiteralPathspecs = (files: string[]) => + encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`)) const git = Effect.fnUntraced( function* (cmd: string[], opts?: { cwd?: string; env?: Record; stdin?: string }) { @@ -107,6 +109,8 @@ export const layer: Layer.Layer() + // check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix. + const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item)) const check = yield* git( [ ...quote, @@ -120,12 +124,17 @@ export const layer: Layer.Layer() - return new Set(check.text.split("\0").filter(Boolean)) + return new Set( + check.text + .split("\0") + .filter(Boolean) + .map((item) => (item.startsWith("./:") ? item.slice(2) : item)), + ) }) const drop = Effect.fnUntraced(function* (files: string[]) { @@ -136,8 +145,8 @@ export const layer: Layer.Layer fs - .stat(path.join(state.directory, item)) + .stat(path.join(state.worktree, item)) .pipe(Effect.catch(() => Effect.void)) .pipe( Effect.map((stat) => { diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 208bc0e16..21ec0872b 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -16,6 +16,8 @@ import { import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)) +// Windows forbids both * and : in directory names. +const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -448,6 +450,97 @@ it.live( }), ) +it.live( + "subdirectory snapshots include scoped changes only", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const frontend = path.join(dir, "frontend") + yield* write(`${frontend}/tracked.txt`, "initial") + yield* write(`${frontend}/deleted.txt`, "initial") + yield* write(`${dir}/backend/tracked.txt`, "initial") + yield* write(`${dir}/backend/deleted.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${frontend}/tracked.txt`, "changed") + yield* write(`${frontend}/untracked.txt`, "new") + yield* rm(`${frontend}/deleted.txt`) + yield* write(`${dir}/backend/tracked.txt`, "changed") + yield* rm(`${dir}/backend/deleted.txt`) + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(frontend, "tracked.txt")) + expect(patch.files).toContain(fwd(frontend, "untracked.txt")) + expect(patch.files).toContain(fwd(frontend, "deleted.txt")) + expect(patch.files).not.toContain(fwd(dir, "backend", "tracked.txt")) + expect(patch.files).not.toContain(fwd(dir, "backend", "deleted.txt")) + expect(diff).not.toContain("backend/tracked.txt") + expect(diff).not.toContain("backend/deleted.txt") + }).pipe(provideInstance(frontend)) + }), +) + +nonWindowsIt( + "subdirectory snapshots treat wildcard characters literally", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const subdir = path.join(dir, "src*") + yield* write(`${subdir}/file.txt`, "initial") + yield* write(`${subdir}/later-ignored.txt`, "initial") + yield* write(`${dir}/srca/file.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/file.txt`, "changed") + yield* write(`${subdir}/later-ignored.txt`, "changed") + yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n") + yield* write(`${dir}/srca/file.txt`, "changed") + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(subdir, "file.txt")) + expect(patch.files).toContain(fwd(subdir, ".gitignore")) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(patch.files).not.toContain(fwd(dir, "srca", "file.txt")) + expect(diff).toContain("src*/later-ignored.txt") + expect(diff).toContain("deleted file mode") + expect(diff).not.toContain("srca/file.txt") + }).pipe(provideInstance(subdir)) + }), +) + +nonWindowsIt( + "subdirectory snapshots treat leading colons literally", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const subdir = path.join(dir, ":src") + yield* write(`${subdir}/kept.txt`, "initial") + yield* write(`${subdir}/later-ignored.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/kept.txt`, "changed") + yield* write(`${subdir}/later-ignored.txt`, "changed") + yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n") + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(subdir, "kept.txt")) + expect(patch.files).toContain(fwd(subdir, ".gitignore")) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(diff).toContain(":src/later-ignored.txt") + expect(diff).toContain("deleted file mode") + }).pipe(provideInstance(subdir)) + }), +) + it.instance( "gitignore changes", withTrackedSnapshot(({ tmp, snapshot, before }) =>