Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 62 additions & 32 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -122,7 +126,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
"-z",
],
{
cwd: state.directory,
cwd: state.worktree,
stdin: feed(files),
},
)
Expand All @@ -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),
},
)
})
Expand All @@ -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
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"])], {
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: listed.code,
stderr: listed.stderr,
})
return
}
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 (checkout.code === 0) return
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: checkout.code,
stderr: checkout.stderr,
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
}).pipe(Effect.ensuring(remove(index)))
}),
)
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")) {
Expand All @@ -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,
},
)

Expand Down
93 changes: 93 additions & 0 deletions packages/opencode/test/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down
Loading