Add git worktree support

This commit is contained in:
2026-03-12 21:08:18 +05:30
parent 5a4752f894
commit 52c701b18e
3 changed files with 262 additions and 44 deletions
+121 -44
View File
@@ -1,4 +1,7 @@
import { tool } from "@opencode-ai/plugin";
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";
type WindowLaunchPlan = {
model: string;
@@ -272,6 +275,33 @@ async function launchModelInWindow(sessionName: string, plan: WindowLaunchPlan):
return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]);
}
/**
* Sanitizes a session name to be safe for paths and branch names.
*/
function sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
/**
* Generates the full absolute path for a worktree.
*/
function getWorktreePath(safeSession: string, windowName: string): string {
return path.join(os.homedir(), ".local/share/opencode/multi-model", safeSession, windowName);
}
/**
* Removes a git worktree and deletes its branch on failure.
*/
async function undoWorktree(worktreePath: string, branchName: string): Promise<string[]> {
const errors: string[] = [];
const removeRes = await runCommand(["git", "worktree", "remove", "-f", worktreePath]);
if (!removeRes.ok) errors.push(`Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`);
const branchRes = await runCommand(["git", "branch", "-D", branchName]);
if (!branchRes.ok) errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`);
return errors;
}
export default tool({
description: "Launch multiple OpenCode models in tmux",
args: {
@@ -283,6 +313,7 @@ export default tool({
},
async execute(args, context) {
const sessionName = args.sessionName.trim();
const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(args.models);
if (!sessionName) {
@@ -298,6 +329,11 @@ export default tool({
return `Error: Duplicate model names are not allowed: ${duplicateModels.map((item) => `'${item}'`).join(", ")}.`;
}
const gitRepoCheck = await runCommand(["git", "rev-parse", "--is-inside-work-tree"]);
if (!gitRepoCheck.ok) {
return "Error: `multi-model` requires a git repository.";
}
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
if (!tmuxExists.ok) {
return "Error: `tmux` is not installed or not on `PATH`.";
@@ -329,66 +365,102 @@ export default tool({
}
const windowPlans = createWindowPlans(models);
const [firstWindow, ...remainingWindows] = windowPlans;
const succeededModels: string[] = [];
const failedModels: string[] = [];
// `tmux new-session` always creates the session's initial window, so we reuse that required first window for the first model.
const sessionCreateResult = await runCommand([
"tmux",
"new-session",
"-d",
"-s",
sessionName,
"-n",
firstWindow.windowName,
"-c",
context.directory,
]);
for (let i = 0; i < windowPlans.length; i++) {
const plan = windowPlans[i];
const isFirst = i === 0;
if (!sessionCreateResult.ok) {
return `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`;
}
const worktreePath = getWorktreePath(safeSessionName, plan.windowName);
const branchName = `opencode/${safeSessionName}/${plan.windowName}`;
const firstLaunchResult = await launchModelInWindow(sessionName, firstWindow);
if (firstLaunchResult.ok) {
succeededModels.push(firstWindow.model);
} else {
failedModels.push(`${firstWindow.model} (${firstLaunchResult.stderr || "failed to send launch command"})`);
}
if (fs.existsSync(worktreePath)) {
if (!isFirst) {
failedModels.push(`${plan.model} (Worktree path already exists at ${worktreePath})`);
continue;
} else {
return `Error: Worktree path already exists at ${worktreePath}. Clean it up first.`;
}
}
for (const plan of remainingWindows) {
const windowCreateResult = await runCommand([
"tmux",
"new-window",
"-d",
"-t",
sessionName,
"-n",
plan.windowName,
"-c",
context.directory,
]);
const branchExists = await runCommand(["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]);
if (branchExists.ok) {
if (!isFirst) {
failedModels.push(`${plan.model} (Branch '${branchName}' already exists)`);
continue;
} else {
return `Error: Branch '${branchName}' already exists.`;
}
}
if (!windowCreateResult.ok) {
failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`);
continue;
const worktreeResult = await runCommand(["git", "worktree", "add", "-b", branchName, worktreePath]);
if (!worktreeResult.ok) {
if (!isFirst) {
failedModels.push(`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`);
continue;
} else {
return `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}`;
}
}
if (isFirst) {
const sessionCreateResult = await runCommand([
"tmux",
"new-session",
"-d",
"-s",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!sessionCreateResult.ok) {
const undoErrors = await undoWorktree(worktreePath, branchName);
let errorMsg = `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`;
if (undoErrors.length > 0) errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`;
return errorMsg;
}
} else {
const windowCreateResult = await runCommand([
"tmux",
"new-window",
"-d",
"-t",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!windowCreateResult.ok) {
failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`);
const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
continue;
}
}
const launchResult = await launchModelInWindow(sessionName, plan);
if (!launchResult.ok) {
if (launchResult.ok) {
succeededModels.push(plan.model);
} else {
failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`);
continue;
const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
}
succeededModels.push(plan.model);
}
const attachCommand = `tmux attach -t ${sessionName}`;
const cleanupCommand = `rm -rf ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')`;
context.metadata({
title: `multi-model ${sessionName}`,
metadata: {
sessionName,
safeSessionName,
modelCount: models.length,
},
});
@@ -399,9 +471,14 @@ export default tool({
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
`Failed: ${failedModels.join(", ")}.`,
`Attach with \`${attachCommand}\` to inspect the session.`,
].join(" ");
`Cleanup with \n\`${cleanupCommand}\`\nwhen done.`
].join("\n");
}
return `Use \`${attachCommand}\` to join session.`;
return [
`Use \`${attachCommand}\` to join session.`,
`When finished, clean up worktrees with:`,
`\`${cleanupCommand}\``
].join("\n");
},
});