From d829d90944280f2d7de59087e797ebc68ec1ea1b Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Fri, 15 May 2026 13:19:45 +0100 Subject: [PATCH] fix(snapshot): anchor git operations to worktree, not launch directory When opencode is launched from a subdirectory of a git worktree, state.directory points at the subdirectory while git is told the worktree via --work-tree. Pathspecs from ls-files came back worktree-relative but were then passed to subsequent git invocations with cwd=subdir, so git tried to resolve src/foo.txt under subdir/src/ and the snapshot silently failed. Use state.worktree consistently for cwd and path joins in this file so operations are anchored to the same root as --work-tree. Fixes #27688 --- packages/opencode/src/snapshot/index.ts | 27 ++- .../opencode/test/snapshot/snapshot.test.ts | 207 ++++++++++++++++++ 2 files changed, 220 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index f974a457ad7b..3f9cda7aec10 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -76,7 +76,6 @@ export const layer: Layer.Layer( Effect.fn("Snapshot.state")(function* (ctx) { const state = { - directory: ctx.directory, worktree: ctx.worktree, gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)), vcs: ctx.project.vcs, @@ -122,7 +121,7 @@ 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) => { @@ -263,7 +262,7 @@ export const layer: Layer.Layer item.ref).join("\n") + "\n" }, @@ -638,7 +637,7 @@ export const layer: Layer.Layer d.file === "pkg/inner/inner.txt") + expect(inner).toBeDefined() + expect(inner!.status).toBe("modified") + expect(inner!.additions).toBeGreaterThan(0) + expect(inner!.deletions).toBeGreaterThan(0) + expect(inner!.patch).toContain("-inner-before") + expect(inner!.patch).toContain("+inner-after") + const root = diffs.find((d) => d.file === "root.txt") + expect(root).toBeDefined() + expect(root!.patch).toContain("-root-before") + expect(root!.patch).toContain("+root-after") + }).pipe(provideInstance(subdir)) + }), +) + +it.live( + "gitignore is honored when running from a subdirectory", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const subdir = `${tmp.path}/src` + yield* mkdirp(subdir) + yield* write(`${tmp.path}/.gitignore`, "*.ignored\nbuild/\n") + yield* write(`${subdir}/keep.txt`, "KEEP") + yield* exec(tmp.path, ["git", "add", "."]) + yield* exec(tmp.path, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/keep.txt`, "MODIFIED") + yield* write(`${subdir}/scratch.ignored`, "SHOULD-BE-IGNORED") + yield* mkdirp(`${tmp.path}/build`) + yield* write(`${tmp.path}/build/out.js`, "SHOULD-BE-IGNORED") + const patch = yield* snapshot.patch(before!) + expect(patch.files).toContain(fwd(subdir, "keep.txt")) + expect(patch.files).not.toContain(fwd(subdir, "scratch.ignored")) + expect(patch.files).not.toContain(fwd(tmp.path, "build/out.js")) + }).pipe(provideInstance(subdir)) + }), +) + +it.live( + "large files inside the subdirectory are skipped when running from a subdirectory", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const subdir = `${tmp.path}/src` + yield* mkdirp(subdir) + yield* write(`${subdir}/seed.txt`, "seed") + yield* exec(tmp.path, ["git", "add", "."]) + yield* exec(tmp.path, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/huge.bin`, new Uint8Array(2 * 1024 * 1024 + 1)) + yield* write(`${tmp.path}/small.txt`, "small at root") + const patch = yield* snapshot.patch(before!) + expect(patch.files).toContain(fwd(tmp.path, "small.txt")) + expect(patch.files).not.toContain(fwd(subdir, "huge.bin")) + }).pipe(provideInstance(subdir)) + }), +) + +it.live( + "files staged then later gitignored are dropped when running from a subdirectory", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const subdir = `${tmp.path}/src` + yield* mkdirp(subdir) + yield* write(`${subdir}/seed.txt`, "seed") + yield* exec(tmp.path, ["git", "add", "."]) + yield* exec(tmp.path, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + yield* write(`${subdir}/later-ignored.txt`, "initial content") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/later-ignored.txt`, "modified content") + yield* write(`${tmp.path}/.gitignore`, "src/later-ignored.txt\n") + yield* write(`${subdir}/still-tracked.txt`, "new tracked file") + const patch = yield* snapshot.patch(before!) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) + expect(patch.files).toContain(fwd(subdir, "still-tracked.txt")) + }).pipe(provideInstance(subdir)) + }), +) + +it.live( + "snapshot operations work from a subdirectory of a linked worktree", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const worktreePath = `${tmp.path}-worktree` + yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"]) + yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath)) + const subdir = `${worktreePath}/src` + yield* mkdirp(subdir) + yield* write(`${subdir}/inner.txt`, "ORIGINAL") + yield* exec(worktreePath, ["git", "add", "."]) + yield* exec(worktreePath, ["git", "commit", "-m", "add src in linked worktree"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/inner.txt`, "MODIFIED") + yield* write(`${worktreePath}/root.txt`, "ROOT-NEW") + const patch = yield* snapshot.patch(before!) + expect(patch.files).toContain(fwd(subdir, "inner.txt")) + expect(patch.files).toContain(fwd(worktreePath, "root.txt")) + yield* snapshot.revert([patch]) + expect(yield* readText(`${subdir}/inner.txt`)).toBe("ORIGINAL") + expect(yield* exists(`${worktreePath}/root.txt`)).toBe(false) + }).pipe(provideInstance(subdir)) + }), +) + +it.live( + "restore from subdirectory rewrites files across the worktree", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const subdir = `${tmp.path}/src` + yield* mkdirp(subdir) + yield* write(`${tmp.path}/root.txt`, "ROOT-ORIGINAL") + yield* write(`${subdir}/inner.txt`, "INNER-ORIGINAL") + yield* exec(tmp.path, ["git", "add", "."]) + yield* exec(tmp.path, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${tmp.path}/root.txt`, "ROOT-MODIFIED") + yield* write(`${subdir}/inner.txt`, "INNER-MODIFIED") + yield* write(`${subdir}/added.txt`, "ADDED") + yield* snapshot.restore(before!) + expect(yield* readText(`${tmp.path}/root.txt`)).toBe("ROOT-ORIGINAL") + expect(yield* readText(`${subdir}/inner.txt`)).toBe("INNER-ORIGINAL") + }).pipe(provideInstance(subdir)) + }), +) + it.live( "patch detects changes in secondary worktree", Effect.gen(function* () {