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
+52
View File
@@ -0,0 +1,52 @@
{
"name": "opencode-multi-model-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencode-multi-model-tools",
"dependencies": {
"@opencode-ai/plugin": "1.2.24"
},
"devDependencies": {
"@types/node": "^25.4.0"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.2.24",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.2.24",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.24",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
@@ -0,0 +1,89 @@
---
llm: google/gemini-3.1-pro-preview
status: draft
---
# Multi-model git worktree support plan
## Goal
Enhance the `multi-model` tool to:
1. Create a unique git worktree for each session + modelName.
2. Store worktree folders inside `~/.local/share/opencode/multi-model/<sessionName>/<windowName>`.
3. Initialize `opencode` with that specific worktree to avoid conflicts.
## Implementation Details
- **Target file:** `.opencode/tools/multi-model.ts`
- **Path Sanitization:**
- Sanitize `sessionName` (e.g., replace non-alphanumeric with hyphens) to ensure safe paths and branch names. `windowName` is already sanitized by existing code.
- **Worktree Path Construction:**
- `worktreePath`: `~/.local/share/opencode/multi-model/<sanitizedSessionName>/<windowName>`.
- **Git Repository Check:**
- Run `git rev-parse --is-inside-work-tree` at the start of `execute`.
- If false, gracefully fail with an error `multi-model requires a git repository`.
- **Creating Worktrees:**
- Iterate through `windowPlans` to create tmux windows:
- Generate `worktreePath`.
- Generate `branchName` as `opencode/<sanitizedSessionName>/<windowName>`.
- Handle edge cases (Fail Fast approach):
- Run `fs.existsSync(worktreePath)` to check if the path exists. If it does, fail fast with an error indicating the path collision.
- Run `git show-ref --verify --quiet refs/heads/<branchName>` to check if the branch exists. If it does, fail fast with an error indicating the branch collision.
- Run `git worktree add -b <branchName> <worktreePath>` to create the new branch and worktree.
- In `tmux new-session` and `tmux new-window`, pass the newly created `worktreePath` to the `-c` argument instead of `context.directory`.
- **Cleanup Strategy:**
- On launch failure: If `tmux` or `opencode` launch fails *after* worktree creation, run `git worktree remove -f <worktreePath>` and `git branch -D <branchName>`. Keep track of successful and failed cleanups (undos) and report them in the tool output if the undo process fails.
- On success: Output a command snippet to the user on how to clean up the session's worktrees and branches when done:
`rm -rf ~/.local/share/opencode/multi-model/<sanitizedSessionName> && git worktree prune && git branch -d $(git branch --format='%(refname:short)' --list 'opencode/<sanitizedSessionName>/*')`
## Non-obvious Code Blocks
**Path Sanitization & Generation (Example):**
```ts
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";
function sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
function getWorktreePath(safeSession: string, windowName: string): string {
return path.join(os.homedir(), ".local/share/opencode/multi-model", safeSession, windowName);
}
```
**Creating Worktree (Example):**
```ts
const safeSession = sanitizeName(sessionName);
const branchName = `opencode/${safeSession}/${plan.windowName}`;
if (fs.existsSync(worktreePath)) {
return `Error: Worktree path already exists at ${worktreePath}. Clean it up first.`;
}
const branchExists = await runCommand(["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]);
if (branchExists.ok) {
return `Error: Branch '${branchName}' already exists.`;
}
const worktreeCmd = ["git", "worktree", "add", "-b", branchName, worktreePath];
const result = await runCommand(worktreeCmd);
if (!result.ok) {
return `Error: Failed to create worktree for ${plan.model}: ${result.stderr}`;
}
```
**Cleanup on Failure (Example):**
```ts
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;
}
```
+99 -22
View File
@@ -1,4 +1,7 @@
import { tool } from "@opencode-ai/plugin"; 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 = { type WindowLaunchPlan = {
model: string; 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"]); 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({ export default tool({
description: "Launch multiple OpenCode models in tmux", description: "Launch multiple OpenCode models in tmux",
args: { args: {
@@ -283,6 +313,7 @@ export default tool({
}, },
async execute(args, context) { async execute(args, context) {
const sessionName = args.sessionName.trim(); const sessionName = args.sessionName.trim();
const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(args.models); const models = normalizeModels(args.models);
if (!sessionName) { if (!sessionName) {
@@ -298,6 +329,11 @@ export default tool({
return `Error: Duplicate model names are not allowed: ${duplicateModels.map((item) => `'${item}'`).join(", ")}.`; 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"]); const tmuxExists = await runCommand(["command", "-v", "tmux"]);
if (!tmuxExists.ok) { if (!tmuxExists.ok) {
return "Error: `tmux` is not installed or not on `PATH`."; return "Error: `tmux` is not installed or not on `PATH`.";
@@ -329,11 +365,46 @@ export default tool({
} }
const windowPlans = createWindowPlans(models); const windowPlans = createWindowPlans(models);
const [firstWindow, ...remainingWindows] = windowPlans;
const succeededModels: string[] = []; const succeededModels: string[] = [];
const failedModels: 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. for (let i = 0; i < windowPlans.length; i++) {
const plan = windowPlans[i];
const isFirst = i === 0;
const worktreePath = getWorktreePath(safeSessionName, plan.windowName);
const branchName = `opencode/${safeSessionName}/${plan.windowName}`;
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.`;
}
}
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.`;
}
}
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([ const sessionCreateResult = await runCommand([
"tmux", "tmux",
"new-session", "new-session",
@@ -341,23 +412,18 @@ export default tool({
"-s", "-s",
sessionName, sessionName,
"-n", "-n",
firstWindow.windowName, plan.windowName,
"-c", "-c",
context.directory, worktreePath,
]); ]);
if (!sessionCreateResult.ok) { if (!sessionCreateResult.ok) {
return `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`; 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;
} }
const firstLaunchResult = await launchModelInWindow(sessionName, firstWindow);
if (firstLaunchResult.ok) {
succeededModels.push(firstWindow.model);
} else { } else {
failedModels.push(`${firstWindow.model} (${firstLaunchResult.stderr || "failed to send launch command"})`);
}
for (const plan of remainingWindows) {
const windowCreateResult = await runCommand([ const windowCreateResult = await runCommand([
"tmux", "tmux",
"new-window", "new-window",
@@ -367,28 +433,34 @@ export default tool({
"-n", "-n",
plan.windowName, plan.windowName,
"-c", "-c",
context.directory, worktreePath,
]); ]);
if (!windowCreateResult.ok) { if (!windowCreateResult.ok) {
failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`); 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; continue;
} }
}
const launchResult = await launchModelInWindow(sessionName, plan); const launchResult = await launchModelInWindow(sessionName, plan);
if (!launchResult.ok) { if (launchResult.ok) {
failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`);
continue;
}
succeededModels.push(plan.model); succeededModels.push(plan.model);
} else {
failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`);
const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
}
} }
const attachCommand = `tmux attach -t ${sessionName}`; 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({ context.metadata({
title: `multi-model ${sessionName}`, title: `multi-model ${sessionName}`,
metadata: { metadata: {
sessionName, safeSessionName,
modelCount: models.length, modelCount: models.length,
}, },
}); });
@@ -399,9 +471,14 @@ export default tool({
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`, `Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
`Failed: ${failedModels.join(", ")}.`, `Failed: ${failedModels.join(", ")}.`,
`Attach with \`${attachCommand}\` to inspect the session.`, `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");
}, },
}); });