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
122 changes: 108 additions & 14 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
import { git as runGit } from "../util/git"

export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
const treehash = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
const retry = [0, 25, 100]
type Gate = {
held: boolean
wait: (() => void)[]
Expand Down Expand Up @@ -134,14 +137,12 @@ export namespace Snapshot {
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
log.info("initialized")
}
await add(git)
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
const staged = await add(git)
if (!staged) return
const tree = await write(git)
if (!tree) return
log.info("tracking", { hash: tree, cwd: Instance.directory, git })
return tree
}

export const Patch = z.object({
Expand All @@ -153,7 +154,8 @@ export namespace Snapshot {
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
using _ = await lock(git)
await add(git)
const staged = await add(git)
if (!staged) return { hash, files: [] }
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet()
Expand Down Expand Up @@ -235,7 +237,8 @@ export namespace Snapshot {
export async function diff(hash: string) {
const git = gitdir()
using _ = await lock(git)
await add(git)
const staged = await add(git)
if (!staged) return ""
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
Expand Down Expand Up @@ -330,10 +333,101 @@ export namespace Snapshot {

async function add(git: string) {
await syncExclude(git)
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
.quiet()
.cwd(Instance.directory)
.nothrow()
const result = await command({
args: [
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"--git-dir",
git,
"--work-tree",
Instance.worktree,
"add",
".",
],
cwd: Instance.directory,
retryLock: true,
})
if (result.exitCode === 0) return true
log.warn("git add failed", {
git,
cwd: Instance.directory,
exitCode: result.exitCode,
stderr: result.stderr,
stdout: result.stdout,
})
return false
}

async function write(git: string) {
const result = await command({
args: ["--git-dir", git, "--work-tree", Instance.worktree, "write-tree"],
cwd: Instance.directory,
retryLock: true,
})
if (result.exitCode !== 0) {
log.warn("write-tree failed", {
git,
cwd: Instance.directory,
exitCode: result.exitCode,
stderr: result.stderr,
stdout: result.stdout,
})
return
}
if (!treehash.test(result.text)) {
log.warn("write-tree returned invalid hash", {
git,
cwd: Instance.directory,
hash: result.text,
})
return
}
return result.text
}

function output(input: Buffer | ReadableStream<Uint8Array>) {
if (Buffer.isBuffer(input)) return input.toString()
return ""
}
Comment on lines +392 to +395
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

output() silently discards ReadableStream stderr — narrow the type to match actual callers.

The ReadableStream<Uint8Array> branch is unreachable: util/git.ts returns Buffer for stderr in both the ACP and non-ACP paths. The overly broad union type creates a false safety guarantee — if stderr were ever actually a stream, conflict() would receive "" and suppress the lock-contention retry silently.

♻️ Proposed fix
-  function output(input: Buffer | ReadableStream<Uint8Array>) {
-    if (Buffer.isBuffer(input)) return input.toString()
-    return ""
-  }
+  function output(input: Buffer) {
+    return input.toString()
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/src/snapshot/index.ts` around lines 392 - 395, The output()
helper currently accepts Buffer | ReadableStream<Uint8Array> and returns "" for
streams, which hides errors when conflict() expects stderr as text; narrow the
signature to accept only Buffer (e.g., change function output(input: Buffer))
and remove the unreachable ReadableStream branch, then update uses (notably
conflict()) to pass the Buffer directly so stderr is always converted via
toString() and no stream path silently drops content; reference the output()
function and the conflict() caller when making this change.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Read the output() function to confirm its signature and implementation
sed -n '392,395p' packages/opencode/src/snapshot/index.ts

Repository: riatzukiza/opencode

Length of output: 200


🏁 Script executed:

# Find all calls to output() in the snapshot/index.ts file
rg -n "output\(" packages/opencode/src/snapshot/index.ts | head -20

Repository: riatzukiza/opencode

Length of output: 177


🏁 Script executed:

# Check the util/git.ts file to understand what stderr type it returns
cat -n packages/opencode/src/util/git.ts | sed -n '1,70p'

Repository: riatzukiza/opencode

Length of output: 2507


Narrow output() parameter type to Buffer — the ReadableStream branch is dead code.

The function accepts Buffer | ReadableStream<Uint8Array> but only handles the Buffer case, silently returning "" for streams. Both branches in util/git.ts always return Buffer for stderr: the ACP branch explicitly creates buffers via Buffer.from(), and the Bun shell branch buffers output by default without stream configuration. The single caller at line 419 passes result.stderr, which is always Buffer. The ReadableStream branch is unreachable and should be removed.

♻️ Proposed fix
-  function output(input: Buffer | ReadableStream<Uint8Array>) {
-    if (Buffer.isBuffer(input)) return input.toString()
-    return ""
-  }
+  function output(input: Buffer) {
+    return input.toString()
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function output(input: Buffer | ReadableStream<Uint8Array>) {
if (Buffer.isBuffer(input)) return input.toString()
return ""
}
function output(input: Buffer) {
return input.toString()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/src/snapshot/index.ts` around lines 392 - 395, The output
function currently accepts Buffer | ReadableStream<Uint8Array> but only handles
Buffer and returns "" for streams; change the signature of output to accept only
Buffer (output(input: Buffer): string), remove the ReadableStream branch and the
unreachable empty-string return, and simply return input.toString(); update any
callers if necessary (the existing caller passes result.stderr which is always a
Buffer) so types align.


function conflict(input: string) {
const text = input.toLowerCase()
if (text.includes("index.lock")) return true
if (text.includes("another git process seems to be running")) return true
return false
}

async function command(input: {
args: string[]
cwd: string
retryLock?: boolean
}) {
let last = {
exitCode: 1,
stderr: "",
stdout: "",
text: "",
}
for (const ms of retry) {
if (ms > 0) await new Promise((resolve) => setTimeout(resolve, ms))
const result = await runGit(input.args, { cwd: input.cwd })
const stdout = (await result.text()).trim()
const stderr = output(result.stderr)
last = {
exitCode: result.exitCode,
stderr,
stdout,
text: stdout,
}
if (last.exitCode === 0) return last
if (!input.retryLock) return last
if (!conflict(`${stderr}\n${stdout}`)) return last
}
return last
}
Comment on lines +404 to 431
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "git\.ts" | head -20

Repository: riatzukiza/opencode

Length of output: 96


🏁 Script executed:

find . -path "*/util/git.ts" -o -path "*/git.ts" | grep -E "(util|src)" | head -10

Repository: riatzukiza/opencode

Length of output: 98


🏁 Script executed:

git ls-files | grep -E "git\.ts" | head -20

Repository: riatzukiza/opencode

Length of output: 96


🏁 Script executed:

cat -n packages/opencode/src/util/git.ts

Repository: riatzukiza/opencode

Length of output: 2507


🏁 Script executed:

cat -n packages/opencode/src/snapshot/index.ts | sed -n '404,431p'

Repository: riatzukiza/opencode

Length of output: 993


🏁 Script executed:

# Check the full snapshot function context and callers
cat -n packages/opencode/src/snapshot/index.ts | sed -n '390,450p'

Repository: riatzukiza/opencode

Length of output: 2133


🏁 Script executed:

# Find where the command() function is called to verify if both stdout and text fields are used
rg -A 3 -B 1 "command\(" packages/opencode/src/snapshot/index.ts | grep -E "(add|write|stdout|text)" | head -40

Repository: riatzukiza/opencode

Length of output: 180


🏁 Script executed:

# Search for calls to the command function to see usage patterns
rg "await command\(" packages/opencode/src/snapshot/index.ts -A 5 -B 2

Repository: riatzukiza/opencode

Length of output: 515


🏁 Script executed:

# Find the full add() and write() functions to see which result fields are used
rg -A 20 "async function add\(git: string\)" packages/opencode/src/snapshot/index.ts

Repository: riatzukiza/opencode

Length of output: 533


🏁 Script executed:

# Find the full write() function
rg -A 15 "async function write\(git: string\)" packages/opencode/src/snapshot/index.ts

Repository: riatzukiza/opencode

Length of output: 518


🏁 Script executed:

# Check if result.text is used anywhere after calling command()
rg "result\.text" packages/opencode/src/snapshot/index.ts

Repository: riatzukiza/opencode

Length of output: 265


🏁 Script executed:

# Let's also verify the retry variable to understand the for loop better
rg -B 30 "async function command" packages/opencode/src/snapshot/index.ts | grep -E "retry|function snapshot"

Repository: riatzukiza/opencode

Length of output: 45


🏁 Script executed:

# Find where retry is defined
rg "retry" packages/opencode/src/snapshot/index.ts -B 5 -A 2 | head -60

Repository: riatzukiza/opencode

Length of output: 1316


🏁 Script executed:

# Check the full context around the command function to see retry definition and usage
cat -n packages/opencode/src/snapshot/index.ts | sed -n '350,410p'

Repository: riatzukiza/opencode

Length of output: 1972


🏁 Script executed:

# Search for where result.text is actually used in the file
rg -n "result\.text" packages/opencode/src/snapshot/index.ts

Repository: riatzukiza/opencode

Length of output: 289


let last with reassignment violates const and functional-methods guidelines; await result.text() is semantically incorrect.

Three issues in this function:

  1. let last with reassignment — the guidelines require const; use early returns instead of accumulation.
  2. for (const ms of retry) imperative loop — the guidelines require functional array methods (find or reduce).
  3. await result.text()GitResult.text() is a synchronous function in both the ACP and Bun shell branches of util/git.ts; await on a synchronous return value is semantically incorrect even though it is harmless.

Additionally, the returned object includes both stdout and text fields with identical values (line 424: text: stdout). The callers in add() use result.exitCode, result.stderr, and result.stdout, while write() needs only result.text; keeping both is unnecessary.

♻️ Proposed refactor addressing all three points
-  async function command(input: {
-    args: string[]
-    cwd: string
-    retryLock?: boolean
-  }) {
-    let last = {
-      exitCode: 1,
-      stderr: "",
-      stdout: "",
-      text: "",
-    }
-    for (const ms of retry) {
-      if (ms > 0) await new Promise((resolve) => setTimeout(resolve, ms))
-      const result = await runGit(input.args, { cwd: input.cwd })
-      const stdout = (await result.text()).trim()
-      const stderr = output(result.stderr)
-      last = {
-        exitCode: result.exitCode,
-        stderr,
-        stdout,
-        text: stdout,
-      }
-      if (last.exitCode === 0) return last
-      if (!input.retryLock) return last
-      if (!conflict(`${stderr}\n${stdout}`)) return last
-    }
-    return last
-  }
+  async function command(input: { args: string[]; cwd: string; retryLock?: boolean }) {
+    const run = async (ms: number) => {
+      if (ms > 0) await new Promise<void>((resolve) => setTimeout(resolve, ms))
+      const result = await runGit(input.args, { cwd: input.cwd })
+      const stdout = result.text().trim()
+      const stderr = output(result.stderr)
+      return { exitCode: result.exitCode, stderr, stdout }
+    }
+    return retry.reduce(
+      async (prev, ms) => {
+        const last = await prev
+        if (last.exitCode === 0) return last
+        if (!input.retryLock) return last
+        if (!conflict(`${last.stderr}\n${last.stdout}`)) return last
+        return run(ms)
+      },
+      run(0),
+    )
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/src/snapshot/index.ts` around lines 404 - 431, The command
function should be rewritten to avoid mutable let and imperative loops, drop the
unnecessary await on result.text(), and stop returning duplicate stdout/text
fields: use a functional array method (e.g., Array.prototype.find or reduce over
retry) to try each delay value, call const result = await runGit(...) and const
stdout = result.text() (no await), compute stderr = output(result.stderr), then
immediately return a const object { exitCode: result.exitCode, stderr, stdout }
as soon as exitCode === 0 or when conflict detection fails; ensure callers that
expect result.text() (write()) use result.stdout (or vice versa) by keeping a
single text field name consistent with add() which uses exitCode, stderr, stdout
(so prefer returning stdout only and remove the duplicate text property).


async function syncExclude(git: string) {
Expand Down
50 changes: 50 additions & 0 deletions packages/opencode/test/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,56 @@ test("cleanup returns immediately when snapshot repo is busy", async () => {
})
})

test("track returns undefined when snapshot index lock blocks staging", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()

const git = path.join(Global.Path.data, "snapshot", Instance.project.id)
const lock = path.join(git, "index.lock")
await Bun.write(lock, "")

try {
const next = await Snapshot.track()
expect(next).toBeUndefined()
} finally {
await fs.unlink(lock).catch(() => {})
}
},
})
})

test("track retries snapshot index lock contention", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()

const git = path.join(Global.Path.data, "snapshot", Instance.project.id)
const lock = path.join(git, "index.lock")
await Bun.write(lock, "")

const timer = setTimeout(() => {
void fs.unlink(lock).catch(() => {})
}, 60)

try {
await Filesystem.write(`${tmp.path}/retry.txt`, "retry")
const next = await Snapshot.track()
expect(next).toBeTruthy()
} finally {
clearTimeout(timer)
await fs.unlink(lock).catch(() => {})
}
},
})
})

test("snapshot state isolation between projects", async () => {
// Test that different projects don't interfere with each other
await using tmp1 = await bootstrap()
Expand Down
Loading