Skip to content

Commit 0884425

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 df4ad95 commit 0884425

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
@@ -865,6 +865,31 @@ prisma-cli branch list
865865
prisma-cli branch list --json
866866
```
867867

868+
## `prisma-cli branch remove <branch> --project <id-or-name> --confirm <branch-id>`
869+
870+
Purpose:
871+
872+
- remove a preview Branch from the resolved project
873+
874+
Behavior:
875+
876+
- requires auth and resolved project context; accepts `--project <id-or-name>` as an explicit fallback
877+
- resolves `<branch>` by exact Branch id or exact git name within the resolved project
878+
- requires `--confirm <branch-id>` where the value exactly matches the resolved Branch id; `--yes` does not satisfy this confirmation
879+
- production Branches and the project's default Branch are protected: removal fails with `BRANCH_PROTECTED`
880+
- 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
881+
- removal is a platform soft-delete: the Branch disappears from `branch list`, and the platform owns retention of soft-deleted Branches
882+
- Branch creation stays implicit (git-push automation and `app deploy`); there is deliberately no `branch create`
883+
- never touches local Git branches
884+
- fails with `BRANCH_NOT_FOUND` when no Branch matches
885+
886+
Examples:
887+
888+
```bash
889+
prisma-cli branch remove feat-login --confirm br_123
890+
prisma-cli branch remove br_123 --confirm br_123 --json
891+
```
892+
868893
## `prisma-cli database list --project <id-or-name> --branch <git-name>`
869894

870895
Purpose:

docs/product/error-conventions.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ These codes are the minimum stable set for the MVP:
181181
- `LOCAL_STATE_WRITE_FAILED`
182182
- `LOCAL_STATE_STALE`
183183
- `BRANCH_NOT_DEPLOYABLE`
184+
- `BRANCH_NOT_FOUND`
185+
- `BRANCH_PROTECTED`
186+
- `BRANCH_NOT_EMPTY`
184187
- `COMPUTE_CONFIG_INVALID`
185188
- `COMPUTE_CONFIG_TARGET_REQUIRED`
186189
- `COMPUTE_CONFIG_TARGET_UNKNOWN`
@@ -252,6 +255,9 @@ Recommended meanings:
252255
- `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
253256
- `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous
254257
- `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context
258+
- `BRANCH_NOT_FOUND`: requested branch id or git name does not exist in the resolved project
259+
- `BRANCH_PROTECTED`: branch removal refused because the branch is the project's production or default branch
260+
- `BRANCH_NOT_EMPTY`: branch removal refused because the branch still has live apps or databases
255261
- `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate
256262
- `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred
257263
- `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
@@ -63,6 +63,11 @@ Rules:
6363
- every other named branch is a preview branch by default
6464
- preview branches are disposable by default
6565
- non-production branches can become durable later
66+
- Branch creation is implicit (git-push automation and `app deploy`); the CLI
67+
has no `branch create`
68+
- `branch remove` removes a preview Branch with exact id confirmation; the
69+
platform refuses production/default Branches and Branches that still have
70+
live Apps or databases, so removal never cascades into member resources
6671
- `local` is local CLI context only, not a branch
6772
- branch context comes from explicit targeting, Git, or safe command defaults,
6873
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
@@ -279,6 +279,43 @@ export class MockApi {
279279
);
280280
}
281281

282+
removeBranch(
283+
branchId: string,
284+
):
285+
| { outcome: "removed"; branch: BranchRecord }
286+
| { outcome: "not-found" }
287+
| { outcome: "protected" }
288+
| { outcome: "not-empty" } {
289+
const branch = this.data.branches.find(
290+
(candidate) => candidate.id === branchId,
291+
);
292+
if (!branch) {
293+
return { outcome: "not-found" };
294+
}
295+
if (branch.role === "production") {
296+
return { outcome: "protected" };
297+
}
298+
299+
// Mirrors the platform rule: removal is refused while the branch still
300+
// has live member resources.
301+
const hasDatabases = (this.data.databases ?? []).some(
302+
(database) => database.branchId === branchId,
303+
);
304+
const hasDeployments = this.data.deployments.some(
305+
(deployment) =>
306+
deployment.projectId === branch.projectId &&
307+
deployment.branch === branch.name,
308+
);
309+
if (hasDatabases || hasDeployments) {
310+
return { outcome: "not-empty" };
311+
}
312+
313+
this.data.branches = this.data.branches.filter(
314+
(candidate) => candidate.id !== branchId,
315+
);
316+
return { outcome: "removed", branch };
317+
}
318+
282319
getDeployment(deploymentId: string): DeploymentRecord | undefined {
283320
return this.data.deployments.find(
284321
(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)