mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f15a8f8d2 | ||
|
|
46d81b4162 | ||
|
|
c6d6c889d8 | ||
|
|
6367d99e7b | ||
|
|
21703fa2f8 | ||
|
|
4f75a920fc | ||
|
|
92708cf68b | ||
|
|
6e2521b70d | ||
|
|
42f153a28f | ||
|
|
ff065300ad | ||
|
|
36c6700437 | ||
|
|
7a164c705c | ||
|
|
068db04355 | ||
|
|
dc60dc674e | ||
|
|
8210080b43 | ||
|
|
545f06b293 | ||
|
|
3bcdc0a90f | ||
|
|
132a15dfc4 | ||
|
|
8bbd741bfa | ||
|
|
0503c474a8 | ||
|
|
d92c9623a8 | ||
|
|
4364910fca | ||
|
|
f956406e9d | ||
|
|
b92fdc692b | ||
|
|
81b25bd348 | ||
|
|
b2621a10de | ||
|
|
fc7feb2095 | ||
|
|
7180569b87 |
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-checkout hook to run bun install when switching branches or checking out files
|
||||
# This ensures dependencies are always up to date
|
||||
|
||||
# Only run if package.json or bun.lock exist (indicating a Node/Bun project)
|
||||
if [ -f "package.json" ] || [ -f "bun.lock" ]; then
|
||||
echo "Running bun install..."
|
||||
bun install
|
||||
fi
|
||||
Generated
-52
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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.5.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.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.27"
|
||||
"@opencode-ai/plugin": "1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.10",
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
---
|
||||
model: opencode-go/glm-5
|
||||
---
|
||||
|
||||
# Plan: Add `opencode-multi-model cleanup` Command
|
||||
|
||||
## Overview
|
||||
|
||||
Add a new`cleanup` command that deletes all archive tags (format: `archive/*`) from localand remote git repositories. Archive tags are created by the `close` command to preserve branch state before deletion.
|
||||
|
||||
## User Requirements
|
||||
|
||||
1. **Scope**: Only archive tags (not orphaned worktrees/branches)
|
||||
2. **Confirmation**: Prompt by default, `--force` to skip
|
||||
3. **Remote handling**: Delete from both local and remote by default
|
||||
4. **Preview**: Show count of tags, not full list
|
||||
5. **Remote discovery**: Check `git remote -v` for available remotes
|
||||
6. **Error handling**: Continue with local cleanup if remote deletion fails
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/types.ts` | MODIFY | Add `CleanupOptions` and `CleanupResult` interfaces |
|
||||
| `src/core/cleanup.ts` | CREATE | Core cleanup implementation |
|
||||
| `src/tools/cleanup.ts` | CREATE | Tool definition for plugin mode |
|
||||
| `src/cli.ts` | MODIFY | Add cleanup command registration |
|
||||
| `src/index.ts` | MODIFY | Export cleanup tool |
|
||||
| `tests/cleanup.test.ts` | CREATE | Unit tests |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. TypeScript Interfaces(`src/types.ts`)
|
||||
|
||||
Add after `WorktreeInfo` interface (around line 98):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Options for cleaning up archive tags.
|
||||
*/
|
||||
export interface CleanupOptions {
|
||||
/** Skip confirmation prompts. */
|
||||
force?: boolean;
|
||||
/**
|
||||
* Remote name to push deletions to.
|
||||
* - If string: use specified remote (error if doesn't exist)
|
||||
* - If undefined: use first available remote from git remote -v
|
||||
* - If false: only local cleanup (--no-remote flag)
|
||||
*/
|
||||
remote?: string | false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned after attempting to cleanup archive tags.
|
||||
*/
|
||||
export interface CleanupResult {
|
||||
/** Whether the cleanup succeeded. */
|
||||
success: boolean;
|
||||
/** Error message if the cleanup failed. */
|
||||
error?: string;
|
||||
/** Summary message for the user. */
|
||||
instructions?: string;
|
||||
/** Number of local tags deleted. */
|
||||
localTagsDeleted: number;
|
||||
/** Number of remote tags deleted. */
|
||||
remoteTagsDeleted: number;
|
||||
/** Names of tags that were deleted locally. */
|
||||
localTagNames: string[];
|
||||
/** Names of tags deleted from remote. */
|
||||
remoteTagNames: string[];
|
||||
/** Tags that failed remote deletion. */
|
||||
remoteTagErrors: Array<{ tag: string; error: string }>;
|
||||
/** Available remotes discovered. */
|
||||
availableRemotes: string[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Core Implementation (`src/core/cleanup.ts`)
|
||||
|
||||
Create new file with the following functions:
|
||||
|
||||
#### Helper Functions
|
||||
|
||||
```typescript
|
||||
import * as readline from "node:readline";
|
||||
import chalk from 'chalk';
|
||||
import type { CleanupOptions, CleanupResult } from "../types";
|
||||
import { runCommand } from "./utils";
|
||||
|
||||
/**
|
||||
* Prompts the user for confirmation on the terminal.
|
||||
*/
|
||||
async function promptUser(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all archive tags matching the pattern archive/*.
|
||||
*/
|
||||
export async function listArchiveTags(): Promise<string[]> {
|
||||
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
|
||||
return stdout.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of available git remotes.
|
||||
*/
|
||||
export async function getAvailableRemotes(): Promise<string[]> {
|
||||
const { stdout } = await runCommand(["git", "remote"]);
|
||||
return stdout.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a local git tag.
|
||||
*/
|
||||
async function deleteLocalTag(tagName: string): Promise<boolean> {
|
||||
const result = await runCommand(["git", "tag", "-d", tagName]);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a tag from a remote repository.
|
||||
*/
|
||||
async function deleteRemoteTag(tagName: string, remoteName: string): Promise<{ ok: boolean; error: string }> {
|
||||
const result = await runCommand(["git", "push", remoteName, "--delete", tagName]);
|
||||
return {
|
||||
ok: result.ok,
|
||||
error: result.ok ? "" : result.stderr,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Main Function
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Cleans up archive tags from local and optionally remote repositories.
|
||||
*
|
||||
* Archive tags have the format: archive/<branch-name>-<timestamp>
|
||||
* These are created by the close command to preserve branch state before deletion.
|
||||
*
|
||||
* @param options Cleanup configuration including force mode and remote settings.
|
||||
* @returns Result with counts and details of deleted tags.
|
||||
*/
|
||||
export async function cleanupArchiveTags(options: CleanupOptions = {}): Promise<CleanupResult> {
|
||||
const { force = false, remote } = options;
|
||||
|
||||
// Get available remotes and archive tags first
|
||||
const availableRemotes = await getAvailableRemotes();
|
||||
const archiveTags = await listArchiveTags();
|
||||
|
||||
// Determine whether to push to remote and which remote to use:
|
||||
// - remote === false: --no-remote flag, local-only mode
|
||||
// - remote === string: use specified remote (error if doesn't exist)
|
||||
// - remote === undefined: use first available remote
|
||||
const shouldPushToRemote = remote !== false;
|
||||
|
||||
let remoteToDeleteFrom: string | undefined;
|
||||
|
||||
if (shouldPushToRemote) {
|
||||
if (typeof remote === "string") {
|
||||
// User specified a remote - validate it exists
|
||||
if (!availableRemotes.includes(remote)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Error: Remote '${remote}' not found. Available remotes: ${availableRemotes.join(", ") || "none"}`,
|
||||
localTagsDeleted: 0,
|
||||
remoteTagsDeleted: 0,
|
||||
localTagNames: [],
|
||||
remoteTagNames: [],
|
||||
remoteTagErrors: [],
|
||||
availableRemotes,
|
||||
};
|
||||
}
|
||||
remoteToDeleteFrom = remote;
|
||||
} else {
|
||||
// remote === undefined, use first available
|
||||
remoteToDeleteFrom = availableRemotes[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (archiveTags.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
instructions: chalk.dim("No archive tags found."),
|
||||
localTagsDeleted: 0,
|
||||
remoteTagsDeleted: 0,
|
||||
localTagNames: [],
|
||||
remoteTagNames: [],
|
||||
remoteTagErrors: [],
|
||||
availableRemotes,
|
||||
};
|
||||
}
|
||||
|
||||
// Confirmation prompt (unless --force)
|
||||
if (!force) {
|
||||
const tagCount = archiveTags.length;
|
||||
const tagWord = tagCount === 1 ? "tag" : "tags";
|
||||
console.log(chalk.yellow(`Found ${tagCount} archive ${tagWord}.`));
|
||||
|
||||
if (shouldPushToRemote && remoteToDeleteFrom) {
|
||||
console.log(chalk.dim(`Remote deletions will use: ${remoteToDeleteFrom}`));
|
||||
} else if (shouldPushToRemote && !remoteToDeleteFrom) {
|
||||
console.log(chalk.dim("No remotes configured. Only local cleanup will be performed."));
|
||||
}
|
||||
|
||||
const answer = await promptUser(chalk.bold("\nProceed with cleanup? (y/N): "));
|
||||
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Cleanup cancelled by user",
|
||||
localTagsDeleted: 0,
|
||||
remoteTagsDeleted: 0,
|
||||
localTagNames: [],
|
||||
remoteTagNames: [],
|
||||
remoteTagErrors: [],
|
||||
availableRemotes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const localTagNames: string[] = [];
|
||||
const remoteTagNames: string[] = [];
|
||||
const remoteTagErrors: Array<{ tag: string; error: string }> = [];
|
||||
|
||||
// Delete local tags
|
||||
for (const tag of archiveTags) {
|
||||
const deleted = await deleteLocalTag(tag);
|
||||
if (deleted) {
|
||||
localTagNames.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from remote (if remote is specified and exists)
|
||||
if (shouldPushToRemote && remoteToDeleteFrom) {
|
||||
for (const tag of localTagNames) {
|
||||
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
|
||||
if (result.ok) {
|
||||
remoteTagNames.push(tag);
|
||||
} else {
|
||||
// Continue with local cleanup even if remote fails
|
||||
remoteTagErrors.push({ tag, error: result.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build instructions message
|
||||
const instructionsArr: string[] = [];
|
||||
if (localTagNames.length > 0) {
|
||||
const word = localTagNames.length === 1 ? "tag" : "tags";
|
||||
instructionsArr.push(chalk.green(`Deleted ${localTagNames.length} local archive ${word}.`));
|
||||
}
|
||||
if (remoteTagNames.length > 0) {
|
||||
const word = remoteTagNames.length === 1 ? "tag" : "tags";
|
||||
instructionsArr.push(chalk.green(`Deleted ${remoteTagNames.length} remote archive ${word}.`));
|
||||
}
|
||||
if (remoteTagErrors.length > 0) {
|
||||
const word = remoteTagErrors.length === 1 ? "tag" : "tags";
|
||||
instructionsArr.push(chalk.yellow(`Failed to delete ${remoteTagErrors.length} remote ${word} (local cleanup continued).`));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
instructions: instructionsArr.join("\n"),
|
||||
localTagsDeleted: localTagNames.length,
|
||||
remoteTagsDeleted: remoteTagNames.length,
|
||||
localTagNames,
|
||||
remoteTagNames,
|
||||
remoteTagErrors,
|
||||
availableRemotes,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Tool Definition (`src/tools/cleanup.ts`)
|
||||
|
||||
Create new file:
|
||||
|
||||
```typescript
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { cleanupArchiveTags } from "../core/cleanup";
|
||||
|
||||
/**
|
||||
* OpenCode tool definition for cleaning up archive tags.
|
||||
*
|
||||
* Deletes archive tags (format: archive/*) from local and remote repositories.
|
||||
* Used to clean up recovery tags created by the close command.
|
||||
*/
|
||||
export const cleanupTool = tool({
|
||||
description: "Delete all archive tags from local and optionally remote repository",
|
||||
args: {
|
||||
remote: tool.schema.union([
|
||||
tool.schema.string().describe("Remote name to push deletions to. Error if doesn't exist."),
|
||||
tool.schema.literal(false).describe("Only delete local tags, don't push to remote."),
|
||||
]).optional().describe("Remote handling: string=specific remote, false=local-only, undefined=first available remote"),
|
||||
},
|
||||
async execute(args, context) {
|
||||
// In plugin mode, we skip confirmation (force=true)
|
||||
const result = await cleanupArchiveTags({
|
||||
force: true,
|
||||
remote: args.remote,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return result.error!;
|
||||
}
|
||||
|
||||
return result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. CLI Command (`src/cli.ts`)
|
||||
|
||||
Add import after line 5:
|
||||
|
||||
```typescript
|
||||
import { cleanupArchiveTags } from "./core/cleanup";
|
||||
```
|
||||
|
||||
Add command after line 69 (after close command):
|
||||
|
||||
```typescript
|
||||
program
|
||||
.command("cleanup")
|
||||
.description("Delete all archive tags from local and remote")
|
||||
.option("-f, --force", "Skip confirmation prompts", false)
|
||||
.option("-r, --remote <remote>", "Remote name to push deletions to (default: first remote from git remote -v)")
|
||||
.option("--no-remote", "Only delete local tags, skip remote")
|
||||
.action(async (options: { force: boolean; remote?: string|boolean }) => {
|
||||
try {
|
||||
const result = await cleanupArchiveTags({
|
||||
force: options.force,
|
||||
remote: options.remote,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(result.instructions);
|
||||
} else {
|
||||
console.error(`Failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Index Export (`src/index.ts`)
|
||||
|
||||
Add import after line 3:
|
||||
|
||||
```typescript
|
||||
import { cleanupTool } from "./tools/cleanup";
|
||||
```
|
||||
|
||||
Update return object (line 22-25):
|
||||
|
||||
```typescript
|
||||
tool: {
|
||||
"multi-model-open": openTool,
|
||||
"multi-model-close": closeTool,
|
||||
"multi-model-cleanup": cleanupTool,
|
||||
},
|
||||
```
|
||||
|
||||
Update exports (line30):
|
||||
|
||||
```typescript
|
||||
export { openTool, closeTool, cleanupTool };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Interactive (shows confirmation prompt, uses first remote from git remote -v)
|
||||
opencode-multi-model cleanup
|
||||
|
||||
# Skip confirmation
|
||||
opencode-multi-model cleanup --force
|
||||
|
||||
# Use specific remote
|
||||
opencode-multi-model cleanup --remote upstream
|
||||
|
||||
# Only local cleanup (don't push to remote)
|
||||
opencode-multi-model cleanup --no-remote
|
||||
|
||||
# Combined options
|
||||
opencode-multi-model cleanup -f --remote upstream
|
||||
```
|
||||
|
||||
### Tool Usage (OpenCode Plugin)
|
||||
|
||||
```typescript
|
||||
// Local-only cleanup (don't push to remote)
|
||||
await multi-model-cleanup({ remote: false });
|
||||
|
||||
// Use first available remote (default behavior)
|
||||
await multi-model-cleanup({ remote: undefined });
|
||||
|
||||
// Specify a remote
|
||||
await multi-model-cleanup({ remote: "upstream" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git Commands Used
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `git tag -l "archive/*"` | List all archive tags |
|
||||
| `git remote` | Get available remotes |
|
||||
| `git tag -d <name>` | Delete local tag |
|
||||
| `git push <remote> --delete <tag>` | Delete remote tag |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation, verify with:
|
||||
|
||||
1. **Build**: `bun run build` or `bun run typecheck`
|
||||
2. **Unit tests**: `bun test tests/cleanup.test.ts`
|
||||
3. **Manual test**:
|
||||
```bash
|
||||
# Create some archive tags for testing
|
||||
git tag archive/test-branch-1-2024-01-01T00-00-00-000Z
|
||||
git tag archive/test-branch-2-2024-01-02T00-00-00-000Z
|
||||
|
||||
# Run cleanup
|
||||
bun run src/cli.ts cleanup
|
||||
|
||||
# Verify tags are deleted
|
||||
git tag -l "archive/*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
Create `tests/cleanup.test.ts` with:
|
||||
|
||||
1. No archive tags found - returns success with message
|
||||
2. User cancels confirmation - returns success=false
|
||||
3. Force mode skips confirmation
|
||||
4. **--no-remote flag (remote=false) - only local cleanup, no remote push**
|
||||
5. Remote deletion failure continues local cleanup
|
||||
6. **Dynamic remote detection - remote=undefined uses first available remote**
|
||||
7. **Remote name validation - uses specified remote if it exists**
|
||||
8. **Remote error - returns error if specified remote doesn't exist**
|
||||
9. No remotes configured - only local cleanup
|
||||
@@ -1,6 +1,8 @@
|
||||
// Re-export open and close tools for project-level use via relative paths.
|
||||
// For published package usage, change imports to "@username/opencode-multi-model".
|
||||
import { openTool } from "../../src/tools/open";
|
||||
import { closeTool } from "../../src/tools/close";
|
||||
|
||||
export { openTool as open, closeTool as close };
|
||||
import { cleanupTool } from "../../src/tools/cleanup";
|
||||
import { closeTool } from "../../src/tools/close";
|
||||
import { openTool } from "../../src/tools/open";
|
||||
|
||||
export { cleanupTool as cleanup, closeTool as close, openTool as open };
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
"types": [
|
||||
"bun-types"
|
||||
]
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
Vendored
+1
-1
@@ -12,5 +12,5 @@
|
||||
"bun.test.filePattern": "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts,cjs,mjs}",
|
||||
|
||||
// The custom script to call for testing instead of `bun test`
|
||||
"bun.test.customScript": "bun test",
|
||||
"bun.test.customScript": "bun test"
|
||||
}
|
||||
@@ -118,6 +118,79 @@ opencode-multi-model open my-session -m openai/gpt-5.2 -b kilo
|
||||
|
||||
Priority: CLI flag > Environment variable > Default ("opencode")
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### `open` - Create a new multi-model tmux session
|
||||
|
||||
```bash
|
||||
opencode-multi-model open <session-name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
- `<session-name>` - Name for the tmux session (required)
|
||||
|
||||
**Options:**
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-m, --models <models...>` | Model IDs to launch (space-separated) |
|
||||
| `-b, --binary <binary>` | Binary to use (opencode or kilo). Defaults to env var `OPENCODE_MULTI_MODEL_BINARY` or 'opencode' |
|
||||
|
||||
### `close` - Close a multi-model tmux session
|
||||
|
||||
```bash
|
||||
opencode-multi-model close <session-name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
- `<session-name>` - Name of the tmux session to close (required)
|
||||
|
||||
**Options:**
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--keep-worktrees` | Do not remove worktrees and delete branches |
|
||||
| `--no-tags` | Do not create archive tags before deleting branches |
|
||||
| `-f, --force` | Skip confirmation prompts |
|
||||
|
||||
### Global Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-h, --help` | Display help information |
|
||||
| `-V, --version` | Display version number |
|
||||
|
||||
## How it works
|
||||
|
||||
### `open` command
|
||||
|
||||
The `open` command performs the following steps:
|
||||
|
||||
1. **Validation**: Checks that session name andmodels are provided, no duplicate models exist, and verifies git repo, tmux, and binary are available
|
||||
|
||||
2. **Model verification**: Validates models against `opencode models` output to ensure only valid model IDs are used
|
||||
|
||||
3. **Git worktree creation**: For each model, creates:
|
||||
- A git worktree at `~/.local/share/opencode/multi-model/<session>/<model>/`
|
||||
- A branch named `opencode/<session>/<model>`
|
||||
|
||||
4. **Tmux session**: Creates a tmux session with one window per model, each window's working directory set to its corresponding worktree
|
||||
|
||||
5. **Launch**: Sends the launch command to each window to start the binary with the specified model
|
||||
|
||||
### `close` command
|
||||
|
||||
The `close` command performs the following steps:
|
||||
|
||||
1. **Kill tmux session**: Terminates the tmux session if it exists
|
||||
|
||||
2. **Worktree discovery**: Finds all worktrees under `~/.local/share/opencode/multi-model/<session>/`
|
||||
|
||||
3. **Worktree cleanup** (unless `--keep-worktrees`):
|
||||
- Removes each gitworktree
|
||||
- Creates an archive tag (unless `--no-tags`): `archive/<branch>-<timestamp>` for recovery
|
||||
- Deletes the associated branches
|
||||
|
||||
4. **Directory cleanup**: Removes the base session directory
|
||||
|
||||
## Requirements
|
||||
|
||||
- tmux
|
||||
@@ -133,6 +206,9 @@ For local development and testing without publishing:
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Setup git hooks
|
||||
bun run setup
|
||||
|
||||
# Build
|
||||
bun run build
|
||||
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!!**/dist"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn",
|
||||
"noUnusedFunctionParameters": "warn",
|
||||
"noUnusedVariables": "warn",
|
||||
"noConstructorReturn": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"useLiteralKeys": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,32 @@
|
||||
"commander": "^12.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.27", "", { "dependencies": { "@opencode-ai/sdk": "1.2.27", "zod": "4.1.8" } }, "sha512-h+8Bw9v9nghMg7T+SUCTzxlIhOrsTqXW7U0HVLGQST5DjbN7uyCUM51roZWZ8LRjGxzbzFhvPnY1bj8i+ioZyw=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="],
|
||||
@@ -30,6 +50,8 @@
|
||||
|
||||
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
+10
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-multi-model",
|
||||
"version": "0.0.5",
|
||||
"version": "0.1.0",
|
||||
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -21,7 +21,12 @@
|
||||
"test": "bun test",
|
||||
"coverage": "bun test --coverage",
|
||||
"prepublishOnly": "bun run build",
|
||||
"release": "bash ./scripts/release.sh"
|
||||
"release": "bash ./scripts/release.sh",
|
||||
"setup": "bash ./scripts/setup.sh",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check . --fix",
|
||||
"format": "biome format . --write",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
@@ -29,8 +34,10 @@
|
||||
"commander": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^25.5.0"
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"keywords": [
|
||||
"opencode",
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup script for development environment
|
||||
|
||||
echo "Setting up git hooks..."
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
echo "Setup complete!"
|
||||
+92
-38
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Command } from "commander";
|
||||
import { openMultiModel } from "./core/open";
|
||||
import { cleanupMultiModel } from "./core/cleanup";
|
||||
import { closeMultiModel } from "./core/close";
|
||||
import { openMultiModel } from "./core/open";
|
||||
import { getBinaryName } from "./core/utils";
|
||||
|
||||
const program = new Command();
|
||||
@@ -15,21 +16,100 @@ program
|
||||
.command("open")
|
||||
.description("Create a new multi-model tmux session")
|
||||
.argument("<session-name>", "Name for the tmux session")
|
||||
.option("-m, --models <models...>", "Model IDs to launch (space-separated)", [])
|
||||
.option("-b, --binary <binary>", "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'")
|
||||
.action(async (sessionName: string, options: { models: string[]; binary?: string }) => {
|
||||
try {
|
||||
const binaryName = options.binary || getBinaryName();
|
||||
.option(
|
||||
"-m, --models <models...>",
|
||||
"Model IDs to launch (space-separated)",
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"-b, --binary <binary>",
|
||||
"Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
sessionName: string,
|
||||
options: { models: string[]; binary?: string },
|
||||
) => {
|
||||
try {
|
||||
const binaryName = options.binary || getBinaryName();
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName,
|
||||
models: options.models,
|
||||
binaryName,
|
||||
mode: "cli",
|
||||
const result = await openMultiModel({
|
||||
sessionName,
|
||||
models: options.models,
|
||||
binaryName,
|
||||
mode: "cli",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
result.instructions || `Created session: ${result.sessionName}`,
|
||||
);
|
||||
} else {
|
||||
console.error(`Failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
program
|
||||
.command("close")
|
||||
.description("Close a multi-model tmux session")
|
||||
.argument("<session-name>", "Name of the tmux session to close")
|
||||
.option(
|
||||
"--keep-worktrees",
|
||||
"Do not remove worktrees and delete branches",
|
||||
false,
|
||||
)
|
||||
.option("--no-tags", "Create archive tags before deleting branches")
|
||||
.option("-f, --force", "Skip confirmation prompts", false)
|
||||
.action(
|
||||
async (
|
||||
sessionName: string,
|
||||
options: { keepWorktrees: boolean; force: boolean; tags: boolean },
|
||||
) => {
|
||||
try {
|
||||
const result = await closeMultiModel({
|
||||
sessionName,
|
||||
cleanupWorktrees: !options.keepWorktrees,
|
||||
force: options.force,
|
||||
createArchiveTags: options.tags,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(result.instructions);
|
||||
} else {
|
||||
console.error(`Failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
program
|
||||
.command("cleanup")
|
||||
.description("Delete all archive tags from local and remote")
|
||||
.option("-f, --force", "Skip confirmation prompts", false)
|
||||
.option(
|
||||
"-r, --remote <remote>",
|
||||
"Remote name to push deletions to (default: first remote from git remote -v)",
|
||||
)
|
||||
.option("--no-remote", "Only delete local tags, skip remote")
|
||||
.action(async (options: { force: boolean; remote?: string | boolean }) => {
|
||||
try {
|
||||
const result = await cleanupMultiModel({
|
||||
force: options.force,
|
||||
remote: options.remote === true ? undefined : options.remote,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(result.instructions || `Created session: ${result.sessionName}`);
|
||||
console.log(result.instructions);
|
||||
} else {
|
||||
console.error(`Failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
@@ -40,30 +120,4 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("close")
|
||||
.description("Close a multi-model tmux session")
|
||||
.argument("<session-name>", "Name of the tmux session to close")
|
||||
.option("-c, --cleanup-worktrees", "Remove worktrees and delete branches", true)
|
||||
.option("-f, --force", "Skip confirmation prompts", false)
|
||||
.action(async (sessionName: string, options: { cleanupWorktrees: boolean; force: boolean }) => {
|
||||
try {
|
||||
const result = await closeMultiModel({
|
||||
sessionName,
|
||||
cleanupWorktrees: options.cleanupWorktrees,
|
||||
force: options.force,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(result.instructions);
|
||||
} else {
|
||||
console.error(`Failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import * as readline from "node:readline";
|
||||
import chalk from "chalk";
|
||||
import type { CleanupOptions, CleanupResult } from "../types";
|
||||
import { runCommand } from "./utils";
|
||||
|
||||
/**
|
||||
* Prompts the user for confirmation on the terminal.
|
||||
*/
|
||||
async function promptUser(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all archive tags matching the pattern archive/*.
|
||||
*/
|
||||
export async function listArchiveTags(): Promise<string[]> {
|
||||
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
|
||||
return stdout.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of available git remotes.
|
||||
*/
|
||||
export async function getAvailableRemotes(): Promise<string[]> {
|
||||
const { stdout } = await runCommand(["git", "remote"]);
|
||||
return stdout.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a local git tag.
|
||||
*/
|
||||
async function deleteLocalTag(tagName: string): Promise<boolean> {
|
||||
const result = await runCommand(["git", "tag", "-d", tagName]);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a tag from a remote repository.
|
||||
*/
|
||||
async function deleteRemoteTag(
|
||||
tagName: string,
|
||||
remoteName: string,
|
||||
): Promise<{ ok: boolean; error: string }> {
|
||||
const result = await runCommand([
|
||||
"git",
|
||||
"push",
|
||||
remoteName,
|
||||
"--delete",
|
||||
tagName,
|
||||
]);
|
||||
return {
|
||||
ok: result.ok,
|
||||
error: result.ok ? "" : result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up archive tags from local and optionally remote repositories.
|
||||
*
|
||||
* Archive tags have the format: archive/<branch-name>-<timestamp>
|
||||
* These are created by the close command to preserve branch state before deletion.
|
||||
*
|
||||
* @param options Cleanup configuration including force mode and remote settings.
|
||||
* @returns Result with counts and details of deleted tags.
|
||||
*/
|
||||
export async function cleanupMultiModel(
|
||||
options: CleanupOptions = {},
|
||||
): Promise<CleanupResult> {
|
||||
const { force = false, remote } = options;
|
||||
|
||||
// Get available remotes and archive tags first
|
||||
const availableRemotes = await getAvailableRemotes();
|
||||
const archiveTags = await listArchiveTags();
|
||||
|
||||
// Determine whether to push to remote and which remote to use:
|
||||
// - remote === false: --no-remote flag, local-only mode
|
||||
// - remote === string: use specified remote (error if doesn't exist)
|
||||
// - remote === undefined: use first available remote
|
||||
const shouldPushToRemote = remote !== false;
|
||||
|
||||
let remoteToDeleteFrom: string | undefined;
|
||||
|
||||
if (shouldPushToRemote) {
|
||||
if (typeof remote === "string") {
|
||||
// User specified a remote - validate it exists
|
||||
if (!availableRemotes.includes(remote)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Error: Remote '${remote}' not found. Available remotes: ${availableRemotes.join(", ") || "none"}`,
|
||||
localTagsDeleted: 0,
|
||||
remoteTagsDeleted: 0,
|
||||
localTagNames: [],
|
||||
remoteTagNames: [],
|
||||
remoteTagErrors: [],
|
||||
availableRemotes,
|
||||
};
|
||||
}
|
||||
remoteToDeleteFrom = remote;
|
||||
} else {
|
||||
// remote === undefined, use first available
|
||||
remoteToDeleteFrom = availableRemotes[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (archiveTags.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
instructions: chalk.dim("No archive tags found."),
|
||||
localTagsDeleted: 0,
|
||||
remoteTagsDeleted: 0,
|
||||
localTagNames: [],
|
||||
remoteTagNames: [],
|
||||
remoteTagErrors: [],
|
||||
availableRemotes,
|
||||
};
|
||||
}
|
||||
|
||||
// Confirmation prompt (unless --force)
|
||||
if (!force) {
|
||||
const tagCount = archiveTags.length;
|
||||
const tagWord = tagCount === 1 ? "tag" : "tags";
|
||||
console.log(chalk.yellow(`Found ${tagCount} archive ${tagWord}.`));
|
||||
|
||||
if (shouldPushToRemote && remoteToDeleteFrom) {
|
||||
console.log(
|
||||
chalk.dim(`Remote deletions will use: ${remoteToDeleteFrom}`),
|
||||
);
|
||||
} else if (shouldPushToRemote && !remoteToDeleteFrom) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"No remotes configured. Only local cleanup will be performed.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const answer = await promptUser(
|
||||
chalk.bold("\nProceed with cleanup? (y/N): "),
|
||||
);
|
||||
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Cleanup cancelled by user",
|
||||
localTagsDeleted: 0,
|
||||
remoteTagsDeleted: 0,
|
||||
localTagNames: [],
|
||||
remoteTagNames: [],
|
||||
remoteTagErrors: [],
|
||||
availableRemotes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const localTagNames: string[] = [];
|
||||
const remoteTagNames: string[] = [];
|
||||
const remoteTagErrors: Array<{ tag: string; error: string }> = [];
|
||||
|
||||
// Delete local tags
|
||||
for (const tag of archiveTags) {
|
||||
const deleted = await deleteLocalTag(tag);
|
||||
if (deleted) {
|
||||
localTagNames.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from remote (if remote is specified and exists)
|
||||
if (shouldPushToRemote && remoteToDeleteFrom) {
|
||||
for (const tag of localTagNames) {
|
||||
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
|
||||
if (result.ok) {
|
||||
remoteTagNames.push(tag);
|
||||
} else {
|
||||
// Continue with local cleanup even if remote fails
|
||||
remoteTagErrors.push({ tag, error: result.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build instructions message
|
||||
const instructionsArr: string[] = [];
|
||||
if (localTagNames.length > 0) {
|
||||
const word = localTagNames.length === 1 ? "tag" : "tags";
|
||||
instructionsArr.push(
|
||||
chalk.green(`Deleted ${localTagNames.length} local archive ${word}.`),
|
||||
);
|
||||
}
|
||||
if (remoteTagNames.length > 0) {
|
||||
const word = remoteTagNames.length === 1 ? "tag" : "tags";
|
||||
instructionsArr.push(
|
||||
chalk.green(`Deleted ${remoteTagNames.length} remote archive ${word}.`),
|
||||
);
|
||||
}
|
||||
if (remoteTagErrors.length > 0) {
|
||||
const word = remoteTagErrors.length === 1 ? "tag" : "tags";
|
||||
instructionsArr.push(
|
||||
chalk.yellow(
|
||||
`Failed to delete ${remoteTagErrors.length} remote ${word} (local cleanup continued).`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
instructions: instructionsArr.join("\n"),
|
||||
localTagsDeleted: localTagNames.length,
|
||||
remoteTagsDeleted: remoteTagNames.length,
|
||||
localTagNames,
|
||||
remoteTagNames,
|
||||
remoteTagErrors,
|
||||
availableRemotes,
|
||||
};
|
||||
}
|
||||
+76
-40
@@ -1,7 +1,12 @@
|
||||
import * as readline from "node:readline";
|
||||
import chalk from 'chalk'; // removed console usage
|
||||
import chalk from "chalk"; // removed console usage
|
||||
import type { CloseSessionOptions, CloseSessionResult } from "../types";
|
||||
import { runCommand, getSessionPath, getWorktreesForSession, sanitizeName } from "./utils";
|
||||
import {
|
||||
getSessionPath,
|
||||
getWorktreesForSession,
|
||||
runCommand,
|
||||
sanitizeName,
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Prompts the user for input on the terminal.
|
||||
@@ -44,24 +49,34 @@ async function promptUser(question: string): Promise<string> {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function closeMultiModel(options: CloseSessionOptions): Promise<CloseSessionResult> {
|
||||
export async function closeMultiModel(
|
||||
options: CloseSessionOptions,
|
||||
): Promise<CloseSessionResult> {
|
||||
const worktreesRemoved: string[] = [];
|
||||
const instructionsArr: string[] = [];
|
||||
const branchesDeleted: string[] = [];
|
||||
const tagsCreated: string[] = [];
|
||||
|
||||
|
||||
const safeSessionName = sanitizeName(options.sessionName);
|
||||
try {
|
||||
// Check if session exists
|
||||
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", safeSessionName]);
|
||||
const { ok: sessionExists } = await runCommand([
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
options.sessionName,
|
||||
]);
|
||||
|
||||
// Kill tmux session if it exists
|
||||
if (sessionExists) {
|
||||
await runCommand(["tmux", "kill-session", "-t", safeSessionName]);
|
||||
instructionsArr.push(chalk.green(`Closed session '${safeSessionName}'`));
|
||||
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
|
||||
instructionsArr.push(
|
||||
chalk.green(`Closed session '${options.sessionName}'`),
|
||||
);
|
||||
} else {
|
||||
instructionsArr.push(chalk.yellow(`Session '${safeSessionName}' does not exist`));
|
||||
instructionsArr.push(
|
||||
chalk.yellow(`Session '${options.sessionName}' does not exist`),
|
||||
);
|
||||
}
|
||||
|
||||
// Get list of worktrees for this session before killing tmux
|
||||
@@ -69,13 +84,17 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
||||
|
||||
// If cleanup requested and not forced, ask for confirmation
|
||||
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
|
||||
instructionsArr.push(chalk.red('The following worktrees and branches will be removed:'));
|
||||
console.log(
|
||||
chalk.red("The following worktrees and branches will be removed:"),
|
||||
);
|
||||
worktrees.forEach((wt) => {
|
||||
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
|
||||
instructionsArr.push(msg.trim());
|
||||
console.log(msg.trim());
|
||||
});
|
||||
|
||||
const answer = await promptUser(chalk.bold("\nDo you want to proceed? (y/N): "));
|
||||
const answer = await promptUser(
|
||||
chalk.bold("\nDo you want to proceed? (y/N): "),
|
||||
);
|
||||
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
|
||||
return {
|
||||
success: false,
|
||||
@@ -96,22 +115,33 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
||||
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
|
||||
worktreesRemoved.push(worktree.path);
|
||||
|
||||
// Create an archive tag before deleting the branch for safe recovery
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
||||
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
|
||||
// Optionally create an archive tag before deleting the branch for safe recovery
|
||||
if (options.createArchiveTags !== false) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
||||
const tagResult = await runCommand([
|
||||
"git",
|
||||
"tag",
|
||||
archiveTag,
|
||||
worktree.branch,
|
||||
]);
|
||||
|
||||
if (tagResult.ok) {
|
||||
tagsCreated.push(archiveTag);
|
||||
} else {
|
||||
const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
|
||||
if (tagResult.ok) {
|
||||
tagsCreated.push(archiveTag);
|
||||
} else {
|
||||
const warningMsg = chalk.red(
|
||||
`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`,
|
||||
);
|
||||
instructionsArr.push(warningMsg);
|
||||
}
|
||||
}
|
||||
|
||||
await runCommand(["git", "branch", "-D", worktree.branch]);
|
||||
branchesDeleted.push(worktree.branch);
|
||||
} catch (err) {
|
||||
instructionsArr.push(chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`));
|
||||
instructionsArr.push(
|
||||
chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,31 +155,37 @@ const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for br
|
||||
|
||||
cleanupPerformed = worktreesRemoved.length > 0;
|
||||
if (cleanupPerformed) {
|
||||
instructionsArr.push(chalk.dim(`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`));
|
||||
instructionsArr.push(
|
||||
chalk.dim(
|
||||
`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`,
|
||||
),
|
||||
);
|
||||
if (tagsCreated.length > 0) {
|
||||
instructionsArr.push(chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`));
|
||||
instructionsArr.push(
|
||||
chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionName: safeSessionName,
|
||||
cleanupPerformed,
|
||||
worktreesRemoved,
|
||||
branchesDeleted,
|
||||
tagsCreated,
|
||||
instructions: instructionsArr.join("\n"),
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
sessionName: safeSessionName,
|
||||
cleanupPerformed,
|
||||
worktreesRemoved,
|
||||
branchesDeleted,
|
||||
tagsCreated,
|
||||
instructions: instructionsArr.join("\n"),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
sessionName: safeSessionName,
|
||||
error: String(error),
|
||||
worktreesRemoved,
|
||||
branchesDeleted,
|
||||
tagsCreated,
|
||||
instructions: instructionsArr.join("\n"),
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
sessionName: safeSessionName,
|
||||
error: String(error),
|
||||
worktreesRemoved,
|
||||
branchesDeleted,
|
||||
tagsCreated,
|
||||
instructions: instructionsArr.join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
export { openMultiModel } from "./open";
|
||||
export { cleanupMultiModel } from "./cleanup";
|
||||
export { closeMultiModel } from "./close";
|
||||
export { openMultiModel } from "./open";
|
||||
export * from "./utils";
|
||||
|
||||
+120
-34
@@ -1,17 +1,16 @@
|
||||
import * as fs from "node:fs";
|
||||
import chalk from 'chalk';
|
||||
import chalk from "chalk";
|
||||
import type { MultiModelOptions, MultiModelResult } from "../types";
|
||||
import {
|
||||
runCommand,
|
||||
normalizeModels,
|
||||
findDuplicates,
|
||||
createWindowPlans,
|
||||
findDuplicates,
|
||||
formatInvalidModelError,
|
||||
sanitizeName,
|
||||
getWorktreePath,
|
||||
getSessionPath,
|
||||
undoWorktree,
|
||||
launchModelInWindow,
|
||||
normalizeModels,
|
||||
runCommand,
|
||||
sanitizeName,
|
||||
undoWorktree,
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
@@ -35,7 +34,9 @@ import {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function openMultiModel(options: MultiModelOptions): Promise<MultiModelResult> {
|
||||
export async function openMultiModel(
|
||||
options: MultiModelOptions,
|
||||
): Promise<MultiModelResult> {
|
||||
const sessionName = options.sessionName.trim();
|
||||
const safeSessionName = sanitizeName(sessionName);
|
||||
const models = normalizeModels(options.models);
|
||||
@@ -43,11 +44,19 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
|
||||
const mode = options.mode || "cli";
|
||||
|
||||
if (!sessionName) {
|
||||
return { success: false, sessionName: "", error: "Error: `sessionName` must not be empty." };
|
||||
return {
|
||||
success: false,
|
||||
sessionName: "",
|
||||
error: "Error: `sessionName` must not be empty.",
|
||||
};
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
return { success: false, sessionName, error: "Error: Model names must not be empty." };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: "Error: Model names must not be empty.",
|
||||
};
|
||||
}
|
||||
|
||||
const duplicateModels = findDuplicates(models);
|
||||
@@ -59,19 +68,35 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
|
||||
};
|
||||
}
|
||||
|
||||
const gitRepoCheck = await runCommand(["git", "rev-parse", "--is-inside-work-tree"]);
|
||||
const gitRepoCheck = await runCommand([
|
||||
"git",
|
||||
"rev-parse",
|
||||
"--is-inside-work-tree",
|
||||
]);
|
||||
if (!gitRepoCheck.ok) {
|
||||
return { success: false, sessionName, error: "Error: `multi-model` requires a git repository." };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: "Error: `multi-model` requires a git repository.",
|
||||
};
|
||||
}
|
||||
|
||||
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
|
||||
if (!tmuxExists.ok) {
|
||||
return { success: false, sessionName, error: "Error: `tmux` is not installed or not on `PATH`." };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: "Error: `tmux` is not installed or not on `PATH`.",
|
||||
};
|
||||
}
|
||||
|
||||
const binaryExists = await runCommand(["command", "-v", binaryName]);
|
||||
if (!binaryExists.ok) {
|
||||
return { success: false, sessionName, error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.` };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.`,
|
||||
};
|
||||
}
|
||||
|
||||
const modelListResult = await runCommand([binaryName, "models"]);
|
||||
@@ -90,12 +115,25 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
|
||||
|
||||
const invalidModels = models.filter((model) => !allowlist.includes(model));
|
||||
if (invalidModels.length > 0) {
|
||||
return { success: false, sessionName, error: formatInvalidModelError(invalidModels, allowlist) };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: formatInvalidModelError(invalidModels, allowlist),
|
||||
};
|
||||
}
|
||||
|
||||
const sessionExists = await runCommand(["tmux", "has-session", "-t", sessionName]);
|
||||
const sessionExists = await runCommand([
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
sessionName,
|
||||
]);
|
||||
if (sessionExists.ok) {
|
||||
return { success: false, sessionName, error: "Error: Session already exists. Use a different `sessionName`." };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: "Error: Session already exists. Use a different `sessionName`.",
|
||||
};
|
||||
}
|
||||
|
||||
const windowPlans = createWindowPlans(models);
|
||||
@@ -111,30 +149,61 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
|
||||
|
||||
if (fs.existsSync(worktreePath)) {
|
||||
if (!isFirst) {
|
||||
failedModels.push(`${plan.model} (Worktree path already exists at ${worktreePath})`);
|
||||
failedModels.push(
|
||||
`${plan.model} (Worktree path already exists at ${worktreePath})`,
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
return { success: false, sessionName, error: `Error: Worktree path already exists at ${worktreePath}. Clean it up first.` };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: `Error: Worktree path already exists at ${worktreePath}. Clean it up first.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const branchExists = await runCommand(["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]);
|
||||
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)`);
|
||||
failedModels.push(
|
||||
`${plan.model} (Branch '${branchName}' already exists)`,
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
return { success: false, sessionName, error: `Error: Branch '${branchName}' already exists.` };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: `Error: Branch '${branchName}' already exists.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const worktreeResult = await runCommand(["git", "worktree", "add", "-b", branchName, worktreePath]);
|
||||
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})`);
|
||||
failedModels.push(
|
||||
`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`,
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
return { success: false, sessionName, error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}` };
|
||||
return {
|
||||
success: false,
|
||||
sessionName,
|
||||
error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,22 +242,35 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
|
||||
]);
|
||||
|
||||
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(", ")}`);
|
||||
failedModels.push(
|
||||
`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const launchResult = await launchModelInWindow(sessionName, plan, binaryName);
|
||||
const launchResult = await launchModelInWindow(
|
||||
sessionName,
|
||||
plan,
|
||||
binaryName,
|
||||
);
|
||||
if (launchResult.ok) {
|
||||
succeededModels.push(plan.model);
|
||||
} else {
|
||||
failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`);
|
||||
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(", ")}`);
|
||||
if (undoErrors.length > 0)
|
||||
failedModels.push(
|
||||
`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +288,13 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
|
||||
sessionName,
|
||||
windows: windowNames,
|
||||
instructions: [
|
||||
chalk.red(`Error: Created tmux session '${sessionName}', but some model launches failed.`),
|
||||
chalk.red(
|
||||
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
|
||||
),
|
||||
chalk.red(`Failed: ${failedModels.join(", ")}.`),
|
||||
chalk.dim(`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`),
|
||||
chalk.dim(
|
||||
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
|
||||
),
|
||||
"",
|
||||
chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
|
||||
"",
|
||||
@@ -216,7 +302,7 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
|
||||
primaryCleanup,
|
||||
"",
|
||||
chalk.dim(`Alternate manual cleanup - `),
|
||||
chalk.dim(cleanupCommand)
|
||||
chalk.dim(cleanupCommand),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -232,7 +318,7 @@ export async function openMultiModel(options: MultiModelOptions): Promise<MultiM
|
||||
primaryCleanup,
|
||||
"",
|
||||
chalk.dim(`Alternate manual cleanup - `),
|
||||
chalk.dim(cleanupCommand)
|
||||
chalk.dim(cleanupCommand),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
+67
-14
@@ -136,7 +136,10 @@ export function createWindowPlans(models: string[]): WindowLaunchPlan[] {
|
||||
}
|
||||
|
||||
const suffix = `-${nextCount}`;
|
||||
const trimmedBase = baseName.slice(0, Math.max(1, WINDOW_NAME_LIMIT - suffix.length));
|
||||
const trimmedBase = baseName.slice(
|
||||
0,
|
||||
Math.max(1, WINDOW_NAME_LIMIT - suffix.length),
|
||||
);
|
||||
|
||||
return {
|
||||
model,
|
||||
@@ -194,7 +197,10 @@ export function levenshtein(left: string, right: string): number {
|
||||
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
|
||||
* ```
|
||||
*/
|
||||
export function suggestModels(requested: string, allowlist: string[]): string[] {
|
||||
export function suggestModels(
|
||||
requested: string,
|
||||
allowlist: string[],
|
||||
): string[] {
|
||||
const normalizedRequested = requested.toLowerCase();
|
||||
|
||||
return allowlist
|
||||
@@ -212,7 +218,11 @@ export function suggestModels(requested: string, allowlist: string[]): string[]
|
||||
score: distance + containsBoost,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.score - right.score || left.candidate.localeCompare(right.candidate))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.score - right.score ||
|
||||
left.candidate.localeCompare(right.candidate),
|
||||
)
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.map(({ candidate }) => candidate);
|
||||
}
|
||||
@@ -229,7 +239,10 @@ export function suggestModels(requested: string, allowlist: string[]): string[]
|
||||
* const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]);
|
||||
* ```
|
||||
*/
|
||||
export function formatInvalidModelError(invalidModels: string[], allowlist: string[]): string {
|
||||
export function formatInvalidModelError(
|
||||
invalidModels: string[],
|
||||
allowlist: string[],
|
||||
): string {
|
||||
const firstInvalidModel = invalidModels[0]!;
|
||||
const suggestions = suggestModels(firstInvalidModel, allowlist);
|
||||
const suggestionText =
|
||||
@@ -264,7 +277,14 @@ export async function launchModelInWindow(
|
||||
): Promise<CommandResult> {
|
||||
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`;
|
||||
|
||||
return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]);
|
||||
return runCommand([
|
||||
"tmux",
|
||||
"send-keys",
|
||||
"-t",
|
||||
`${sessionName}:${plan.windowName}`,
|
||||
launchCommand,
|
||||
"C-m",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +300,10 @@ export async function launchModelInWindow(
|
||||
* ```
|
||||
*/
|
||||
export function sanitizeName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +320,14 @@ export function sanitizeName(name: string): string {
|
||||
*/
|
||||
export function getSessionPath(sessionName: string): string {
|
||||
const homedir = os.homedir();
|
||||
return path.join(homedir, ".local", "share", "opencode", "multi-model", sessionName);
|
||||
return path.join(
|
||||
homedir,
|
||||
".local",
|
||||
"share",
|
||||
"opencode",
|
||||
"multi-model",
|
||||
sessionName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,7 +343,10 @@ export function getSessionPath(sessionName: string): string {
|
||||
* // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2"
|
||||
* ```
|
||||
*/
|
||||
export function getWorktreePath(safeSession: string, windowName: string): string {
|
||||
export function getWorktreePath(
|
||||
safeSession: string,
|
||||
windowName: string,
|
||||
): string {
|
||||
return path.join(getSessionPath(safeSession), windowName);
|
||||
}
|
||||
|
||||
@@ -328,8 +361,15 @@ export function getWorktreePath(safeSession: string, windowName: string): string
|
||||
* const worktrees = await getWorktreesForSession("my-session");
|
||||
* ```
|
||||
*/
|
||||
export async function getWorktreesForSession(sessionName: string): Promise<WorktreeInfo[]> {
|
||||
const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]);
|
||||
export async function getWorktreesForSession(
|
||||
sessionName: string,
|
||||
): Promise<WorktreeInfo[]> {
|
||||
const { stdout } = await runCommand([
|
||||
"git",
|
||||
"worktree",
|
||||
"list",
|
||||
"--porcelain",
|
||||
]);
|
||||
const worktrees: WorktreeInfo[] = [];
|
||||
|
||||
const sessionPath = getSessionPath(sanitizeName(sessionName));
|
||||
@@ -371,13 +411,26 @@ export async function getWorktreesForSession(sessionName: string): Promise<Workt
|
||||
* if (errors.length > 0) console.error(errors);
|
||||
* ```
|
||||
*/
|
||||
export async function undoWorktree(worktreePath: string, branchName: string): Promise<string[]> {
|
||||
export 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 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}`);
|
||||
if (!branchRes.ok)
|
||||
errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -1,13 +1,15 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { openTool } from "./tools/open";
|
||||
import { cleanupTool } from "./tools/cleanup";
|
||||
import { closeTool } from "./tools/close";
|
||||
import { openTool } from "./tools/open";
|
||||
|
||||
/**
|
||||
* OpenCode plugin that provides multi-model tmux session management tools.
|
||||
*
|
||||
* Registers two tools:
|
||||
* Registers three tools:
|
||||
* - `multi-model-open`: Launch multiple AI models in a tmux session
|
||||
* - `multi-model-close`: Close a multi-model session and cleanup resources
|
||||
* - `multi-model-cleanup`: Delete all archive tags from local and remote repositories
|
||||
*
|
||||
* @example
|
||||
* ```json
|
||||
@@ -22,11 +24,12 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
|
||||
tool: {
|
||||
"multi-model-open": openTool,
|
||||
"multi-model-close": closeTool,
|
||||
"multi-model-cleanup": cleanupTool,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default OpenCodeMultiModelPlugin;
|
||||
export { openTool, closeTool };
|
||||
export * from "./core/index";
|
||||
export * from "./types";
|
||||
export { cleanupTool, closeTool, openTool };
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { cleanupMultiModel } from "../core/cleanup";
|
||||
|
||||
/**
|
||||
* OpenCode tool definition for cleaning up archive tags.
|
||||
*
|
||||
* Deletes archive tags (format: archive/*) from local and remote repositories.
|
||||
* Used to clean up recovery tags created by the close command.
|
||||
*/
|
||||
export const cleanupTool = tool({
|
||||
description: `Delete all archive tags from the local repository and, optionally, from a remote.
|
||||
|
||||
Arguments:
|
||||
-\`remote\` (string, optional): Specify a remote name to which deletions should be pushed. If omitted, the first available remote is used.
|
||||
- \`noRemote\` (boolean, optional): When true, only delete local tags and do not push deletions to any remote.`,
|
||||
args: {
|
||||
remote: tool.schema
|
||||
.union([
|
||||
tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Remote name to push deletions to. Error if doesn't exist.",
|
||||
)
|
||||
])
|
||||
.optional()
|
||||
.describe(
|
||||
"Remote handling: string=specific remote, undefined=first available remote",
|
||||
),
|
||||
noRemote: tool.schema
|
||||
.literal(true)
|
||||
.describe("Only delete local tags, don't push to remote.")
|
||||
.optional(),
|
||||
},
|
||||
async execute(args, _context) {
|
||||
// In plugin mode, we skip confirmation (force=true)
|
||||
const result = await cleanupMultiModel({
|
||||
force: true,
|
||||
remote: args.noRemote ? false : args.remote,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return result.error!;
|
||||
}
|
||||
|
||||
return (
|
||||
result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`
|
||||
);
|
||||
},
|
||||
});
|
||||
+24
-4
@@ -8,16 +8,36 @@ import { closeMultiModel } from "../core/close";
|
||||
* creates archive tags, and deletes associated branches.
|
||||
*/
|
||||
export const closeTool = tool({
|
||||
description: "Close a multi-model tmux session and optionally cleanup worktrees and branches",
|
||||
description: `Close a multi-model tmux session and optionally clean up worktrees and branches.
|
||||
|
||||
Arguments:
|
||||
- \`sessionName\` (string, required): tmux session name to close.
|
||||
- \`cleanupWorktrees\` (boolean, optional, default: true): Whether to remove worktrees and delete branches.
|
||||
- \`createArchiveTags\` (boolean, optional, default: true): Whether to create archive tags before deleting branches.`,
|
||||
args: {
|
||||
sessionName: tool.schema.string().min(1).describe("tmux session name to close"),
|
||||
cleanupWorktrees: tool.schema.boolean().default(true).describe("whether to remove worktrees and delete branches (default: true)"),
|
||||
sessionName: tool.schema
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("tmux session name to close"),
|
||||
cleanupWorktrees: tool.schema
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
"whether to remove worktrees and delete branches (default: true)",
|
||||
),
|
||||
createArchiveTags: tool.schema
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
"whether to create archive tags before deleting branches (default: true)",
|
||||
),
|
||||
},
|
||||
async execute(args, context) {
|
||||
async execute(args, _context) {
|
||||
// In plugin mode, we skip confirmation (force=true) since there's no interactive terminal
|
||||
const result = await closeMultiModel({
|
||||
sessionName: args.sessionName,
|
||||
cleanupWorktrees: args.cleanupWorktrees,
|
||||
createArchiveTags: args.createArchiveTags,
|
||||
force: true,
|
||||
});
|
||||
|
||||
|
||||
+13
-3
@@ -9,9 +9,16 @@ import { getBinaryName } from "../core/utils";
|
||||
* Returns instructions for attaching to and cleaning up the session.
|
||||
*/
|
||||
export const openTool = tool({
|
||||
description: "Launch multiple OpenCode models in tmux",
|
||||
description: `Launch multiple OpenCode models in tmux.
|
||||
|
||||
Arguments:
|
||||
- \`sessionName\` (string, required): tmux session name to create.
|
||||
- \`models\` (array of strings, required): one or more OpenCode model IDs to launch.`,
|
||||
args: {
|
||||
sessionName: tool.schema.string().min(1).describe("tmux session name to create"),
|
||||
sessionName: tool.schema
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("tmux session name to create"),
|
||||
models: tool.schema
|
||||
.array(tool.schema.string().min(1))
|
||||
.min(1)
|
||||
@@ -39,6 +46,9 @@ export const openTool = tool({
|
||||
return result.error!;
|
||||
}
|
||||
|
||||
return result.instructions || `Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`;
|
||||
return (
|
||||
result.instructions ||
|
||||
`Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+41
-1
@@ -32,6 +32,8 @@ export interface MultiModelResult {
|
||||
* Options for closing a multi-model tmux session.
|
||||
*/
|
||||
export interface CloseSessionOptions {
|
||||
/** Whether to create archive tags before deleting branches. */
|
||||
createArchiveTags?: boolean;
|
||||
/** Name of the tmux session to close. */
|
||||
sessionName: string;
|
||||
/** Whether to remove worktrees and delete branches. */
|
||||
@@ -60,7 +62,6 @@ export interface CloseSessionResult {
|
||||
branchesDeleted?: string[];
|
||||
/** Archive tags that were created before branch deletion. */
|
||||
tagsCreated?: string[];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +74,45 @@ export interface WindowLaunchPlan {
|
||||
windowName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for cleaning up archive tags.
|
||||
*/
|
||||
export interface CleanupOptions {
|
||||
/** Skip confirmation prompts. */
|
||||
force?: boolean;
|
||||
/**
|
||||
* Remote name to push deletions to.
|
||||
* - If string: use specified remote (error if doesn't exist)
|
||||
* - If undefined: use first available remote from git remote -v
|
||||
* - If false: only local cleanup (--no-remote flag)
|
||||
*/
|
||||
remote?: string | false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned after attempting to cleanup archive tags.
|
||||
*/
|
||||
export interface CleanupResult {
|
||||
/** Whether the cleanup succeeded. */
|
||||
success: boolean;
|
||||
/** Error message if the cleanup failed. */
|
||||
error?: string;
|
||||
/** Summary message for the user. */
|
||||
instructions?: string;
|
||||
/** Number of local tags deleted. */
|
||||
localTagsDeleted: number;
|
||||
/** Number of remote tags deleted. */
|
||||
remoteTagsDeleted: number;
|
||||
/** Names of tags that were deleted locally. */
|
||||
localTagNames: string[];
|
||||
/** Names of tags deleted from remote. */
|
||||
remoteTagNames: string[];
|
||||
/** Tags that failed remote deletion. */
|
||||
remoteTagErrors: Array<{ tag: string; error: string }>;
|
||||
/** Available remotes discovered. */
|
||||
availableRemotes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of running a shell command.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import * as readline from "node:readline";
|
||||
import { cleanupMultiModel } from "../src/core/cleanup";
|
||||
|
||||
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<
|
||||
string,
|
||||
{ ok: boolean; stdout: string; stderr: string }
|
||||
> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const commandSignature = parts.join(" ");
|
||||
executedCommands.push(commandSignature);
|
||||
const response = mockCommandResponses[commandSignature] ?? {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => ({
|
||||
exitCode: response.ok ? 0 : 1,
|
||||
stdout: { toString: () => response.stdout },
|
||||
stderr: { toString: () => response.stderr },
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
(globalThis as any).Bun.$ = mockFn;
|
||||
return mockFn;
|
||||
}
|
||||
|
||||
function restoreOriginalBun$() {
|
||||
(globalThis as any).Bun.$ = originalBun$;
|
||||
}
|
||||
|
||||
describe("cleanupMultiModel", () => {
|
||||
let _bunMock: ReturnType<typeof setupMockBun$>;
|
||||
|
||||
beforeEach(() => {
|
||||
_bunMock = setupMockBun$();
|
||||
executedCommands = [];
|
||||
mockCommandResponses = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreOriginalBun$();
|
||||
});
|
||||
|
||||
test("no archive tags found - returns success with message", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "origin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(0);
|
||||
expect(result.remoteTagsDeleted).toBe(0);
|
||||
expect(result.instructions).toContain("No archive tags found");
|
||||
});
|
||||
|
||||
test("user cancels confirmation - returns success=false", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "origin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1\narchive/test-2",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const mockInterface = {
|
||||
question: (_q: string, cb: (a: string) => void) => cb("n"),
|
||||
close: () => {},
|
||||
} as any;
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
|
||||
const result = await cleanupMultiModel({ force: false });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Cleanup cancelled by user");
|
||||
expect(result.localTagsDeleted).toBe(0);
|
||||
expect(result.remoteTagsDeleted).toBe(0);
|
||||
});
|
||||
|
||||
test("force mode skips confirmation", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "origin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git push origin --delete archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(1);
|
||||
expect(executedCommands).toContain("git tag -d archive/test-1");
|
||||
});
|
||||
|
||||
test("--no-remote flag (remote=false) - only local cleanup, no remote push", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "origin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true, remote: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(1);
|
||||
expect(result.remoteTagsDeleted).toBe(0);
|
||||
expect(executedCommands).toContain("git tag -d archive/test-1");
|
||||
expect(executedCommands.some((c) => c.startsWith("git push"))).toBe(false);
|
||||
});
|
||||
|
||||
test("remote deletion failure continues local cleanup", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "origin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1\narchive/test-2",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-2"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git push origin --delete archive/test-1"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "remote error",
|
||||
};
|
||||
mockCommandResponses["git push origin --delete archive/test-2"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(2);
|
||||
expect(result.remoteTagsDeleted).toBe(1);
|
||||
expect(result.remoteTagErrors.length).toBe(1);
|
||||
expect(result.remoteTagErrors[0]?.tag).toBe("archive/test-1");
|
||||
});
|
||||
|
||||
test("dynamic remote detection - remote=undefined uses first available remote", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "upstream\norigin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git push upstream --delete archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true, remote: undefined });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(executedCommands).toContain(
|
||||
"git push upstream --delete archive/test-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("remote name validation - uses specified remote if it exists", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "upstream\norigin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git push upstream --delete archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({
|
||||
force: true,
|
||||
remote: "upstream",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.availableRemotes).toEqual(["upstream", "origin"]);
|
||||
expect(executedCommands).toContain(
|
||||
"git push upstream --delete archive/test-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("remote error - returns error if specified remote doesn't exist", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "origin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({
|
||||
force: true,
|
||||
remote: "nonexistent",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Remote 'nonexistent' not found");
|
||||
expect(result.error).toContain("Available remotes: origin");
|
||||
});
|
||||
|
||||
test("no remotes configured - only local cleanup", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.availableRemotes).toEqual([]);
|
||||
expect(result.remoteTagsDeleted).toBe(0);
|
||||
expect(executedCommands.some((c) => c.startsWith("git push"))).toBe(false);
|
||||
});
|
||||
|
||||
test("user confirms cleanup when not forced - proceeds with deletion", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "origin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git push origin --delete archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const mockInterface = {
|
||||
question: (_q: string, cb: (a: string) => void) => cb("y"),
|
||||
close: () => {},
|
||||
} as any;
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
|
||||
const result = await cleanupMultiModel({ force: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(1);
|
||||
expect(result.remoteTagsDeleted).toBe(1);
|
||||
expect(executedCommands).toContain("git tag -d archive/test-1");
|
||||
expect(executedCommands).toContain(
|
||||
"git push origin --delete archive/test-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("multiple archive tags deleted", async () => {
|
||||
mockCommandResponses["git remote"] = {
|
||||
ok: true,
|
||||
stdout: "origin",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -l archive/*"] = {
|
||||
ok: true,
|
||||
stdout: "archive/test-1\narchive/test-2\narchive/test-3",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-2"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git tag -d archive/test-3"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git push origin --delete archive/test-1"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git push origin --delete archive/test-2"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git push origin --delete archive/test-3"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await cleanupMultiModel({ force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.localTagsDeleted).toBe(3);
|
||||
expect(result.remoteTagsDeleted).toBe(3);
|
||||
expect(result.localTagNames).toEqual([
|
||||
"archive/test-1",
|
||||
"archive/test-2",
|
||||
"archive/test-3",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+326
-65
@@ -1,18 +1,33 @@
|
||||
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import * as os from "node:os";
|
||||
import * as readline from "node:readline";
|
||||
import { closeMultiModel } from "../src/core/close";
|
||||
|
||||
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
|
||||
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<
|
||||
string,
|
||||
{ ok: boolean; stdout: string; stderr: string }
|
||||
> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const commandSignature = parts.join(" ");
|
||||
executedCommands.push(commandSignature);
|
||||
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
|
||||
const response = mockCommandResponses[commandSignature] ?? {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => ({
|
||||
@@ -32,11 +47,11 @@ function restoreOriginalBun$() {
|
||||
}
|
||||
|
||||
describe("closeMultiModel", () => {
|
||||
let bunMock: ReturnType<typeof setupMockBun$>;
|
||||
let _bunMock: ReturnType<typeof setupMockBun$>;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
bunMock = setupMockBun$();
|
||||
_bunMock = setupMockBun$();
|
||||
executedCommands = [];
|
||||
mockCommandResponses = {};
|
||||
});
|
||||
@@ -46,12 +61,28 @@ describe("closeMultiModel", () => {
|
||||
});
|
||||
|
||||
test("kills existing session without cleanup", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["tmux kill-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["tmux kill-session -t test-session"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// No worktrees
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false });
|
||||
const result = await closeMultiModel({
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: false,
|
||||
force: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(false);
|
||||
expect(result.worktreesRemoved).toEqual([]);
|
||||
@@ -60,37 +91,81 @@ describe("closeMultiModel", () => {
|
||||
});
|
||||
|
||||
test("no session exists, no cleanup", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false });
|
||||
const result = await closeMultiModel({
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: false,
|
||||
force: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(false);
|
||||
expect(result.worktreesRemoved).toEqual([]);
|
||||
expect(executedCommands).toContain("tmux has-session -t test-session");
|
||||
// No kill-session should be issued
|
||||
expect(executedCommands.some(c => c.startsWith("tmux kill-session"))).toBe(false);
|
||||
expect(
|
||||
executedCommands.some((c) => c.startsWith("tmux kill-session")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("cleanup works with worktrees and force, all succeeds", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const worktreePath =
|
||||
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: worktreeListOutput,
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git branch -D opencode/test-session/model"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// Tag command will default to ok:true via fallback
|
||||
mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"rm -rf /home/user/.local/share/opencode/multi-model/test-session"
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
// Freeze timestamp for deterministic tag name
|
||||
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
||||
const OriginalDate = Date;
|
||||
// @ts-ignore
|
||||
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
|
||||
global.Date = class extends OriginalDate {
|
||||
constructor() {
|
||||
super();
|
||||
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
|
||||
return fixedDate;
|
||||
}
|
||||
override toISOString() {
|
||||
return "2023-01-01T00:00:00.000Z";
|
||||
}
|
||||
} as any;
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true });
|
||||
const result = await closeMultiModel({
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: true,
|
||||
});
|
||||
// Restore Date
|
||||
// @ts-ignore
|
||||
global.Date = OriginalDate;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -100,37 +175,63 @@ describe("closeMultiModel", () => {
|
||||
expect(result.tagsCreated?.length).toBe(1);
|
||||
|
||||
// Ensure removal commands were executed
|
||||
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
|
||||
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`);
|
||||
expect(executedCommands).toContain(
|
||||
`git worktree remove -f ${worktreePath}`,
|
||||
);
|
||||
expect(executedCommands).toContain(
|
||||
`git branch -D opencode/test-session/model`,
|
||||
);
|
||||
});
|
||||
|
||||
test("user cancels cleanup when not forced", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const worktreePath =
|
||||
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: worktreeListOutput,
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
// Stub readline to return "n"
|
||||
const mockInterface = {
|
||||
question: (q: string, cb: (a: string) => void) => cb("n"),
|
||||
question: (_q: string, cb: (a: string) => void) => cb("n"),
|
||||
close: () => {},
|
||||
} as any;
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false });
|
||||
const result = await closeMultiModel({
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: false,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Cleanup cancelled by user");
|
||||
});
|
||||
|
||||
test("user confirms cleanup when not forced", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const worktreePath =
|
||||
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: worktreeListOutput,
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
// Stub readline to simulate user confirming cleanup (answers "y")
|
||||
const mockInterface = {
|
||||
question: (q: string, cb: (a: string) => void) => cb("y"),
|
||||
question: (_q: string, cb: (a: string) => void) => cb("y"),
|
||||
close: () => {},
|
||||
} as any;
|
||||
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
||||
@@ -138,13 +239,24 @@ describe("closeMultiModel", () => {
|
||||
// Freeze timestamp for deterministic tag name
|
||||
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
||||
const OriginalDate = Date;
|
||||
// @ts-ignore
|
||||
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
|
||||
global.Date = class extends OriginalDate {
|
||||
constructor() {
|
||||
super();
|
||||
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
|
||||
return fixedDate;
|
||||
}
|
||||
override toISOString() {
|
||||
return "2023-01-01T00:00:00.000Z";
|
||||
}
|
||||
} as any;
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false });
|
||||
const result = await closeMultiModel({
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: false,
|
||||
});
|
||||
|
||||
// Restore Date after invocation
|
||||
// @ts-ignore
|
||||
global.Date = OriginalDate;
|
||||
|
||||
// Verify result indicates successful cleanup
|
||||
@@ -154,37 +266,72 @@ describe("closeMultiModel", () => {
|
||||
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
|
||||
expect(result.tagsCreated?.length).toBe(1);
|
||||
|
||||
|
||||
// Verify the expected git commands were executed
|
||||
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
|
||||
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`);
|
||||
expect(executedCommands).toContain(
|
||||
`git worktree remove -f ${worktreePath}`,
|
||||
);
|
||||
expect(executedCommands).toContain(
|
||||
`git branch -D opencode/test-session/model`,
|
||||
);
|
||||
const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
|
||||
expect(executedCommands).toContain(expectedTagCmd);
|
||||
});
|
||||
|
||||
test("warning when tag creation fails", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const worktreePath =
|
||||
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: worktreeListOutput,
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git branch -D opencode/test-session/model"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// Tag command will fail
|
||||
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
||||
const OriginalDate = Date;
|
||||
// @ts-ignore
|
||||
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
|
||||
global.Date = class extends OriginalDate {
|
||||
constructor() {
|
||||
super();
|
||||
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
|
||||
return fixedDate;
|
||||
}
|
||||
override toISOString() {
|
||||
return "2023-01-01T00:00:00.000Z";
|
||||
}
|
||||
} as any;
|
||||
const tagCommand = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
|
||||
mockCommandResponses[tagCommand] = { ok: false, stdout: "", stderr: "tag error" };
|
||||
mockCommandResponses[tagCommand] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "tag error",
|
||||
};
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true });
|
||||
const result = await closeMultiModel({
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: true,
|
||||
});
|
||||
// Restore Date
|
||||
// @ts-ignore
|
||||
global.Date = OriginalDate;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Expect the warning message to be captured in instructions
|
||||
expect(result.instructions.includes('Failed to create tag')).toBe(true);
|
||||
expect(result.instructions.includes("Failed to create tag")).toBe(true);
|
||||
expect(result.tagsCreated).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -192,23 +339,52 @@ describe("closeMultiModel", () => {
|
||||
const unsanitized = "test session!";
|
||||
const sanitized = "test-session"; // result of sanitizeName
|
||||
// tmux lookup uses unsanitized name
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`;
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${sanitized}/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: worktreeListOutput,
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
// Freeze timestamp for deterministic tag name
|
||||
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
||||
const OriginalDate = Date;
|
||||
// @ts-ignore
|
||||
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
|
||||
global.Date = class extends OriginalDate {
|
||||
constructor() {
|
||||
super();
|
||||
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
|
||||
return fixedDate;
|
||||
}
|
||||
override toISOString() {
|
||||
return "2023-01-01T00:00:00.000Z";
|
||||
}
|
||||
} as any;
|
||||
|
||||
const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: true, force: true });
|
||||
const result = await closeMultiModel({
|
||||
sessionName: unsanitized,
|
||||
cleanupWorktrees: true,
|
||||
force: true,
|
||||
});
|
||||
// Restore Date
|
||||
// @ts-ignore
|
||||
global.Date = OriginalDate;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -217,7 +393,92 @@ describe("closeMultiModel", () => {
|
||||
expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]);
|
||||
expect(result.tagsCreated?.length).toBe(1);
|
||||
// Verify that the commands used the sanitized paths for worktree removal and branch deletion
|
||||
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
|
||||
expect(executedCommands).toContain(`git branch -D opencode/${sanitized}/model`);
|
||||
expect(executedCommands).toContain(
|
||||
`git worktree remove -f ${worktreePath}`,
|
||||
);
|
||||
expect(executedCommands).toContain(
|
||||
`git branch -D opencode/${sanitized}/model`,
|
||||
);
|
||||
});
|
||||
|
||||
test("tmux commands use sessionName, not safeSessionName", async () => {
|
||||
const unsanitized = "my session!";
|
||||
const sanitized = "my-session";
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// No worktrees
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await closeMultiModel({
|
||||
sessionName: unsanitized,
|
||||
cleanupWorktrees: false,
|
||||
force: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
// Verify tmux commands used unsanitized name
|
||||
expect(executedCommands).toContain(`tmux has-session -t ${unsanitized}`);
|
||||
expect(executedCommands).toContain(`tmux kill-session -t ${unsanitized}`);
|
||||
// Ensure sanitized name not used in tmux commands
|
||||
expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("skip archive tags when flag disabled", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const worktreePath =
|
||||
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: worktreeListOutput,
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git branch -D opencode/test-session/model"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
"rm -rf /home/user/.local/share/opencode/multi-model/test-session"
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await closeMultiModel({
|
||||
sessionName: "test-session",
|
||||
cleanupWorktrees: true,
|
||||
force: true,
|
||||
createArchiveTags: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(true);
|
||||
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
||||
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
|
||||
expect(result.tagsCreated?.length ?? 0).toBe(0);
|
||||
// Ensure no tag command was executed
|
||||
expect(executedCommands.some((cmd) => cmd.startsWith("git tag"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import * as os from "node:os";
|
||||
import { closeMultiModel } from "../src/core/close";
|
||||
|
||||
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
|
||||
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<
|
||||
string,
|
||||
{ ok: boolean; stdout: string; stderr: string }
|
||||
> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const cmd = parts.join(" ");
|
||||
executedCommands.push(cmd);
|
||||
@@ -15,7 +26,11 @@ function setupMockBun$() {
|
||||
if (cmd === "git worktree list --porcelain") {
|
||||
throw new Error("unexpected error");
|
||||
}
|
||||
const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" };
|
||||
const response = mockCommandResponses[cmd] ?? {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => ({
|
||||
@@ -46,7 +61,11 @@ describe("closeMultiModel unexpected error handling", () => {
|
||||
});
|
||||
|
||||
test("catches thrown error and returns failure", async () => {
|
||||
const result = await closeMultiModel({ sessionName: "any-session", cleanupWorktrees: false, force: false });
|
||||
const result = await closeMultiModel({
|
||||
sessionName: "any-session",
|
||||
cleanupWorktrees: false,
|
||||
force: false,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("unexpected error");
|
||||
});
|
||||
|
||||
+234
-65
@@ -1,32 +1,25 @@
|
||||
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { openMultiModel } from "../src/core/open";
|
||||
import {
|
||||
shellQuote,
|
||||
normalizeModels,
|
||||
findDuplicates,
|
||||
createWindowBaseName,
|
||||
createWindowPlans,
|
||||
levenshtein,
|
||||
suggestModels,
|
||||
formatInvalidModelError,
|
||||
sanitizeName,
|
||||
runCommand,
|
||||
getWorktreePath,
|
||||
getSessionPath,
|
||||
undoWorktree,
|
||||
launchModelInWindow,
|
||||
getWorktreesForSession,
|
||||
getBinaryName,
|
||||
} from "../src/core/utils";
|
||||
|
||||
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
|
||||
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<
|
||||
string,
|
||||
{ ok: boolean; stdout: string; stderr: string }
|
||||
> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const commandSignature = parts.join(" ");
|
||||
|
||||
@@ -35,7 +28,11 @@ function setupMockBun$() {
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => {
|
||||
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
|
||||
const response = mockCommandResponses[commandSignature] ?? {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
return {
|
||||
exitCode: response.ok ? 0 : 1,
|
||||
stdout: { toString: () => response.stdout },
|
||||
@@ -56,21 +53,38 @@ function restoreOriginalBun$() {
|
||||
|
||||
describe("multi-model launch", () => {
|
||||
let existsSyncMock: ReturnType<typeof spyOn>;
|
||||
let bunMock: ReturnType<typeof setupMockBun$>;
|
||||
let _bunMock: ReturnType<typeof setupMockBun$>;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(os, "homedir").mockReturnValue("/mock/home");
|
||||
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
bunMock = setupMockBun$();
|
||||
_bunMock = setupMockBun$();
|
||||
executedCommands = [];
|
||||
|
||||
mockCommandResponses = {
|
||||
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
|
||||
"git rev-parse --is-inside-work-tree": {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
},
|
||||
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
|
||||
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
|
||||
"opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" },
|
||||
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" },
|
||||
"command -v opencode": {
|
||||
ok: true,
|
||||
stdout: "/usr/bin/opencode",
|
||||
stderr: "",
|
||||
},
|
||||
"opencode models": {
|
||||
ok: true,
|
||||
stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet",
|
||||
stderr: "",
|
||||
},
|
||||
"tmux has-session -t test-session": {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "session not found",
|
||||
},
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2":
|
||||
{ ok: false, stdout: "", stderr: "" },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -112,7 +126,11 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if not inside a git repository", async () => {
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "not a git repo",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -120,12 +138,18 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `multi-model` requires a git repository.");
|
||||
expect(result.error).toContain(
|
||||
"Error: `multi-model` requires a git repository.",
|
||||
);
|
||||
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
|
||||
});
|
||||
|
||||
test("fails if tmux is not installed", async () => {
|
||||
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -133,7 +157,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`.");
|
||||
expect(result.error).toContain(
|
||||
"Error: `tmux` is not installed or not on `PATH`.",
|
||||
);
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
@@ -141,7 +167,11 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if opencode is not installed", async () => {
|
||||
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -149,7 +179,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`.");
|
||||
expect(result.error).toContain(
|
||||
"Error: `opencode` is not installed or not on `PATH`.",
|
||||
);
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
@@ -164,7 +196,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: Model name 'invalid/model' not found");
|
||||
expect(result.error).toContain(
|
||||
"Error: Model name 'invalid/model' not found",
|
||||
);
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
@@ -174,7 +208,11 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if session already exists", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -199,13 +237,25 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test-session` to join session",
|
||||
);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux send-keys -t test-session:gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("successfully launches multiple models", async () => {
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -213,14 +263,30 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test-session` to join session",
|
||||
);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("sanitizes session name", async () => {
|
||||
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["tmux has-session -t test session!"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test session!",
|
||||
@@ -228,14 +294,83 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test session!` to join session",
|
||||
);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("uses sessionName for tmux commands, not safeSessionName", async () => {
|
||||
const unsanitized = "my awesome session!!";
|
||||
const sanitized = "my-awesome-session";
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`git worktree add -b opencode/${sanitized}/gpt-5-2 /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux new-session -d -s ${unsanitized} -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: unsanitized,
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// tmux commands should use unsanitized name
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith(`tmux has-session -t ${unsanitized}`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith(`tmux new-session -d -s ${unsanitized}`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith(`tmux send-keys -t ${unsanitized}:`),
|
||||
),
|
||||
).toBe(true);
|
||||
// sanitized name should NOT appear in tmux commands
|
||||
expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
|
||||
false,
|
||||
);
|
||||
// git branches and paths should use sanitized name
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith(`git worktree add -b opencode/${sanitized}/gpt-5-2`),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
|
||||
mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" };
|
||||
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
|
||||
] = { ok: false, stdout: "", stderr: "tmux error" };
|
||||
mockCommandResponses[
|
||||
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -280,7 +415,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if git branch exists for first model", async () => {
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -288,7 +425,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists");
|
||||
expect(result.error).toContain(
|
||||
"Branch 'opencode/test-session/gpt-5-2' already exists",
|
||||
);
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
@@ -300,7 +439,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if git worktree add fails for first model", async () => {
|
||||
mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" };
|
||||
mockCommandResponses[
|
||||
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
|
||||
] = { ok: false, stdout: "", stderr: "git error" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -321,9 +462,17 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("model name collision creates unique window names", async () => {
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: "openai/gpt-5.2\nanthropic/gpt-5.2",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -331,15 +480,31 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test-session` to join session",
|
||||
);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("long model name is truncated in window name", async () => {
|
||||
const longModel = "verylongmodelfrontexampleprovider";
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: longModel,
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -347,8 +512,12 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test-session` to join session",
|
||||
);
|
||||
const windowName = executedCommands
|
||||
.find((c) => c.includes("tmux new-session"))
|
||||
?.match(/-n (\S+)/)?.[1];
|
||||
expect(windowName?.length).toBeLessThanOrEqual(24);
|
||||
});
|
||||
});
|
||||
|
||||
+471
-168
@@ -1,18 +1,33 @@
|
||||
import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test";
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { openMultiModel } from "../src/core/open";
|
||||
|
||||
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
|
||||
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<
|
||||
string,
|
||||
{ ok: boolean; stdout: string; stderr: string }
|
||||
> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const cmd = parts.join(" ");
|
||||
executedCommands.push(cmd);
|
||||
const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" };
|
||||
const response = mockCommandResponses[cmd] ?? {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => ({
|
||||
@@ -44,193 +59,481 @@ describe("openMultiModel error paths", () => {
|
||||
|
||||
test("fails when model list command fails", async () => {
|
||||
const session = "sess";
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = { ok: false, stdout: "", stderr: "model error" };
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: true,
|
||||
stdout: "tmux",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: true,
|
||||
stdout: "opencode",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "model error",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2"], binaryName: "opencode", mode: "cli" });
|
||||
const result = await openMultiModel({
|
||||
sessionName: session,
|
||||
models: ["openai/gpt-5.2"],
|
||||
binaryName: "opencode",
|
||||
mode: "cli",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Failed to load valid models");
|
||||
});
|
||||
|
||||
test("fails when duplicate models provided", async () => {
|
||||
const session = "dup";
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2", stderr: "" };
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: true,
|
||||
stdout: "tmux",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: true,
|
||||
stdout: "opencode",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: "openai/gpt-5.2",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2", "openai/gpt-5.2"], binaryName: "opencode", mode: "cli" });
|
||||
const result = await openMultiModel({
|
||||
sessionName: session,
|
||||
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
|
||||
binaryName: "opencode",
|
||||
mode: "cli",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Duplicate model names are not allowed");
|
||||
});
|
||||
|
||||
test("fails when session already exists", async () => {
|
||||
const session = "existing";
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2", stderr: "" };
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: true,
|
||||
stdout: "tmux",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: true,
|
||||
stdout: "opencode",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: "openai/gpt-5.2",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2"], binaryName: "opencode", mode: "cli" });
|
||||
const result = await openMultiModel({
|
||||
sessionName: session,
|
||||
models: ["openai/gpt-5.2"],
|
||||
binaryName: "opencode",
|
||||
mode: "cli",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Session already exists");
|
||||
});
|
||||
|
||||
test("second model fails because branch already exists", async () => {
|
||||
const session = "sess-add";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
// common mocks
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model branch exists
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
|
||||
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch");
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
test("second model fails because branch already exists", async () => {
|
||||
const session = "sess-add";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
// common mocks
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: true,
|
||||
stdout: "tmux",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: true,
|
||||
stdout: "opencode",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: models.join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${firstBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model branch exists
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${secondBranch}`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
const result = await openMultiModel({
|
||||
sessionName: session,
|
||||
models,
|
||||
binaryName: binary,
|
||||
mode: "cli",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch");
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
});
|
||||
|
||||
test("second model worktree creation fails", async () => {
|
||||
const session = "sess-wt";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: true,
|
||||
stdout: "tmux",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: true,
|
||||
stdout: "opencode",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: models.join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${firstBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model fails worktree
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${secondBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
|
||||
{ ok: false, stdout: "", stderr: "wt error" };
|
||||
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`git branch -D ${secondBranch}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const result = await openMultiModel({
|
||||
sessionName: session,
|
||||
models,
|
||||
binaryName: binary,
|
||||
mode: "cli",
|
||||
});
|
||||
|
||||
test("second model worktree creation fails", async () => {
|
||||
const session = "sess-wt";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model fails worktree
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: false, stdout: "", stderr: "wt error" };
|
||||
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
|
||||
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Error: Created tmux session");
|
||||
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
});
|
||||
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
test("second model window creation fails", async () => {
|
||||
const session = "sess-win";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: true,
|
||||
stdout: "tmux",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: true,
|
||||
stdout: "opencode",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: models.join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${firstBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model window failure
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${secondBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
|
||||
{ ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`
|
||||
] = { ok: false, stdout: "", stderr: "win err" };
|
||||
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`git branch -D ${secondBranch}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const result = await openMultiModel({
|
||||
sessionName: session,
|
||||
models,
|
||||
binaryName: binary,
|
||||
mode: "cli",
|
||||
});
|
||||
|
||||
test("second model window creation fails", async () => {
|
||||
const session = "sess-win";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model window failure
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`] = { ok: false, stdout: "", stderr: "win err" };
|
||||
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
|
||||
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Error: Created tmux session");
|
||||
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
});
|
||||
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
});
|
||||
|
||||
test("second model launch command fails", async () => {
|
||||
const session = "sess-launch";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model launch fails
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m`] = { ok: false, stdout: "", stderr: "launch err" };
|
||||
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
|
||||
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (launch err");
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
});
|
||||
|
||||
test("second model worktree path already exists", async () => {
|
||||
const session = "sess-wtpath";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model path exists
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
|
||||
// mock fs existsSync to return true for secondPath
|
||||
spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath);
|
||||
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Error: Created tmux session");
|
||||
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Worktree path already exists at");
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
test("second model launch command fails", async () => {
|
||||
const session = "sess-launch";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: true,
|
||||
stdout: "tmux",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: true,
|
||||
stdout: "opencode",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: models.join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${firstBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model launch fails
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${secondBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
|
||||
{ ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m`
|
||||
] = { ok: false, stdout: "", stderr: "launch err" };
|
||||
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`git branch -D ${secondBranch}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const result = await openMultiModel({
|
||||
sessionName: session,
|
||||
models,
|
||||
binaryName: binary,
|
||||
mode: "cli",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Failed: anthropic/gpt-5-2 (launch err",
|
||||
);
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
});
|
||||
|
||||
test("second model worktree path already exists", async () => {
|
||||
const session = "sess-wtpath";
|
||||
const binary = "opencode";
|
||||
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: true,
|
||||
stdout: "tmux",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: true,
|
||||
stdout: "opencode",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: models.join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[`tmux has-session -t ${session}`] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
// first model success
|
||||
const firstBranch = `opencode/${session}/gpt-5-2`;
|
||||
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${firstBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
// second model path exists
|
||||
const secondBranch = `opencode/${session}/gpt-5-2-2`;
|
||||
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/${secondBranch}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
// mock fs existsSync to return true for secondPath
|
||||
spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath);
|
||||
const result = await openMultiModel({
|
||||
sessionName: session,
|
||||
models,
|
||||
binaryName: binary,
|
||||
mode: "cli",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Error: Created tmux session");
|
||||
expect(result.instructions).toContain(
|
||||
"Failed: anthropic/gpt-5-2 (Worktree path already exists at",
|
||||
);
|
||||
expect(result.windows).toContain("gpt-5-2");
|
||||
expect(result.windows).toContain("gpt-5-2-2");
|
||||
});
|
||||
});
|
||||
|
||||
+163
-53
@@ -1,38 +1,52 @@
|
||||
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
shellQuote,
|
||||
normalizeModels,
|
||||
findDuplicates,
|
||||
createWindowBaseName,
|
||||
createWindowPlans,
|
||||
levenshtein,
|
||||
suggestModels,
|
||||
findDuplicates,
|
||||
formatInvalidModelError,
|
||||
sanitizeName,
|
||||
runCommand,
|
||||
getWorktreePath,
|
||||
getSessionPath,
|
||||
undoWorktree,
|
||||
launchModelInWindow,
|
||||
getWorktreesForSession,
|
||||
getBinaryName,
|
||||
getSessionPath,
|
||||
getWorktreePath,
|
||||
getWorktreesForSession,
|
||||
launchModelInWindow,
|
||||
levenshtein,
|
||||
normalizeModels,
|
||||
runCommand,
|
||||
sanitizeName,
|
||||
shellQuote,
|
||||
suggestModels,
|
||||
undoWorktree,
|
||||
} from "../src/core/utils";
|
||||
|
||||
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
|
||||
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<
|
||||
string,
|
||||
{ ok: boolean; stdout: string; stderr: string }
|
||||
> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
|
||||
const parts = values[0] as string[];
|
||||
const commandSignature = parts.join(" ");
|
||||
executedCommands.push(commandSignature);
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => {
|
||||
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
|
||||
const response = mockCommandResponses[commandSignature] ?? {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
return {
|
||||
exitCode: response.ok ? 0 : 1,
|
||||
stdout: { toString: () => response.stdout },
|
||||
@@ -51,11 +65,11 @@ function restoreOriginalBun$() {
|
||||
}
|
||||
|
||||
describe("core utils", () => {
|
||||
let bunMock: ReturnType<typeof setupMockBun$>;
|
||||
let _bunMock: ReturnType<typeof setupMockBun$>;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(os, "homedir").mockReturnValue("/mock/home");
|
||||
bunMock = setupMockBun$();
|
||||
_bunMock = setupMockBun$();
|
||||
executedCommands = [];
|
||||
mockCommandResponses = {};
|
||||
});
|
||||
@@ -75,7 +89,9 @@ describe("core utils", () => {
|
||||
expect(shellQuote("test'value")).toBe("'test'\"'\"'value'");
|
||||
});
|
||||
test("string with multiple single quotes", () => {
|
||||
expect(shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'");
|
||||
expect(shellQuote("te's't'v'alue")).toBe(
|
||||
"'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'",
|
||||
);
|
||||
});
|
||||
test("empty string", () => {
|
||||
expect(shellQuote("")).toBe("''");
|
||||
@@ -93,7 +109,9 @@ describe("core utils", () => {
|
||||
expect(normalizeModels([" ", " "])).toEqual([]);
|
||||
});
|
||||
test("array with mixed valid models, padded models, and empty strings", () => {
|
||||
expect(normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "])).toEqual(["openai/gpt-5.2", "anthropic/claude"]);
|
||||
expect(
|
||||
normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "]),
|
||||
).toEqual(["openai/gpt-5.2", "anthropic/claude"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +129,11 @@ describe("core utils", () => {
|
||||
expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]);
|
||||
});
|
||||
test("verifying first-repeated-occurrence order", () => {
|
||||
expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]);
|
||||
expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual([
|
||||
"a",
|
||||
"x",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,10 +158,14 @@ describe("core utils", () => {
|
||||
|
||||
describe("createWindowPlans", () => {
|
||||
test("single model", () => {
|
||||
expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([{ model: "openai/gpt-5-2", windowName: "gpt-5-2" }]);
|
||||
expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([
|
||||
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
|
||||
]);
|
||||
});
|
||||
test("two models with the same base name", () => {
|
||||
expect(createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"])).toEqual([
|
||||
expect(
|
||||
createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"]),
|
||||
).toEqual([
|
||||
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
|
||||
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" },
|
||||
]);
|
||||
@@ -194,7 +220,12 @@ describe("core utils", () => {
|
||||
expect(suggestions[0]).toBe("gpt-5.2");
|
||||
});
|
||||
test("maximum of 3 suggestions", () => {
|
||||
const allowlist = ["gpt-5.2", "gpt-5.2-mini", "gpt-5.2-pro", "gpt-5.2-ultra"];
|
||||
const allowlist = [
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-mini",
|
||||
"gpt-5.2-pro",
|
||||
"gpt-5.2-ultra",
|
||||
];
|
||||
expect(suggestModels("gpt4o", allowlist).length).toBe(3);
|
||||
});
|
||||
test("sorting logic", () => {
|
||||
@@ -239,14 +270,22 @@ describe("core utils", () => {
|
||||
|
||||
describe("runCommand", () => {
|
||||
test("returns ok true for successful command", async () => {
|
||||
mockCommandResponses["echo hello"] = { ok: true, stdout: "hello", stderr: "" };
|
||||
mockCommandResponses["echo hello"] = {
|
||||
ok: true,
|
||||
stdout: "hello",
|
||||
stderr: "",
|
||||
};
|
||||
const result = await runCommand(["echo", "hello"]);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.stdout).toBe("hello");
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
test("returns ok false for failed command", async () => {
|
||||
mockCommandResponses["false"] = { ok: false, stdout: "", stderr: "error" };
|
||||
mockCommandResponses.false = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "error",
|
||||
};
|
||||
const result = await runCommand(["false"]);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.stderr).toBe("error");
|
||||
@@ -257,12 +296,16 @@ describe("core utils", () => {
|
||||
test("returns correct worktree path", () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
const result = getWorktreePath("my-session", "gpt-5.2");
|
||||
expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
|
||||
expect(result).toBe(
|
||||
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
|
||||
);
|
||||
});
|
||||
test("handles nested session names", () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
const result = getWorktreePath("parent/child", "window");
|
||||
expect(result).toBe("/home/user/.local/share/opencode/multi-model/parent/child/window");
|
||||
expect(result).toBe(
|
||||
"/home/user/.local/share/opencode/multi-model/parent/child/window",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,57 +313,118 @@ describe("core utils", () => {
|
||||
test("returns correct session path", () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
const result = getSessionPath("my-session");
|
||||
expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session");
|
||||
expect(result).toBe(
|
||||
"/home/user/.local/share/opencode/multi-model/my-session",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undoWorktree", () => {
|
||||
test("returns empty array on success", async () => {
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
|
||||
const errors = await undoWorktree(
|
||||
"/path/to/worktree",
|
||||
"opencode/session/window",
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
test("returns error when worktree remove fails", async () => {
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" };
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "remove failed",
|
||||
};
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
|
||||
const errors = await undoWorktree(
|
||||
"/path/to/worktree",
|
||||
"opencode/session/window",
|
||||
);
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0]).toContain("Failed to remove worktree");
|
||||
});
|
||||
test("returns error when branch delete fails", async () => {
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" };
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "branch delete failed",
|
||||
};
|
||||
|
||||
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
|
||||
const errors = await undoWorktree(
|
||||
"/path/to/worktree",
|
||||
"opencode/session/window",
|
||||
);
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0]).toContain("Failed to delete branch");
|
||||
});
|
||||
test("returns both errors when both operations fail", async () => {
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" };
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" };
|
||||
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "remove failed",
|
||||
};
|
||||
mockCommandResponses["git branch -D opencode/session/window"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "branch delete failed",
|
||||
};
|
||||
|
||||
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
|
||||
const errors = await undoWorktree(
|
||||
"/path/to/worktree",
|
||||
"opencode/session/window",
|
||||
);
|
||||
expect(errors.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchModelInWindow", () => {
|
||||
test("sends correct tmux command", async () => {
|
||||
mockCommandResponses["tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchModelInWindow("session", { model: "openai/gpt-5.2", windowName: "window" });
|
||||
const result = await launchModelInWindow("session", {
|
||||
model: "openai/gpt-5.2",
|
||||
windowName: "window",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(executedCommands).toContain("tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m");
|
||||
expect(executedCommands).toContain(
|
||||
"tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m",
|
||||
);
|
||||
});
|
||||
test("uses custom binary name", async () => {
|
||||
mockCommandResponses["tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await launchModelInWindow("session", { model: "openai/gpt-5.2", windowName: "window" }, "kilo");
|
||||
const result = await launchModelInWindow(
|
||||
"session",
|
||||
{ model: "openai/gpt-5.2", windowName: "window" },
|
||||
"kilo",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(executedCommands).toContain("tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m");
|
||||
expect(executedCommands).toContain(
|
||||
"tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,26 +433,32 @@ describe("core utils", () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2\n\nworktree /home/user/other-repo\nbranch refs/heads/main",
|
||||
stdout:
|
||||
"worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2\n\nworktree /home/user/other-repo\nbranch refs/heads/main",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const worktrees = await getWorktreesForSession("my-session");
|
||||
expect(worktrees.length).toBe(1);
|
||||
expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
|
||||
expect(worktrees[0]?.path).toBe(
|
||||
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
|
||||
);
|
||||
expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2");
|
||||
});
|
||||
test("sanitizes session name when filtering worktrees", async () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
mockCommandResponses["git worktree list --porcelain"] = {
|
||||
ok: true,
|
||||
stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2",
|
||||
stdout:
|
||||
"worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const worktrees = await getWorktreesForSession("my session!");
|
||||
expect(worktrees.length).toBe(1);
|
||||
expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
|
||||
expect(worktrees[0]?.path).toBe(
|
||||
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
|
||||
);
|
||||
});
|
||||
test("returns empty array when no matching worktrees", async () => {
|
||||
spyOn(os, "homedir").mockReturnValue("/home/user");
|
||||
|
||||
Reference in New Issue
Block a user