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:
@@ -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