From d771b568b403242b17c5cde210823997584f7671 Mon Sep 17 00:00:00 2001 From: snf Date: Wed, 18 Mar 2026 11:38:10 +0200 Subject: [PATCH] fix: add timeout to snapshot git add to handle large worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When opencode is used in a workspace containing large non-code files (media, datasets, ML models, etc.), the `git add .` in the snapshot system can run indefinitely — consuming excessive CPU and memory. This adds a 15-second timeout to `git add .`. If it times out, the snapshot system generates an exclude file that: - Skips known binary/large file extensions (.mp4, .zip, .onnx, etc.) - Copies the project's .gitignore rules - Scans top-level directories and excludes any over 100MB The retry `git add .` then runs with these exclusions in place. Normal-sized projects see zero overhead since the timeout is never hit. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/snapshot/index.ts | 117 +++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a9489451c410..cdd098e13350 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -11,6 +11,24 @@ import { Log } from "../util/log" const log = Log.create({ service: "snapshot" }) const PRUNE = "7.days" +const GIT_ADD_TIMEOUT = Duration.seconds(15) + +const LARGE_FILE_EXTENSIONS = [ + ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", + ".iso", ".img", ".dmg", + ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", + ".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".psd", ".raw", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".bin", ".dat", ".db", ".sqlite", ".sqlite3", + ".so", ".dylib", ".dll", ".exe", ".o", ".a", + ".whl", ".egg", ".jar", ".war", + ".pack", ".idx", + ".parquet", ".arrow", ".feather", ".h5", ".hdf5", + ".onnx", ".pt", ".pth", ".safetensors", ".gguf", +] + +const LARGE_DIR_THRESHOLD = 100 * 1024 * 1024 // 100MB // Common git config flags shared across snapshot operations const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] @@ -169,9 +187,106 @@ export class SnapshotService extends ServiceMap.Service => + Effect.gen(function* () { + let total = 0 + if (maxDepth <= 0) return total + const dirExists = yield* exists(dir) + if (!dirExists) return total + const entries = yield* fileSystem.readDirectory(dir).pipe(Effect.orDie) + for (const entry of entries) { + if (entry.startsWith(".")) continue + const fullPath = path.join(dir, entry) + const stat = yield* fileSystem.stat(fullPath).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ) + if (!stat) continue + if (stat.type === "File") { + total += stat.size + } else if (stat.type === "Directory") { + total += yield* scanDir(fullPath, maxDepth - 1) + } + if (total > LARGE_DIR_THRESHOLD) return total + } + return total + }) + + const worktreeExists = yield* exists(worktree) + if (worktreeExists) { + const entries = yield* fileSystem.readDirectory(worktree).pipe(Effect.orDie) + for (const entry of entries) { + if (entry.startsWith(".")) continue + const fullPath = path.join(worktree, entry) + const stat = yield* fileSystem.stat(fullPath).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ) + if (!stat || stat.type !== "Directory") continue + const size = yield* scanDir(fullPath, 3) + if (size > LARGE_DIR_THRESHOLD) { + rules.push(`${entry}/`) + log.info("excluding large directory from snapshots", { + dir: entry, + sizeMB: Math.round(size / 1024 / 1024), + }) + } + } + } + + yield* writeFile(target, rules.join("\n") + "\n") + log.info("generated snapshot exclude file for large worktree", { rules: rules.length }) + }) + const add = Effect.gen(function* () { yield* syncExclude - yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }) + // Run git add with a timeout — if it takes too long, the worktree likely + // contains large non-code files. Generate exclude rules and retry. + const timedOut = yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }).pipe( + Effect.timeout(GIT_ADD_TIMEOUT), + Effect.as(false), + Effect.catchTag("TimeoutError", () => Effect.succeed(true)), + ) + if (timedOut) { + log.warn("git add timed out, scanning for large files to exclude", { + timeoutMs: Duration.toMillis(GIT_ADD_TIMEOUT), + }) + yield* generateLargeFileExcludes + yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }) + } }) // --- service methods ---