From 58eadb2621e3807767b12f9129c676bf28feabd4 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 3 Jun 2026 04:18:48 +0200 Subject: [PATCH 1/3] feat: list platform branch roles --- docs/product/command-principles.md | 9 +- docs/product/command-spec.md | 24 +- docs/product/output-conventions.md | 2 - docs/product/resource-model.md | 15 +- packages/cli/README.md | 4 +- packages/cli/fixtures/mock-api.json | 5 + packages/cli/src/adapters/mock-api.ts | 1 + packages/cli/src/commands/branch/index.ts | 51 +- packages/cli/src/controllers/branch.ts | 184 +++---- packages/cli/src/presenters/branch.ts | 155 +----- packages/cli/src/shell/command-meta.ts | 14 +- packages/cli/src/types/branch.ts | 32 +- packages/cli/src/use-cases/branch.ts | 133 +---- packages/cli/src/use-cases/contracts.ts | 16 +- .../cli/src/use-cases/create-cli-gateways.ts | 24 +- packages/cli/tests/branch-controller.test.ts | 177 ++++--- packages/cli/tests/branch-usecases.test.ts | 81 +-- packages/cli/tests/branch.test.ts | 490 +----------------- packages/cli/tests/use-case-helpers.ts | 25 +- 19 files changed, 281 insertions(+), 1161 deletions(-) diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 63e546c..450d90a 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -66,7 +66,6 @@ Display one resource or one current context. Examples: - `project show` -- `branch show` - `app show-deploy` ### `list` @@ -87,9 +86,7 @@ Change local CLI context only. `use` changes local active context only. -Example: - -- `branch use production` +No current branch command uses `use`; branch targeting follows explicit flags or source context. ### `deploy` @@ -156,9 +153,7 @@ MVP rule: 2. local active context is next 3. safe command defaults come last -Only `branch use` changes local active branch context. - -Other commands may act on remote state, but they must not silently mutate local context. +Commands may act on remote state, but they must not silently mutate local context. ## Production Must Be Intentionally Harder diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4787c5c..ee29621 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -538,9 +538,9 @@ Purpose: Behavior: - shows known remote branches for the resolved project -- marks active context +- shows each branch's name, role, and resolved env map - does not create remote state -- does not expose branch `role` or `durability` fields yet +- does not expose durability, protection, deployment, or env override annotations yet Examples: @@ -549,26 +549,6 @@ prisma-cli branch list prisma-cli branch list --json ``` -## `prisma-cli branch show` - -Purpose: - -- show the Platform branch matching your current Git branch - -Behavior: - -- reads local branch context -- shows resolved project context when known -- does not mutate local or remote state -- does not expose branch `role` or `durability` fields yet - -Examples: - -```bash -prisma-cli branch show -prisma-cli branch show --json -``` - ## `prisma-cli app build --entry --build-type ` Purpose: diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 3b40c12..505d35f 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -104,8 +104,6 @@ Current MVP commands map to patterns like this: | `git connect` | `mutate` | | `git disconnect` | `mutate` | | `branch list` | `list` | -| `branch show` | `show` | -| `branch use` | `mutate` | No current MVP command uses `verify` or `inspect`, but new commands must still choose one existing pattern rather than inventing a new one casually. diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 4983464..0a3a6de 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -59,9 +59,8 @@ Rules: - preview branches are disposable by default - non-production branches can become durable later - `local` is local CLI context only, not a branch -- active branch context is local CLI state, not `prisma.config.ts` -- selecting a branch changes local CLI context only; it does not create remote - state by itself +- branch context comes from explicit targeting, Git, or safe command defaults, + not `prisma.config.ts` Examples of preview branches: @@ -71,8 +70,8 @@ Examples of preview branches: - `pr-123` Branch role and durability are product concepts in the current docs. The preview -command JSON for `branch list` and `branch show` does not expose dedicated -`role` or `durability` fields yet. +command JSON for `branch list` exposes `role`; `durability` remains target-model +until the Management API returns it everywhere. ### Branch Role And Durability @@ -231,13 +230,13 @@ feature-branch code into a service owned by another branch. Commands that use branch context resolve it in this order: 1. explicit branch argument when the command accepts one -2. active branch context in local CLI state -3. `preview` +2. local Git branch when available +3. `main` Consequences: - `local` never becomes a branch or deploy target -- first remote app work defaults to `preview` +- first remote app work falls back to `main` when no Git branch is available - production requires explicit user intent ### Inspect Resolution diff --git a/packages/cli/README.md b/packages/cli/README.md index 1820c42..a4c5f91 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -73,7 +73,7 @@ The beta package exposes `prisma-cli` so it can coexist with the existing | `auth` | Log in, log out, and inspect the active Prisma account. | | `project` | List projects, show the resolved project, and manage project environment variables. | | `git` | Connect or disconnect a project from a GitHub repository. | -| `branch` | Inspect the Prisma branch that maps to the current work context. | +| `branch` | List Prisma branches for the resolved project. | | `app` | Build, run, deploy, inspect, open, stream logs, promote, roll back, and remove apps. | Common examples: @@ -82,7 +82,7 @@ Common examples: npx prisma-cli version npx prisma-cli auth whoami npx prisma-cli project show -npx prisma-cli branch show +npx prisma-cli branch list npx prisma-cli app deploy --branch feat-login --framework nextjs npx prisma-cli app promote ``` diff --git a/packages/cli/fixtures/mock-api.json b/packages/cli/fixtures/mock-api.json index 6becec8..fc8e3b6 100644 --- a/packages/cli/fixtures/mock-api.json +++ b/packages/cli/fixtures/mock-api.json @@ -71,30 +71,35 @@ "id": "br_123", "projectId": "proj_123", "name": "preview", + "role": "preview", "currentDeploymentId": "dep_123" }, { "id": "br_234", "projectId": "proj_123", "name": "pr-123", + "role": "preview", "currentDeploymentId": "dep_234" }, { "id": "br_345", "projectId": "proj_123", "name": "staging", + "role": "preview", "currentDeploymentId": null }, { "id": "br_456", "projectId": "proj_123", "name": "production", + "role": "production", "currentDeploymentId": "dep_456" }, { "id": "br_789", "projectId": "proj_456", "name": "preview", + "role": "preview", "currentDeploymentId": "dep_789" } ], diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 692d4f5..6b15594 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -37,6 +37,7 @@ interface BranchRecord { id: string; projectId: string; name: string; + role: "production" | "preview"; currentDeploymentId: string | null; } diff --git a/packages/cli/src/commands/branch/index.ts b/packages/cli/src/commands/branch/index.ts index eae7722..43f1334 100644 --- a/packages/cli/src/commands/branch/index.ts +++ b/packages/cli/src/commands/branch/index.ts @@ -1,12 +1,12 @@ import { Command } from "commander"; -import { runBranchList, runBranchShow, runBranchUse } from "../../controllers/branch"; -import { renderBranchList, renderBranchShow, renderBranchUse, serializeBranchList, serializeBranchShow } from "../../presenters/branch"; +import { runBranchList } from "../../controllers/branch"; +import { renderBranchList, serializeBranchList } from "../../presenters/branch"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags"; import { runCommand } from "../../shell/command-runner"; import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime"; -import type { BranchListResult, BranchShowResult } from "../../types/branch"; +import type { BranchListResult } from "../../types/branch"; export function createBranchCommand(runtime: CliRuntime): Command { const branch = attachCommandDescriptor(configureRuntimeCommand(new Command("branch"), runtime), "branch"); @@ -14,8 +14,6 @@ export function createBranchCommand(runtime: CliRuntime): Command { addCompactGlobalFlags(branch); branch.addCommand(createBranchListCommand(runtime)); - branch.addCommand(createBranchShowCommand(runtime)); - branch.addCommand(createBranchUseCommand(runtime)); return branch; } @@ -40,46 +38,3 @@ function createBranchListCommand(runtime: CliRuntime): Command { return command; } - -function createBranchShowCommand(runtime: CliRuntime): Command { - const command = attachCommandDescriptor(configureRuntimeCommand(new Command("show"), runtime), "branch.show"); - - addGlobalFlags(command); - - command.action(async (options) => { - await runCommand( - runtime, - "branch.show", - options as Record, - (context) => runBranchShow(context), - { - renderHuman: (context, descriptor, result) => renderBranchShow(context, descriptor, result), - renderJson: (result) => serializeBranchShow(result), - }, - ); - }); - - return command; -} - -function createBranchUseCommand(runtime: CliRuntime): Command { - const command = attachCommandDescriptor(configureRuntimeCommand(new Command("use"), runtime), "branch.use"); - - command.argument("[name]", "Branch name"); - addGlobalFlags(command); - - command.action(async (branchName: string | undefined, options) => { - await runCommand( - runtime, - "branch.use", - options as Record, - (context) => runBranchUse(context, branchName), - { - renderHuman: (context, descriptor, result) => renderBranchUse(context, descriptor, result), - renderJson: (result) => serializeBranchShow(result), - }, - ); - }); - - return command; -} diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index bfce19b..87195e0 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,20 +1,35 @@ -import { featureUnavailableError, usageError } from "../shell/errors"; +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +import { authRequiredError, CliError, workspaceRequiredError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; -import { canPrompt, type CommandContext } from "../shell/runtime"; -import type { BranchShowResult, BranchListResult } from "../types/branch"; +import type { CommandContext } from "../shell/runtime"; +import type { BranchListResult, BranchRole, BranchSummary } from "../types/branch"; import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways"; import { createBranchUseCases } from "../use-cases/branch"; +import { requireComputeAuth } from "../lib/auth/guard"; +import { resolveProjectTarget } from "../lib/project/resolution"; +import { requireAuthenticatedAuthState } from "./auth"; +import { listRealWorkspaceProjects } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; -const PREVIEW_BRANCH_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - function isRealMode(context: CommandContext): boolean { return !context.runtime.fixturePath && !context.runtime.env.PRISMA_CLI_MOCK_FIXTURE_PATH; } +interface RawBranchRecord { + id: string; + gitName: string; + role: BranchRole; +} + export async function runBranchList(context: CommandContext): Promise> { if (isRealMode(context)) { - throw branchCommandsUnavailableError(); + return { + command: "branch.list", + result: await listRealBranches(context), + warnings: [], + nextSteps: [], + }; } const useCases = createBranchUseCases(createCliUseCaseGateways(context)); @@ -28,122 +43,81 @@ export async function runBranchList(context: CommandContext): Promise> { - if (isRealMode(context)) { - throw branchCommandsUnavailableError(); +async function listRealBranches(context: CommandContext): Promise { + const authState = await requireAuthenticatedAuthState(context); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); + if (!client) { + throw authRequiredError(["prisma-cli auth login"]); } - const useCases = createBranchUseCases(createCliUseCaseGateways(context)); - const result = await useCases.show(); - - return { - command: "branch.show", - result, - warnings: [], - nextSteps: - result.branch.kind === "preview" && !result.branch.remoteState ? ["prisma-cli app deploy"] : [], - }; -} - -export async function runBranchUse( - context: CommandContext, - branchName: string | undefined, -): Promise> { - if (isRealMode(context)) { - throw branchCommandsUnavailableError(); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); } - const useCases = createBranchUseCases(createCliUseCaseGateways(context)); - const resolvedBranchName = await resolveBranchNameForUse(context, useCases, branchName); - validateBranchName(resolvedBranchName); + const target = await resolveProjectTarget({ + context, + workspace, + listProjects: () => listRealWorkspaceProjects(client, workspace, context.runtime.signal), + prompt: createSelectPromptPort(context), + remember: true, + }); - const result = await useCases.use(resolvedBranchName); - const warnings = result.branch.kind === "production" ? ["Production is protected and durable. Use with care."] : []; + const branches = await listBranches(client, target.project.id, context.runtime.signal); return { - command: "branch.use", - result, - warnings, - nextSteps: - result.branch.kind === "preview" && !result.branch.remoteState - ? ["prisma-cli branch show", "prisma-cli app deploy"] - : ["prisma-cli branch show"], + projectId: target.project.id, + projectName: target.project.name, + branches: branches.map(toBranchSummary), }; } -async function resolveBranchNameForUse( - context: CommandContext, - useCases: ReturnType, - branchName: string | undefined, -): Promise { - if (branchName) { - return branchName; - } - - if (!canPrompt(context)) { - throw branchSelectionRequiredError(); - } - - const result = await useCases.list(); - const prompt = createSelectPromptPort(context); - - return prompt.select({ - message: "Select a branch", - choices: result.branches.map((branch) => ({ - label: renderBranchChoiceLabel(branch), - value: branch.name, - })), +async function listBranches( + client: ManagementApiClient, + projectId: string, + signal: AbortSignal, +): Promise { + const { data, error, response } = await client.GET("/v1/projects/{projectId}/branches", { + params: { path: { projectId } }, + signal, }); -} - -function renderBranchChoiceLabel(branch: BranchListResult["branches"][number]): string { - const markers = []; - - if (branch.active) { - markers.push("active"); - } - - if (!branch.remoteState) { - markers.push("not created yet"); + if (error || !data) { + throw branchApiError("Failed to list branches", response, error); } - return markers.length > 0 ? `${branch.name} (${markers.join(", ")})` : branch.name; + return data.data as RawBranchRecord[]; } -function validateBranchName(branchName: string): void { - if (branchName === "production") { - return; - } - - if (PREVIEW_BRANCH_PATTERN.test(branchName)) { - return; - } - - throw usageError( - "Branch name must use the documented form", - "Branch names must be production or a lowercase preview slug such as preview or feat-auth.", - "Use production or a lowercase preview branch name with letters, numbers, and hyphens.", - ["prisma-cli branch list"], - "branch", - ); +function toBranchSummary(branch: RawBranchRecord): BranchSummary { + return { + id: branch.id, + name: branch.gitName, + role: branch.role, + envMap: branch.role, + }; } -function branchSelectionRequiredError() { - return usageError( - "Branch use requires a target in non-interactive mode", - "This command cannot prompt for branch selection in the current mode.", - "Re-run prisma-cli branch use in a TTY, or pass a branch name explicitly.", - ["prisma-cli branch list"], - "branch", - ); +interface ApiErrorBody { + error?: { + code?: string; + message?: string; + hint?: string; + }; } -function branchCommandsUnavailableError() { - return featureUnavailableError( - "Branch commands are not available in this preview", - "The current preview cannot resolve or change remote branch context yet.", - "Use prisma-cli app deploy for preview app deployment workflows.", - ["prisma-cli app deploy --app "], - "branch", - ); +function branchApiError( + summary: string, + response: Response | undefined, + error: ApiErrorBody | undefined, +): CliError { + const status = response?.status ?? 0; + return new CliError({ + code: error?.error?.code ?? "BRANCH_API_ERROR", + domain: "branch", + summary, + why: error?.error?.message ?? `The Management API returned status ${status || "unknown"}.`, + fix: error?.error?.hint ?? "Re-run with --trace for the underlying API response details.", + exitCode: 1, + nextSteps: [], + }); } diff --git a/packages/cli/src/presenters/branch.ts b/packages/cli/src/presenters/branch.ts index 3f20f48..fb48e52 100644 --- a/packages/cli/src/presenters/branch.ts +++ b/packages/cli/src/presenters/branch.ts @@ -1,145 +1,42 @@ import type { CommandDescriptor } from "../shell/command-meta"; +import { formatDescriptorLabel } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; -import type { BranchListResult, BranchShowResult } from "../types/branch"; -import { renderList, renderMutate, renderShow, serializeList } from "../output/patterns"; +import { formatColumns } from "../shell/ui"; +import type { BranchListResult } from "../types/branch"; export function renderBranchList( context: CommandContext, descriptor: CommandDescriptor, result: BranchListResult, ): string[] { - return renderList( - { - title: "Listing branches for the resolved project.", - descriptor, - parentContext: { - key: "project", - value: result.projectName ?? "not resolved", - }, - items: result.branches.map((branch) => ({ - noun: "branch", - label: branch.name, - id: branch.id, - status: branch.active ? "active" : null, - })), - emptyMessage: "No branches found.", - }, - context.ui, - ); -} + const ui = context.ui; + const lines = [`${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing branches for the resolved project.")}`, ""]; + const rail = ui.dim("│"); + lines.push(`${rail} ${ui.accent("project:")} ${result.projectName}`); + lines.push(rail); + + if (result.branches.length === 0) { + lines.push(`${rail} ${ui.dim("No branches found.")}`); + return lines; + } -export function serializeBranchList(result: BranchListResult) { - return serializeList({ - context: { - project: result.projectName ?? "not resolved", - }, - items: result.branches.map((branch) => ({ - noun: "branch", - label: branch.name, - id: branch.id, - status: branch.active ? "active" : null, - })), - }); + const widths = [ + Math.max("Name".length, ...result.branches.map((branch) => branch.name.length)), + Math.max("Role".length, ...result.branches.map((branch) => branch.role.length)), + Math.max("Env map".length, ...result.branches.map((branch) => branch.envMap.length)), + ]; + lines.push(`${rail} ${ui.accent(formatColumns(["Name", "Role", "Env map"], widths))}`); + for (const branch of result.branches) { + lines.push(`${rail} ${formatColumns([branch.name, branch.role, branch.envMap], widths)}`); + } + + return lines; } -export function serializeBranchShow(result: BranchShowResult) { +export function serializeBranchList(result: BranchListResult) { return { projectId: result.projectId, projectName: result.projectName, - branch: { - name: result.branch.name, - kind: result.branch.kind, - active: result.branch.active, - remoteState: result.branch.remoteState, - liveDeployment: result.branch.liveDeployment, - }, + branches: result.branches, }; } - -export function renderBranchShow( - context: CommandContext, - descriptor: CommandDescriptor, - result: BranchShowResult, -): string[] { - const fields: Array<{ - key: string; - value: string; - tone?: "default" | "dim" | "success" | "warning" | "error" | "link"; - }> = [ - { - key: "project", - value: result.projectName ?? "not resolved", - tone: result.projectName ? ("default" as const) : ("dim" as const), - }, - { - key: "branch", - value: result.branch.name, - tone: result.branch.active ? ("success" as const) : ("default" as const), - }, - { key: "kind", value: result.branch.kind }, - ]; - - if (result.branch.liveDeployment) { - fields.push({ - key: "status", - value: result.branch.liveDeployment.status, - tone: toneForDeploymentStatus(result.branch.liveDeployment.status), - }); - - if (result.branch.liveDeployment.url) { - fields.push({ key: "url", value: result.branch.liveDeployment.url, tone: "link" }); - } - } else if (!result.branch.remoteState) { - fields.push({ key: "remote state", value: "not created yet", tone: "dim" }); - } - - return renderShow( - { - title: "Showing the current active branch context.", - descriptor, - fields, - }, - context.ui, - ); -} - -function toneForDeploymentStatus(status: string): "success" | "warning" | "error" | "default" { - if (status === "ready" || status === "active" || status === "healthy") { - return "success"; - } - - if (status === "pending" || status === "building" || status === "starting") { - return "warning"; - } - - if (status === "failed" || status === "error") { - return "error"; - } - - return "default"; -} - -export function renderBranchUse( - context: CommandContext, - descriptor: CommandDescriptor, - result: BranchShowResult, -): string[] { - return renderMutate( - { - title: "Changing the local default branch context.", - descriptor, - context: [ - { key: "project", value: result.projectName ?? "not resolved", tone: result.projectName ? "default" : "dim" }, - { key: "branch", value: result.branch.name }, - ], - operationDescription: "Applying active branch change", - operationCount: 1, - details: ["Active branch updated in local CLI state for this repo."], - alerts: - result.branch.kind === "production" - ? [{ tone: "warning", text: "Production is protected and durable. Use with care" }] - : undefined, - }, - context.ui, - ); -} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 511dcdb..086ffa5 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -69,7 +69,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ id: "branch", path: ["prisma", "branch"], description: "View your active Platform branches", - examples: ["prisma-cli branch list", "prisma-cli branch show"], + examples: ["prisma-cli branch list"], }, { id: "git", @@ -123,18 +123,6 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "List active Platform branches for the resolved project", examples: ["prisma-cli branch list", "prisma-cli branch list --json"], }, - { - id: "branch.show", - path: ["prisma", "branch", "show"], - description: "Show the Platform branch matching your current Git branch", - examples: ["prisma-cli branch show", "prisma-cli branch show --json"], - }, - { - id: "branch.use", - path: ["prisma", "branch", "use"], - description: "Change the local default branch context.", - examples: ["prisma-cli branch use", "prisma-cli branch use production"], - }, { id: "app.build", path: ["prisma", "app", "build"], diff --git a/packages/cli/src/types/branch.ts b/packages/cli/src/types/branch.ts index d83b712..bcdf71e 100644 --- a/packages/cli/src/types/branch.ts +++ b/packages/cli/src/types/branch.ts @@ -1,36 +1,14 @@ -export type BranchKind = "preview" | "production"; - -export interface LiveDeploymentSummary { - id: string; - status: string; - url: string | null; -} +export type BranchRole = "preview" | "production"; export interface BranchSummary { id: string; name: string; - kind: BranchKind; - active: boolean; - remoteState: boolean; -} - -export interface BranchDetail { - name: string; - kind: BranchKind; - active: true; - remoteState: boolean; - liveDeployment: LiveDeploymentSummary | null; + role: BranchRole; + envMap: BranchRole; } export interface BranchListResult { - projectId: string | null; - projectName: string | null; - activeBranch: string; + projectId: string; + projectName: string; branches: BranchSummary[]; } - -export interface BranchShowResult { - projectId: string | null; - projectName: string | null; - branch: BranchDetail; -} diff --git a/packages/cli/src/use-cases/branch.ts b/packages/cli/src/use-cases/branch.ts index f83a350..9e97d2f 100644 --- a/packages/cli/src/use-cases/branch.ts +++ b/packages/cli/src/use-cases/branch.ts @@ -1,9 +1,7 @@ -import type { BranchDetail, BranchKind, BranchSummary, BranchListResult, BranchShowResult } from "../types/branch"; +import type { BranchSummary, BranchListResult } from "../types/branch"; import type { - DeploymentRecord, BranchUseCases, BranchGateway, - BranchStateGateway, ProjectGateway, ProjectStateGateway, RemoteBranchRecord, @@ -11,7 +9,6 @@ import type { interface BranchUseCaseDependencies { branchGateway: BranchGateway; - branchStateGateway: BranchStateGateway; projectGateway: ProjectGateway; projectStateGateway: ProjectStateGateway; } @@ -19,51 +16,22 @@ interface BranchUseCaseDependencies { export function createBranchUseCases(dependencies: BranchUseCaseDependencies): BranchUseCases { return { list: async (): Promise => { - const [projectId, activeBranch] = await Promise.all([ - dependencies.projectStateGateway.readRememberedProjectId(), - dependencies.branchStateGateway.readActiveBranch(), - ]); - const remoteBranches = await listRemoteBranches(dependencies.branchGateway, projectId); - const projectName = resolveProjectName(dependencies.projectGateway, projectId); - - return { - projectId, - projectName, - activeBranch, - branches: buildBranchSummaries(activeBranch, remoteBranches), - }; - }, - show: async (): Promise => { - const [projectId, activeBranch] = await Promise.all([ - dependencies.projectStateGateway.readRememberedProjectId(), - dependencies.branchStateGateway.readActiveBranch(), - ]); - const projectName = resolveProjectName(dependencies.projectGateway, projectId); - - return { - projectId, - projectName, - branch: buildBranchDetail( - dependencies.branchGateway, - projectId, - activeBranch, - ), - }; - }, - use: async (branchName: string): Promise => { - await dependencies.branchStateGateway.writeActiveBranch(branchName); - const projectId = await dependencies.projectStateGateway.readRememberedProjectId(); + if (!projectId) { + return { + projectId: "", + projectName: "not resolved", + branches: [], + }; + } + + const remoteBranches = await listRemoteBranches(dependencies.branchGateway, projectId); const projectName = resolveProjectName(dependencies.projectGateway, projectId); return { projectId, - projectName, - branch: buildBranchDetail( - dependencies.branchGateway, - projectId, - branchName, - ), + projectName: projectName ?? "not resolved", + branches: buildBranchSummaries(remoteBranches), }; }, }; @@ -88,62 +56,13 @@ async function listRemoteBranches( return branchGateway.listBranchesForProject(projectId); } -function buildBranchSummaries( - activeBranch: string, - remoteBranches: RemoteBranchRecord[], -): BranchSummary[] { - const byName = new Map(); - - for (const branch of remoteBranches) { - byName.set(branch.name, { - id: branch.id, - name: branch.name, - kind: branch.kind, - active: activeBranch === branch.name, - remoteState: true, - }); - } - - if (!byName.has(activeBranch)) { - byName.set(activeBranch, { - id: activeBranch, - name: activeBranch, - kind: toBranchKind(activeBranch), - active: true, - remoteState: false, - }); - } - - return sortBranches([...byName.values()]); -} - -function buildBranchDetail( - branchGateway: BranchGateway, - projectId: string | null, - branchName: string, -): BranchDetail { - const kind = toBranchKind(branchName); - const remoteBranch = - projectId ? branchGateway.getBranchForProject(projectId, branchName) : undefined; - - return { - name: branchName, - kind, - active: true, - remoteState: Boolean(remoteBranch), - liveDeployment: - remoteBranch && remoteBranch.currentDeploymentId - ? toLiveDeployment(branchGateway.getDeployment(remoteBranch.currentDeploymentId)) - : null, - }; -} - -function toBranchKind(name: string): BranchKind { - if (name === "production") { - return "production"; - } - - return "preview"; +function buildBranchSummaries(remoteBranches: RemoteBranchRecord[]): BranchSummary[] { + return sortBranches(remoteBranches.map((branch) => ({ + id: branch.id, + name: branch.name, + role: branch.role, + envMap: branch.role, + }))); } function sortBranches(branches: BranchSummary[]): BranchSummary[] { @@ -162,21 +81,9 @@ function sortBranches(branches: BranchSummary[]): BranchSummary[] { } function branchOrder(branch: BranchSummary): number { - if (branch.name === "production") { + if (branch.role === "production") { return 0; } return 1; } - -function toLiveDeployment(deployment: DeploymentRecord | undefined) { - if (!deployment) { - return null; - } - - return { - id: deployment.id, - status: deployment.status, - url: deployment.url, - }; -} diff --git a/packages/cli/src/use-cases/contracts.ts b/packages/cli/src/use-cases/contracts.ts index ede97ad..fd1add1 100644 --- a/packages/cli/src/use-cases/contracts.ts +++ b/packages/cli/src/use-cases/contracts.ts @@ -1,5 +1,5 @@ import type { AuthProviderId, AuthStateResult, AuthUser, AuthWorkspace } from "../types/auth"; -import type { BranchKind, BranchListResult, BranchShowResult, LiveDeploymentSummary } from "../types/branch"; +import type { BranchListResult, BranchRole } from "../types/branch"; import type { ProjectSummary } from "../types/project"; export interface ProviderInfo { @@ -20,11 +20,14 @@ export interface RemoteBranchRecord { id: string; projectId: string; name: string; - kind: BranchKind; + role: BranchRole; currentDeploymentId: string | null; } -export interface DeploymentRecord extends LiveDeploymentSummary { +export interface DeploymentRecord { + id: string; + status: string; + url: string | null; projectId: string; branch: string; } @@ -64,11 +67,6 @@ export interface SessionGateway { clearAuthSession(): Promise; } -export interface BranchStateGateway { - readActiveBranch(): Promise; - writeActiveBranch(branchName: string): Promise; -} - export interface ProjectStateGateway { readRememberedProjectId(): Promise; } @@ -110,6 +108,4 @@ export interface ProjectUseCases { export interface BranchUseCases { list(): Promise; - show(): Promise; - use(branchName: string): Promise; } diff --git a/packages/cli/src/use-cases/create-cli-gateways.ts b/packages/cli/src/use-cases/create-cli-gateways.ts index 5c5ff55..d8f6cdb 100644 --- a/packages/cli/src/use-cases/create-cli-gateways.ts +++ b/packages/cli/src/use-cases/create-cli-gateways.ts @@ -1,7 +1,6 @@ import type { CommandContext } from "../shell/runtime"; import type { BranchGateway, - BranchStateGateway, IdentityGateway, ProjectGateway, ProjectStateGateway, @@ -14,7 +13,6 @@ export interface CliUseCaseGateways { branchGateway: BranchGateway; projectStateGateway: ProjectStateGateway; sessionGateway: SessionGateway; - branchStateGateway: BranchStateGateway; } export function createCliUseCaseGateways(context: CommandContext): CliUseCaseGateways { @@ -48,18 +46,9 @@ export function createCliUseCaseGateways(context: CommandContext): CliUseCaseGat }, branchGateway: { listBranchesForProject: (projectId) => - context.api.listBranchesForProject(projectId).map((branch) => ({ - ...branch, - kind: branch.name === "production" ? "production" : "preview", - })), + context.api.listBranchesForProject(projectId), getBranchForProject: (projectId, name) => { - const branch = context.api.getBranchForProject(projectId, name); - return branch - ? { - ...branch, - kind: branch.name === "production" ? "production" : "preview", - } - : undefined; + return context.api.getBranchForProject(projectId, name); }, getDeployment: (deploymentId) => context.api.getDeployment(deploymentId), }, @@ -81,15 +70,6 @@ export function createCliUseCaseGateways(context: CommandContext): CliUseCaseGat await context.stateStore.clearAuthSession(); }, }, - branchStateGateway: { - readActiveBranch: async () => { - const state = await context.stateStore.read(); - return state.branch.active; - }, - writeActiveBranch: async (branchName) => { - await context.stateStore.setActiveBranch(branchName); - }, - }, }; } diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index 67244a2..9010c49 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -1,100 +1,113 @@ +import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { runBranchList, runBranchShow, runBranchUse } from "../src/controllers/branch"; +import { afterEach, describe, expect, it, vi } from "vitest"; + import { createTempCwd, createTestCommandContext } from "./helpers"; -const fixturePath = path.resolve("fixtures/mock-api.json"); +afterEach(() => { + vi.doUnmock("../src/lib/auth/auth-ops"); + vi.doUnmock("../src/lib/auth/guard"); + vi.resetModules(); + vi.restoreAllMocks(); +}); -describe("branch controller", () => { - it("returns FEATURE_UNAVAILABLE for branch list in preview mode", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); +function createMockClient() { + return { + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/projects") { + return { + data: { + data: [ + { + id: "proj_123", + name: "Acme Dashboard", + slug: "acme-dashboard", + workspace: { id: "ws_123", name: "Acme Inc" }, + }, + ], + }, + response: { status: 200 }, + }; + } - await expect(runBranchList(context)).rejects.toMatchObject({ - code: "FEATURE_UNAVAILABLE", - domain: "branch", - summary: "Branch commands are not available in this preview", - }); - }); + if (pathName === "/v1/projects/{projectId}/branches") { + return { + data: { + data: [ + { id: "br_main", gitName: "main", role: "production" }, + { id: "br_feature", gitName: "feature/auth", role: "preview" }, + ], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }; + } - it("returns FEATURE_UNAVAILABLE for branch show in preview mode", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); + throw new Error(`Unexpected path ${pathName}`); + }), + }; +} - await expect(runBranchShow(context)).rejects.toMatchObject({ - code: "FEATURE_UNAVAILABLE", - domain: "branch", - summary: "Branch commands are not available in this preview", - }); - }); +async function writeLocalPin(cwd: string, projectId = "proj_123") { + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile( + path.join(cwd, ".prisma/local.json"), + `${JSON.stringify({ workspaceId: "ws_123", projectId }, null, 2)}\n`, + "utf8", + ); +} - it("returns FEATURE_UNAVAILABLE for branch use in preview mode", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); +async function loadController(client: ReturnType) { + vi.resetModules(); - await expect(runBranchUse(context, "preview")).rejects.toMatchObject({ - code: "FEATURE_UNAVAILABLE", - domain: "branch", - summary: "Branch commands are not available in this preview", - }); - }); + vi.doMock("../src/lib/auth/auth-ops", () => ({ + readAuthState: vi.fn().mockResolvedValue({ + authenticated: true, + provider: null, + user: { email: "test@example.com" }, + workspace: { id: "ws_123", name: "Acme Inc" }, + credential: null, + }), + performLogin: vi.fn(), + performLogout: vi.fn(), + })); + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth: vi.fn().mockResolvedValue(client), + })); - it("returns a structured usage error when branch use cannot prompt and no target is provided", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - fixturePath, - isTTY: false, - }); + return import("../src/controllers/branch"); +} - await expect(runBranchUse(context, undefined)).rejects.toMatchObject({ - code: "USAGE_ERROR", - domain: "branch", - summary: "Branch use requires a target in non-interactive mode", - }); - }); - - it("returns a structured usage error for an invalid branch name", async () => { +describe("branch controller", () => { + it("lists real Platform branches for the resolved project", async () => { + const client = createMockClient(); + const { runBranchList } = await loadController(client); const cwd = await createTempCwd(); + await writeLocalPin(cwd); const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - fixturePath, - isTTY: false, - }); + const { context } = await createTestCommandContext({ cwd, stateDir }); + + const result = await runBranchList(context); - await expect(runBranchUse(context, "Preview Space")).rejects.toMatchObject({ - code: "USAGE_ERROR", - domain: "branch", - summary: "Branch name must use the documented form", + expect(client.GET).toHaveBeenCalledWith( + "/v1/projects/{projectId}/branches", + expect.objectContaining({ + params: { path: { projectId: "proj_123" } }, + }), + ); + expect(result).toEqual({ + command: "branch.list", + result: { + projectId: "proj_123", + projectName: "Acme Dashboard", + branches: [ + { id: "br_main", name: "main", role: "production", envMap: "production" }, + { id: "br_feature", name: "feature/auth", role: "preview", envMap: "preview" }, + ], + }, + warnings: [], + nextSteps: [], }); }); }); diff --git a/packages/cli/tests/branch-usecases.test.ts b/packages/cli/tests/branch-usecases.test.ts index 128ea6b..81da326 100644 --- a/packages/cli/tests/branch-usecases.test.ts +++ b/packages/cli/tests/branch-usecases.test.ts @@ -4,104 +4,41 @@ import { createBranchUseCases } from "../src/use-cases/branch"; import { createUseCaseGateways } from "./use-case-helpers"; describe("branch use cases", () => { - it("lists all resolved-project branches and keeps an active preview without remote state visible", async () => { + it("lists all resolved-project branches with role and env map metadata", async () => { const { gateways } = await createUseCaseGateways({ projectId: "proj_123", - activeBranch: "feat-auth", }); const useCases = createBranchUseCases(gateways); await expect(useCases.list()).resolves.toEqual({ projectId: "proj_123", projectName: "Acme Dashboard", - activeBranch: "feat-auth", branches: [ { id: "br_456", name: "production", - kind: "production", - active: false, - remoteState: true, - }, - { - id: "feat-auth", - name: "feat-auth", - kind: "preview", - active: true, - remoteState: false, + role: "production", + envMap: "production", }, { id: "br_234", name: "pr-123", - kind: "preview", - active: false, - remoteState: true, + role: "preview", + envMap: "preview", }, { id: "br_123", name: "preview", - kind: "preview", - active: false, - remoteState: true, + role: "preview", + envMap: "preview", }, { id: "br_345", name: "staging", - kind: "preview", - active: false, - remoteState: true, + role: "preview", + envMap: "preview", }, ], }); }); - - it("shows live deployment details when remote state exists", async () => { - const { gateways } = await createUseCaseGateways({ - projectId: "proj_123", - activeBranch: "preview", - }); - const useCases = createBranchUseCases(gateways); - - await expect(useCases.show()).resolves.toEqual({ - projectId: "proj_123", - projectName: "Acme Dashboard", - branch: { - name: "preview", - kind: "preview", - active: true, - remoteState: true, - liveDeployment: { - id: "dep_123", - status: "ready", - url: "https://preview.acme-dashboard.prisma.app", - }, - }, - }); - }); - - it("updates the active branch without mutating resolved project state", async () => { - const { gateways, readState } = await createUseCaseGateways({ - projectId: "proj_123", - }); - const useCases = createBranchUseCases(gateways); - - await expect(useCases.use("production")).resolves.toEqual({ - projectId: "proj_123", - projectName: "Acme Dashboard", - branch: { - name: "production", - kind: "production", - active: true, - remoteState: true, - liveDeployment: { - id: "dep_456", - status: "ready", - url: "https://acme-dashboard.prisma.app", - }, - }, - }); - - expect(readState().projectId).toBe("proj_123"); - expect(readState().activeBranch).toBe("production"); - }); }); diff --git a/packages/cli/tests/branch.test.ts b/packages/cli/tests/branch.test.ts index 801bbb5..f488834 100644 --- a/packages/cli/tests/branch.test.ts +++ b/packages/cli/tests/branch.test.ts @@ -1,10 +1,10 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; + import { describe, expect, it } from "vitest"; import stripAnsi from "strip-ansi"; import { createTempCwd, executeCli } from "./helpers"; -import { DEFAULT_STATE_DIR_NAME } from "../src/shell/runtime"; const fixturePath = path.resolve("fixtures/mock-api.json"); @@ -43,115 +43,7 @@ async function rememberProject(stateDir: string, projectId = "proj_123") { } describe("branch commands", () => { - it("returns FEATURE_UNAVAILABLE for branch list in preview mode instead of crashing", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["branch", "list", "--json"], - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "branch.list", - error: { - code: "FEATURE_UNAVAILABLE", - domain: "branch", - severity: "error", - summary: "Branch commands are not available in this preview", - why: "The current preview cannot resolve or change remote branch context yet.", - fix: "Use prisma-cli app deploy for preview app deployment workflows.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli app deploy --app "], - nextActions: [], - }); - }); - - it("returns FEATURE_UNAVAILABLE for branch show in preview mode instead of crashing", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["branch", "show", "--json"], - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "branch.show", - error: { - code: "FEATURE_UNAVAILABLE", - domain: "branch", - severity: "error", - summary: "Branch commands are not available in this preview", - why: "The current preview cannot resolve or change remote branch context yet.", - fix: "Use prisma-cli app deploy for preview app deployment workflows.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli app deploy --app "], - nextActions: [], - }); - }); - - it("returns FEATURE_UNAVAILABLE for branch use in preview mode instead of crashing", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["branch", "use", "preview", "--json"], - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "branch.use", - error: { - code: "FEATURE_UNAVAILABLE", - domain: "branch", - severity: "error", - summary: "Branch commands are not available in this preview", - why: "The current preview cannot resolve or change remote branch context yet.", - fix: "Use prisma-cli app deploy for preview app deployment workflows.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli app deploy --app "], - nextActions: [], - }); - }); - - it("renders the documented human output for branch list", async () => { + it("renders branch list with name, role, and env map", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); await rememberProject(stateDir); @@ -167,61 +59,11 @@ describe("branch commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(stripAnsi(result.stderr)).toBe( - "branch list → Listing branches for the resolved project.\n\n│ project: Acme Dashboard\n│ ⚬ branch: production\n│ ⚬ branch: pr-123\n│ ⚬ branch: preview (active)\n│ ⚬ branch: staging\n", - ); - }); - - it("shows the default preview branch context before remote state exists", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["branch", "show"], - cwd, - stateDir, - fixturePath, - isTTY: true, - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(stripAnsi(result.stderr)).toBe( - "branch show → Showing the current active branch context.\n\n│ project: not resolved\n│ branch: preview\n│ kind: preview\n│ remote state: not created yet\n", + "branch list → Listing branches for the resolved project.\n\n│ project: Acme Dashboard\n│\n│ Name Role Env map\n│ production production production\n│ pr-123 preview preview\n│ preview preview preview\n│ staging preview preview\n", ); }); - it("shows remote branch status and url without leaking deployment ids in human output", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await rememberProject(stateDir); - - await executeCli({ - argv: ["branch", "use", "preview"], - cwd, - stateDir, - fixturePath, - }); - - const result = await executeCli({ - argv: ["branch", "show"], - cwd, - stateDir, - fixturePath, - isTTY: true, - }); - const stderr = stripAnsi(result.stderr); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(stderr).toContain("branch show → Showing the current active branch context."); - expect(stderr).toContain("project: Acme Dashboard"); - expect(stderr).toContain("branch: preview"); - expect(stderr).toContain("status: ready"); - expect(stderr).toContain("url: https://preview.acme-dashboard.prisma.app"); - expect(stderr).not.toContain("dep_123"); - }); - - it("returns the shared list JSON shape for branch list", async () => { + it("returns the direct branch list JSON shape", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); await rememberProject(stateDir); @@ -238,264 +80,26 @@ describe("branch commands", () => { expect(JSON.parse(result.stdout)).toEqual({ ok: true, command: "branch.list", - result: { - context: { - project: "Acme Dashboard", - }, - items: [ - { - id: "br_456", - name: "production", - status: null, - }, - { - id: "br_234", - name: "pr-123", - status: null, - }, - { - id: "br_123", - name: "preview", - status: "active", - }, - { - id: "br_345", - name: "staging", - status: null, - }, - ], - count: 4, - }, - warnings: [], - nextSteps: [], nextActions: [], - }); - }); - - it("returns the documented JSON shape for branch show when remote state exists", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await rememberProject(stateDir); - - await executeCli({ - argv: ["branch", "use", "preview"], - cwd, - stateDir, - fixturePath, - }); - - const result = await executeCli({ - argv: ["branch", "show", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: true, - command: "branch.show", result: { projectId: "proj_123", projectName: "Acme Dashboard", - branch: { - name: "preview", - kind: "preview", - active: true, - remoteState: true, - liveDeployment: { - id: "dep_123", - status: "ready", - url: "https://preview.acme-dashboard.prisma.app", - }, - }, + branches: [ + { id: "br_456", name: "production", role: "production", envMap: "production" }, + { id: "br_234", name: "pr-123", role: "preview", envMap: "preview" }, + { id: "br_123", name: "preview", role: "preview", envMap: "preview" }, + { id: "br_345", name: "staging", role: "preview", envMap: "preview" }, + ], }, warnings: [], nextSteps: [], - nextActions: [], - }); - }); - - it("returns the documented JSON shape for branch use production", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await rememberProject(stateDir); - - const result = await executeCli({ - argv: ["branch", "use", "production", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: true, - command: "branch.use", - result: { - projectId: "proj_123", - projectName: "Acme Dashboard", - branch: { - name: "production", - kind: "production", - active: true, - remoteState: true, - liveDeployment: { - id: "dep_456", - status: "ready", - url: "https://acme-dashboard.prisma.app", - }, - }, - }, - warnings: ["Production is protected and durable. Use with care."], - nextSteps: ["prisma-cli branch show"], - nextActions: [], - }); - }); - - it("prompts for branch selection when no name is passed in interactive mode", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await rememberProject(stateDir); - - const result = await executeCli({ - argv: ["branch", "use"], - cwd, - stateDir, - fixturePath, - isTTY: true, - stdinText: "\r", - }); - const stderr = stripAnsi(result.stderr); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(stderr).toContain("branch use → Changing the local default branch context."); - expect(stderr).toContain("Select a branch"); - expect(stderr).toContain("production"); - expect(stderr).toContain("pr-123"); - expect(stderr).toContain("preview (active)"); - expect(stderr).toContain("staging"); - expect(stderr).toContain("✔ Applied 1 operation(s)"); - expect(stderr).toContain("branch: production"); - expect(JSON.parse(await readFile(path.join(stateDir, "state.json"), "utf8"))).toMatchObject({ - branch: { - active: "production", - }, - }); - }); - - it("returns a structured usage error for an invalid branch name", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["branch", "use", "Preview Space", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "branch.use", - error: { - code: "USAGE_ERROR", - domain: "branch", - severity: "error", - summary: "Branch name must use the documented form", - why: "Branch names must be production or a lowercase preview slug such as preview or feat-auth.", - fix: "Use production or a lowercase preview branch name with letters, numbers, and hyphens.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli branch list"], - nextActions: [], - }); - }); - - it("returns a structured usage error for branch use in JSON mode when no name is passed", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["branch", "use", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "branch.use", - error: { - code: "USAGE_ERROR", - domain: "branch", - severity: "error", - summary: "Branch use requires a target in non-interactive mode", - why: "This command cannot prompt for branch selection in the current mode.", - fix: "Re-run prisma-cli branch use in a TTY, or pass a branch name explicitly.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli branch list"], - nextActions: [], }); }); - it("returns a structured usage error for branch use with --no-interactive when no name is passed", async () => { + it("shows only branch list in branch help", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - const result = await executeCli({ - argv: ["branch", "use", "--no-interactive", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "branch.use", - error: { - code: "USAGE_ERROR", - domain: "branch", - severity: "error", - summary: "Branch use requires a target in non-interactive mode", - why: "This command cannot prompt for branch selection in the current mode.", - fix: "Re-run prisma-cli branch use in a TTY, or pass a branch name explicitly.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli branch list"], - nextActions: [], - }); - }); - - it("shows the documented help text for branch commands and adds branch to root help", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const rootHelp = await executeCli({ - argv: ["--help"], - cwd, - stateDir, - fixturePath, - }); const branchHelp = await executeCli({ argv: ["branch", "--help"], cwd, @@ -508,82 +112,16 @@ describe("branch commands", () => { stateDir, fixturePath, }); - const showHelp = await executeCli({ - argv: ["branch", "show", "--help"], - cwd, - stateDir, - fixturePath, - }); - const useHelp = await executeCli({ - argv: ["branch", "use", "--help"], - cwd, - stateDir, - fixturePath, - }); - - expect(rootHelp.exitCode).toBe(0); - expect(rootHelp.stderr).toContain("branch"); expect(branchHelp.exitCode).toBe(0); expect(branchHelp.stderr).toContain("View your active Platform branches"); expect(branchHelp.stderr).toContain("$ prisma-cli branch list"); - expect(branchHelp.stderr).toContain("$ prisma-cli branch show"); + expect(branchHelp.stderr).not.toContain("branch show"); + expect(branchHelp.stderr).not.toContain("branch use"); expect(listHelp.exitCode).toBe(0); expect(listHelp.stderr).toContain("List active Platform branches for the resolved project"); expect(listHelp.stderr).toContain("$ prisma-cli branch list"); expect(listHelp.stderr).toContain("$ prisma-cli branch list --json"); - - expect(showHelp.exitCode).toBe(0); - expect(showHelp.stderr).toContain("Show the Platform branch matching your current Git branch"); - expect(showHelp.stderr).toContain("$ prisma-cli branch show"); - expect(showHelp.stderr).toContain("$ prisma-cli branch show --json"); - - expect(useHelp.exitCode).toBe(0); - expect(useHelp.stderr).toContain("Change the local default branch context."); - expect(useHelp.stderr).toContain("$ prisma-cli branch use"); - expect(useHelp.stderr).toContain("$ prisma-cli branch use production"); - }); - - it("writes only local branch state and does not mutate fixture data", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await rememberProject(stateDir); - const fixtureBefore = await readFile(fixturePath, "utf8"); - - const result = await executeCli({ - argv: ["branch", "use", "feat-auth"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(await readFile(path.join(stateDir, "state.json"), "utf8"))).toMatchObject({ - branch: { - active: "feat-auth", - }, - }); - expect(await readFile(fixturePath, "utf8")).toBe(fixtureBefore); - }); - - it("uses .prisma/cli/state.json as the default local state location", async () => { - const cwd = await createTempCwd(); - - const result = await executeCli({ - argv: ["branch", "use", "feat-auth"], - cwd, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect( - JSON.parse(await readFile(path.join(cwd, DEFAULT_STATE_DIR_NAME, "state.json"), "utf8")), - ).toMatchObject({ - branch: { - active: "feat-auth", - }, - }); - expect(stripAnsi(result.stderr)).toContain("Active branch updated in local CLI state for this repo."); }); }); diff --git a/packages/cli/tests/use-case-helpers.ts b/packages/cli/tests/use-case-helpers.ts index 6f94971..41d08a9 100644 --- a/packages/cli/tests/use-case-helpers.ts +++ b/packages/cli/tests/use-case-helpers.ts @@ -9,19 +9,16 @@ const fixturePath = path.resolve("fixtures/mock-api.json"); export async function createUseCaseGateways(options?: { authSession?: AuthSessionRecord | null; projectId?: string | null; - activeBranch?: string; }): Promise<{ gateways: CliUseCaseGateways; readState: () => { authSession: AuthSessionRecord | null; projectId: string | null; - activeBranch: string; }; }> { const api = await MockApi.load(fixturePath); let authSession = options?.authSession ?? null; let projectId = options?.projectId ?? null; - let activeBranch = options?.activeBranch ?? "preview"; return { gateways: { @@ -53,20 +50,9 @@ export async function createUseCaseGateways(options?: { getProjectForWorkspace: (workspaceId, projectId) => api.getProjectForWorkspace(workspaceId, projectId), }, branchGateway: { - listBranchesForProject: (projectId) => - api.listBranchesForProject(projectId).map((branch) => ({ - ...branch, - kind: branch.name === "production" ? "production" : "preview", - })), + listBranchesForProject: (projectId) => api.listBranchesForProject(projectId), getBranchForProject: (projectId, name) => { - const branch = api.getBranchForProject(projectId, name); - - return branch - ? { - ...branch, - kind: branch.name === "production" ? "production" : "preview", - } - : undefined; + return api.getBranchForProject(projectId, name); }, getDeployment: (deploymentId) => api.getDeployment(deploymentId), }, @@ -82,17 +68,10 @@ export async function createUseCaseGateways(options?: { authSession = null; }, }, - branchStateGateway: { - readActiveBranch: async () => activeBranch, - writeActiveBranch: async (branchName) => { - activeBranch = branchName; - }, - }, }, readState: () => ({ authSession, projectId, - activeBranch, }), }; } From 594a2d81b2d3bda37cb0da5b3a8e5fbac246c87a Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 3 Jun 2026 04:35:16 +0200 Subject: [PATCH 2/3] fix: paginate platform branch list --- packages/cli/src/controllers/branch.ts | 32 +++++++++++++++----- packages/cli/tests/branch-controller.test.ts | 26 +++++++++++++--- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index 87195e0..49609ad 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -77,15 +77,33 @@ async function listBranches( projectId: string, signal: AbortSignal, ): Promise { - const { data, error, response } = await client.GET("/v1/projects/{projectId}/branches", { - params: { path: { projectId } }, - signal, - }); - if (error || !data) { - throw branchApiError("Failed to list branches", response, error); + const collected: RawBranchRecord[] = []; + let cursor: string | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const query: Record = {}; + if (cursor !== undefined) { + query.cursor = cursor; + } + + const { data, error, response } = await client.GET("/v1/projects/{projectId}/branches", { + params: { path: { projectId }, query }, + signal, + }); + if (error || !data) { + throw branchApiError("Failed to list branches", response, error); + } + + collected.push(...data.data as RawBranchRecord[]); + + if (!data.pagination.hasMore || !data.pagination.nextCursor) { + break; + } + cursor = data.pagination.nextCursor; } - return data.data as RawBranchRecord[]; + return collected; } function toBranchSummary(branch: RawBranchRecord): BranchSummary { diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index 9010c49..08de131 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -14,7 +14,7 @@ afterEach(() => { function createMockClient() { return { - GET: vi.fn().mockImplementation((pathName: string) => { + GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { query?: { cursor?: string } } }) => { if (pathName === "/v1/projects") { return { data: { @@ -32,13 +32,25 @@ function createMockClient() { } if (pathName === "/v1/projects/{projectId}/branches") { + const cursor = request?.params?.query?.cursor; + if (cursor === "cursor_2") { + return { + data: { + data: [ + { id: "br_feature", gitName: "feature/auth", role: "preview" }, + ], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }; + } + return { data: { data: [ { id: "br_main", gitName: "main", role: "production" }, - { id: "br_feature", gitName: "feature/auth", role: "preview" }, ], - pagination: { hasMore: false, nextCursor: null }, + pagination: { hasMore: true, nextCursor: "cursor_2" }, }, response: { status: 200 }, }; @@ -93,7 +105,13 @@ describe("branch controller", () => { expect(client.GET).toHaveBeenCalledWith( "/v1/projects/{projectId}/branches", expect.objectContaining({ - params: { path: { projectId: "proj_123" } }, + params: { path: { projectId: "proj_123" }, query: {} }, + }), + ); + expect(client.GET).toHaveBeenCalledWith( + "/v1/projects/{projectId}/branches", + expect.objectContaining({ + params: { path: { projectId: "proj_123" }, query: { cursor: "cursor_2" } }, }), ); expect(result).toEqual({ From 63bdf7f174391859185d569ee9635d0be498ae2b Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 3 Jun 2026 08:35:23 +0200 Subject: [PATCH 3/3] fix: address branch list review feedback --- docs/product/command-spec.md | 6 +++--- packages/cli/src/controllers/branch.ts | 19 ++++++++++++++++++- packages/cli/src/shell/command-meta.ts | 4 ++-- packages/cli/tests/branch-controller.test.ts | 4 ++-- packages/cli/tests/branch.test.ts | 4 ++-- packages/cli/tests/shell.test.ts | 2 +- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index ee29621..d4a99b8 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -533,14 +533,14 @@ prisma-cli git disconnect --json Purpose: -- list active Platform branches for the resolved project +- list Platform branches for the resolved project Behavior: - shows known remote branches for the resolved project -- shows each branch's name, role, and resolved env map +- shows each branch's name, role, and role-derived env map (`production` for `role=production`, `preview` for `role=preview`) - does not create remote state -- does not expose durability, protection, deployment, or env override annotations yet +- does not include branch-specific env overrides, durability, protection, or deployment metadata Examples: diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index 49609ad..5f98ba6 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -68,10 +68,27 @@ async function listRealBranches(context: CommandContext): Promise { + const leftRank = branchOrder(left); + const rightRank = branchOrder(right); + + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + + return left.name.localeCompare(right.name); + }); +} + +function branchOrder(branch: BranchSummary): number { + return branch.role === "production" ? 0 : 1; +} + async function listBranches( client: ManagementApiClient, projectId: string, diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 086ffa5..bcf9d18 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -68,7 +68,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ { id: "branch", path: ["prisma", "branch"], - description: "View your active Platform branches", + description: "View your Platform branches", examples: ["prisma-cli branch list"], }, { @@ -120,7 +120,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ { id: "branch.list", path: ["prisma", "branch", "list"], - description: "List active Platform branches for the resolved project", + description: "List Platform branches for the resolved project", examples: ["prisma-cli branch list", "prisma-cli branch list --json"], }, { diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index 08de131..087d86d 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -37,7 +37,7 @@ function createMockClient() { return { data: { data: [ - { id: "br_feature", gitName: "feature/auth", role: "preview" }, + { id: "br_main", gitName: "main", role: "production" }, ], pagination: { hasMore: false, nextCursor: null }, }, @@ -48,7 +48,7 @@ function createMockClient() { return { data: { data: [ - { id: "br_main", gitName: "main", role: "production" }, + { id: "br_feature", gitName: "feature/auth", role: "preview" }, ], pagination: { hasMore: true, nextCursor: "cursor_2" }, }, diff --git a/packages/cli/tests/branch.test.ts b/packages/cli/tests/branch.test.ts index f488834..cfb8854 100644 --- a/packages/cli/tests/branch.test.ts +++ b/packages/cli/tests/branch.test.ts @@ -114,13 +114,13 @@ describe("branch commands", () => { }); expect(branchHelp.exitCode).toBe(0); - expect(branchHelp.stderr).toContain("View your active Platform branches"); + expect(branchHelp.stderr).toContain("View your Platform branches"); expect(branchHelp.stderr).toContain("$ prisma-cli branch list"); expect(branchHelp.stderr).not.toContain("branch show"); expect(branchHelp.stderr).not.toContain("branch use"); expect(listHelp.exitCode).toBe(0); - expect(listHelp.stderr).toContain("List active Platform branches for the resolved project"); + expect(listHelp.stderr).toContain("List Platform branches for the resolved project"); expect(listHelp.stderr).toContain("$ prisma-cli branch list"); expect(listHelp.stderr).toContain("$ prisma-cli branch list --json"); }); diff --git a/packages/cli/tests/shell.test.ts b/packages/cli/tests/shell.test.ts index 23ff6bf..4cde6ec 100644 --- a/packages/cli/tests/shell.test.ts +++ b/packages/cli/tests/shell.test.ts @@ -95,7 +95,7 @@ describe("shell behavior", () => { expect(projectResult.stderr).toContain("Global options:"); expect(branchResult.exitCode).toBe(0); - expect(branchResult.stderr).toContain("branch → View your active Platform branches"); + expect(branchResult.stderr).toContain("branch → View your Platform branches"); expect(branchResult.stderr).toContain("Global options:"); });