diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4150a180..50f6fe1c 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -865,6 +865,34 @@ prisma-cli branch list prisma-cli branch list --json ``` +## `prisma-cli branch remove --project --confirm --cascade` + +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`, 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 + +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 +``` + ## `prisma-cli database list --project --branch ` Purpose: @@ -1829,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 76f610ec..155cd281 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -181,6 +181,10 @@ 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` +- `BRANCH_CASCADE_INCOMPLETE` - `COMPUTE_CONFIG_INVALID` - `COMPUTE_CONFIG_TARGET_REQUIRED` - `COMPUTE_CONFIG_TARGET_UNKNOWN` @@ -252,6 +256,10 @@ 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 +- `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 db73619f..8347e60f 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -63,6 +63,14 @@ 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 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 8b43bc7e..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; } @@ -279,6 +280,94 @@ 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, + ): + | { 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" }; + } + // Mirrors the platform rule: production and default branches are + // protected outright. + if (branch.role === "production" || branch.isDefault) { + 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/app/index.ts b/packages/cli/src/commands/app/index.ts index 03df71de..e3e769f5 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -835,18 +835,25 @@ 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 fdf9621c..24bbeae3 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,53 @@ 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"), + ) + .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, cascade }), + { + 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/app.ts b/packages/cli/src/controllers/app.ts index 678ed811..eaa6f14a 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -2041,9 +2041,13 @@ export async function runAppRollback( export async function runAppRemove( context: CommandContext, appName: string | undefined, - projectRef?: string, - configTarget?: string, + options: { + projectRef?: string; + configTarget?: string; + branchName?: string; + } = {}, ): Promise> { + const { projectRef, configTarget, branchName } = options; ensurePreviewAppMode(context); const compute = await resolveComputeManagementContext( @@ -2056,6 +2060,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 0e1a7d0a..0853eb1c 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,8 +1,15 @@ // 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 { createAppProvider } from "../lib/app/app-provider"; import { requireComputeAuth } from "../lib/auth/guard"; +import { createManagementDatabaseProvider } from "../lib/database/provider"; import { projectResolutionErrorToCliError, + type ResolvedProjectTarget, resolveProjectTarget, } from "../lib/project/resolution"; import { @@ -14,13 +21,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 ( @@ -33,6 +44,7 @@ interface RawBranchRecord { id: string; gitName: string; role: BranchRole; + isDefault?: boolean; } export async function runBranchList( @@ -104,6 +116,345 @@ async function listRealBranches( }; } +export interface BranchRemoveFlags { + projectRef?: string; + confirm?: string; + cascade?: boolean; +} + +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, + isDefault: branch.isDefault, + })); + const branch = resolveBranchForRemoval(branchRef, branches, target); + + // 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); + } + + requireBranchRemoveConfirmation({ + id: branch.id, + confirm: flags.confirm, + 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 { + 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), + ...(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[], + 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( + branch: RawBranchRecord, + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + return new CliError({ + code: "BRANCH_NOT_EMPTY", + domain: "branch", + summary: "Branch still has live resources", + 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", + branch.gitName, + ]), + formatCommand(["database", "list", "--branch", branch.gitName]), + ], + }); +} + +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, 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, 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..884442bf 100644 --- a/packages/cli/src/presenters/branch.ts +++ b/packages/cli/src/presenters/branch.ts @@ -1,8 +1,13 @@ +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, + BranchRemovedResource, + BranchRemoveResult, +} from "../types/branch"; import { renderResolvedProjectContextBlock } from "./verbose-context"; export function renderBranchList( @@ -67,3 +72,51 @@ function renderBranchResolvedContextBlock( ): string[] { 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, + 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.", + ...(result.removed + ? [ + formatRemovedResourceLine("apps", result.removed.apps), + formatRemovedResourceLine("databases", result.removed.databases), + ] + : []), + ], + }, + 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..07f68b23 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,15 @@ 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", + "prisma-cli branch remove feat-login --confirm br_123 --cascade", + ], + }, { 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..41c4cd3a 100644 --- a/packages/cli/src/types/branch.ts +++ b/packages/cli/src/types/branch.ts @@ -21,3 +21,24 @@ export interface BranchListResult { }; branches: BranchSummary[]; } + +export interface BranchRemovedResource { + id: string; + name: string; +} + +export interface BranchRemoveResult { + projectId: string; + projectName: string; + verboseContext?: { + workspace: AuthWorkspace; + project: ProjectSummary; + 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..dfd45359 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -6620,6 +6620,74 @@ 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", { + branchName: "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 new file mode 100644 index 00000000..26f7b8f3 --- /dev/null +++ b/packages/cli/tests/branch-remove.test.ts @@ -0,0 +1,275 @@ +import { mkdir, readFile, 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" }, + }, + }); + expect(payload.result).not.toHaveProperty("removed"); + }); + + 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 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(); + + // 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"); + }); +}); + +describe("branch remove --cascade", () => { + it("offers the cascade rerun in the BRANCH_NOT_EMPTY recovery steps", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + 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 setupLinkedProject(); + + 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 setupLinkedProject(); + + 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"); + }); +}); 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:");