mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
- Bump `@opencode-ai/plugin` dependency from 1.2.27 to 1.3.0. - Introduce `cleanup` command in CLI with force and remote options. - Export `cleanupMultiModel` from core index. - Register `multi-model-cleanup` tool in plugin and update exports. - Add `CleanupOptions` and `CleanupResult` types to define cleanup behavior and results.
475 lines
13 KiB
Markdown
475 lines
13 KiB
Markdown
---
|
|
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 |