Motivation
/opencode:rescue --write currently edits the working directory in-place. This is fine when the user wants to review OpenCode's changes against live files, but it's risky for:
- exploratory rescue runs ("try something, I'll keep it if it works")
- parallel rescue runs (two concurrent rescues clobber each other)
- running OpenCode against a dirty tree where the user wants to keep their in-progress work untouched
The upstream PR adds a --worktree flag that creates a disposable git worktree, runs OpenCode inside it with full write access, and gives the user an explicit "keep" / "discard" choice at the end — with the keep path applying the worktree diff back to the main tree as a staged patch.
Proposed UX
/opencode:rescue --worktree --write "refactor the auth module"
# ...OpenCode runs...
# Output includes:
# Worktree branch: opencode/1712345678
# Path: /repo/.worktrees/opencode-1712345678
# Changes: 4 files changed, 127 insertions(+), 42 deletions(-)
# To keep: node .../opencode-companion.mjs worktree-cleanup <job-id> --action keep
# To discard: node .../opencode-companion.mjs worktree-cleanup <job-id> --action discard
Implementation from upstream
New file plugins/opencode/scripts/lib/worktree.mjs:
import {
createWorktree,
removeWorktree,
deleteWorktreeBranch,
getWorktreeDiff,
applyWorktreePatch,
} from "./git.mjs";
import { getGitRoot } from "./git.mjs";
export async function createWorktreeSession(cwd) {
const repoRoot = await getGitRoot(cwd);
if (!repoRoot) throw new Error("Not a git repository — --worktree requires one.");
return createWorktree(repoRoot);
}
export function diffWorktreeSession(session) {
return getWorktreeDiff(session.worktreePath, session.baseCommit);
}
export function cleanupWorktreeSession(session, { keep = false } = {}) {
if (keep) {
const result = applyWorktreePatch(session.repoRoot, session.worktreePath, session.baseCommit);
if (!result.applied && result.detail !== "No changes to apply.") {
return result;
}
removeWorktree(session.repoRoot, session.worktreePath);
deleteWorktreeBranch(session.repoRoot, session.branch);
return result;
}
removeWorktree(session.repoRoot, session.worktreePath);
deleteWorktreeBranch(session.repoRoot, session.branch);
return { applied: false, detail: "Worktree discarded." };
}
Additions to plugins/opencode/scripts/lib/git.mjs:
import fs from "node:fs";
import path from "node:path";
export async function createWorktree(repoRoot) {
const ts = Date.now();
const worktreesDir = path.join(repoRoot, ".worktrees");
fs.mkdirSync(worktreesDir, { recursive: true });
// Add .worktrees/ to git/info/exclude if not already present, so the
// worktree dir never shows up in the main repo's status.
const rawGitDir = (await runCommand("git", ["rev-parse", "--git-dir"], { cwd: repoRoot })).stdout.trim();
const gitDir = path.resolve(repoRoot, rawGitDir);
const excludePath = path.join(gitDir, "info", "exclude");
const excludeContent = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
if (!excludeContent.includes(".worktrees")) {
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
fs.appendFileSync(
excludePath,
`${excludeContent.endsWith("\n") || !excludeContent ? "" : "\n"}.worktrees/\n`
);
}
const worktreePath = path.join(worktreesDir, `opencode-${ts}`);
const branch = `opencode/${ts}`;
const baseCommit = (await runCommand("git", ["rev-parse", "HEAD"], { cwd: repoRoot })).stdout.trim();
await runCommand("git", ["worktree", "add", worktreePath, "-b", branch], { cwd: repoRoot });
return { worktreePath, branch, repoRoot, baseCommit, timestamp: ts };
}
export async function removeWorktree(repoRoot, worktreePath) {
await runCommand("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot });
}
export async function deleteWorktreeBranch(repoRoot, branch) {
await runCommand("git", ["branch", "-D", branch], { cwd: repoRoot });
}
export async function getWorktreeDiff(worktreePath, baseCommit) {
await runCommand("git", ["add", "-A"], { cwd: worktreePath });
const statR = await runCommand("git", ["diff", "--cached", baseCommit, "--stat"], { cwd: worktreePath });
if (statR.exitCode !== 0 || !statR.stdout.trim()) return { stat: "", patch: "" };
const patchR = await runCommand("git", ["diff", "--cached", baseCommit], { cwd: worktreePath });
return { stat: statR.stdout.trim(), patch: patchR.stdout };
}
export async function applyWorktreePatch(repoRoot, worktreePath, baseCommit) {
await runCommand("git", ["add", "-A"], { cwd: worktreePath });
const patchR = await runCommand("git", ["diff", "--cached", baseCommit], { cwd: worktreePath });
if (patchR.exitCode !== 0 || !patchR.stdout.trim()) {
return { applied: false, detail: "No changes to apply." };
}
const patchPath = path.join(
repoRoot,
`.opencode-worktree-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`
);
try {
fs.writeFileSync(patchPath, patchR.stdout, "utf8");
const applyR = await runCommand("git", ["apply", "--index", patchPath], { cwd: repoRoot });
if (applyR.exitCode !== 0) {
return { applied: false, detail: applyR.stderr.trim() || "Patch apply failed (conflicts?)." };
}
return { applied: true, detail: "Changes applied and staged." };
} finally {
fs.rmSync(patchPath, { force: true });
}
}
opencode-companion.mjs changes:
- Add
worktree to booleanOptions in handleTask's parseArgs call.
- When
options.worktree && options.write, wrap the task run in createWorktreeSession / diffWorktreeSession / cleanup-on-error.
- Add a new subcommand
worktree-cleanup that takes a job ID and --action keep|discard, reads the saved worktree session from the job data file, and calls cleanupWorktreeSession.
agents/opencode-rescue.md + commands/rescue.md:
- Add
--worktree to the argument hint.
- Document: "
--worktree is an isolation flag. Preserve it for the forwarded task call. When present, OpenCode runs in an isolated git worktree instead of editing the working directory in-place."
Test plan (new file tests/worktree.test.mjs)
- Create a temp git repo, call
createWorktreeSession, assert worktree dir exists, branch opencode/<ts> exists, .git/info/exclude contains .worktrees/.
- Write a file in the worktree, call
diffWorktreeSession, assert stat + patch non-empty.
- Call
cleanupWorktreeSession with keep: true, assert patch applies cleanly to repoRoot and shows up in git diff --cached, worktree and branch removed.
- Discard path: call with
keep: false, assert worktree and branch gone, main tree untouched.
- Conflict path: make an edit in the main tree that conflicts with the worktree edit, call keep, assert
{ applied: false, detail: ... } returned, worktree preserved.
Upstream reference
openai/codex-plugin-cc#137 (open).
Port of openai/codex-plugin-cc#137 (open, novel feature)
Motivation
/opencode:rescue --writecurrently edits the working directory in-place. This is fine when the user wants to review OpenCode's changes against live files, but it's risky for:The upstream PR adds a
--worktreeflag that creates a disposable git worktree, runs OpenCode inside it with full write access, and gives the user an explicit "keep" / "discard" choice at the end — with the keep path applying the worktree diff back to the main tree as a staged patch.Proposed UX
Implementation from upstream
New file
plugins/opencode/scripts/lib/worktree.mjs:Additions to
plugins/opencode/scripts/lib/git.mjs:opencode-companion.mjschanges:worktreetobooleanOptionsinhandleTask'sparseArgscall.options.worktree && options.write, wrap the task run increateWorktreeSession/diffWorktreeSession/ cleanup-on-error.worktree-cleanupthat takes a job ID and--action keep|discard, reads the saved worktree session from the job data file, and callscleanupWorktreeSession.agents/opencode-rescue.md+commands/rescue.md:--worktreeto the argument hint.--worktreeis an isolation flag. Preserve it for the forwardedtaskcall. When present, OpenCode runs in an isolated git worktree instead of editing the working directory in-place."Test plan (new file
tests/worktree.test.mjs)createWorktreeSession, assert worktree dir exists, branchopencode/<ts>exists,.git/info/excludecontains.worktrees/.diffWorktreeSession, assert stat + patch non-empty.cleanupWorktreeSessionwithkeep: true, assert patch applies cleanly torepoRootand shows up ingit diff --cached, worktree and branch removed.keep: false, assert worktree and branch gone, main tree untouched.{ applied: false, detail: ... }returned, worktree preserved.Upstream reference
openai/codex-plugin-cc#137 (open).