diff --git a/.opencode/plans/1774116737450-clever-canyon.md b/.opencode/plans/1774116737450-clever-canyon.md new file mode 100644 index 0000000..b37aa32 --- /dev/null +++ b/.opencode/plans/1774116737450-clever-canyon.md @@ -0,0 +1,478 @@ +--- +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, + // If --no-remote is passed, set remote to false (local-only) + // Otherwise, remote is either specified (string) or undefined (use first available) + remote: options.noRemote ? false : 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 \ No newline at end of file