Skip to content

Commit dcf7b4e

Browse files
authored
fix(opencode): handle snapshot paths from subdirectories (#33506)
1 parent 5152150 commit dcf7b4e

2 files changed

Lines changed: 112 additions & 10 deletions

File tree

packages/opencode/src/snapshot/index.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
8282

8383
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
8484

85-
const feed = (list: string[]) => list.join("\0") + "\0"
85+
const encodeNulTerminatedPaths = (files: string[]) => files.join("\0") + "\0"
86+
const encodeTopLevelLiteralPathspecs = (files: string[]) =>
87+
encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`))
8688

8789
const git = Effect.fnUntraced(
8890
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
107109

108110
const ignore = Effect.fnUntraced(function* (files: string[]) {
109111
if (!files.length) return new Set<string>()
112+
// check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix.
113+
const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item))
110114
const check = yield* git(
111115
[
112116
...quote,
@@ -120,12 +124,17 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
120124
"-z",
121125
],
122126
{
123-
cwd: state.directory,
124-
stdin: feed(files),
127+
cwd: state.worktree,
128+
stdin: encodeNulTerminatedPaths(checkIgnorePaths),
125129
},
126130
)
127131
if (check.code !== 0 && check.code !== 1) return new Set<string>()
128-
return new Set(check.text.split("\0").filter(Boolean))
132+
return new Set(
133+
check.text
134+
.split("\0")
135+
.filter(Boolean)
136+
.map((item) => (item.startsWith("./:") ? item.slice(2) : item)),
137+
)
129138
})
130139

131140
const drop = Effect.fnUntraced(function* (files: string[]) {
@@ -136,8 +145,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
136145
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
137146
],
138147
{
139-
cwd: state.directory,
140-
stdin: feed(files),
148+
cwd: state.worktree,
149+
stdin: encodeTopLevelLiteralPathspecs(files),
141150
},
142151
)
143152
})
@@ -147,8 +156,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
147156
const result = yield* git(
148157
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
149158
{
150-
cwd: state.directory,
151-
stdin: feed(files),
159+
cwd: state.worktree,
160+
stdin: encodeTopLevelLiteralPathspecs(files),
152161
},
153162
)
154163
if (result.code === 0) return
@@ -238,7 +247,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
238247
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
239248
cwd: state.directory,
240249
}),
241-
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
250+
git([...quote, ...args(["ls-files", "--full-name", "--others", "--exclude-standard", "-z", "--", "."])], {
242251
cwd: state.directory,
243252
}),
244253
],
@@ -277,7 +286,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
277286
(yield* Effect.all(
278287
allow.map((item) =>
279288
fs
280-
.stat(path.join(state.directory, item))
289+
.stat(path.join(state.worktree, item))
281290
.pipe(Effect.catch(() => Effect.void))
282291
.pipe(
283292
Effect.map((stat) => {

packages/opencode/test/snapshot/snapshot.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
import { testEffect } from "../lib/effect"
1717

1818
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer))
19+
// Windows forbids both * and : in directory names.
20+
const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live
1921

2022
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
2123
// with path.join (which produces \ on Windows) then normalizes back to /.
@@ -448,6 +450,97 @@ it.live(
448450
}),
449451
)
450452

453+
it.live(
454+
"subdirectory snapshots include scoped changes only",
455+
Effect.gen(function* () {
456+
const dir = yield* scopedGitTmpdir()
457+
const frontend = path.join(dir, "frontend")
458+
yield* write(`${frontend}/tracked.txt`, "initial")
459+
yield* write(`${frontend}/deleted.txt`, "initial")
460+
yield* write(`${dir}/backend/tracked.txt`, "initial")
461+
yield* write(`${dir}/backend/deleted.txt`, "initial")
462+
yield* exec(dir, ["git", "add", "."])
463+
yield* exec(dir, ["git", "commit", "-m", "init"])
464+
yield* Effect.gen(function* () {
465+
const snapshot = yield* Snapshot.Service
466+
const before = yield* snapshot.track()
467+
expect(before).toBeTruthy()
468+
yield* write(`${frontend}/tracked.txt`, "changed")
469+
yield* write(`${frontend}/untracked.txt`, "new")
470+
yield* rm(`${frontend}/deleted.txt`)
471+
yield* write(`${dir}/backend/tracked.txt`, "changed")
472+
yield* rm(`${dir}/backend/deleted.txt`)
473+
const patch = yield* snapshot.patch(before!)
474+
const diff = yield* snapshot.diff(before!)
475+
expect(patch.files).toContain(fwd(frontend, "tracked.txt"))
476+
expect(patch.files).toContain(fwd(frontend, "untracked.txt"))
477+
expect(patch.files).toContain(fwd(frontend, "deleted.txt"))
478+
expect(patch.files).not.toContain(fwd(dir, "backend", "tracked.txt"))
479+
expect(patch.files).not.toContain(fwd(dir, "backend", "deleted.txt"))
480+
expect(diff).not.toContain("backend/tracked.txt")
481+
expect(diff).not.toContain("backend/deleted.txt")
482+
}).pipe(provideInstance(frontend))
483+
}),
484+
)
485+
486+
nonWindowsIt(
487+
"subdirectory snapshots treat wildcard characters literally",
488+
Effect.gen(function* () {
489+
const dir = yield* scopedGitTmpdir()
490+
const subdir = path.join(dir, "src*")
491+
yield* write(`${subdir}/file.txt`, "initial")
492+
yield* write(`${subdir}/later-ignored.txt`, "initial")
493+
yield* write(`${dir}/srca/file.txt`, "initial")
494+
yield* exec(dir, ["git", "add", "."])
495+
yield* exec(dir, ["git", "commit", "-m", "init"])
496+
yield* Effect.gen(function* () {
497+
const snapshot = yield* Snapshot.Service
498+
const before = yield* snapshot.track()
499+
expect(before).toBeTruthy()
500+
yield* write(`${subdir}/file.txt`, "changed")
501+
yield* write(`${subdir}/later-ignored.txt`, "changed")
502+
yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
503+
yield* write(`${dir}/srca/file.txt`, "changed")
504+
const patch = yield* snapshot.patch(before!)
505+
const diff = yield* snapshot.diff(before!)
506+
expect(patch.files).toContain(fwd(subdir, "file.txt"))
507+
expect(patch.files).toContain(fwd(subdir, ".gitignore"))
508+
expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
509+
expect(patch.files).not.toContain(fwd(dir, "srca", "file.txt"))
510+
expect(diff).toContain("src*/later-ignored.txt")
511+
expect(diff).toContain("deleted file mode")
512+
expect(diff).not.toContain("srca/file.txt")
513+
}).pipe(provideInstance(subdir))
514+
}),
515+
)
516+
517+
nonWindowsIt(
518+
"subdirectory snapshots treat leading colons literally",
519+
Effect.gen(function* () {
520+
const dir = yield* scopedGitTmpdir()
521+
const subdir = path.join(dir, ":src")
522+
yield* write(`${subdir}/kept.txt`, "initial")
523+
yield* write(`${subdir}/later-ignored.txt`, "initial")
524+
yield* exec(dir, ["git", "add", "."])
525+
yield* exec(dir, ["git", "commit", "-m", "init"])
526+
yield* Effect.gen(function* () {
527+
const snapshot = yield* Snapshot.Service
528+
const before = yield* snapshot.track()
529+
expect(before).toBeTruthy()
530+
yield* write(`${subdir}/kept.txt`, "changed")
531+
yield* write(`${subdir}/later-ignored.txt`, "changed")
532+
yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
533+
const patch = yield* snapshot.patch(before!)
534+
const diff = yield* snapshot.diff(before!)
535+
expect(patch.files).toContain(fwd(subdir, "kept.txt"))
536+
expect(patch.files).toContain(fwd(subdir, ".gitignore"))
537+
expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
538+
expect(diff).toContain(":src/later-ignored.txt")
539+
expect(diff).toContain("deleted file mode")
540+
}).pipe(provideInstance(subdir))
541+
}),
542+
)
543+
451544
it.instance(
452545
"gitignore changes",
453546
withTrackedSnapshot(({ tmp, snapshot, before }) =>

0 commit comments

Comments
 (0)