mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
13 KiB
13 KiB
model
| model |
|---|
| opencode-go/glm-5 |
Plan: Add opencode-multi-model cleanup Command
Overview
Add a newcleanup 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
- Scope: Only archive tags (not orphaned worktrees/branches)
- Confirmation: Prompt by default,
--forceto skip - Remote handling: Delete from both local and remote by default
- Preview: Show count of tags, not full list
- Remote discovery: Check
git remote -vfor available remotes - 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):
/**
* 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
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
/**
* 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:
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."),
tool.schema.undefined().describe("Use first available remote from git remote -v."),
]).optional().describe("Remote handling: string=specific remote, false=local-only, undefined=first available"),
},
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:
import { cleanupArchiveTags } from "./core/cleanup";
Add command after line 69 (after close command):
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", false)
.action(async (options: { force: boolean; remote?: string; noRemote: 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:
import { cleanupTool } from "./tools/cleanup";
Update return object (line 22-25):
tool: {
"multi-model-open": openTool,
"multi-model-close": closeTool,
"multi-model-cleanup": cleanupTool,
},
Update exports (line30):
export { openTool, closeTool, cleanupTool };
Usage Examples
CLI Usage
# 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)
// 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:
- Build:
bun run buildorbun run typecheck - Unit tests:
bun test tests/cleanup.test.ts - Manual test:
# 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:
- No archive tags found - returns success with message
- User cancels confirmation - returns success=false
- Force mode skips confirmation
- --no-remote flag (remote=false) - only local cleanup, no remote push
- Remote deletion failure continues local cleanup
- Dynamic remote detection - remote=undefined uses first available remote
- Remote name validation - uses specified remote if it exists
- Remote error - returns error if specified remote doesn't exist
- No remotes configured - only local cleanup