瀏覽代碼

fix(opencode): handle snapshot paths from subdirectories (#33506)

Kit Langton 1 月之前
父節點
當前提交
dcf7b4e792
共有 2 個文件被更改,包括 112 次插入10 次删除
  1. 19 10
      packages/opencode/src/snapshot/index.ts
  2. 93 0
      packages/opencode/test/snapshot/snapshot.test.ts

+ 19 - 10
packages/opencode/src/snapshot/index.ts

@@ -82,7 +82,9 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
 
         const args = (cmd: string[]) => ["--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<string, string>; stdin?: string }) {
@@ -107,6 +109,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
 
         const ignore = Effect.fnUntraced(function* (files: string[]) {
           if (!files.length) return new Set<string>()
+          // 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<Service, never, FSUtil.Service | AppProcess.Serv
               "-z",
             ],
             {
-              cwd: state.directory,
-              stdin: feed(files),
+              cwd: state.worktree,
+              stdin: encodeNulTerminatedPaths(checkIgnorePaths),
             },
           )
           if (check.code !== 0 && check.code !== 1) return new Set<string>()
-          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<Service, never, FSUtil.Service | AppProcess.Serv
               ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
             ],
             {
-              cwd: state.directory,
-              stdin: feed(files),
+              cwd: state.worktree,
+              stdin: encodeTopLevelLiteralPathspecs(files),
             },
           )
         })
@@ -147,8 +156,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
           const result = yield* git(
             [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
             {
-              cwd: state.directory,
-              stdin: feed(files),
+              cwd: state.worktree,
+              stdin: encodeTopLevelLiteralPathspecs(files),
             },
           )
           if (result.code === 0) return
@@ -238,7 +247,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
               git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
                 cwd: state.directory,
               }),
-              git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
+              git([...quote, ...args(["ls-files", "--full-name", "--others", "--exclude-standard", "-z", "--", "."])], {
                 cwd: state.directory,
               }),
             ],
@@ -277,7 +286,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
             (yield* Effect.all(
               allow.map((item) =>
                 fs
-                  .stat(path.join(state.directory, item))
+                  .stat(path.join(state.worktree, item))
                   .pipe(Effect.catch(() => Effect.void))
                   .pipe(
                     Effect.map((stat) => {

+ 93 - 0
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 }) =>