From 08844253537fbc291358998c927fc5c24f69dce7 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 3 Jul 2026 14:11:49 +0530 Subject: [PATCH 1/4] feat(cli): add branch remove for preview branches Preview branch cleanup previously required the Console. branch remove takes an explicit branch id or git name in the resolved project, the exact-id --confirm convention, and maps the platform's guarantees to structured codes: BRANCH_PROTECTED for production/default branches (422) and BRANCH_NOT_EMPTY when live apps or databases remain (409), so removal never cascades into member resources. Removal is a platform soft-delete and never touches local Git branches. Branch creation deliberately stays implicit (git-push automation and app deploy); there is no branch create, per team alignment. --- docs/product/command-spec.md | 25 +++ docs/product/error-conventions.md | 6 + docs/product/resource-model.md | 5 + packages/cli/src/adapters/mock-api.ts | 37 ++++ packages/cli/src/commands/branch/index.ts | 48 ++++- packages/cli/src/controllers/branch.ts | 222 +++++++++++++++++++++- packages/cli/src/presenters/branch.ts | 36 +++- packages/cli/src/shell/command-meta.ts | 13 +- packages/cli/src/types/branch.ts | 11 ++ packages/cli/tests/branch-remove.test.ts | 154 +++++++++++++++ packages/cli/tests/branch.test.ts | 8 +- packages/cli/tests/shell.test.ts | 2 +- 12 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 packages/cli/tests/branch-remove.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4150a180..8dce6f35 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -865,6 +865,31 @@ prisma-cli branch list prisma-cli branch list --json ``` +## `prisma-cli branch remove --project --confirm ` + +Purpose: + +- remove a preview Branch from the resolved project + +Behavior: + +- requires auth and resolved project context; accepts `--project ` as an explicit fallback +- resolves `` by exact Branch id or exact git name within the resolved project +- requires `--confirm ` where the value exactly matches the resolved Branch id; `--yes` does not satisfy this confirmation +- production Branches and the project's default Branch are protected: removal fails with `BRANCH_PROTECTED` +- a Branch that still has live Apps or databases fails with `BRANCH_NOT_EMPTY`; branch removal never deletes member resources, so remove the Branch's apps and databases first +- removal is a platform soft-delete: the Branch disappears from `branch list`, and the platform owns retention of soft-deleted Branches +- Branch creation stays implicit (git-push automation and `app deploy`); there is deliberately no `branch create` +- never touches local Git branches +- fails with `BRANCH_NOT_FOUND` when no Branch matches + +Examples: + +```bash +prisma-cli branch remove feat-login --confirm br_123 +prisma-cli branch remove br_123 --confirm br_123 --json +``` + ## `prisma-cli database list --project --branch ` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 76f610ec..295468dc 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -181,6 +181,9 @@ These codes are the minimum stable set for the MVP: - `LOCAL_STATE_WRITE_FAILED` - `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` +- `BRANCH_NOT_FOUND` +- `BRANCH_PROTECTED` +- `BRANCH_NOT_EMPTY` - `COMPUTE_CONFIG_INVALID` - `COMPUTE_CONFIG_TARGET_REQUIRED` - `COMPUTE_CONFIG_TARGET_UNKNOWN` @@ -252,6 +255,9 @@ Recommended meanings: - `LOCAL_STATE_WRITE_FAILED`: the CLI could not save local Project binding state such as `.prisma/local.json` or the matching `.gitignore` entry; callers should fix directory permissions or filesystem state before retrying - `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context +- `BRANCH_NOT_FOUND`: requested branch id or git name does not exist in the resolved project +- `BRANCH_PROTECTED`: branch removal refused because the branch is the project's production or default branch +- `BRANCH_NOT_EMPTY`: branch removal refused because the branch still has live apps or databases - `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate - `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred - `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index db73619f..d1c7970f 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -63,6 +63,11 @@ Rules: - every other named branch is a preview branch by default - preview branches are disposable by default - non-production branches can become durable later +- Branch creation is implicit (git-push automation and `app deploy`); the CLI + has no `branch create` +- `branch remove` removes a preview Branch with exact id confirmation; the + platform refuses production/default Branches and Branches that still have + live Apps or databases, so removal never cascades into member resources - `local` is local CLI context only, not a branch - branch context comes from explicit targeting, Git, or safe command defaults, not `prisma.config.ts` diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 8b43bc7e..09df2f1d 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -279,6 +279,43 @@ export class MockApi { ); } + removeBranch( + branchId: string, + ): + | { outcome: "removed"; branch: BranchRecord } + | { outcome: "not-found" } + | { outcome: "protected" } + | { outcome: "not-empty" } { + const branch = this.data.branches.find( + (candidate) => candidate.id === branchId, + ); + if (!branch) { + return { outcome: "not-found" }; + } + if (branch.role === "production") { + return { outcome: "protected" }; + } + + // Mirrors the platform rule: removal is refused while the branch still + // has live member resources. + const hasDatabases = (this.data.databases ?? []).some( + (database) => database.branchId === branchId, + ); + const hasDeployments = this.data.deployments.some( + (deployment) => + deployment.projectId === branch.projectId && + deployment.branch === branch.name, + ); + if (hasDatabases || hasDeployments) { + return { outcome: "not-empty" }; + } + + this.data.branches = this.data.branches.filter( + (candidate) => candidate.id !== branchId, + ); + return { outcome: "removed", branch }; + } + getDeployment(deploymentId: string): DeploymentRecord | undefined { return this.data.deployments.find( (deployment) => deployment.id === deploymentId, diff --git a/packages/cli/src/commands/branch/index.ts b/packages/cli/src/commands/branch/index.ts index fdf9621c..3aa1a24f 100644 --- a/packages/cli/src/commands/branch/index.ts +++ b/packages/cli/src/commands/branch/index.ts @@ -1,7 +1,12 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; -import { runBranchList } from "../../controllers/branch"; -import { renderBranchList, serializeBranchList } from "../../presenters/branch"; +import { runBranchList, runBranchRemove } from "../../controllers/branch"; +import { + renderBranchList, + renderBranchRemove, + serializeBranchList, + serializeBranchRemove, +} from "../../presenters/branch"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { runCommand } from "../../shell/command-runner"; import { @@ -9,7 +14,7 @@ import { addGlobalFlags, } from "../../shell/global-flags"; import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; -import type { BranchListResult } from "../../types/branch"; +import type { BranchListResult, BranchRemoveResult } from "../../types/branch"; export function createBranchCommand(runtime: CliRuntime): Command { const branch = attachCommandDescriptor( @@ -20,10 +25,45 @@ export function createBranchCommand(runtime: CliRuntime): Command { addCompactGlobalFlags(branch); branch.addCommand(createBranchListCommand(runtime)); + branch.addCommand(createBranchRemoveCommand(runtime)); return branch; } +function createBranchRemoveCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("remove"), runtime), + "branch.remove", + ); + + command + .argument("", "Branch id or git name") + .addOption(new Option("--project ", "Project id or name")) + .addOption( + new Option("--confirm ", "Exact branch id required to remove"), + ); + addGlobalFlags(command); + + command.action(async (branchRef: string, options) => { + const projectRef = (options as { project?: string }).project; + const confirm = (options as { confirm?: string }).confirm; + + await runCommand( + runtime, + "branch.remove", + options as Record, + (context) => runBranchRemove(context, branchRef, { projectRef, confirm }), + { + renderHuman: (context, descriptor, result) => + renderBranchRemove(context, descriptor, result), + renderJson: (result) => serializeBranchRemove(result), + }, + ); + }); + + return command; +} + function createBranchListCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor( configureRuntimeCommand(new Command("list"), runtime), diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index 0e1a7d0a..b2b9cc74 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,8 +1,13 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Branch pagination requests must run sequentially. import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { + type PrismaCliPackageCommandFormatter, + resolvePrismaCliPackageCommandFormatterSync, +} from "../lib/agent/cli-command"; import { requireComputeAuth } from "../lib/auth/guard"; import { projectResolutionErrorToCliError, + type ResolvedProjectTarget, resolveProjectTarget, } from "../lib/project/resolution"; import { @@ -14,13 +19,17 @@ import type { CommandSuccess } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; import type { BranchListResult, + BranchRemoveResult, BranchRole, BranchSummary, } from "../types/branch"; import { createBranchUseCases } from "../use-cases/branch"; import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways"; import { requireAuthenticatedAuthState } from "./auth"; -import { listRealWorkspaceProjects } from "./project"; +import { + listFixtureWorkspaceProjects, + listRealWorkspaceProjects, +} from "./project"; function isRealMode(context: CommandContext): boolean { return ( @@ -104,6 +113,217 @@ async function listRealBranches( }; } +export interface BranchRemoveFlags { + projectRef?: string; + confirm?: string; +} + +export async function runBranchRemove( + context: CommandContext, + branchRef: string, + flags: BranchRemoveFlags, +): Promise> { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + const realMode = isRealMode(context); + const client = realMode + ? await requireComputeAuth(context.runtime.env, context.runtime.signal) + : null; + if (realMode && !client) { + throw authRequiredError(["prisma-cli auth login"]); + } + + const targetResult = await resolveProjectTarget({ + context, + workspace, + explicitProject: flags.projectRef, + listProjects: () => + client + ? listRealWorkspaceProjects(client, workspace, context.runtime.signal) + : Promise.resolve(listFixtureWorkspaceProjects(context, workspace)), + commandName: "branch remove", + }); + if (targetResult.isErr()) { + throw projectResolutionErrorToCliError(targetResult.error); + } + const target = targetResult.value; + + const branches = client + ? await listBranches(client, target.project.id, context.runtime.signal) + : context.api.listBranchesForProject(target.project.id).map((branch) => ({ + id: branch.id, + gitName: branch.name, + role: branch.role, + })); + const branch = resolveBranchForRemoval(branchRef, branches, target); + + if (branch.role === "production") { + throw branchProtectedError(branch.gitName); + } + + requireBranchRemoveConfirmation({ + id: branch.id, + confirm: flags.confirm, + formatCommand, + }); + + if (client) { + await deleteBranch(client, branch, context.runtime.signal, formatCommand); + } else { + removeFixtureBranch(context, branch, formatCommand); + } + + return { + command: "branch.remove", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: { + workspace, + project: target.project, + resolution: target.resolution, + }, + branch: toBranchSummary(branch), + }, + warnings: [], + nextSteps: [], + }; +} + +function resolveBranchForRemoval( + branchRef: string, + branches: RawBranchRecord[], + target: ResolvedProjectTarget, +): RawBranchRecord { + const ref = branchRef.trim(); + const match = branches.find( + (branch) => branch.id === ref || branch.gitName === ref, + ); + if (!match) { + throw new CliError({ + code: "BRANCH_NOT_FOUND", + domain: "branch", + summary: "Branch not found", + why: `No branch matched "${branchRef}" in project "${target.project.name}".`, + fix: "Pass a branch id or git name from prisma-cli branch list.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); + } + return match; +} + +function requireBranchRemoveConfirmation(options: { + id: string; + confirm: string | undefined; + formatCommand: PrismaCliPackageCommandFormatter; +}): void { + if (options.confirm === options.id) { + return; + } + + throw new CliError({ + code: "CONFIRMATION_REQUIRED", + domain: "branch", + summary: "Confirm branch removal", + why: "Removing a branch is destructive and requires the exact branch id.", + fix: `Rerun with --confirm ${options.id}.`, + exitCode: 2, + nextSteps: [ + options.formatCommand([ + "branch", + "remove", + options.id, + "--confirm", + options.id, + ]), + ], + meta: { + expectedConfirm: options.id, + receivedConfirm: options.confirm ?? null, + }, + }); +} + +function branchProtectedError(branchName: string): CliError { + return new CliError({ + code: "BRANCH_PROTECTED", + domain: "branch", + summary: "Branch is protected", + why: `"${branchName}" is the project's production or default branch; protected branches cannot be removed.`, + fix: "Only preview branches can be removed.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); +} + +function branchNotEmptyError( + branchName: string, + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + return new CliError({ + code: "BRANCH_NOT_EMPTY", + domain: "branch", + summary: "Branch still has live resources", + why: `"${branchName}" still has live apps or databases; branch removal never deletes member resources.`, + fix: "Remove the branch's apps and databases first, then retry.", + exitCode: 1, + nextSteps: [ + formatCommand([ + "app", + "remove", + "--app", + "", + "--branch", + branchName, + ]), + formatCommand(["database", "list", "--branch", branchName]), + ], + }); +} + +async function deleteBranch( + client: ManagementApiClient, + branch: RawBranchRecord, + signal: AbortSignal, + formatCommand: PrismaCliPackageCommandFormatter, +): Promise { + const { error, response } = await client.DELETE("/v1/branches/{branchId}", { + params: { path: { branchId: branch.id } }, + signal, + }); + if (response?.status === 422) { + throw branchProtectedError(branch.gitName); + } + if (response?.status === 409) { + throw branchNotEmptyError(branch.gitName, formatCommand); + } + if (error) { + throw branchApiError("Failed to remove branch", response, error); + } +} + +function removeFixtureBranch( + context: CommandContext, + branch: RawBranchRecord, + formatCommand: PrismaCliPackageCommandFormatter, +): void { + const removed = context.api.removeBranch(branch.id); + if (removed.outcome === "protected") { + throw branchProtectedError(branch.gitName); + } + if (removed.outcome === "not-empty") { + throw branchNotEmptyError(branch.gitName, formatCommand); + } +} + function sortBranches(branches: BranchSummary[]): BranchSummary[] { return branches.slice().sort((left, right) => { const leftRank = branchOrder(left); diff --git a/packages/cli/src/presenters/branch.ts b/packages/cli/src/presenters/branch.ts index 47a9bc5e..f624c369 100644 --- a/packages/cli/src/presenters/branch.ts +++ b/packages/cli/src/presenters/branch.ts @@ -1,8 +1,9 @@ +import { renderMutate } from "../output/patterns"; import type { CommandDescriptor } from "../shell/command-meta"; import { formatDescriptorLabel } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import { formatColumns } from "../shell/ui"; -import type { BranchListResult } from "../types/branch"; +import type { BranchListResult, BranchRemoveResult } from "../types/branch"; import { renderResolvedProjectContextBlock } from "./verbose-context"; export function renderBranchList( @@ -67,3 +68,36 @@ function renderBranchResolvedContextBlock( ): string[] { return renderResolvedProjectContextBlock(context.ui, result.verboseContext); } + +export function renderBranchRemove( + context: CommandContext, + descriptor: CommandDescriptor, + result: BranchRemoveResult, +): string[] { + const lines = renderMutate( + { + title: "Removing branch.", + descriptor, + context: [ + { key: "project", value: result.projectName }, + { key: "branch", value: result.branch.name }, + { key: "id", value: result.branch.id, tone: "dim" }, + ], + operationDescription: "Removing branch", + operationCount: 1, + details: [ + "The branch was removed from the platform. Local Git branches are untouched.", + ], + }, + context.ui, + ); + lines.push( + ...renderResolvedProjectContextBlock(context.ui, result.verboseContext), + ); + return lines; +} + +export function serializeBranchRemove(result: BranchRemoveResult) { + const { verboseContext: _verboseContext, ...serialized } = result; + return serialized; +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 3091b284..1d9b4b3c 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -174,8 +174,11 @@ const DESCRIPTORS: CommandDescriptor[] = [ { id: "branch", path: ["prisma", "branch"], - description: "View your Platform branches", - examples: ["prisma-cli branch list"], + description: "View and manage your Platform branches", + examples: [ + "prisma-cli branch list", + "prisma-cli branch remove feat-login --confirm br_123", + ], }, { id: "build", @@ -292,6 +295,12 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "List Platform branches for the resolved project", examples: ["prisma-cli branch list", "prisma-cli branch list --json"], }, + { + id: "branch.remove", + path: ["prisma", "branch", "remove"], + description: "Remove a preview branch after exact id confirmation", + examples: ["prisma-cli branch remove feat-login --confirm br_123"], + }, { id: "database.list", path: ["prisma", "database", "list"], diff --git a/packages/cli/src/types/branch.ts b/packages/cli/src/types/branch.ts index 408d3173..cceb196f 100644 --- a/packages/cli/src/types/branch.ts +++ b/packages/cli/src/types/branch.ts @@ -21,3 +21,14 @@ export interface BranchListResult { }; branches: BranchSummary[]; } + +export interface BranchRemoveResult { + projectId: string; + projectName: string; + verboseContext?: { + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; + }; + branch: BranchSummary; +} diff --git a/packages/cli/tests/branch-remove.test.ts b/packages/cli/tests/branch-remove.test.ts new file mode 100644 index 00000000..7d37776e --- /dev/null +++ b/packages/cli/tests/branch-remove.test.ts @@ -0,0 +1,154 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { createTempCwd, executeCli } from "./helpers"; + +const fixturePath = path.resolve("fixtures/mock-api.json"); + +async function setupLinkedProject() { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile( + path.join(cwd, ".prisma/local.json"), + `${JSON.stringify({ workspaceId: "ws_123", projectId: "proj_123" }, null, 2)}\n`, + "utf8", + ); + return { cwd, stateDir }; +} + +describe("branch remove", () => { + it("requires exact branch id confirmation", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["branch", "remove", "staging", "--confirm", "staging", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload).toMatchObject({ + ok: false, + command: "branch.remove", + error: { + code: "CONFIRMATION_REQUIRED", + domain: "branch", + meta: { + expectedConfirm: "br_345", + receivedConfirm: "staging", + }, + }, + }); + expect(payload.nextSteps[0]).toContain("branch remove br_345"); + }); + + it("removes an empty preview branch by git name", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["branch", "remove", "staging", "--confirm", "br_345", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "branch.remove", + result: { + projectId: "proj_123", + branch: { id: "br_345", name: "staging", role: "preview" }, + }, + }); + }); + + it("refuses production branches with BRANCH_PROTECTED", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["branch", "remove", "production", "--confirm", "br_456", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("BRANCH_PROTECTED"); + }); + + it("refuses branches that still have live resources", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + // br_123 "preview" has a database and deployments attached in the fixture. + const result = await executeCli({ + argv: ["branch", "remove", "br_123", "--confirm", "br_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("BRANCH_NOT_EMPTY"); + expect(payload.nextSteps.join(" ")).toContain("database list --branch"); + }); + + it("fails with BRANCH_NOT_FOUND for unknown branches", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["branch", "remove", "nope", "--confirm", "nope", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("BRANCH_NOT_FOUND"); + }); + + it("resolves an explicit --project", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); + + const result = await executeCli({ + argv: [ + "branch", + "remove", + "staging", + "--project", + "proj_123", + "--confirm", + "br_345", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.branch.id).toBe("br_345"); + }); +}); diff --git a/packages/cli/tests/branch.test.ts b/packages/cli/tests/branch.test.ts index df9f2eb7..a39920ad 100644 --- a/packages/cli/tests/branch.test.ts +++ b/packages/cli/tests/branch.test.ts @@ -134,7 +134,7 @@ describe("branch commands", () => { }); }); - it("shows only branch list in branch help", async () => { + it("shows branch list and remove in branch help", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -152,10 +152,14 @@ describe("branch commands", () => { }); expect(branchHelp.exitCode).toBe(0); - expect(branchHelp.stderr).toContain("View your Platform branches"); + expect(branchHelp.stderr).toContain( + "View and manage your Platform branches", + ); expect(branchHelp.stderr).toContain("$ prisma-cli branch list"); + expect(branchHelp.stderr).toContain("remove "); expect(branchHelp.stderr).not.toContain("branch show"); expect(branchHelp.stderr).not.toContain("branch use"); + expect(branchHelp.stderr).not.toContain("branch create"); expect(listHelp.exitCode).toBe(0); expect(listHelp.stderr).toContain( diff --git a/packages/cli/tests/shell.test.ts b/packages/cli/tests/shell.test.ts index 574f57e1..6f42fd76 100644 --- a/packages/cli/tests/shell.test.ts +++ b/packages/cli/tests/shell.test.ts @@ -142,7 +142,7 @@ describe("shell behavior", () => { expect(branchResult.exitCode).toBe(0); expect(branchResult.stderr).toContain( - "branch → View your Platform branches", + "branch → View and manage your Platform branches", ); expect(branchResult.stderr).toContain("Global options:"); From f437d87c74026812219218ab4e39ef3a3129324e Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 3 Jul 2026 16:53:11 +0530 Subject: [PATCH 2/4] feat(cli): app remove --branch and branch remove --cascade Review feedback from #110: - app remove now accepts --branch, honored as-is like the other read-branch commands, so branch cleanup can target apps on branches that are not checked out locally. The BRANCH_NOT_EMPTY recovery command previously pointed at this flag before it existed, which was a dead end. - branch remove --cascade removes the branch's apps, then its databases, then the branch, for preview branches only: production and default branches stay refused before any member resource is touched. The result lists every removed resource (human output and result.removed in JSON) so the blast radius is explicit. Cascade is client-orchestrated because the platform's branch delete refuses non-empty branches; a mid-cascade failure stops immediately with BRANCH_CASCADE_INCOMPLETE, whose meta lists what was already removed. - BRANCH_NOT_EMPTY recovery steps now offer the cascade rerun first, then individual app/database cleanup. --- docs/product/command-spec.md | 19 +-- docs/product/error-conventions.md | 2 + docs/product/resource-model.md | 7 +- packages/cli/src/adapters/mock-api.ts | 49 ++++++++ packages/cli/src/commands/app/index.ts | 7 +- packages/cli/src/commands/branch/index.ts | 10 +- packages/cli/src/controllers/app.ts | 7 ++ packages/cli/src/controllers/branch.ts | 146 ++++++++++++++++++++-- packages/cli/src/presenters/branch.ts | 10 ++ packages/cli/src/shell/command-meta.ts | 5 +- packages/cli/src/types/branch.ts | 10 ++ packages/cli/tests/app-controller.test.ts | 72 +++++++++++ packages/cli/tests/branch-remove.test.ts | 95 ++++++++++++++ 13 files changed, 418 insertions(+), 21 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 8dce6f35..50f6fe1c 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -865,7 +865,7 @@ prisma-cli branch list prisma-cli branch list --json ``` -## `prisma-cli branch remove --project --confirm ` +## `prisma-cli branch remove --project --confirm --cascade` Purpose: @@ -876,9 +876,11 @@ Behavior: - requires auth and resolved project context; accepts `--project ` as an explicit fallback - resolves `` by exact Branch id or exact git name within the resolved project - requires `--confirm ` where the value exactly matches the resolved Branch id; `--yes` does not satisfy this confirmation -- production Branches and the project's default Branch are protected: removal fails with `BRANCH_PROTECTED` -- a Branch that still has live Apps or databases fails with `BRANCH_NOT_EMPTY`; branch removal never deletes member resources, so remove the Branch's apps and databases first -- removal is a platform soft-delete: the Branch disappears from `branch list`, and the platform owns retention of soft-deleted Branches +- production Branches and the project's default Branch are protected: removal fails with `BRANCH_PROTECTED`, and `--cascade` never widens that; the protection check runs before any member resource is touched +- without `--cascade`, a Branch that still has live Apps or databases fails with `BRANCH_NOT_EMPTY`; plain removal never deletes member resources, and the recovery steps offer both the cascade rerun and individual `app remove --branch` / `database` cleanup +- with `--cascade`, the CLI removes the Branch's Apps, then its databases, then the Branch itself; the result lists every removed resource so the blast radius is explicit, in human output and in `result.removed` for `--json` +- cascade is client-orchestrated because the platform's branch delete refuses non-empty Branches; a mid-cascade failure stops immediately and fails with `BRANCH_CASCADE_INCOMPLETE`, whose `meta` lists what was already removed (removed resources are not restored, and the Branch itself remains) +- removal is a platform soft-delete: the Branch disappears from `branch list`, and the platform owns retention of soft-deleted Branches; cascaded member resources are removed through their own APIs - Branch creation stays implicit (git-push automation and `app deploy`); there is deliberately no `branch create` - never touches local Git branches - fails with `BRANCH_NOT_FOUND` when no Branch matches @@ -887,6 +889,7 @@ Examples: ```bash prisma-cli branch remove feat-login --confirm br_123 +prisma-cli branch remove feat-login --confirm br_123 --cascade prisma-cli branch remove br_123 --confirm br_123 --json ``` @@ -1854,16 +1857,18 @@ prisma-cli app rollback prisma-cli app rollback --app hello-world --to dep_123 ``` -## `prisma-cli app remove [app] --app -y --yes` +## `prisma-cli app remove [app] --app --branch -y --yes` Purpose: -- remove the app from the current branch +- remove the app from the resolved branch Behavior: - requires auth and project context -- resolves the selected app +- resolves the branch it reads like the other management commands: explicit `--branch` honored as-is, then the active Git branch when it exists in the project, then the project's default branch +- `--branch ` makes branch cleanup possible for branches that are not checked out locally, such as a teammate's preview branch +- resolves the selected app within that branch - requires confirmation unless `-y` or `--yes` is passed - clears local selected app state when the removed app was selected diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 295468dc..155cd281 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -184,6 +184,7 @@ These codes are the minimum stable set for the MVP: - `BRANCH_NOT_FOUND` - `BRANCH_PROTECTED` - `BRANCH_NOT_EMPTY` +- `BRANCH_CASCADE_INCOMPLETE` - `COMPUTE_CONFIG_INVALID` - `COMPUTE_CONFIG_TARGET_REQUIRED` - `COMPUTE_CONFIG_TARGET_UNKNOWN` @@ -258,6 +259,7 @@ Recommended meanings: - `BRANCH_NOT_FOUND`: requested branch id or git name does not exist in the resolved project - `BRANCH_PROTECTED`: branch removal refused because the branch is the project's production or default branch - `BRANCH_NOT_EMPTY`: branch removal refused because the branch still has live apps or databases +- `BRANCH_CASCADE_INCOMPLETE`: a --cascade branch removal failed partway; meta lists the resources already removed and the branch remains - `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate - `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred - `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index d1c7970f..8347e60f 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -66,8 +66,11 @@ Rules: - Branch creation is implicit (git-push automation and `app deploy`); the CLI has no `branch create` - `branch remove` removes a preview Branch with exact id confirmation; the - platform refuses production/default Branches and Branches that still have - live Apps or databases, so removal never cascades into member resources + platform refuses production/default Branches outright, and plain removal + refuses Branches that still have live Apps or databases +- `branch remove --cascade` removes a preview Branch's Apps and databases with + it, with the blast radius listed explicitly; production/default Branches stay + refused regardless of flags - `local` is local CLI context only, not a branch - branch context comes from explicit targeting, Git, or safe command defaults, not `prisma.config.ts` diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 09df2f1d..2ba3d329 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -279,6 +279,55 @@ export class MockApi { ); } + cascadeBranchResources(branchId: string): { + apps: Array<{ id: string; name: string }>; + databases: Array<{ id: string; name: string }>; + } { + const branch = this.data.branches.find( + (candidate) => candidate.id === branchId, + ); + if (!branch) { + return { apps: [], databases: [] }; + } + + const databases = (this.data.databases ?? []).filter( + (database) => database.branchId === branchId, + ); + const databaseIds = new Set(databases.map((database) => database.id)); + // The fixture has no standalone app records; deployments on the branch + // stand in for its apps. + const apps = this.data.deployments.filter( + (deployment) => + deployment.projectId === branch.projectId && + deployment.branch === branch.name, + ); + + this.data.databases = (this.data.databases ?? []).filter( + (database) => database.branchId !== branchId, + ); + this.data.databaseConnections = ( + this.data.databaseConnections ?? [] + ).filter((connection) => !databaseIds.has(connection.databaseId)); + this.data.deployments = this.data.deployments.filter( + (deployment) => + !( + deployment.projectId === branch.projectId && + deployment.branch === branch.name + ), + ); + + return { + apps: apps.map((deployment) => ({ + id: deployment.id, + name: deployment.id, + })), + databases: databases.map((database) => ({ + id: database.id, + name: database.name, + })), + }; + } + removeBranch( branchId: string, ): diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index 03df71de..45a83534 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -835,18 +835,21 @@ function createRemoveCommand(runtime: CliRuntime): Command { "App target from prisma.compute.ts when the config defines multiple apps", ) .addOption(new Option("--app ", "App name")) - .addOption(new Option("--project ", "Project id or name")); + .addOption(new Option("--project ", "Project id or name")) + .addOption(new Option("--branch ", "Branch name")); addGlobalFlags(command); command.action(async (configTarget: string | undefined, options) => { const appName = (options as { app?: string }).app; const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; await runCommand( runtime, "app.remove", options as Record, - (context) => runAppRemove(context, appName, projectRef, configTarget), + (context) => + runAppRemove(context, appName, projectRef, configTarget, branchName), { renderHuman: (context, descriptor, result) => renderAppRemove(context, descriptor, result), diff --git a/packages/cli/src/commands/branch/index.ts b/packages/cli/src/commands/branch/index.ts index 3aa1a24f..24bbeae3 100644 --- a/packages/cli/src/commands/branch/index.ts +++ b/packages/cli/src/commands/branch/index.ts @@ -41,18 +41,26 @@ function createBranchRemoveCommand(runtime: CliRuntime): Command { .addOption(new Option("--project ", "Project id or name")) .addOption( new Option("--confirm ", "Exact branch id required to remove"), + ) + .addOption( + new Option( + "--cascade", + "Also remove the branch's apps and databases (preview branches only)", + ), ); addGlobalFlags(command); command.action(async (branchRef: string, options) => { const projectRef = (options as { project?: string }).project; const confirm = (options as { confirm?: string }).confirm; + const cascade = (options as { cascade?: boolean }).cascade; await runCommand( runtime, "branch.remove", options as Record, - (context) => runBranchRemove(context, branchRef, { projectRef, confirm }), + (context) => + runBranchRemove(context, branchRef, { projectRef, confirm, cascade }), { renderHuman: (context, descriptor, result) => renderBranchRemove(context, descriptor, result), diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 678ed811..a3647bb3 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -2043,6 +2043,7 @@ export async function runAppRemove( appName: string | undefined, projectRef?: string, configTarget?: string, + branchName?: string, ): Promise> { ensurePreviewAppMode(context); @@ -2056,6 +2057,12 @@ export async function runAppRemove( await requireProviderAndProjectContext(context, projectRef, { commandName: "app remove", projectDir: compute.projectDir, + // Branch cleanup needs "remove this app from that branch" even when the + // branch is not checked out locally, so an explicit --branch is honored + // as-is like the other read-branch commands. + branch: branchName + ? await resolveDeployBranch(context, branchName) + : undefined, }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await requireReleaseAppSelection( diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index b2b9cc74..b63227c9 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -4,7 +4,9 @@ import { type PrismaCliPackageCommandFormatter, resolvePrismaCliPackageCommandFormatterSync, } from "../lib/agent/cli-command"; +import { createAppProvider } from "../lib/app/app-provider"; import { requireComputeAuth } from "../lib/auth/guard"; +import { createManagementDatabaseProvider } from "../lib/database/provider"; import { projectResolutionErrorToCliError, type ResolvedProjectTarget, @@ -42,6 +44,7 @@ interface RawBranchRecord { id: string; gitName: string; role: BranchRole; + isDefault?: boolean; } export async function runBranchList( @@ -116,6 +119,7 @@ async function listRealBranches( export interface BranchRemoveFlags { projectRef?: string; confirm?: string; + cascade?: boolean; } export async function runBranchRemove( @@ -164,7 +168,9 @@ export async function runBranchRemove( })); const branch = resolveBranchForRemoval(branchRef, branches, target); - if (branch.role === "production") { + // Production and default branches are protected outright; --cascade never + // widens that. The check runs before any member resource is touched. + if (branch.role === "production" || branch.isDefault) { throw branchProtectedError(branch.gitName); } @@ -174,6 +180,19 @@ export async function runBranchRemove( formatCommand, }); + let removed: BranchRemoveResult["removed"]; + if (flags.cascade) { + removed = client + ? await cascadeRealBranchResources( + context, + client, + target.project.id, + branch, + formatCommand, + ) + : cascadeFixtureBranchResources(context, branch); + } + if (client) { await deleteBranch(client, branch, context.runtime.signal, formatCommand); } else { @@ -191,12 +210,115 @@ export async function runBranchRemove( resolution: target.resolution, }, branch: toBranchSummary(branch), + ...(removed ? { removed } : {}), }, warnings: [], nextSteps: [], }; } +/** + * Client-orchestrated cascade: the platform's branch delete refuses non-empty + * branches, so the CLI removes the branch's apps, then its databases, before + * the branch itself. A failure stops immediately and reports exactly what was + * already removed, so the partial state is never silent. + */ +async function cascadeRealBranchResources( + context: CommandContext, + client: ManagementApiClient, + projectId: string, + branch: RawBranchRecord, + formatCommand: PrismaCliPackageCommandFormatter, +): Promise> { + const signal = context.runtime.signal; + const appProvider = createAppProvider(client); + const databaseProvider = createManagementDatabaseProvider(client, { + formatCommand, + }); + + const apps = await appProvider.listApps(projectId, { + branchName: branch.gitName, + signal, + }); + const databases = await databaseProvider.listDatabases({ + projectId, + branchName: branch.gitName, + signal, + }); + + const removed: NonNullable = { + apps: [], + databases: [], + }; + + for (const app of apps) { + try { + await appProvider.removeApp(app.id, { signal }); + } catch (error) { + throw branchCascadeIncompleteError(branch.gitName, removed, { + kind: "app", + id: app.id, + name: app.name, + error, + }); + } + removed.apps.push({ id: app.id, name: app.name }); + } + + for (const database of databases) { + try { + await databaseProvider.removeDatabase(database.id, { signal }); + } catch (error) { + throw branchCascadeIncompleteError(branch.gitName, removed, { + kind: "database", + id: database.id, + name: database.name, + error, + }); + } + removed.databases.push({ id: database.id, name: database.name }); + } + + return removed; +} + +function cascadeFixtureBranchResources( + context: CommandContext, + branch: RawBranchRecord, +): NonNullable { + const cascaded = context.api.cascadeBranchResources(branch.id); + return { + apps: cascaded.apps, + databases: cascaded.databases, + }; +} + +function branchCascadeIncompleteError( + branchName: string, + removed: NonNullable, + failed: { + kind: "app" | "database"; + id: string; + name: string; + error: unknown; + }, +): CliError { + return new CliError({ + code: "BRANCH_CASCADE_INCOMPLETE", + domain: "branch", + summary: "Branch cascade stopped before completing", + why: `Removing ${failed.kind} "${failed.name}" (${failed.id}) on branch "${branchName}" failed: ${failed.error instanceof Error ? failed.error.message.split("\n")[0] : String(failed.error)}. The branch was not removed.`, + fix: "Resolve the failure and rerun the cascade; already-removed resources are listed in meta and are not restored.", + exitCode: 1, + nextSteps: [], + meta: { + removedApps: removed.apps, + removedDatabases: removed.databases, + failed: { kind: failed.kind, id: failed.id, name: failed.name }, + }, + }); +} + function resolveBranchForRemoval( branchRef: string, branches: RawBranchRecord[], @@ -265,26 +387,34 @@ function branchProtectedError(branchName: string): CliError { } function branchNotEmptyError( - branchName: string, + branch: RawBranchRecord, formatCommand: PrismaCliPackageCommandFormatter, ): CliError { return new CliError({ code: "BRANCH_NOT_EMPTY", domain: "branch", summary: "Branch still has live resources", - why: `"${branchName}" still has live apps or databases; branch removal never deletes member resources.`, - fix: "Remove the branch's apps and databases first, then retry.", + why: `"${branch.gitName}" still has live apps or databases; plain branch removal never deletes member resources.`, + fix: "Rerun with --cascade to remove the branch's apps and databases with it, or remove them individually first.", exitCode: 1, nextSteps: [ + formatCommand([ + "branch", + "remove", + branch.gitName, + "--confirm", + branch.id, + "--cascade", + ]), formatCommand([ "app", "remove", "--app", "", "--branch", - branchName, + branch.gitName, ]), - formatCommand(["database", "list", "--branch", branchName]), + formatCommand(["database", "list", "--branch", branch.gitName]), ], }); } @@ -303,7 +433,7 @@ async function deleteBranch( throw branchProtectedError(branch.gitName); } if (response?.status === 409) { - throw branchNotEmptyError(branch.gitName, formatCommand); + throw branchNotEmptyError(branch, formatCommand); } if (error) { throw branchApiError("Failed to remove branch", response, error); @@ -320,7 +450,7 @@ function removeFixtureBranch( throw branchProtectedError(branch.gitName); } if (removed.outcome === "not-empty") { - throw branchNotEmptyError(branch.gitName, formatCommand); + throw branchNotEmptyError(branch, formatCommand); } } diff --git a/packages/cli/src/presenters/branch.ts b/packages/cli/src/presenters/branch.ts index f624c369..1090c204 100644 --- a/packages/cli/src/presenters/branch.ts +++ b/packages/cli/src/presenters/branch.ts @@ -87,6 +87,16 @@ export function renderBranchRemove( operationCount: 1, details: [ "The branch was removed from the platform. Local Git branches are untouched.", + ...(result.removed + ? [ + result.removed.apps.length > 0 + ? `Removed apps: ${result.removed.apps.map((app) => app.name).join(", ")}` + : "No apps were on the branch.", + result.removed.databases.length > 0 + ? `Removed databases: ${result.removed.databases.map((database) => database.name).join(", ")}` + : "No databases were on the branch.", + ] + : []), ], }, context.ui, diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 1d9b4b3c..07f68b23 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -299,7 +299,10 @@ const DESCRIPTORS: CommandDescriptor[] = [ id: "branch.remove", path: ["prisma", "branch", "remove"], description: "Remove a preview branch after exact id confirmation", - examples: ["prisma-cli branch remove feat-login --confirm br_123"], + examples: [ + "prisma-cli branch remove feat-login --confirm br_123", + "prisma-cli branch remove feat-login --confirm br_123 --cascade", + ], }, { id: "database.list", diff --git a/packages/cli/src/types/branch.ts b/packages/cli/src/types/branch.ts index cceb196f..41c4cd3a 100644 --- a/packages/cli/src/types/branch.ts +++ b/packages/cli/src/types/branch.ts @@ -22,6 +22,11 @@ export interface BranchListResult { branches: BranchSummary[]; } +export interface BranchRemovedResource { + id: string; + name: string; +} + export interface BranchRemoveResult { projectId: string; projectName: string; @@ -31,4 +36,9 @@ export interface BranchRemoveResult { resolution: ProjectResolution; }; branch: BranchSummary; + /** Member resources removed by --cascade; absent for plain removal. */ + removed?: { + apps: BranchRemovedResource[]; + databases: BranchRemovedResource[]; + }; } diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 385b5b7d..b7e5fd11 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -6620,6 +6620,78 @@ describe("app controller", () => { }); }); + it("remove honors an explicit --branch for apps on branches not checked out", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_9", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: null, + }, + ]); + const removeApp = vi.fn().mockResolvedValue({ + id: "app_9", + name: "hello-world", + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/app-provider", () => ({ + createAppProvider: vi.fn(() => + withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + createProject: vi.fn(), + listApps, + removeApp, + promoteDeployment: vi.fn(), + deployApp: vi.fn(), + listDeployments: vi.fn(), + showDeployment: vi.fn(), + }), + ), + })); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppRemove } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await writeLocalPin(cwd, { workspaceId: "ws_123", projectId: "proj_123" }); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + flags: { + yes: true, + }, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + const result = await runAppRemove( + context, + "hello-world", + undefined, + undefined, + "feat-not-checked-out", + ); + + // The explicit --branch scopes app resolution to that branch, without any + // local Git branch of that name existing. + expect(listApps).toHaveBeenCalledWith( + "proj_123", + expect.objectContaining({ branchName: "feat-not-checked-out" }), + ); + expect(removeApp).toHaveBeenCalledWith("app_9", { + signal: context.runtime.signal, + }); + expect(result.result.removed).toBe(true); + }); + it("remove deletes the selected app when --yes is passed", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ diff --git a/packages/cli/tests/branch-remove.test.ts b/packages/cli/tests/branch-remove.test.ts index 7d37776e..dc9ccec6 100644 --- a/packages/cli/tests/branch-remove.test.ts +++ b/packages/cli/tests/branch-remove.test.ts @@ -152,3 +152,98 @@ describe("branch remove", () => { expect(payload.result.branch.id).toBe("br_345"); }); }); + +describe("branch remove --cascade", () => { + async function setup() { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile( + path.join(cwd, ".prisma/local.json"), + `${JSON.stringify({ workspaceId: "ws_123", projectId: "proj_123" }, null, 2)}\n`, + "utf8", + ); + return { cwd, stateDir }; + } + + it("offers the cascade rerun in the BRANCH_NOT_EMPTY recovery steps", async () => { + const { cwd, stateDir } = await setup(); + + const result = await executeCli({ + argv: ["branch", "remove", "br_123", "--confirm", "br_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("BRANCH_NOT_EMPTY"); + expect(payload.nextSteps[0]).toContain("--cascade"); + expect(payload.nextSteps.join(" ")).toContain( + "app remove --app --branch preview", + ); + }); + + it("removes the branch and its resources with --cascade and lists them", async () => { + const { cwd, stateDir } = await setup(); + + const result = await executeCli({ + argv: [ + "branch", + "remove", + "preview", + "--confirm", + "br_123", + "--cascade", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "branch.remove", + result: { + branch: { id: "br_123", name: "preview" }, + removed: { + databases: [{ id: "db_123", name: "acme-preview" }], + }, + }, + }); + expect(payload.result.removed.apps.length).toBeGreaterThan(0); + }); + + it("still refuses production branches with --cascade", async () => { + const { cwd, stateDir } = await setup(); + + const result = await executeCli({ + argv: [ + "branch", + "remove", + "production", + "--confirm", + "br_456", + "--cascade", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("BRANCH_PROTECTED"); + }); +}); From 9c667dc9e1eebca1cd34afac3de3767efd383d6b Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 6 Jul 2026 17:00:57 +0530 Subject: [PATCH 3/4] fix(cli): map isDefault through fixture branches so default-branch protection holds in mock mode --- packages/cli/src/adapters/mock-api.ts | 5 ++- packages/cli/src/controllers/branch.ts | 1 + packages/cli/tests/branch-remove.test.ts | 45 +++++++++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 2ba3d329..51d13cdd 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -38,6 +38,7 @@ interface BranchRecord { projectId: string; name: string; role: "production" | "preview"; + isDefault?: boolean; currentDeploymentId: string | null; } @@ -341,7 +342,9 @@ export class MockApi { if (!branch) { return { outcome: "not-found" }; } - if (branch.role === "production") { + // Mirrors the platform rule: production and default branches are + // protected outright. + if (branch.role === "production" || branch.isDefault) { return { outcome: "protected" }; } diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index b63227c9..0853eb1c 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -165,6 +165,7 @@ export async function runBranchRemove( id: branch.id, gitName: branch.name, role: branch.role, + isDefault: branch.isDefault, })); const branch = resolveBranchForRemoval(branchRef, branches, target); diff --git a/packages/cli/tests/branch-remove.test.ts b/packages/cli/tests/branch-remove.test.ts index dc9ccec6..5501bbca 100644 --- a/packages/cli/tests/branch-remove.test.ts +++ b/packages/cli/tests/branch-remove.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -89,6 +89,49 @@ describe("branch remove", () => { expect(payload.error.code).toBe("BRANCH_PROTECTED"); }); + it("refuses default branches with BRANCH_PROTECTED even when role is preview", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const raw = JSON.parse(await readFile(fixturePath, "utf8")) as { + branches: Array>; + }; + raw.branches.push({ + id: "br_567", + projectId: "proj_123", + name: "legacy-default", + role: "preview", + isDefault: true, + currentDeploymentId: null, + }); + const defaultBranchFixturePath = path.join( + cwd, + "default-branch-fixture.json", + ); + await writeFile( + defaultBranchFixturePath, + `${JSON.stringify(raw, null, 2)}\n`, + "utf8", + ); + + const result = await executeCli({ + argv: [ + "branch", + "remove", + "legacy-default", + "--confirm", + "br_567", + "--json", + ], + cwd, + stateDir, + fixturePath: defaultBranchFixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("BRANCH_PROTECTED"); + }); + it("refuses branches that still have live resources", async () => { const { cwd, stateDir } = await setupLinkedProject(); From 97bee23427e154f075957da62ba9fb7414af92a5 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 6 Jul 2026 17:07:00 +0530 Subject: [PATCH 4/4] refactor(cli): address review nits on branch remove - extract formatRemovedResourceLine helper in the branch presenter - assert non-cascade removals omit result.removed - reuse setupLinkedProject in the cascade test suite - bundle runAppRemove optional inputs into an options object --- packages/cli/src/commands/app/index.ts | 6 +++++- packages/cli/src/controllers/app.ts | 9 +++++--- packages/cli/src/presenters/branch.ts | 23 ++++++++++++++------- packages/cli/tests/app-controller.test.ts | 10 +++------ packages/cli/tests/branch-remove.test.ts | 25 ++++------------------- 5 files changed, 34 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index 45a83534..e3e769f5 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -849,7 +849,11 @@ function createRemoveCommand(runtime: CliRuntime): Command { "app.remove", options as Record, (context) => - runAppRemove(context, appName, projectRef, configTarget, branchName), + runAppRemove(context, appName, { + projectRef, + configTarget, + branchName, + }), { renderHuman: (context, descriptor, result) => renderAppRemove(context, descriptor, result), diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index a3647bb3..eaa6f14a 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -2041,10 +2041,13 @@ export async function runAppRollback( export async function runAppRemove( context: CommandContext, appName: string | undefined, - projectRef?: string, - configTarget?: string, - branchName?: string, + options: { + projectRef?: string; + configTarget?: string; + branchName?: string; + } = {}, ): Promise> { + const { projectRef, configTarget, branchName } = options; ensurePreviewAppMode(context); const compute = await resolveComputeManagementContext( diff --git a/packages/cli/src/presenters/branch.ts b/packages/cli/src/presenters/branch.ts index 1090c204..884442bf 100644 --- a/packages/cli/src/presenters/branch.ts +++ b/packages/cli/src/presenters/branch.ts @@ -3,7 +3,11 @@ import type { CommandDescriptor } from "../shell/command-meta"; import { formatDescriptorLabel } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import { formatColumns } from "../shell/ui"; -import type { BranchListResult, BranchRemoveResult } from "../types/branch"; +import type { + BranchListResult, + BranchRemovedResource, + BranchRemoveResult, +} from "../types/branch"; import { renderResolvedProjectContextBlock } from "./verbose-context"; export function renderBranchList( @@ -69,6 +73,15 @@ function renderBranchResolvedContextBlock( return renderResolvedProjectContextBlock(context.ui, result.verboseContext); } +function formatRemovedResourceLine( + label: string, + resources: BranchRemovedResource[], +): string { + return resources.length > 0 + ? `Removed ${label}: ${resources.map((resource) => resource.name).join(", ")}` + : `No ${label} were on the branch.`; +} + export function renderBranchRemove( context: CommandContext, descriptor: CommandDescriptor, @@ -89,12 +102,8 @@ export function renderBranchRemove( "The branch was removed from the platform. Local Git branches are untouched.", ...(result.removed ? [ - result.removed.apps.length > 0 - ? `Removed apps: ${result.removed.apps.map((app) => app.name).join(", ")}` - : "No apps were on the branch.", - result.removed.databases.length > 0 - ? `Removed databases: ${result.removed.databases.map((database) => database.name).join(", ")}` - : "No databases were on the branch.", + formatRemovedResourceLine("apps", result.removed.apps), + formatRemovedResourceLine("databases", result.removed.databases), ] : []), ], diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index b7e5fd11..dfd45359 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -6672,13 +6672,9 @@ describe("app controller", () => { }, }); - const result = await runAppRemove( - context, - "hello-world", - undefined, - undefined, - "feat-not-checked-out", - ); + const result = await runAppRemove(context, "hello-world", { + branchName: "feat-not-checked-out", + }); // The explicit --branch scopes app resolution to that branch, without any // local Git branch of that name existing. diff --git a/packages/cli/tests/branch-remove.test.ts b/packages/cli/tests/branch-remove.test.ts index 5501bbca..26f7b8f3 100644 --- a/packages/cli/tests/branch-remove.test.ts +++ b/packages/cli/tests/branch-remove.test.ts @@ -72,6 +72,7 @@ describe("branch remove", () => { branch: { id: "br_345", name: "staging", role: "preview" }, }, }); + expect(payload.result).not.toHaveProperty("removed"); }); it("refuses production branches with BRANCH_PROTECTED", async () => { @@ -197,26 +198,8 @@ describe("branch remove", () => { }); describe("branch remove --cascade", () => { - async function setup() { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); - await mkdir(path.join(cwd, ".prisma"), { recursive: true }); - await writeFile( - path.join(cwd, ".prisma/local.json"), - `${JSON.stringify({ workspaceId: "ws_123", projectId: "proj_123" }, null, 2)}\n`, - "utf8", - ); - return { cwd, stateDir }; - } - it("offers the cascade rerun in the BRANCH_NOT_EMPTY recovery steps", async () => { - const { cwd, stateDir } = await setup(); + const { cwd, stateDir } = await setupLinkedProject(); const result = await executeCli({ argv: ["branch", "remove", "br_123", "--confirm", "br_123", "--json"], @@ -235,7 +218,7 @@ describe("branch remove --cascade", () => { }); it("removes the branch and its resources with --cascade and lists them", async () => { - const { cwd, stateDir } = await setup(); + const { cwd, stateDir } = await setupLinkedProject(); const result = await executeCli({ argv: [ @@ -268,7 +251,7 @@ describe("branch remove --cascade", () => { }); it("still refuses production branches with --cascade", async () => { - const { cwd, stateDir } = await setup(); + const { cwd, stateDir } = await setupLinkedProject(); const result = await executeCli({ argv: [