diff --git a/.opencode/package.json b/.opencode/package.json index 4aabb36..31a74b2 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -6,10 +6,10 @@ "test": "bun test" }, "dependencies": { - "@opencode-ai/plugin": "1.2.27" + "@opencode-ai/plugin": "1.3.0" }, "devDependencies": { "@types/bun": "^1.3.10", "@types/node": "^25.5.0" } -} +} \ No newline at end of file diff --git a/.opencode/plans/1774116737450-clever-canyon.md b/.opencode/plans/archive/1774116737450-clever-canyon.md similarity index 100% rename from .opencode/plans/1774116737450-clever-canyon.md rename to .opencode/plans/archive/1774116737450-clever-canyon.md diff --git a/src/cli.ts b/src/cli.ts index db96303..4179a8c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { Command } from "commander"; +import { cleanupMultiModel } from "./core/cleanup"; import { closeMultiModel } from "./core/close"; import { openMultiModel } from "./core/open"; import { getBinaryName } from "./core/utils"; @@ -91,4 +92,32 @@ program }, ); +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") + .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); + } else { + console.error(`Failed: ${result.error}`); + process.exit(1); + } + } catch (error) { + console.error(`Error: ${error}`); + process.exit(1); + } + }); + program.parse(); diff --git a/src/core/cleanup.ts b/src/core/cleanup.ts new file mode 100644 index 0000000..52b35bb --- /dev/null +++ b/src/core/cleanup.ts @@ -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 { + 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, + }; +} + +/** + * 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 cleanupMultiModel( + 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, + }; +} diff --git a/src/core/index.ts b/src/core/index.ts index dc3f42f..60732d7 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,3 +1,4 @@ +export { cleanupMultiModel } from "./cleanup"; export { closeMultiModel } from "./close"; export { openMultiModel } from "./open"; export * from "./utils"; diff --git a/src/index.ts b/src/index.ts index 6940b3f..661eaa0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,15 @@ import type { Plugin } from "@opencode-ai/plugin"; +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,6 +24,7 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => { tool: { "multi-model-open": openTool, "multi-model-close": closeTool, + "multi-model-cleanup": cleanupTool, }, }; }; @@ -29,4 +32,4 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => { export default OpenCodeMultiModelPlugin; export * from "./core/index"; export * from "./types"; -export { closeTool, openTool }; +export { cleanupTool, closeTool, openTool }; diff --git a/src/tools/cleanup.ts b/src/tools/cleanup.ts new file mode 100644 index 0000000..740ae56 --- /dev/null +++ b/src/tools/cleanup.ts @@ -0,0 +1,45 @@ +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 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 cleanupMultiModel({ + force: true, + remote: args.remote, + }); + + if (!result.success) { + return result.error!; + } + + return ( + result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.` + ); + }, +}); diff --git a/src/types.ts b/src/types.ts index 1338376..55a60f1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,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. */ diff --git a/tests/cleanup.test.ts b/tests/cleanup.test.ts new file mode 100644 index 0000000..7b23b07 --- /dev/null +++ b/tests/cleanup.test.ts @@ -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; + + 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", + ]); + }); +});