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,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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>",
|
||||
"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();
|
||||
|
||||
@@ -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 { openMultiModel } from "./open";
|
||||
export * from "./utils";
|
||||
|
||||
+5
-2
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -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