Skip to content

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

@JohnnyVicious

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).

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