Skip to content

Commit 70fc1f2

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/pro-174-version-command
# Conflicts: # docs/product/command-spec.md
2 parents e1a1e66 + 3311de0 commit 70fc1f2

18 files changed

Lines changed: 2479 additions & 23 deletions

docs/product/command-principles.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ The long-term command surface grows through workflow groups such as:
3636
- `database`
3737
- `migrate`
3838
- `app`
39+
- `git`
3940

40-
The preview implements only `auth`, `project`, `branch`, and `app`.
41+
The preview implements only `auth`, `project`, `git`, `branch`, and `app`.
4142

4243
## Stable Nouns
4344

docs/product/command-spec.md

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The preview package includes these command groups:
1111

1212
- `auth`
1313
- `project` (includes `project env` subgroup)
14+
- `git`
1415
- `branch`
1516
- `app`
1617

@@ -20,6 +21,9 @@ The preview package also includes one top-level utility command:
2021

2122
`version` is intentionally outside the workflow groups: it reports CLI build and environment state, requires no auth, no project context, and no network, and is the canonical answer to "is this CLI installed and on the build I expect?"
2223

24+
The Git repository connection slice uses the `git` group. It does not add a
25+
provider-specific `GitHub` group.
26+
2327
Out of scope for the current preview:
2428

2529
- `init`
@@ -173,7 +177,7 @@ Rules:
173177
- `cli.version` is the published package version.
174178
- `node.version` mirrors `process.version` exactly, including the leading `v`.
175179
- `os.platform` and `os.arch` mirror `process.platform` and `process.arch`.
176-
- `invocation` is best-effort and may be `null` when no signal is conclusive.
180+
- `invocation` is best-effort and falls back to `"unknown"` when no signal is conclusive.
177181

178182
Examples:
179183

@@ -308,6 +312,76 @@ prisma-cli project show --json
308312
prisma-cli project show --project proj_123 --json
309313
```
310314

315+
## `prisma-cli git connect [git-url]`
316+
317+
Purpose:
318+
319+
- connect the resolved Prisma project to a GitHub repository
320+
321+
Behavior:
322+
323+
- requires auth
324+
- resolves project context without creating projects
325+
- supports `--project <id-or-name>` for explicit project selection
326+
- if `[git-url]` is provided, parses it as a GitHub repository URL
327+
- if `[git-url]` is omitted, reads the local Git `origin` remote URL
328+
- accepts common GitHub URL forms such as:
329+
- `https://github.com/prisma/prisma-cli`
330+
- `https://github.com/prisma/prisma-cli.git`
331+
- `git@github.com:prisma/prisma-cli.git`
332+
- rejects unsupported providers with `REPO_PROVIDER_UNSUPPORTED`
333+
- stores the repository connection server-side through the Management API
334+
- does not write repository data to `prisma.config.ts`
335+
- does not create branches synchronously
336+
- when the connection is active, enables platform webhook automation to map GitHub branch activity to Prisma Branch state
337+
338+
Current backend contract:
339+
340+
- the CLI lists GitHub App installations for the authenticated workspace through the Management API
341+
- if no installation exists, the CLI creates a GitHub App install intent and returns the install URL
342+
- in interactive mode, the CLI attempts to open the install URL in the browser and waits for the installation to become available
343+
- in non-interactive or `--json` mode, the CLI exits with `REPO_INSTALLATION_REQUIRED` and includes the install URL
344+
- the CLI lists repositories visible to the installation and finds the matching `owner/repo`
345+
- if the repository is not visible to any installation, the CLI creates a GitHub App install intent and exposes the install URL
346+
- if the repository still is not visible after the installation or repository-access step, the command fails with `REPO_NOT_ACCESSIBLE`
347+
- if the project is already connected to the same repository, the command returns the existing connection without creating a duplicate
348+
- if the project is already connected to a different repository, the command fails with `REPO_ALREADY_CONNECTED`
349+
- the CLI links the project to the repository with `POST /v1/source-repositories`
350+
- the link call sends `projectId`, `provider: "github"`, `providerRepositoryId`, and `installationId`
351+
352+
Examples:
353+
354+
```bash
355+
prisma-cli git connect
356+
prisma-cli git connect git@github.com:prisma/prisma-cli.git
357+
prisma-cli git connect --project proj_123
358+
prisma-cli git connect https://github.com/prisma/prisma-cli --project proj_123
359+
```
360+
361+
## `prisma-cli git disconnect`
362+
363+
Purpose:
364+
365+
- disconnect the GitHub repository from the resolved Prisma project
366+
367+
Behavior:
368+
369+
- requires auth
370+
- resolves project context without creating projects
371+
- supports `--project <id-or-name>` for explicit project selection
372+
- removes the active server-side repository connection
373+
- stops future GitHub branch automation for that project
374+
- does not delete the resolved Prisma project
375+
- does not delete existing Branches synchronously; server-side retention rules own that behavior
376+
377+
Examples:
378+
379+
```bash
380+
prisma-cli git disconnect
381+
prisma-cli git disconnect --project proj_123
382+
prisma-cli git disconnect --json
383+
```
384+
311385
## `prisma-cli branch list`
312386

313387
Purpose:

docs/product/error-conventions.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ These codes are the minimum stable set for the MVP:
160160
- `CONFIRMATION_REQUIRED`
161161
- `REMOVE_FAILED`
162162
- `FEATURE_UNAVAILABLE`
163+
- `REPO_PROVIDER_UNSUPPORTED`
164+
- `REPO_INSTALLATION_REQUIRED`
165+
- `REPO_NOT_ACCESSIBLE`
166+
- `REPO_NOT_CONNECTED`
167+
- `REPO_ALREADY_CONNECTED`
168+
- `REPO_CONNECTION_FAILED`
163169
- `BUILD_FAILED`
164170
- `RUN_FAILED`
165171
- `DEPLOY_FAILED`
@@ -182,6 +188,12 @@ Recommended meanings:
182188
- `CONFIRMATION_REQUIRED`: command cannot continue without confirmation in the current mode
183189
- `REMOVE_FAILED`: app removal could not complete remotely
184190
- `FEATURE_UNAVAILABLE`: the command exists in the CLI model, but the current preview cannot support it yet
191+
- `REPO_PROVIDER_UNSUPPORTED`: repository connection received a non-GitHub repository URL
192+
- `REPO_INSTALLATION_REQUIRED`: repository connection needs a GitHub App installation before the project can be linked
193+
- `REPO_NOT_ACCESSIBLE`: the connected GitHub App installations do not expose the requested repository
194+
- `REPO_NOT_CONNECTED`: a command expected a project repository connection, but none exists
195+
- `REPO_ALREADY_CONNECTED`: a project already has a different GitHub repository connected
196+
- `REPO_CONNECTION_FAILED`: the Management API repository connection operation failed
185197
- `BUILD_FAILED`: build failed before a healthy deployment existed
186198
- `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully
187199
- `DEPLOY_FAILED`: deployment or post-build health failed

docs/product/output-conventions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ Current MVP commands map to patterns like this:
6868
| `auth whoami` | `show` |
6969
| `project list` | `list` |
7070
| `project show` | `show` |
71+
| `git connect` | `mutate` |
72+
| `git disconnect` | `mutate` |
7173
| `branch list` | `list` |
7274
| `branch show` | `show` |
7375
| `branch use` | `mutate` |

packages/cli/src/adapters/git.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { execFile } from "node:child_process";
2+
import { promisify } from "node:util";
3+
4+
const execFileAsync = promisify(execFile);
5+
6+
export interface GitHubRepositoryReference {
7+
provider: "github";
8+
owner: string;
9+
name: string;
10+
fullName: string;
11+
url: string;
12+
}
13+
14+
export async function readGitOriginRemote(cwd: string): Promise<string | null> {
15+
try {
16+
const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], {
17+
cwd,
18+
timeout: 5_000,
19+
});
20+
const remote = stdout.trim();
21+
return remote.length > 0 ? remote : null;
22+
} catch {
23+
return null;
24+
}
25+
}
26+
27+
export function parseGitHubRepositoryUrl(value: string): GitHubRepositoryReference | null {
28+
const input = value.trim();
29+
const shorthand = input.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
30+
31+
if (shorthand) {
32+
return toGitHubRepositoryReference(shorthand[1], shorthand[2]);
33+
}
34+
35+
let parsed: URL;
36+
try {
37+
parsed = new URL(input);
38+
} catch {
39+
return null;
40+
}
41+
42+
if (parsed.hostname !== "github.com") {
43+
return null;
44+
}
45+
46+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:" && parsed.protocol !== "ssh:") {
47+
return null;
48+
}
49+
50+
const parts = parsed.pathname.split("/").filter(Boolean);
51+
if (parts.length !== 2) {
52+
return null;
53+
}
54+
55+
const [owner, rawName] = parts;
56+
const name = rawName.endsWith(".git") ? rawName.slice(0, -4) : rawName;
57+
58+
return toGitHubRepositoryReference(owner, name);
59+
}
60+
61+
function toGitHubRepositoryReference(owner: string | undefined, name: string | undefined): GitHubRepositoryReference | null {
62+
if (!owner || !name || owner.includes("/") || name.includes("/")) {
63+
return null;
64+
}
65+
66+
return {
67+
provider: "github",
68+
owner,
69+
name,
70+
fullName: `${owner}/${name}`,
71+
url: `https://github.com/${owner}/${name}`,
72+
};
73+
}

packages/cli/src/adapters/local-state.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
22
import path from "node:path";
33

44
import type { AuthProviderId } from "../types/auth";
5+
import type { GitRepositoryConnection } from "../types/project";
56

67
export interface LocalState {
78
auth: {
@@ -12,6 +13,7 @@ export interface LocalState {
1213
project: {
1314
rememberedByWorkspace: Record<string, RememberedProjectState>;
1415
lastResolved: RememberedProjectState | null;
16+
repositoryConnectionsByProject: Record<string, GitRepositoryConnection>;
1517
};
1618
branch: {
1719
active: string;
@@ -38,6 +40,7 @@ const DEFAULT_STATE: LocalState = {
3840
project: {
3941
rememberedByWorkspace: {},
4042
lastResolved: null,
43+
repositoryConnectionsByProject: {},
4144
},
4245
branch: {
4346
active: "preview",
@@ -70,6 +73,7 @@ export class LocalStateStore {
7073
project: {
7174
rememberedByWorkspace: parsed.project?.rememberedByWorkspace ?? {},
7275
lastResolved: parsed.project?.lastResolved ?? null,
76+
repositoryConnectionsByProject: parsed.project?.repositoryConnectionsByProject ?? {},
7377
},
7478
branch: {
7579
active: parsed.branch?.active ?? DEFAULT_STATE.branch.active,
@@ -132,6 +136,28 @@ export class LocalStateStore {
132136
return state;
133137
}
134138

139+
async readRepositoryConnection(projectId: string): Promise<GitRepositoryConnection | null> {
140+
const state = await this.read();
141+
return state.project.repositoryConnectionsByProject[projectId] ?? null;
142+
}
143+
144+
async setRepositoryConnection(
145+
projectId: string,
146+
connection: GitRepositoryConnection,
147+
): Promise<LocalState> {
148+
const state = await this.read();
149+
state.project.repositoryConnectionsByProject[projectId] = connection;
150+
await this.write(state);
151+
return state;
152+
}
153+
154+
async clearRepositoryConnection(projectId: string): Promise<LocalState> {
155+
const state = await this.read();
156+
delete state.project.repositoryConnectionsByProject[projectId];
157+
await this.write(state);
158+
return state;
159+
}
160+
135161
async readSelectedApp(projectId: string): Promise<SelectedAppState | null> {
136162
const state = await this.read();
137163
return state.app.selectedByProject[projectId] ?? null;

packages/cli/src/cli.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Command, CommanderError, Option } from "commander";
55
import { createAppCommand } from "./commands/app";
66
import { createAuthCommand } from "./commands/auth";
77
import { createBranchCommand } from "./commands/branch";
8-
8+
import { createGitCommand } from "./commands/git";
99
import { createProjectCommand } from "./commands/project";
1010
import { createVersionCommand } from "./commands/version";
1111
import { runVersion } from "./controllers/version";
@@ -67,8 +67,9 @@ export function createProgram(runtime: CliRuntime): Command {
6767

6868
program.addCommand(createVersionCommand(runtime));
6969
program.addCommand(createAuthCommand(runtime));
70-
program.addCommand(createBranchCommand(runtime));
7170
program.addCommand(createProjectCommand(runtime));
71+
program.addCommand(createGitCommand(runtime));
72+
program.addCommand(createBranchCommand(runtime));
7273
program.addCommand(createAppCommand(runtime));
7374

7475
return program;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Command } from "commander";
2+
3+
import { runGitConnect, runGitDisconnect } from "../../controllers/project";
4+
import { renderGitConnect, renderGitDisconnect } from "../../presenters/project";
5+
import { runCommand } from "../../shell/command-runner";
6+
import { attachCommandDescriptor } from "../../shell/command-meta";
7+
import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags";
8+
import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime";
9+
import type { ProjectRepositoryConnectionResult } from "../../types/project";
10+
11+
export function createGitCommand(runtime: CliRuntime): Command {
12+
const git = attachCommandDescriptor(configureRuntimeCommand(new Command("git"), runtime), "git");
13+
14+
addCompactGlobalFlags(git);
15+
16+
git.addCommand(createGitConnectCommand(runtime));
17+
git.addCommand(createGitDisconnectCommand(runtime));
18+
19+
return git;
20+
}
21+
22+
function createGitConnectCommand(runtime: CliRuntime): Command {
23+
const command = attachCommandDescriptor(configureRuntimeCommand(new Command("connect"), runtime), "git.connect");
24+
25+
command.argument("[git-url]", "GitHub repository URL");
26+
command.option("--project <id-or-name>", "Project id or name");
27+
addGlobalFlags(command);
28+
29+
command.action(async (gitUrl: string | undefined, options) => {
30+
await runCommand<ProjectRepositoryConnectionResult>(
31+
runtime,
32+
"git.connect",
33+
options as Record<string, unknown>,
34+
(context) => runGitConnect(context, gitUrl, {
35+
project: typeof options.project === "string" ? options.project : undefined,
36+
}),
37+
{
38+
renderHuman: (context, descriptor, result) => renderGitConnect(context, descriptor, result),
39+
},
40+
);
41+
});
42+
43+
return command;
44+
}
45+
46+
function createGitDisconnectCommand(runtime: CliRuntime): Command {
47+
const command = attachCommandDescriptor(configureRuntimeCommand(new Command("disconnect"), runtime), "git.disconnect");
48+
49+
command.option("--project <id-or-name>", "Project id or name");
50+
addGlobalFlags(command);
51+
52+
command.action(async (options) => {
53+
await runCommand<ProjectRepositoryConnectionResult>(
54+
runtime,
55+
"git.disconnect",
56+
options as Record<string, unknown>,
57+
(context) => runGitDisconnect(context, {
58+
project: typeof options.project === "string" ? options.project : undefined,
59+
}),
60+
{
61+
renderHuman: (context, descriptor, result) => renderGitDisconnect(context, descriptor, result),
62+
},
63+
);
64+
});
65+
66+
return command;
67+
}

0 commit comments

Comments
 (0)