--- 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 { 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 { 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 { const { stdout } = await runCommand(["git", "remote"]); return stdout.split("\n").filter(Boolean); } /** * Deletes a local git tag. */ async function deleteLocalTag(tagName: string): Promise { 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/- * 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 { 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."), 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: ```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 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: ```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 ` | Delete local tag | | `git push --delete ` | 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