Skip to content
Open
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
185 changes: 106 additions & 79 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Log } from "../util/log"
Expand All @@ -13,6 +12,34 @@ export namespace Snapshot {
const hour = 60 * 60 * 1000
const prune = "7.days"

// Helper to run raw commands using Bun.spawn (works better with non-ASCII paths on Windows)
async function gitSpawnRaw(args: string[], cwd: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const proc = Bun.spawn(args, {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
return { exitCode, stdout, stderr }
}

// Helper to run git commands with --git-dir and --work-tree using Bun.spawn
async function gitSpawn(args: string[], cwd: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const git = gitdir()
const fullArgs = ["git", "--git-dir", git, "--work-tree", Instance.worktree, ...args]
return gitSpawnRaw(fullArgs, cwd)
}

// Helper for git commands with extra config options
async function gitSpawnWithConfig(config: string[], args: string[], cwd: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const git = gitdir()
const configArgs = config.flatMap(c => ["-c", c])
const fullArgs = ["git", ...configArgs, "--git-dir", git, "--work-tree", Instance.worktree, ...args]
return gitSpawnRaw(fullArgs, cwd)
}

export function init() {
Scheduler.register({
id: "snapshot.cleanup",
Expand All @@ -32,15 +59,12 @@ export namespace Snapshot {
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
.quiet()
.cwd(Instance.directory)
.nothrow()
const result = await gitSpawn(["gc", `--prune=${prune}`], Instance.directory)
if (result.exitCode !== 0) {
log.warn("cleanup failed", {
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
stderr: result.stderr,
stdout: result.stdout,
})
return
}
Expand All @@ -53,26 +77,17 @@ export namespace Snapshot {
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
})
.quiet()
.nothrow()
// Configure git to not convert line endings on Windows
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
// Use Bun.spawn for Windows non-ASCII path compatibility
await gitSpawnRaw(["git", "init", "--bare", git], Instance.worktree)
await gitSpawnRaw(["git", "--git-dir", git, "config", "core.autocrlf", "false"], Instance.worktree)
log.info("initialized")
}
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
// Use Bun.spawn instead of $ template for Windows non-ASCII path compatibility
await gitSpawn(["add", "."], Instance.directory)
const result = await gitSpawn(["write-tree"], Instance.directory)
const hash = result.stdout.trim()
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
return hash
}

export const Patch = z.object({
Expand All @@ -82,21 +97,20 @@ export namespace Snapshot {
export type Patch = z.infer<typeof Patch>

export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const result =
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
await gitSpawn(["add", "."], Instance.directory)
const result = await gitSpawnWithConfig(
["core.autocrlf=false"],
["diff", "--no-ext-diff", "--name-only", hash, "--", "."],
Instance.directory
)

// If git diff fails, return empty patch
if (result.exitCode !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.exitCode })
return { hash, files: [] }
}

const files = result.text()
const files = result.stdout
return {
hash,
files: files
Expand All @@ -111,19 +125,24 @@ export namespace Snapshot {

export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result =
await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
.quiet()
.cwd(Instance.worktree)
.nothrow()

if (result.exitCode !== 0) {
log.error("failed to restore snapshot", {
// Run read-tree and checkout-index as separate commands
const readTreeResult = await gitSpawn(["read-tree", snapshot], Instance.worktree)
if (readTreeResult.exitCode !== 0) {
log.error("failed to read-tree snapshot", {
snapshot,
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
exitCode: readTreeResult.exitCode,
stderr: readTreeResult.stderr,
stdout: readTreeResult.stdout,
})
return
}
const checkoutResult = await gitSpawn(["checkout-index", "-a", "-f"], Instance.worktree)
if (checkoutResult.exitCode !== 0) {
log.error("failed to checkout-index snapshot", {
snapshot,
exitCode: checkoutResult.exitCode,
stderr: checkoutResult.stderr,
stdout: checkoutResult.stdout,
})
}
}
Expand All @@ -134,24 +153,30 @@ export namespace Snapshot {
for (const item of patches) {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
// Convert to relative path with forward slashes for git compatibility on Windows
const relativePath = path.relative(Instance.worktree, file).replace(/\\/g, "/")
log.info("reverting", { file, relativePath, hash: item.hash, git })
// Use Bun.spawn instead of $ template for Windows non-ASCII path compatibility
const result = await gitSpawn(["checkout", item.hash, "--", relativePath], Instance.worktree)
if (result.exitCode !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree =
await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
log.info("checkout failed, checking if file exists in snapshot", {
file,
relativePath,
hash: item.hash,
exitCode: result.exitCode,
stderr: result.stderr,
})
const checkTree = await gitSpawn(["ls-tree", item.hash, "--", relativePath], Instance.worktree)
const treeOutput = checkTree.stdout.trim()
log.info("ls-tree result", { relativePath, exitCode: checkTree.exitCode, output: treeOutput, stderr: checkTree.stderr })
if (checkTree.exitCode === 0 && treeOutput) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
relativePath,
stderr: result.stderr,
})
} else {
log.info("file did not exist in snapshot, deleting", { file })
log.info("file did not exist in snapshot, deleting", { file, relativePath })
await fs.unlink(file).catch(() => {})
}
}
Expand All @@ -161,25 +186,24 @@ export namespace Snapshot {
}

export async function diff(hash: string) {
const git = gitdir()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const result =
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
.cwd(Instance.worktree)
.nothrow()
await gitSpawn(["add", "."], Instance.directory)
const result = await gitSpawnWithConfig(
["core.autocrlf=false"],
["diff", "--no-ext-diff", hash, "--", "."],
Instance.worktree
)

if (result.exitCode !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
stderr: result.stderr,
stdout: result.stdout,
})
return ""
}

return result.text().trim()
return result.stdout.trim()
}

export const FileDiff = z
Expand All @@ -194,26 +218,29 @@ export namespace Snapshot {
ref: "FileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>

export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
const result: FileDiff[] = []

const show = async (hash: string, file: string) => {
const response =
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${hash}:${file}`
.quiet()
.nothrow()
if (response.exitCode === 0) return response.text()
const stderr = response.stderr.toString()
const response = await gitSpawnWithConfig(
["core.autocrlf=false"],
["show", `${hash}:${file}`],
Instance.worktree
)
if (response.exitCode === 0) return response.stdout
const stderr = response.stderr
if (stderr.toLowerCase().includes("does not exist in")) return ""
return `[DEBUG ERROR] git show ${hash}:${file} failed: ${stderr}`
}

for await (const line of $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.lines()) {
const diffResult = await gitSpawnWithConfig(
["core.autocrlf=false"],
["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."],
Instance.directory
)

for (const line of diffResult.stdout.split("\n")) {
if (!line) continue
const [additions, deletions, rawFile] = line.split("\t")
const file = unquote(rawFile)
Expand Down