Skip to content

Commit f437d87

Browse files
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.
1 parent 0884425 commit f437d87

13 files changed

Lines changed: 418 additions & 21 deletions

File tree

docs/product/command-spec.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,7 @@ 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>`
868+
## `prisma-cli branch remove <branch> --project <id-or-name> --confirm <branch-id> --cascade`
869869

870870
Purpose:
871871

@@ -876,9 +876,11 @@ Behavior:
876876
- requires auth and resolved project context; accepts `--project <id-or-name>` as an explicit fallback
877877
- resolves `<branch>` by exact Branch id or exact git name within the resolved project
878878
- 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
879+
- 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
880+
- 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
881+
- 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`
882+
- 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)
883+
- 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
882884
- Branch creation stays implicit (git-push automation and `app deploy`); there is deliberately no `branch create`
883885
- never touches local Git branches
884886
- fails with `BRANCH_NOT_FOUND` when no Branch matches
@@ -887,6 +889,7 @@ Examples:
887889

888890
```bash
889891
prisma-cli branch remove feat-login --confirm br_123
892+
prisma-cli branch remove feat-login --confirm br_123 --cascade
890893
prisma-cli branch remove br_123 --confirm br_123 --json
891894
```
892895

@@ -1854,16 +1857,18 @@ prisma-cli app rollback
18541857
prisma-cli app rollback --app hello-world --to dep_123
18551858
```
18561859

1857-
## `prisma-cli app remove [app] --app <name> -y --yes`
1860+
## `prisma-cli app remove [app] --app <name> --branch <name> -y --yes`
18581861

18591862
Purpose:
18601863

1861-
- remove the app from the current branch
1864+
- remove the app from the resolved branch
18621865

18631866
Behavior:
18641867

18651868
- requires auth and project context
1866-
- resolves the selected app
1869+
- 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
1870+
- `--branch <name>` makes branch cleanup possible for branches that are not checked out locally, such as a teammate's preview branch
1871+
- resolves the selected app within that branch
18671872
- requires confirmation unless `-y` or `--yes` is passed
18681873
- clears local selected app state when the removed app was selected
18691874

docs/product/error-conventions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ These codes are the minimum stable set for the MVP:
184184
- `BRANCH_NOT_FOUND`
185185
- `BRANCH_PROTECTED`
186186
- `BRANCH_NOT_EMPTY`
187+
- `BRANCH_CASCADE_INCOMPLETE`
187188
- `COMPUTE_CONFIG_INVALID`
188189
- `COMPUTE_CONFIG_TARGET_REQUIRED`
189190
- `COMPUTE_CONFIG_TARGET_UNKNOWN`
@@ -258,6 +259,7 @@ Recommended meanings:
258259
- `BRANCH_NOT_FOUND`: requested branch id or git name does not exist in the resolved project
259260
- `BRANCH_PROTECTED`: branch removal refused because the branch is the project's production or default branch
260261
- `BRANCH_NOT_EMPTY`: branch removal refused because the branch still has live apps or databases
262+
- `BRANCH_CASCADE_INCOMPLETE`: a --cascade branch removal failed partway; meta lists the resources already removed and the branch remains
261263
- `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate
262264
- `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred
263265
- `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app

docs/product/resource-model.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ Rules:
6666
- Branch creation is implicit (git-push automation and `app deploy`); the CLI
6767
has no `branch create`
6868
- `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
69+
platform refuses production/default Branches outright, and plain removal
70+
refuses Branches that still have live Apps or databases
71+
- `branch remove --cascade` removes a preview Branch's Apps and databases with
72+
it, with the blast radius listed explicitly; production/default Branches stay
73+
refused regardless of flags
7174
- `local` is local CLI context only, not a branch
7275
- branch context comes from explicit targeting, Git, or safe command defaults,
7376
not `prisma.config.ts`

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,55 @@ export class MockApi {
279279
);
280280
}
281281

282+
cascadeBranchResources(branchId: string): {
283+
apps: Array<{ id: string; name: string }>;
284+
databases: Array<{ id: string; name: string }>;
285+
} {
286+
const branch = this.data.branches.find(
287+
(candidate) => candidate.id === branchId,
288+
);
289+
if (!branch) {
290+
return { apps: [], databases: [] };
291+
}
292+
293+
const databases = (this.data.databases ?? []).filter(
294+
(database) => database.branchId === branchId,
295+
);
296+
const databaseIds = new Set(databases.map((database) => database.id));
297+
// The fixture has no standalone app records; deployments on the branch
298+
// stand in for its apps.
299+
const apps = this.data.deployments.filter(
300+
(deployment) =>
301+
deployment.projectId === branch.projectId &&
302+
deployment.branch === branch.name,
303+
);
304+
305+
this.data.databases = (this.data.databases ?? []).filter(
306+
(database) => database.branchId !== branchId,
307+
);
308+
this.data.databaseConnections = (
309+
this.data.databaseConnections ?? []
310+
).filter((connection) => !databaseIds.has(connection.databaseId));
311+
this.data.deployments = this.data.deployments.filter(
312+
(deployment) =>
313+
!(
314+
deployment.projectId === branch.projectId &&
315+
deployment.branch === branch.name
316+
),
317+
);
318+
319+
return {
320+
apps: apps.map((deployment) => ({
321+
id: deployment.id,
322+
name: deployment.id,
323+
})),
324+
databases: databases.map((database) => ({
325+
id: database.id,
326+
name: database.name,
327+
})),
328+
};
329+
}
330+
282331
removeBranch(
283332
branchId: string,
284333
):

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -835,18 +835,21 @@ function createRemoveCommand(runtime: CliRuntime): Command {
835835
"App target from prisma.compute.ts when the config defines multiple apps",
836836
)
837837
.addOption(new Option("--app <name>", "App name"))
838-
.addOption(new Option("--project <id-or-name>", "Project id or name"));
838+
.addOption(new Option("--project <id-or-name>", "Project id or name"))
839+
.addOption(new Option("--branch <name>", "Branch name"));
839840
addGlobalFlags(command);
840841

841842
command.action(async (configTarget: string | undefined, options) => {
842843
const appName = (options as { app?: string }).app;
843844
const projectRef = (options as { project?: string }).project;
845+
const branchName = (options as { branch?: string }).branch;
844846

845847
await runCommand<AppRemoveResult>(
846848
runtime,
847849
"app.remove",
848850
options as Record<string, unknown>,
849-
(context) => runAppRemove(context, appName, projectRef, configTarget),
851+
(context) =>
852+
runAppRemove(context, appName, projectRef, configTarget, branchName),
850853
{
851854
renderHuman: (context, descriptor, result) =>
852855
renderAppRemove(context, descriptor, result),

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,26 @@ function createBranchRemoveCommand(runtime: CliRuntime): Command {
4141
.addOption(new Option("--project <id-or-name>", "Project id or name"))
4242
.addOption(
4343
new Option("--confirm <branch-id>", "Exact branch id required to remove"),
44+
)
45+
.addOption(
46+
new Option(
47+
"--cascade",
48+
"Also remove the branch's apps and databases (preview branches only)",
49+
),
4450
);
4551
addGlobalFlags(command);
4652

4753
command.action(async (branchRef: string, options) => {
4854
const projectRef = (options as { project?: string }).project;
4955
const confirm = (options as { confirm?: string }).confirm;
56+
const cascade = (options as { cascade?: boolean }).cascade;
5057

5158
await runCommand<BranchRemoveResult>(
5259
runtime,
5360
"branch.remove",
5461
options as Record<string, unknown>,
55-
(context) => runBranchRemove(context, branchRef, { projectRef, confirm }),
62+
(context) =>
63+
runBranchRemove(context, branchRef, { projectRef, confirm, cascade }),
5664
{
5765
renderHuman: (context, descriptor, result) =>
5866
renderBranchRemove(context, descriptor, result),

packages/cli/src/controllers/app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2043,6 +2043,7 @@ export async function runAppRemove(
20432043
appName: string | undefined,
20442044
projectRef?: string,
20452045
configTarget?: string,
2046+
branchName?: string,
20462047
): Promise<CommandSuccess<AppRemoveResult>> {
20472048
ensurePreviewAppMode(context);
20482049

@@ -2056,6 +2057,12 @@ export async function runAppRemove(
20562057
await requireProviderAndProjectContext(context, projectRef, {
20572058
commandName: "app remove",
20582059
projectDir: compute.projectDir,
2060+
// Branch cleanup needs "remove this app from that branch" even when the
2061+
// branch is not checked out locally, so an explicit --branch is honored
2062+
// as-is like the other read-branch commands.
2063+
branch: branchName
2064+
? await resolveDeployBranch(context, branchName)
2065+
: undefined,
20592066
});
20602067
const apps = await listApps(context, provider, projectId, target.branch.name);
20612068
const selectedApp = await requireReleaseAppSelection(

0 commit comments

Comments
 (0)