Skip to content

Commit b7c4576

Browse files
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.
1 parent f152fe3 commit b7c4576

12 files changed

Lines changed: 556 additions & 11 deletions

File tree

docs/product/command-spec.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,31 @@ prisma-cli branch list
786786
prisma-cli branch list --json
787787
```
788788

789+
## `prisma-cli branch remove <branch> --project <id-or-name> --confirm <branch-id>`
790+
791+
Purpose:
792+
793+
- remove a preview Branch from the resolved project
794+
795+
Behavior:
796+
797+
- requires auth and resolved project context; accepts `--project <id-or-name>` as an explicit fallback
798+
- resolves `<branch>` by exact Branch id or exact git name within the resolved project
799+
- requires `--confirm <branch-id>` where the value exactly matches the resolved Branch id; `--yes` does not satisfy this confirmation
800+
- production Branches and the project's default Branch are protected: removal fails with `BRANCH_PROTECTED`
801+
- 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
802+
- removal is a platform soft-delete: the Branch disappears from `branch list`, and the platform owns retention of soft-deleted Branches
803+
- Branch creation stays implicit (git-push automation and `app deploy`); there is deliberately no `branch create`
804+
- never touches local Git branches
805+
- fails with `BRANCH_NOT_FOUND` when no Branch matches
806+
807+
Examples:
808+
809+
```bash
810+
prisma-cli branch remove feat-login --confirm br_123
811+
prisma-cli branch remove br_123 --confirm br_123 --json
812+
```
813+
789814
## `prisma-cli database list --project <id-or-name> --branch <git-name>`
790815

791816
Purpose:

docs/product/error-conventions.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ These codes are the minimum stable set for the MVP:
175175
- `LOCAL_STATE_WRITE_FAILED`
176176
- `LOCAL_STATE_STALE`
177177
- `BRANCH_NOT_DEPLOYABLE`
178+
- `BRANCH_NOT_FOUND`
179+
- `BRANCH_PROTECTED`
180+
- `BRANCH_NOT_EMPTY`
178181
- `COMPUTE_CONFIG_INVALID`
179182
- `COMPUTE_CONFIG_TARGET_REQUIRED`
180183
- `COMPUTE_CONFIG_TARGET_UNKNOWN`
@@ -237,6 +240,9 @@ Recommended meanings:
237240
- `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
238241
- `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous
239242
- `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context
243+
- `BRANCH_NOT_FOUND`: requested branch id or git name does not exist in the resolved project
244+
- `BRANCH_PROTECTED`: branch removal refused because the branch is the project's production or default branch
245+
- `BRANCH_NOT_EMPTY`: branch removal refused because the branch still has live apps or databases
240246
- `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate
241247
- `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred
242248
- `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app

docs/product/resource-model.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ Rules:
5959
- every other named branch is a preview branch by default
6060
- preview branches are disposable by default
6161
- non-production branches can become durable later
62+
- Branch creation is implicit (git-push automation and `app deploy`); the CLI
63+
has no `branch create`
64+
- `branch remove` removes a preview Branch with exact id confirmation; the
65+
platform refuses production/default Branches and Branches that still have
66+
live Apps or databases, so removal never cascades into member resources
6267
- `local` is local CLI context only, not a branch
6368
- branch context comes from explicit targeting, Git, or safe command defaults,
6469
not `prisma.config.ts`

packages/cli/src/adapters/mock-api.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,43 @@ export class MockApi {
183183
);
184184
}
185185

186+
removeBranch(
187+
branchId: string,
188+
):
189+
| { outcome: "removed"; branch: BranchRecord }
190+
| { outcome: "not-found" }
191+
| { outcome: "protected" }
192+
| { outcome: "not-empty" } {
193+
const branch = this.data.branches.find(
194+
(candidate) => candidate.id === branchId,
195+
);
196+
if (!branch) {
197+
return { outcome: "not-found" };
198+
}
199+
if (branch.role === "production") {
200+
return { outcome: "protected" };
201+
}
202+
203+
// Mirrors the platform rule: removal is refused while the branch still
204+
// has live member resources.
205+
const hasDatabases = (this.data.databases ?? []).some(
206+
(database) => database.branchId === branchId,
207+
);
208+
const hasDeployments = this.data.deployments.some(
209+
(deployment) =>
210+
deployment.projectId === branch.projectId &&
211+
deployment.branch === branch.name,
212+
);
213+
if (hasDatabases || hasDeployments) {
214+
return { outcome: "not-empty" };
215+
}
216+
217+
this.data.branches = this.data.branches.filter(
218+
(candidate) => candidate.id !== branchId,
219+
);
220+
return { outcome: "removed", branch };
221+
}
222+
186223
getDeployment(deploymentId: string): DeploymentRecord | undefined {
187224
return this.data.deployments.find(
188225
(deployment) => deployment.id === deploymentId,

packages/cli/src/commands/branch/index.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import { Command } from "commander";
1+
import { Command, Option } from "commander";
22

3-
import { runBranchList } from "../../controllers/branch";
4-
import { renderBranchList, serializeBranchList } from "../../presenters/branch";
3+
import { runBranchList, runBranchRemove } from "../../controllers/branch";
4+
import {
5+
renderBranchList,
6+
renderBranchRemove,
7+
serializeBranchList,
8+
serializeBranchRemove,
9+
} from "../../presenters/branch";
510
import { attachCommandDescriptor } from "../../shell/command-meta";
611
import { runCommand } from "../../shell/command-runner";
712
import {
813
addCompactGlobalFlags,
914
addGlobalFlags,
1015
} from "../../shell/global-flags";
1116
import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime";
12-
import type { BranchListResult } from "../../types/branch";
17+
import type { BranchListResult, BranchRemoveResult } from "../../types/branch";
1318

1419
export function createBranchCommand(runtime: CliRuntime): Command {
1520
const branch = attachCommandDescriptor(
@@ -20,10 +25,45 @@ export function createBranchCommand(runtime: CliRuntime): Command {
2025
addCompactGlobalFlags(branch);
2126

2227
branch.addCommand(createBranchListCommand(runtime));
28+
branch.addCommand(createBranchRemoveCommand(runtime));
2329

2430
return branch;
2531
}
2632

33+
function createBranchRemoveCommand(runtime: CliRuntime): Command {
34+
const command = attachCommandDescriptor(
35+
configureRuntimeCommand(new Command("remove"), runtime),
36+
"branch.remove",
37+
);
38+
39+
command
40+
.argument("<branch>", "Branch id or git name")
41+
.addOption(new Option("--project <id-or-name>", "Project id or name"))
42+
.addOption(
43+
new Option("--confirm <branch-id>", "Exact branch id required to remove"),
44+
);
45+
addGlobalFlags(command);
46+
47+
command.action(async (branchRef: string, options) => {
48+
const projectRef = (options as { project?: string }).project;
49+
const confirm = (options as { confirm?: string }).confirm;
50+
51+
await runCommand<BranchRemoveResult>(
52+
runtime,
53+
"branch.remove",
54+
options as Record<string, unknown>,
55+
(context) => runBranchRemove(context, branchRef, { projectRef, confirm }),
56+
{
57+
renderHuman: (context, descriptor, result) =>
58+
renderBranchRemove(context, descriptor, result),
59+
renderJson: (result) => serializeBranchRemove(result),
60+
},
61+
);
62+
});
63+
64+
return command;
65+
}
66+
2767
function createBranchListCommand(runtime: CliRuntime): Command {
2868
const command = attachCommandDescriptor(
2969
configureRuntimeCommand(new Command("list"), runtime),

0 commit comments

Comments
 (0)