mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
feat: add multi-model cleanup tool and CLI command
- 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.
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.2.27"
|
"@opencode-ai/plugin": "1.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.10",
|
"@types/bun": "^1.3.10",
|
||||||
|
|||||||
+29
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import { cleanupMultiModel } from "./core/cleanup";
|
||||||
import { closeMultiModel } from "./core/close";
|
import { closeMultiModel } from "./core/close";
|
||||||
import { openMultiModel } from "./core/open";
|
import { openMultiModel } from "./core/open";
|
||||||
import { getBinaryName } from "./core/utils";
|
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>",
|
||||||
|
"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();
|
program.parse();
|
||||||
|
|||||||
@@ -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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 cleanupMultiModel(
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { cleanupMultiModel } from "./cleanup";
|
||||||
export { closeMultiModel } from "./close";
|
export { closeMultiModel } from "./close";
|
||||||
export { openMultiModel } from "./open";
|
export { openMultiModel } from "./open";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
|||||||
+5
-2
@@ -1,13 +1,15 @@
|
|||||||
import type { Plugin } from "@opencode-ai/plugin";
|
import type { Plugin } from "@opencode-ai/plugin";
|
||||||
|
import { cleanupTool } from "./tools/cleanup";
|
||||||
import { closeTool } from "./tools/close";
|
import { closeTool } from "./tools/close";
|
||||||
import { openTool } from "./tools/open";
|
import { openTool } from "./tools/open";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenCode plugin that provides multi-model tmux session management tools.
|
* 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-open`: Launch multiple AI models in a tmux session
|
||||||
* - `multi-model-close`: Close a multi-model session and cleanup resources
|
* - `multi-model-close`: Close a multi-model session and cleanup resources
|
||||||
|
* - `multi-model-cleanup`: Delete all archive tags from local and remote repositories
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```json
|
* ```json
|
||||||
@@ -22,6 +24,7 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
|
|||||||
tool: {
|
tool: {
|
||||||
"multi-model-open": openTool,
|
"multi-model-open": openTool,
|
||||||
"multi-model-close": closeTool,
|
"multi-model-close": closeTool,
|
||||||
|
"multi-model-cleanup": cleanupTool,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -29,4 +32,4 @@ const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
|
|||||||
export default OpenCodeMultiModelPlugin;
|
export default OpenCodeMultiModelPlugin;
|
||||||
export * from "./core/index";
|
export * from "./core/index";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export { closeTool, openTool };
|
export { cleanupTool, closeTool, openTool };
|
||||||
|
|||||||
@@ -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.`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -74,6 +74,45 @@ export interface WindowLaunchPlan {
|
|||||||
windowName: string;
|
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.
|
* Result of running a shell command.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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<typeof setupMockBun$>;
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user