mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Add git worktree support
This commit is contained in:
+121
-44
@@ -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");
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user