Skip to content

Port: --worktree flag for isolated write-capable rescue tasks #43

Closed

Description

@JohnnyVicious

Port of openai/codex-plugin-cc#137 (open, novel feature)

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)

  1. Create a temp git repo, call createWorktreeSession, assert worktree dir exists, branch opencode/<ts> exists, .git/info/exclude contains .worktrees/.
  2. Write a file in the worktree, call diffWorktreeSession, assert stat + patch non-empty.
  3. Call cleanupWorktreeSession with keep: true, assert patch applies cleanly to repoRoot and shows up in git diff --cached, worktree and branch removed.
  4. Discard path: call with keep: false, assert worktree and branch gone, main tree untouched.
  5. 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).

Activity

  1. JohnnyVicious commented on Apr 12, 2026

    @JohnnyVicious
    OwnerAuthor

    Consolidated into #34. The --worktree flag is the concrete UX for this same isolated-rescue-worktree feature.

  2. added 2 commits that reference this issue on Apr 12, 2026
    23f7fef
    e6a18bc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions