diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 2d95c5219f7..c9db31837e0 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -880,6 +880,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, pr: null, }); }), @@ -909,6 +910,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, pr: null, }); }), @@ -1671,6 +1673,41 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("pushes existing commits without committing dirty worktree changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "push-dirty.txt"), "push dirty\n"); + yield* runGit(repoDir, ["add", "push-dirty.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]); + fs.mkdirSync(path.join(repoDir, ".vercel")); + fs.writeFileSync(path.join(repoDir, ".vercel", "project.json"), "{}\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "push", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect( + yield* runGit(repoDir, ["status", "--porcelain"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toContain("?? .vercel/"); + expect( + yield* runGit(remoteDir, ["log", "-1", "--pretty=%s", "feature/push-dirty"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toBe("Push dirty branch"); + }), + ); + it.effect("create_pr pushes a clean branch before creating the PR when needed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2417,6 +2454,113 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "restores same-repository upstream tracking after local PR checkout without a remote ref", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]); + fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n"); + yield* runGit(repoDir, ["add", "upstream.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/pr-local-upstream", + ]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 65, + title: "Local upstream PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/65", + baseRefName: "main", + headRefName: "feature/pr-local-upstream", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + repositoryCloneUrls: { + "pingdotgg/codething-mvp": { + url: remoteDir, + sshUrl: remoteDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "65", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-upstream"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("origin/feature/pr-local-upstream"); + }), + ); + + it.effect( + "restores same-repository upstream tracking when provider omits head repository metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]); + fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n"); + yield* runGit(repoDir, ["add", "no-head-repo.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/pr-local-no-head-repo", + ]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 66, + title: "Local upstream PR without repo metadata", + url: "https://github.com/pingdotgg/codething-mvp/pull/66", + baseRefName: "main", + headRefName: "feature/pr-local-no-head-repo", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "66", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-no-head-repo"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("origin/feature/pr-local-no-head-repo"); + }), + ); + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index abebc70d02b..b77ee5169b1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -568,6 +568,22 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { localBranch = pullRequest.headBranch, ) { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository !== true) { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + yield* gitCore.fetchRemoteTrackingBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + return; + } + if (repositoryNameWithOwner.length === 0) { return; } @@ -588,6 +604,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { url: remoteUrl, }); + yield* gitCore.fetchRemoteTrackingBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + }); yield* gitCore.setBranchUpstream({ cwd, branch: localBranch, @@ -690,6 +711,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, } satisfies GitStatusDetails; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { const details = yield* gitCore @@ -748,6 +770,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, + aheadOfDefaultCount: details.aheadOfDefaultCount, pr, } satisfies VcsStatusRemoteResult; }); @@ -1593,12 +1616,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { "Feature-branch checkout is only supported for commit actions.", ); } - if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit or stash local changes before pushing.", - ); - } if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { return yield* gitManagerError( "runStackedAction", diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 1357ae249a9..bc0624beafb 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -65,6 +65,7 @@ describe("GitWorkflowService", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, pr: null, }); }).pipe( diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 99f0cc2a6d3..70ab6eecf1f 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -112,6 +112,7 @@ function nonRepositoryStatus(): VcsStatusResult { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, pr: null, }; } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4b31543e594..e35f4572a0b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,7 @@ import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; @@ -165,7 +166,7 @@ const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge( SourceControlProviderRegistry.layer.pipe( - Layer.provide(GitHubCli.layer), + Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), Layer.provideMerge(VcsDriverRegistryLayerLive), ), ), diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts new file mode 100644 index 00000000000..5eb6276e3a2 --- /dev/null +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -0,0 +1,266 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, expect, vi } from "vitest"; + +import { VcsProcessExitError } from "@t3tools/contracts"; + +import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts"; +import * as GitLabCli from "./GitLabCli.ts"; + +const mockedRun = vi.fn(); +const layer = it.layer( + GitLabCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess)({ + run: mockedRun, + }), + ), + ), +); + +function processOutput(stdout: string): VcsProcessOutput { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +afterEach(() => { + mockedRun.mockReset(); +}); + +layer("GitLabCli.layer", (it) => { + it.effect("parses merge request view output", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + iid: 42, + title: "Add MR thread creation", + web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + target_branch: "main", + source_branch: "feature/mr-threads", + state: "opened", + source_project_id: 101, + target_project_id: 100, + source_project: { + path_with_namespace: "octocat/t3code", + }, + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getMergeRequest({ + cwd: "/repo", + reference: "42", + }); + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add MR thread creation", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/mr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/t3code", + headRepositoryOwnerLogin: "octocat", + }); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["mr", "view", "42", "--output", "json"], + }), + ); + }), + ); + + it.effect("skips invalid entries when parsing MR lists", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify([ + { + iid: 0, + title: "invalid", + web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0", + target_branch: "main", + source_branch: "feature/invalid", + }, + { + iid: 43, + title: " Valid MR ", + web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ", + target_branch: " main ", + source_branch: " feature/mr-list ", + state: "merged", + }, + ]), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.listMergeRequests({ + cwd: "/repo", + headSelector: "feature/mr-list", + state: "all", + }); + }); + + assert.deepStrictEqual(result, [ + { + number: 43, + title: "Valid MR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43", + baseRefName: "main", + headRefName: "feature/mr-list", + state: "merged", + }, + ]); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "mr", + "list", + "--source-branch", + "feature/mr-list", + "--all", + "--per-page", + "20", + "--output", + "json", + ], + }), + ); + }), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + path_with_namespace: "octocat/t3code", + web_url: "https://gitlab.com/octocat/t3code", + http_url_to_repo: "https://gitlab.com/octocat/t3code.git", + ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/t3code", + }); + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/t3code", + url: "https://gitlab.com/octocat/t3code.git", + sshUrl: "git@gitlab.com:octocat/t3code.git", + }); + }), + ); + + it.effect("creates merge requests through the GitLab API without placing the body in argv", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.createMergeRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider MR", + bodyFile: "/tmp/t3-mr-body.md", + }); + + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "api", + "--method", + "POST", + "projects/:fullpath/merge_requests", + "--raw-field", + "source_branch=feature/provider", + "--raw-field", + "target_branch=main", + "--raw-field", + "title=Provider MR", + "--field", + "description=@/tmp/t3-mr-body.md", + ], + }), + ); + }), + ); + + it.effect("does not pass unsupported force flags when checking out merge requests", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.checkoutMergeRequest({ + cwd: "/repo", + reference: "42", + force: true, + }); + + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["mr", "checkout", "42"], + }), + ); + }), + ); + + it.effect("surfaces a friendly error when the merge request is not found", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab mr view 4888", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 merge request not found", + }), + ), + ); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getMergeRequest({ + cwd: "/repo", + reference: "4888", + }); + }).pipe(Effect.flip); + + assert.equal(error.message.includes("Merge request not found"), true); + }), + ); +}); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts new file mode 100644 index 00000000000..04c597b0e7b --- /dev/null +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -0,0 +1,368 @@ +import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; + +import { TrimmedNonEmptyString } from "@t3tools/contracts"; + +import { + decodeGitLabMergeRequestJson, + decodeGitLabMergeRequestListJson, + formatGitLabJsonDecodeError, +} from "./gitLabMergeRequests.ts"; +import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export interface GitLabMergeRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly updatedAt?: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitLabRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface GitLabCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitLabCliError>; + + readonly getMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createMergeRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class GitLabCli extends Context.Service()( + "t3/source-control/GitLabCli", +) {} + +function isVcsProcessSpawnError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "VcsProcessSpawnError" + ); +} + +function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { + if (error instanceof Error) { + if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) { + return new GitLabCliError({ + operation, + detail: "GitLab CLI (`glab`) is required but not available on PATH.", + cause: error, + }); + } + + const lower = error.message.toLowerCase(); + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("glab auth login") || + lower.includes("token") + ) { + return new GitLabCliError({ + operation, + detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("merge request not found") || + lower.includes("not found") || + lower.includes("404") + ) { + return new GitLabCliError({ + operation, + detail: "Merge request not found. Check the MR number or URL and try again.", + cause: error, + }); + } + + return new GitLabCliError({ + operation, + detail: `GitLab CLI command failed: ${error.message}`, + cause: error, + }); + } + + return new GitLabCliError({ + operation, + detail: "GitLab CLI command failed.", + cause: error, + }); +} + +const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ + path_with_namespace: TrimmedNonEmptyString, + web_url: TrimmedNonEmptyString, + http_url_to_repo: TrimmedNonEmptyString, + ssh_url_to_repo: TrimmedNonEmptyString, +}); + +const RawGitLabDefaultBranchSchema = Schema.Struct({ + default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): GitLabRepositoryCloneUrls { + return { + nameWithOwner: raw.path_with_namespace, + url: raw.http_url_to_repo || raw.web_url, + sshUrl: raw.ssh_url_to_repo, + }; +} + +function decodeGitLabJson( + raw: string, + schema: S, + operation: "getRepositoryCloneUrls" | "getDefaultBranch", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new GitLabCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { + switch (state) { + case "open": + return []; + case "closed": + return ["--closed"]; + case "merged": + return ["--merged"]; + case "all": + return ["--all"]; + } +} + +function normalizeHeadSelector(headSelector: string): string { + const trimmed = headSelector.trim(); + const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed); + return ownerBranch?.[1]?.trim() || trimmed; +} + +function sourceRefName(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeHeadSelector(input.headSelector); +} + +function sourceProjectIdentifier(source: SourceControlRefSelector | undefined): string | null { + return source?.repository ?? source?.owner ?? null; +} + +function toSummaryWithOptionalUpdatedAt( + record: GitLabMergeRequestSummary & { + readonly updatedAt: Option.Option; + }, +): GitLabMergeRequestSummary { + const { updatedAt, ...summary } = record; + return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; +} + +export const make = Effect.fn("makeGitLabCli")(function* () { + const process = yield* VcsProcess; + + const execute: GitLabCliShape["execute"] = (input) => + process + .run({ + operation: "GitLabCli.execute", + command: "glab", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); + + return GitLabCli.of({ + execute, + listMergeRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "mr", + "list", + "--source-branch", + sourceRefName(input), + ...stateArgs(input.state), + "--per-page", + String(input.limit ?? 20), + "--output", + "json", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabCliError({ + operation: "listMergeRequests", + detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)); + }), + ), + ), + ), + getMergeRequest: (input) => + execute({ + cwd: input.cwd, + args: ["mr", "view", input.reference, "--output", "json"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabCliError({ + operation: "getMergeRequest", + detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success)); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["api", `projects/${encodeURIComponent(input.repository)}`], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabJson( + raw, + RawGitLabRepositoryCloneUrlsSchema, + "getRepositoryCloneUrls", + "GitLab CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createMergeRequest: (input) => { + const sourceProject = sourceProjectIdentifier(input.source); + return execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + "projects/:fullpath/merge_requests", + "--raw-field", + `source_branch=${sourceRefName(input)}`, + "--raw-field", + `target_branch=${input.target?.refName ?? input.baseBranch}`, + ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []), + "--raw-field", + `title=${input.title}`, + "--field", + `description=@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid); + }, + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["api", "projects/:fullpath"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabJson( + raw, + RawGitLabDefaultBranchSchema, + "getDefaultBranch", + "GitLab CLI returned invalid repository JSON.", + ), + ), + Effect.map((value) => value.default_branch ?? null), + ), + checkoutMergeRequest: (input) => + execute({ + cwd: input.cwd, + args: ["mr", "checkout", input.reference], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitLabCli, make()); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts new file mode 100644 index 00000000000..7773a3b9056 --- /dev/null +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -0,0 +1,105 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { GitLabCli, type GitLabCliShape } from "./GitLabCli.ts"; +import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; + +function makeProvider(gitlab: Partial) { + return GitLabSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitLabCli)(gitlab))); +} + +it.effect("maps GitLab MR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getMergeRequest: () => + Effect.succeed({ + number: 42, + title: "Add GitLab provider", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "gitlab", + number: 42, + title: "Add GitLab provider", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("lists GitLab MRs through provider-neutral input names", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listMergeRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + }), +); + +it.effect("creates GitLab MRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + createMergeRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider MR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + source: { + owner: "owner", + refName: "feature/provider", + }, + title: "Provider MR", + bodyFile: "/tmp/body.md", + }); + }), +); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts new file mode 100644 index 00000000000..f61f1f5926c --- /dev/null +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -0,0 +1,106 @@ +import { Effect, Layer, Option } from "effect"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; +import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; + +function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "gitlab", + operation, + detail: cause.detail, + cause, + }); +} + +function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { + return { + provider: "gitlab", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: summary.updatedAt ?? Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +function sourceFromInput(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): SourceControlRefSelector | undefined { + if (input.source) { + return input.source; + } + + const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + const owner = match?.[1]?.trim(); + const refName = match?.[2]?.trim(); + return owner && refName ? { owner, refName } : undefined; +} + +export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { + const gitlab = yield* GitLabCli; + + return SourceControlProvider.of({ + kind: "gitlab", + listChangeRequests: (input) => { + const source = sourceFromInput(input); + return gitlab + .listMergeRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + }, + getChangeRequest: (input) => + gitlab.getMergeRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => { + const source = sourceFromInput(input); + return gitlab + .createMergeRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, + getRepositoryCloneUrls: (input) => + gitlab + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + getDefaultBranch: (input) => + gitlab + .getDefaultBranch(input) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + gitlab + .checkoutMergeRequest(input) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 53ab4806593..501edff9886 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -94,7 +94,7 @@ Logged in to github.com account juliusmarminge (keyring) }, { kind: "gitlab", - implemented: false, + implemented: true, status: "missing", auth: "unknown", account: Option.none(), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 7d8b5bc2d8c..1e94a090015 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -83,7 +83,7 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ versionArgs: ["--version"], authArgs: ["auth", "status"], parseAuth: parseGitLabAuth, - implemented: false, + implemented: true, installHint: "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", }, diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 23cdc3e1fd2..fbfe8a36892 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { DateTime, Effect, Layer, Option } from "effect"; import { GitHubCli } from "./GitHubCli.ts"; +import { GitLabCli } from "./GitLabCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; @@ -50,7 +51,9 @@ function makeRegistry(input: { }); return SourceControlProviderRegistry.make().pipe( - Effect.provide(Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}))), + Effect.provide( + Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})), + ), ); } @@ -78,27 +81,16 @@ it.effect("routes directly by provider kind for remote-first workflows", () => }), ); -it.effect( - "detects GitLab remotes and returns an unsupported provider until one is registered", - () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "gitlab"); - const error = yield* Effect.flip( - provider.listChangeRequests({ - cwd: "/repo", - headSelector: "feature/source-control", - state: "open", - }), - ); - - assert.strictEqual(error.provider, "gitlab"); - }), +it.effect("routes GitLab remotes to the GitLab provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "gitlab"); + }), ); it.effect("falls back to a non-origin remote when origin is not configured", () => diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index bddbca15ee4..3ead930d38d 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -9,6 +9,7 @@ import { type SourceControlProviderShape, } from "./SourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; @@ -151,11 +152,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { const github = yield* GitHubSourceControlProvider.make(); + const gitlab = yield* GitLabSourceControlProvider.make(); return yield* makeWithProviders([ { kind: "github", provider: github, }, + { + kind: "gitlab", + provider: gitlab, + }, ]); }); diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts new file mode 100644 index 00000000000..d8245d3249a --- /dev/null +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -0,0 +1,148 @@ +import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGitLabMergeRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +const GitLabProjectReferenceSchema = Schema.Struct({ + path_with_namespace: Schema.optional(Schema.String), + pathWithNamespace: Schema.optional(Schema.String), + namespace: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.String), + full_path: Schema.optional(Schema.String), + fullPath: Schema.optional(Schema.String), + }), + ), + ), +}); + +const GitLabMergeRequestSchema = Schema.Struct({ + iid: PositiveInt, + title: TrimmedNonEmptyString, + web_url: TrimmedNonEmptyString, + source_branch: TrimmedNonEmptyString, + target_branch: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), + target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), + source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), + target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeGitLabMergeRequestState( + state: string | null | undefined, +): "open" | "closed" | "merged" { + const normalized = state?.trim().toLowerCase(); + if (normalized === "merged") { + return "merged"; + } + if (normalized === "closed") { + return "closed"; + } + return "open"; +} + +function projectPathWithNamespace( + project: Schema.Schema.Type | null | undefined, +): string | null { + const explicit = + trimOptionalString(project?.path_with_namespace) ?? + trimOptionalString(project?.pathWithNamespace); + if (explicit) { + return explicit; + } + + const namespacePath = + trimOptionalString(project?.namespace?.full_path) ?? + trimOptionalString(project?.namespace?.fullPath) ?? + trimOptionalString(project?.namespace?.path); + return namespacePath; +} + +function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null { + const [owner] = pathWithNamespace?.split("/") ?? []; + return trimOptionalString(owner); +} + +function normalizeGitLabMergeRequestRecord( + raw: Schema.Schema.Type, +): NormalizedGitLabMergeRequestRecord { + const sourceProjectPath = projectPathWithNamespace(raw.source_project); + const targetProjectPath = projectPathWithNamespace(raw.target_project); + const isCrossRepository = + typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number" + ? raw.source_project_id !== raw.target_project_id + : sourceProjectPath !== null && targetProjectPath !== null + ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase() + : undefined; + const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath); + + return { + number: raw.iid, + title: raw.title, + url: raw.web_url, + baseRefName: raw.target_branch, + headRefName: raw.source_branch, + state: normalizeGitLabMergeRequestState(raw.state), + updatedAt: raw.updated_at ?? Option.none(), + ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), + ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); +const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); + +export const formatGitLabJsonDecodeError = formatSchemaError; + +export function decodeGitLabMergeRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeGitLabMergeRequestList(raw); + if (Result.isSuccess(result)) { + const mergeRequests: NormalizedGitLabMergeRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeGitLabMergeRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value)); + } + return Result.succeed(mergeRequests); + } + return Result.fail(result.failure); +} + +export function decodeGitLabMergeRequestJson( + raw: string, +): Result.Result> { + const result = decodeGitLabMergeRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeGitLabMergeRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 0cc73da5e67..661490b71ad 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -55,6 +55,7 @@ export interface GitStatusDetails { hasUpstream: boolean; aheadCount: number; behindCount: number; + aheadOfDefaultCount: number; } export interface GitPreparedCommitContext { @@ -133,6 +134,12 @@ export interface GitFetchRemoteBranchInput { localBranch: string; } +export interface GitFetchRemoteTrackingBranchInput { + cwd: string; + remoteName: string; + remoteBranch: string; +} + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -176,9 +183,13 @@ export interface GitVcsDriverShape { input: GitFetchPullRequestBranchInput, ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; + readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly fetchRemoteBranch: ( input: GitFetchRemoteBranchInput, ) => Effect.Effect; + readonly fetchRemoteTrackingBranch: ( + input: GitFetchRemoteTrackingBranchInput, + ) => Effect.Effect; readonly setBranchUpstream: ( input: GitSetBranchUpstreamInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 38472fd5b0d..aa15bff76b8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -102,6 +102,51 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }), ); + + it.effect("reports default-branch delta separately from upstream delta", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/synced"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + yield* git(cwd, ["push", "-u", "origin", "feature/synced"]); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.hasUpstream, true); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + assert.equal(status.aheadOfDefaultCount, 1); + }), + ); + + it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/no-upstream"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 1); + assert.equal(status.behindCount, 0); + assert.equal(status.aheadOfDefaultCount, 1); + }), + ); }); describe("refName operations", () => { @@ -242,5 +287,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); }), ); + + it.effect( + "pushes upstream branches to the remote branch name, not the upstream shorthand", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add upstream update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "main", + upstreamBranch: "origin/main", + setUpstream: false, + }); + assert.equal( + yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), + "Add upstream update", + ); + const badBranch = yield* driver.execute({ + operation: "GitVcsDriver.test.showBadRemoteBranch", + cwd: remote, + args: ["show-ref", "--verify", "--quiet", "refs/heads/origin/main"], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + assert.notEqual(badBranch.exitCode, 0); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3ba1c27981e..dd71d53f906 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -62,6 +62,7 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, }); type TraceTailState = { @@ -969,6 +970,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map(parseRemoteNamesInGitOrder), ); + const resolvePublishBranchName = Effect.fn("resolvePublishBranchName")(function* ( + cwd: string, + branchName: string, + ) { + const remoteNames = yield* listRemoteNames(cwd).pipe(Effect.catch(() => Effect.succeed([]))); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(branchName, remoteNames); + return parsedRemoteRef?.branchName ?? branchName; + }); + const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { if (yield* originRemoteExists(cwd)) { return "origin"; @@ -1213,6 +1223,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let upstreamRef: string | null = null; let aheadCount = 0; let behindCount = 0; + let aheadOfDefaultCount = 0; let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set(); @@ -1241,13 +1252,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - if (!upstreamRef && refName) { - aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe( - Effect.catch(() => Effect.succeed(0)), - ); + const fallbackAheadCount = + !upstreamRef && refName + ? yield* computeAheadCountAgainstBase(cwd, refName).pipe( + Effect.catch(() => Effect.succeed(0)), + ) + : null; + + if (fallbackAheadCount !== null) { + aheadCount = fallbackAheadCount; behindCount = 0; } + const isDefaultBranch = + refName !== null && + (refName === defaultBranch || + (defaultBranch === null && (refName === "main" || refName === "master"))); + if (refName && !isDefaultBranch) { + aheadOfDefaultCount = + fallbackAheadCount !== null + ? fallbackAheadCount + : yield* computeAheadCountAgainstBase(cwd, refName).pipe( + Effect.catch(() => Effect.succeed(0)), + ); + } + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); const fileStatMap = new Map(); @@ -1277,10 +1306,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { isRepo: true, hasOriginRemote: hasPrimaryRemote, - isDefaultBranch: - refName !== null && - (refName === defaultBranch || - (defaultBranch === null && (refName === "main" || refName === "master"))), + isDefaultBranch, branch: refName, upstreamRef, hasWorkingTreeChanges, @@ -1292,6 +1318,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* hasUpstream: upstreamRef !== null, aheadCount, behindCount, + aheadOfDefaultCount, }; }); @@ -1323,6 +1350,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, + aheadOfDefaultCount: details.aheadOfDefaultCount, pr: null, })), ); @@ -1461,16 +1489,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "Cannot push because no git remote is configured for this repository.", ); } + const publishBranch = yield* resolvePublishBranchName(cwd, branch); yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ "push", "-u", publishRemoteName, - `HEAD:refs/heads/${branch}`, + `HEAD:refs/heads/${publishBranch}`, ]); return { status: "pushed" as const, branch, - upstreamBranch: `${publishRemoteName}/${branch}`, + upstreamBranch: `${publishRemoteName}/${publishBranch}`, setUpstream: true, }; } @@ -1482,7 +1511,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ "push", currentUpstream.remoteName, - `HEAD:${currentUpstream.upstreamRef}`, + `HEAD:refs/heads/${currentUpstream.branchName}`, ]); return { status: "pushed" as const, @@ -1894,6 +1923,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const fetchRemoteTrackingBranch: GitVcsDriverShape["fetchRemoteTrackingBranch"] = Effect.fn( + "fetchRemoteTrackingBranch", + )(function* (input) { + yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + }); + const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) => runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ "branch", @@ -2075,7 +2116,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* createWorktree, fetchPullRequestBranch, ensureRemote, + resolvePrimaryRemoteName, fetchRemoteBranch, + fetchRemoteTrackingBranch, setBranchUpstream, removeWorktree, renameBranch, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 08c85e3649f..ac026e36b0a 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -280,6 +280,26 @@ describe("when: source control provider uses merge requests", () => { }); describe("when: ref is clean, up to date, and has no open PR", () => { + it("enables create PR when synced with upstream but ahead of default", () => { + const syncedFeature = status({ + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 1, + pr: null, + }); + + const quick = resolveQuickAction(syncedFeature, false); + assert.deepInclude(quick, { + label: "Create PR", + disabled: false, + kind: "run_action", + action: "create_pr", + }); + + const items = buildMenuItems(syncedFeature, false); + assert.equal(items.find((item) => item.id === "pr")?.disabled, false); + }); + it("resolveQuickAction returns disabled no-action state", () => { const quick = resolveQuickAction( status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), @@ -444,6 +464,48 @@ describe("when: working tree has local changes", () => { }, ]); }); + + it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => { + const items = buildMenuItems( + status({ + refName: "feature/test", + hasWorkingTreeChanges: true, + aheadCount: 1, + workingTree: { + files: [{ path: ".vercel/project.json", insertions: 1, deletions: 0 }], + insertions: 1, + deletions: 0, + }, + }), + false, + ); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: false, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: false, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); }); describe("when: on default ref without open PR", () => { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 70ca9decb63..6ce9eb9ed37 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -103,12 +103,12 @@ export function buildMenuItems( const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; + const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; const canPush = !isBusy && hasBranch && - !hasChanges && !isBehind && gitStatus.aheadCount > 0 && (gitStatus.hasUpstream || canPushWithoutUpstream); @@ -117,7 +117,7 @@ export function buildMenuItems( hasBranch && !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + hasDefaultBranchDelta && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -181,6 +181,7 @@ export function resolveQuickAction( const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; + const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; const terminology = resolveChangeRequestTerminology(gitStatus); @@ -286,6 +287,15 @@ export function resolveQuickAction( return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } + if (hasDefaultBranchDelta && !isDefaultRef) { + return { + label: `Create ${terminology.shortLabel}`, + disabled: false, + kind: "run_action", + action: "create_pr", + }; + } + return { label: "Commit", disabled: true, diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 38da4abf6a4..4ff01efa4dd 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -15,31 +15,180 @@ export const GitHubIcon: Icon = (props) => ( ); -export const GitLabIcon: Icon = (props) => ( - - - - +export const GitIcon: Icon = (props) => ( + + ); -export const AzureDevOpsIcon: Icon = (props) => ( - - - - -); +export const JujutsuIcon: Icon = (props) => { + const groupId = `${useId().replaceAll(":", "")}-jj-a`; -export const BitbucketIcon: Icon = (props) => ( - - + return ( + + + + + + + + + + + + + + + + + ); +}; + +export const GitLabIcon: Icon = (props) => ( + + + + ); +export const AzureDevOpsIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const gradientA = `${id}-azure-a`; + const gradientB = `${id}-azure-b`; + const gradientC = `${id}-azure-c`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const BitbucketIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const gradientId = `${id}-bitbucket-a`; + + return ( + + + + + + + + + + ); +}; + export const CursorIcon: Icon = ({ className, ...props }) => ( ( - - - - - - -); - -const AzureDevOpsIcon: Icon = (props) => { - const id = useId().replaceAll(":", ""); - const gradientA = `${id}-azure-a`; - const gradientB = `${id}-azure-b`; - const gradientC = `${id}-azure-c`; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -const BitbucketIcon: Icon = (props) => { - const id = useId().replaceAll(":", ""); - const gradientId = `${id}-bitbucket-a`; - - return ( - - - - - - - - - - ); -}; - const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { github: GitHubIcon, gitlab: GitLabIcon, @@ -164,6 +52,11 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { + git: GitIcon, + jj: JujutsuIcon, +}; + function optionLabel(value: Option.Option): string | null { return Option.getOrNull(value); } @@ -211,7 +104,9 @@ function SourceControlItemMark({ readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; }) { const dotClassName = itemStatusDot(item); - const Icon = isProviderDiscoveryItem(item) ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] : null; + const Icon = isProviderDiscoveryItem(item) + ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] + : VCS_ICONS[item.kind]; if (!Icon) { return ; @@ -342,7 +237,13 @@ function SourceControlSectionSkeleton({
- + + + +
diff --git a/apps/web/src/rpc/wsRpcClient.test.ts b/apps/web/src/rpc/wsRpcClient.test.ts index f8fb4cecb19..f1c0d06bc0b 100644 --- a/apps/web/src/rpc/wsRpcClient.test.ts +++ b/apps/web/src/rpc/wsRpcClient.test.ts @@ -83,6 +83,7 @@ describe("wsRpcClient", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, pr: null, }, ], diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index dd12e749da4..b647f24e6ba 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -219,6 +219,7 @@ const VcsStatusRemoteShape = { hasUpstream: Schema.Boolean, aheadCount: NonNegativeInt, behindCount: NonNegativeInt, + aheadOfDefaultCount: Schema.optional(NonNegativeInt), pr: Schema.NullOr(VcsStatusChangeRequest), }; diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 02d9c61d6aa..1ad17f52bd4 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -202,6 +202,7 @@ const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, pr: null, }; @@ -220,6 +221,9 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { hasUpstream: status.hasUpstream, aheadCount: status.aheadCount, behindCount: status.behindCount, + ...(status.aheadOfDefaultCount === undefined + ? {} + : { aheadOfDefaultCount: status.aheadOfDefaultCount }), pr: status.pr, }; }