mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
snapshot: fix cwd for git commands in subdirs
When opencode is launched from a git repository subdirectory, the snapshot service passes file paths relative to the worktree root but sets cwd to the launch directory. Git cannot resolve the paths and fails with "did not match any files". Use the worktree root as cwd so paths resolve correctly regardless of which subdirectory opencode is started from. Fix #27688 Close #27737
This commit is contained in:
@@ -85,6 +85,10 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
|
||||
const feed = (list: string[]) => list.join("\0") + "\0"
|
||||
const feedSpec = (list: string[]) => feed(list.map((item) => `:(top,literal)${item}`))
|
||||
|
||||
const scope = path.relative(state.worktree, state.directory).replaceAll("\\", "/")
|
||||
const spec = scope ? `:(top,literal)${scope}` : "."
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
|
||||
@@ -122,7 +126,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
"-z",
|
||||
],
|
||||
{
|
||||
cwd: state.directory,
|
||||
cwd: state.worktree,
|
||||
stdin: feed(files),
|
||||
},
|
||||
)
|
||||
@@ -138,8 +142,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
|
||||
],
|
||||
{
|
||||
cwd: state.directory,
|
||||
stdin: feed(files),
|
||||
cwd: state.worktree,
|
||||
stdin: feedSpec(files),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -149,8 +153,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
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: feedSpec(files),
|
||||
},
|
||||
)
|
||||
if (result.code === 0) return
|
||||
@@ -197,11 +201,11 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
yield* sync()
|
||||
const [diff, other] = yield* Effect.all(
|
||||
[
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", spec])], {
|
||||
cwd: state.worktree,
|
||||
}),
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", spec])], {
|
||||
cwd: state.worktree,
|
||||
}),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
@@ -239,7 +243,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
(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) => {
|
||||
@@ -306,9 +310,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", spec])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
cwd: state.worktree,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
@@ -338,24 +342,47 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
const listed = yield* git([...quote, ...args(["ls-tree", "-r", "-z", "--name-only", snapshot, "--", spec])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (listed.code !== 0) {
|
||||
log.error("failed to list snapshot files", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
exitCode: listed.code,
|
||||
stderr: listed.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
const files = listed.text.split("\0").filter(Boolean)
|
||||
if (!files.length) return
|
||||
|
||||
const index = path.join(state.gitdir, "restore.index")
|
||||
yield* remove(index)
|
||||
yield* Effect.gen(function* () {
|
||||
const result = yield* git([...core, ...args(["read-tree", `--index-output=${index}`, snapshot])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-f", "--stdin", "-z"])], {
|
||||
cwd: state.worktree,
|
||||
env: { GIT_INDEX_FILE: index },
|
||||
stdin: feed(files),
|
||||
})
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}).pipe(Effect.ensuring(remove(index)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -479,9 +506,12 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", spec])],
|
||||
{
|
||||
cwd: state.worktree,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
@@ -637,8 +667,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: state.directory },
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", spec])],
|
||||
{ cwd: state.worktree },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
@@ -649,9 +679,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", spec])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
cwd: state.worktree,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -476,6 +476,99 @@ it.instance(
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live(
|
||||
"subdirectory instances stage snapshot files relative to the worktree root",
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* scopedGitTmpdir()
|
||||
const subdir = `${dir}/src`
|
||||
yield* mkdirp(subdir)
|
||||
yield* write(`${subdir}/tracked.txt`, "tracked content")
|
||||
yield* exec(dir, ["git", "add", "."])
|
||||
yield* exec(dir, ["git", "commit", "-m", "add subdir"])
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
yield* write(`${subdir}/date.txt`, "subdirectory content")
|
||||
const patch = yield* snapshot.patch(before!)
|
||||
expect(patch.files).toContain(fwd(subdir, "date.txt"))
|
||||
}).pipe(provideInstance(subdir))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"subdirectory instances keep gitignored snapshot files out of patches",
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* scopedGitTmpdir()
|
||||
const subdir = `${dir}/src`
|
||||
yield* mkdirp(subdir)
|
||||
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(`${subdir}/.gitignore`, "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(subdir, ".gitignore"))
|
||||
expect(patch.files).toContain(fwd(subdir, "still-tracked.txt"))
|
||||
}).pipe(provideInstance(subdir))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"subdirectory restore does not overwrite files outside the subdirectory",
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* scopedGitTmpdir()
|
||||
const subdir = `${dir}/src`
|
||||
yield* write(`${dir}/root.txt`, "original root")
|
||||
yield* write(`${subdir}/file.txt`, "original src")
|
||||
yield* exec(dir, ["git", "add", "."])
|
||||
yield* exec(dir, ["git", "commit", "-m", "init"])
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
yield* write(`${dir}/root.txt`, "root snapshot")
|
||||
expect(yield* snapshot.track()).toBeTruthy()
|
||||
}).pipe(provideInstance(dir))
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
yield* write(`${subdir}/file.txt`, "src snapshot")
|
||||
const before = yield* snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
yield* write(`${dir}/root.txt`, "root current")
|
||||
yield* write(`${subdir}/file.txt`, "src current")
|
||||
yield* snapshot.restore(before!)
|
||||
expect(yield* readText(`${dir}/root.txt`)).toBe("root current")
|
||||
expect(yield* readText(`${subdir}/file.txt`)).toBe("src snapshot")
|
||||
}).pipe(provideInstance(subdir))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"subdirectory scope is treated as a literal git pathspec",
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* scopedGitTmpdir()
|
||||
const subdir = `${dir}/src*`
|
||||
const sibling = `${dir}/srca`
|
||||
yield* write(`${subdir}/file.txt`, "literal original")
|
||||
yield* write(`${sibling}/file.txt`, "sibling original")
|
||||
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`, "literal modified")
|
||||
yield* write(`${sibling}/file.txt`, "sibling modified")
|
||||
const patch = yield* snapshot.patch(before!)
|
||||
expect(patch.files).toContain(fwd(subdir, "file.txt"))
|
||||
expect(patch.files).not.toContain(fwd(sibling, "file.txt"))
|
||||
}).pipe(provideInstance(subdir))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"gitignore updated between track calls filters from diff",
|
||||
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
||||
|
||||
Reference in New Issue
Block a user