Skip to content

Commit 577152d

Browse files
fix(app): resolve management-command branch to the project default (#91)
## Problem `app show`, `logs`, `list-deploys`, `open`, `promote`, `rollback`, and `remove` scope the app lookup to a single branch via `GET /v1/compute-services?projectId=…&branchGitName=…`. The branch is resolved as `--branch` → active git branch → literal `main` (per `command-spec.md`). The management API returns an empty page (HTTP 200, `data: []`) when `branchGitName` matches no live branch. So an app deployed via git-push whose default/production branch is not `main` — e.g. `master` — is invisible to these commands unless the caller happens to be on a local `master` checkout: ``` $ prisma-cli app logs --project proj_… ✘ No deployments available to stream logs [NO_DEPLOYMENTS] ``` The control plane and management API are correct; the app, its branch (`master`, `isDefault: true`), and its versions all exist. The defect is the CLI's branch resolution. ## Fix Management commands never create branches, so they now resolve the branch they read against the project's actual branches: 1. `--branch <name>` — honored as-is 2. the active git branch — only when a branch with that name exists in the project 3. the project's default (production) branch — from `GET /v1/projects/:id/branches` This drops the implicit `main` fallback for read commands. Deploy is unchanged. - `src/lib/app/read-branch.ts` (new): read-only `resolveReadBranch` — match by name, else default branch, else null. Lives in its own module so the existing `preview-provider` test mocks don't need to stub it. - `src/controllers/app.ts`: `resolveProjectContext` resolves the inferred branch via the API. - `docs/product/command-spec.md`: branch-resolution spec updated (the `main` fallback was documented; management commands now resolve existing-or-default). ## Testing - `tests/read-branch.test.ts` (new): match / default-fallback / no-branches. - Updated the shared `verboseContext.branch` expectation in `tests/app-controller.test.ts` (now carries the resolved branch id + role). - Full suite: **484 passing**. `tsc --noEmit` clean. Biome clean on changed lines. ## Note Even once merged, `bunx @prisma/cli@latest` only picks this up after a release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e67907a commit 577152d

6 files changed

Lines changed: 235 additions & 7 deletions

File tree

docs/product/command-spec.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,24 @@ resolved production Branch and fail when none exists.
135135

136136
### Branch
137137

138-
Commands that use branch context resolve it in this order:
138+
Deploy resolves the branch it writes to in this order:
139139

140140
1. explicit branch argument or `--branch <name>` when the command accepts one
141141
2. active Git branch for local deploy workflows
142142
3. `main`
143143

144+
App management commands (`show`, `open`, `logs`, `list-deploys`, `promote`,
145+
`rollback`, `remove`) never create branches, so they resolve the branch they
146+
read in this order:
147+
148+
1. explicit `--branch <name>` when the command accepts one, honored as-is
149+
2. the active Git branch when a branch with that name exists in the project
150+
3. the project's default (production) branch
151+
152+
Resolving the default branch from the project keeps a git-push app deployed on
153+
a non-`main` default branch (for example `master`) visible to management
154+
commands regardless of the local Git branch.
155+
144156
`local` is local CLI context only. It is never a branch or deploy target.
145157
Production is a protected durable branch and must require explicit user intent.
146158

packages/cli/src/controllers/app.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import {
8686
runLocalApp,
8787
} from "../lib/app/local-dev";
8888
import { enforceProductionDeployGate } from "../lib/app/production-deploy-gate";
89+
import { resolveReadBranch } from "../lib/app/read-branch";
8990
import { readAuthState } from "../lib/auth/auth-ops";
9091
import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client";
9192
import { requireComputeAuth } from "../lib/auth/guard";
@@ -3279,15 +3280,27 @@ async function resolveProjectContext(
32793280
throw projectResolutionErrorToCliError(resolvedResult.error);
32803281
}
32813282
const resolved = resolvedResult.value;
3282-
const branch =
3283+
const requested =
32833284
options?.branch ?? (await resolveDeployBranch(context, undefined));
32843285

3286+
// An explicit --branch is honored as-is. An inferred branch (active Git
3287+
// branch or the default) is resolved against the project's branches and
3288+
// falls back to the default branch so a git-push app on a non-`main`
3289+
// default branch stays visible.
3290+
const remoteBranch = options?.branch
3291+
? null
3292+
: await resolveReadBranch(client, {
3293+
projectId: resolved.project.id,
3294+
branchName: requested.name,
3295+
signal: context.runtime.signal,
3296+
});
3297+
32853298
return {
32863299
...resolved,
3287-
branch: {
3300+
branch: remoteBranch ?? {
32883301
id: null,
3289-
name: branch.name,
3290-
kind: toBranchKind(branch.name),
3302+
name: requested.name,
3303+
kind: toBranchKind(requested.name),
32913304
},
32923305
};
32933306
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { ManagementApiClient } from "@prisma/management-api-sdk";
2+
3+
import type { BranchKind } from "../../types/branch";
4+
5+
export interface ReadBranch {
6+
id: string;
7+
name: string;
8+
kind: BranchKind;
9+
}
10+
11+
/**
12+
* Resolves the branch an app management command should read from, without ever
13+
* creating one. Returns the branch whose `gitName` matches `branchName`, else
14+
* the project's default branch, else null when the project has no branches.
15+
*/
16+
export async function resolveReadBranch(
17+
client: ManagementApiClient,
18+
options: { projectId: string; branchName: string; signal?: AbortSignal },
19+
): Promise<ReadBranch | null> {
20+
const branches: Array<{
21+
id: string;
22+
gitName: string;
23+
isDefault: boolean;
24+
role: BranchKind;
25+
}> = [];
26+
let cursor: string | undefined;
27+
28+
do {
29+
const result = await client.GET("/v1/projects/{projectId}/branches", {
30+
params: { path: { projectId: options.projectId }, query: { cursor } },
31+
signal: options.signal,
32+
});
33+
if (result.error || !result.data) {
34+
throw new Error(
35+
`Failed to list branches for project ${options.projectId}: ${JSON.stringify(result.error)}`,
36+
);
37+
}
38+
39+
branches.push(...result.data.data);
40+
cursor = result.data.pagination.hasMore
41+
? (result.data.pagination.nextCursor ?? undefined)
42+
: undefined;
43+
} while (cursor);
44+
45+
const chosen =
46+
branches.find((branch) => branch.gitName === options.branchName) ??
47+
branches.find((branch) => branch.isDefault) ??
48+
null;
49+
return chosen
50+
? { id: chosen.id, name: chosen.gitName, kind: chosen.role }
51+
: null;
52+
}

packages/cli/tests/app-controller.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ function expectedAppVerboseContext() {
7171
name: "Acme Dashboard",
7272
},
7373
branch: {
74-
id: null,
74+
id: "branch_main",
7575
name: "main",
76-
kind: "production",
76+
kind: "preview",
7777
},
7878
resolution: {
7979
projectSource: "local-pin",

packages/cli/tests/helpers/mock-factories.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export function createProjectClient(
5555
options.branchExists === false
5656
? []
5757
: [branchRecord(branchName)],
58+
pagination: { hasMore: false, nextCursor: null },
5859
},
5960
};
6061
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { ManagementApiClient } from "@prisma/management-api-sdk";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
import { resolveReadBranch } from "../src/lib/app/read-branch";
5+
6+
type RawBranch = {
7+
id: string;
8+
gitName: string;
9+
isDefault: boolean;
10+
role: "production" | "preview";
11+
};
12+
13+
function pageResponse(branches: RawBranch[], nextCursor: string | null) {
14+
return {
15+
data: {
16+
data: branches,
17+
pagination: { hasMore: nextCursor !== null, nextCursor },
18+
},
19+
};
20+
}
21+
22+
function clientReturning(branches: RawBranch[]): ManagementApiClient {
23+
return {
24+
GET: vi.fn().mockResolvedValue(pageResponse(branches, null)),
25+
} as unknown as ManagementApiClient;
26+
}
27+
28+
describe("resolveReadBranch", () => {
29+
it("returns the branch whose gitName matches the request", async () => {
30+
const client = clientReturning([
31+
{
32+
id: "b_master",
33+
gitName: "master",
34+
isDefault: true,
35+
role: "production",
36+
},
37+
{ id: "b_feat", gitName: "feat/x", isDefault: false, role: "preview" },
38+
]);
39+
40+
const result = await resolveReadBranch(client, {
41+
projectId: "proj_1",
42+
branchName: "feat/x",
43+
});
44+
45+
expect(result).toEqual({ id: "b_feat", name: "feat/x", kind: "preview" });
46+
});
47+
48+
it("falls back to the default branch when the requested branch does not exist", async () => {
49+
const client = clientReturning([
50+
{
51+
id: "b_master",
52+
gitName: "master",
53+
isDefault: true,
54+
role: "production",
55+
},
56+
]);
57+
58+
const result = await resolveReadBranch(client, {
59+
projectId: "proj_1",
60+
branchName: "main",
61+
});
62+
63+
expect(result).toEqual({
64+
id: "b_master",
65+
name: "master",
66+
kind: "production",
67+
});
68+
});
69+
70+
it("returns null when the project has no branches", async () => {
71+
const client = clientReturning([]);
72+
73+
const result = await resolveReadBranch(client, {
74+
projectId: "proj_1",
75+
branchName: "main",
76+
});
77+
78+
expect(result).toBeNull();
79+
});
80+
81+
it("throws when the branches request fails", async () => {
82+
const client = {
83+
GET: vi.fn().mockResolvedValue({
84+
error: { message: "Unauthorized" },
85+
response: { status: 401 },
86+
}),
87+
} as unknown as ManagementApiClient;
88+
89+
await expect(
90+
resolveReadBranch(client, { projectId: "proj_1", branchName: "main" }),
91+
).rejects.toThrow();
92+
});
93+
94+
it("follows pagination to a branch on a later page", async () => {
95+
const GET = vi
96+
.fn()
97+
.mockResolvedValueOnce(
98+
pageResponse(
99+
[
100+
{
101+
id: "b_main",
102+
gitName: "main",
103+
isDefault: true,
104+
role: "production",
105+
},
106+
],
107+
"cursor_1",
108+
),
109+
)
110+
.mockResolvedValueOnce(
111+
pageResponse(
112+
[
113+
{
114+
id: "b_feat",
115+
gitName: "feat/x",
116+
isDefault: false,
117+
role: "preview",
118+
},
119+
],
120+
null,
121+
),
122+
);
123+
const client = { GET } as unknown as ManagementApiClient;
124+
125+
const result = await resolveReadBranch(client, {
126+
projectId: "proj_1",
127+
branchName: "feat/x",
128+
});
129+
130+
expect(result).toEqual({ id: "b_feat", name: "feat/x", kind: "preview" });
131+
expect(GET).toHaveBeenCalledTimes(2);
132+
expect(GET).toHaveBeenNthCalledWith(
133+
1,
134+
"/v1/projects/{projectId}/branches",
135+
expect.objectContaining({
136+
params: { path: { projectId: "proj_1" }, query: { cursor: undefined } },
137+
}),
138+
);
139+
expect(GET).toHaveBeenNthCalledWith(
140+
2,
141+
"/v1/projects/{projectId}/branches",
142+
expect.objectContaining({
143+
params: {
144+
path: { projectId: "proj_1" },
145+
query: { cursor: "cursor_1" },
146+
},
147+
}),
148+
);
149+
});
150+
});

0 commit comments

Comments
 (0)