From 47fedca392ede48a332bfcba85906aaadd540e36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 01:40:16 -0700 Subject: [PATCH 01/11] Add GitLab source control support - Add GitLab CLI and source control provider wiring - Update UI copy and actions for merge requests - Extend contracts and tests for GitLab flows --- apps/server/src/git/gitlabMergeRequests.ts | 148 ++++++++ apps/server/src/server.ts | 3 +- .../src/sourceControl/GitLabCli.test.ts | 178 +++++++++ apps/server/src/sourceControl/GitLabCli.ts | 343 ++++++++++++++++++ .../GitLabSourceControlProvider.test.ts | 101 ++++++ .../GitLabSourceControlProvider.ts | 89 +++++ .../SourceControlProviderRegistry.test.ts | 34 +- .../SourceControlProviderRegistry.ts | 6 + packages/contracts/src/git.ts | 11 + 9 files changed, 890 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/git/gitlabMergeRequests.ts create mode 100644 apps/server/src/sourceControl/GitLabCli.test.ts create mode 100644 apps/server/src/sourceControl/GitLabCli.ts create mode 100644 apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts create mode 100644 apps/server/src/sourceControl/GitLabSourceControlProvider.ts diff --git a/apps/server/src/git/gitlabMergeRequests.ts b/apps/server/src/git/gitlabMergeRequests.ts new file mode 100644 index 00000000000..d8245d3249a --- /dev/null +++ b/apps/server/src/git/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/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..9d653bb8525 --- /dev/null +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -0,0 +1,178 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { afterEach, expect, vi } from "vitest"; + +vi.mock("../processRunner", () => ({ + runProcess: vi.fn(), +})); + +import { runProcess } from "../processRunner.ts"; +import * as GitLabCli from "./GitLabCli.ts"; + +const mockedRunProcess = vi.mocked(runProcess); +const layer = it.layer(GitLabCli.layer); + +afterEach(() => { + mockedRunProcess.mockReset(); +}); + +layer("GitLabCli.layer", (it) => { + it.effect("parses merge request view output", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: 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", + }, + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + 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(mockedRunProcess).toHaveBeenCalledWith( + "glab", + ["mr", "view", "42", "--output", "json"], + expect.objectContaining({ cwd: "/repo" }), + ); + }), + ); + + it.effect("skips invalid entries when parsing MR lists", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: 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", + }, + ]), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + 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(mockedRunProcess).toHaveBeenCalledWith( + "glab", + [ + "mr", + "list", + "--source-branch", + "feature/mr-list", + "--all", + "--per-page", + "20", + "--output", + "json", + ], + expect.objectContaining({ cwd: "/repo" }), + ); + }), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: 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", + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + 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("surfaces a friendly error when the merge request is not found", () => + Effect.gen(function* () { + mockedRunProcess.mockRejectedValueOnce(new Error("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..0ead507235e --- /dev/null +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -0,0 +1,343 @@ +import { readFile } from "node:fs/promises"; + +import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; + +import { GitLabCliError, TrimmedNonEmptyString } from "@t3tools/contracts"; + +import type { ProcessRunResult } from "../processRunner.ts"; +import { runProcess } from "../processRunner.ts"; +import { + decodeGitLabMergeRequestJson, + decodeGitLabMergeRequestListJson, + formatGitLabJsonDecodeError, +} from "../git/gitlabMergeRequests.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +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 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 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 normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { + if (error instanceof Error) { + if (error.message.includes("Command not found: glab")) { + 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 toSummaryWithOptionalUpdatedAt( + record: GitLabMergeRequestSummary & { + readonly updatedAt: Option.Option; + }, +): GitLabMergeRequestSummary { + const { updatedAt, ...summary } = record; + return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; +} + +export const make = Effect.sync(() => { + const execute: GitLabCliShape["execute"] = (input) => + Effect.tryPromise({ + try: () => + runProcess("glab", input.args, { + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }), + catch: (error) => normalizeGitLabCliError("execute", error), + }); + + return GitLabCli.of({ + execute, + listMergeRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "mr", + "list", + "--source-branch", + normalizeHeadSelector(input.headSelector), + ...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) => + Effect.tryPromise({ + try: () => readFile(input.bodyFile, "utf8"), + catch: (error) => + new GitLabCliError({ + operation: "createMergeRequest", + detail: "Failed to read merge request body file.", + cause: error, + }), + }).pipe( + Effect.flatMap((body) => + execute({ + cwd: input.cwd, + args: [ + "mr", + "create", + "--target-branch", + input.baseBranch, + "--source-branch", + normalizeHeadSelector(input.headSelector), + "--title", + input.title, + "--description", + body, + "--yes", + ], + }), + ), + 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..440250cbbe0 --- /dev/null +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -0,0 +1,101 @@ +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", + 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..dc06c5f4c21 --- /dev/null +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -0,0 +1,89 @@ +import { Effect, Layer, Option } from "effect"; +import { + SourceControlProviderError, + type ChangeRequest, + type GitLabCliError, +} from "@t3tools/contracts"; + +import { GitLabCli, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; +import { SourceControlProvider } 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 } + : {}), + }; +} + +export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { + const gitlab = yield* GitLabCli; + + return SourceControlProvider.of({ + kind: "gitlab", + listChangeRequests: (input) => + gitlab + .listMergeRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + 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) => + gitlab + .createMergeRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + 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/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 23cdc3e1fd2..1dd2715de38 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,7 @@ 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 +79,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/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index dd12e749da4..6938c5da4f0 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -338,6 +338,16 @@ export class GitHubCliError extends Schema.TaggedErrorClass()("G } } +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 class TextGenerationError extends Schema.TaggedErrorClass()( "TextGenerationError", { @@ -365,6 +375,7 @@ export const GitManagerServiceError = Schema.Union([ GitManagerError, GitCommandError, GitHubCliError, + GitLabCliError, SourceControlProviderError, TextGenerationError, ]); From 3addcb0e2550b07cb6fc51d9e6fbc4138226cd15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 02:00:17 -0700 Subject: [PATCH 02/11] Use GitLab API for merge request creation - Switch MR creation to `glab api` so the body file is sent by path instead of argv - Add coverage for forced MR checkout and API-based MR creation --- .../src/sourceControl/GitLabCli.test.ts | 250 ++++++++++++------ apps/server/src/sourceControl/GitLabCli.ts | 89 +++---- 2 files changed, 212 insertions(+), 127 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 9d653bb8525..81c839d56d9 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -1,43 +1,60 @@ import { assert, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { afterEach, expect, vi } from "vitest"; -vi.mock("../processRunner", () => ({ - runProcess: vi.fn(), -})); +import { VcsProcessExitError } from "@t3tools/contracts"; -import { runProcess } from "../processRunner.ts"; +import { VcsProcess, type VcsProcessOutput, type VcsProcessShape } from "../vcs/VcsProcess.ts"; import * as GitLabCli from "./GitLabCli.ts"; -const mockedRunProcess = vi.mocked(runProcess); -const layer = it.layer(GitLabCli.layer); +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(() => { - mockedRunProcess.mockReset(); + mockedRun.mockReset(); }); layer("GitLabCli.layer", (it) => { it.effect("parses merge request view output", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: 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", - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + 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; @@ -58,39 +75,41 @@ layer("GitLabCli.layer", (it) => { headRepositoryNameWithOwner: "octocat/t3code", headRepositoryOwnerLogin: "octocat", }); - expect(mockedRunProcess).toHaveBeenCalledWith( - "glab", - ["mr", "view", "42", "--output", "json"], - expect.objectContaining({ cwd: "/repo" }), + 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* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: 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", - }, - ]), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + 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; @@ -111,38 +130,40 @@ layer("GitLabCli.layer", (it) => { state: "merged", }, ]); - expect(mockedRunProcess).toHaveBeenCalledWith( - "glab", - [ - "mr", - "list", - "--source-branch", - "feature/mr-list", - "--all", - "--per-page", - "20", - "--output", - "json", - ], - expect.objectContaining({ cwd: "/repo" }), + 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* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: 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", - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + 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; @@ -160,9 +181,76 @@ layer("GitLabCli.layer", (it) => { }), ); + 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("passes --force when checking out merge requests with force enabled", () => + 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", "--force"], + }), + ); + }), + ); + it.effect("surfaces a friendly error when the merge request is not found", () => Effect.gen(function* () { - mockedRunProcess.mockRejectedValueOnce(new Error("GET 404 merge request not found")); + 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; diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 0ead507235e..ed4ac658ca7 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -1,16 +1,13 @@ -import { readFile } from "node:fs/promises"; - import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; import { GitLabCliError, TrimmedNonEmptyString } from "@t3tools/contracts"; -import type { ProcessRunResult } from "../processRunner.ts"; -import { runProcess } from "../processRunner.ts"; import { decodeGitLabMergeRequestJson, decodeGitLabMergeRequestListJson, formatGitLabJsonDecodeError, } from "../git/gitlabMergeRequests.ts"; +import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -38,7 +35,7 @@ export interface GitLabCliShape { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listMergeRequests: (input: { readonly cwd: string; @@ -80,9 +77,18 @@ 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")) { + 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.", @@ -197,16 +203,19 @@ function toSummaryWithOptionalUpdatedAt( return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; } -export const make = Effect.sync(() => { +export const make = Effect.fn("makeGitLabCli")(function* () { + const process = yield* VcsProcess; + const execute: GitLabCliShape["execute"] = (input) => - Effect.tryPromise({ - try: () => - runProcess("glab", input.args, { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }), - catch: (error) => normalizeGitLabCliError("execute", error), - }); + 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, @@ -287,35 +296,23 @@ export const make = Effect.sync(() => { Effect.map(normalizeRepositoryCloneUrls), ), createMergeRequest: (input) => - Effect.tryPromise({ - try: () => readFile(input.bodyFile, "utf8"), - catch: (error) => - new GitLabCliError({ - operation: "createMergeRequest", - detail: "Failed to read merge request body file.", - cause: error, - }), - }).pipe( - Effect.flatMap((body) => - execute({ - cwd: input.cwd, - args: [ - "mr", - "create", - "--target-branch", - input.baseBranch, - "--source-branch", - normalizeHeadSelector(input.headSelector), - "--title", - input.title, - "--description", - body, - "--yes", - ], - }), - ), - Effect.asVoid, - ), + execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + "projects/:fullpath/merge_requests", + "--raw-field", + `source_branch=${normalizeHeadSelector(input.headSelector)}`, + "--raw-field", + `target_branch=${input.baseBranch}`, + "--raw-field", + `title=${input.title}`, + "--field", + `description=@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ cwd: input.cwd, @@ -335,9 +332,9 @@ export const make = Effect.sync(() => { checkoutMergeRequest: (input) => execute({ cwd: input.cwd, - args: ["mr", "checkout", input.reference], + args: ["mr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), }); }); -export const layer = Layer.effect(GitLabCli, make); +export const layer = Layer.effect(GitLabCli, make()); From 29cd1254e38e3ec6940ee05ba449bc941a927069 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 09:28:53 -0700 Subject: [PATCH 03/11] Align GitLab provider with shared routing core --- apps/server/src/sourceControl/GitLabCli.ts | 30 ++++++++++++---- .../GitLabSourceControlProvider.ts | 35 +++++++++++++++---- .../SourceControlProviderRegistry.test.ts | 4 ++- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index ed4ac658ca7..b1c9a862d33 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -8,6 +8,7 @@ import { formatGitLabJsonDecodeError, } from "../git/gitlabMergeRequests.ts"; import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import type { SourceControlRefSelector } from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -40,6 +41,7 @@ export interface GitLabCliShape { readonly listMergeRequests: (input: { readonly cwd: string; readonly headSelector: string; + readonly source?: SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect, GitLabCliError>; @@ -58,6 +60,8 @@ export interface GitLabCliShape { readonly cwd: string; readonly baseBranch: string; readonly headSelector: string; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; readonly title: string; readonly bodyFile: string; }) => Effect.Effect; @@ -194,6 +198,17 @@ function normalizeHeadSelector(headSelector: string): string { 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; @@ -226,7 +241,7 @@ export const make = Effect.fn("makeGitLabCli")(function* () { "mr", "list", "--source-branch", - normalizeHeadSelector(input.headSelector), + sourceRefName(input), ...stateArgs(input.state), "--per-page", String(input.limit ?? 20), @@ -295,8 +310,9 @@ export const make = Effect.fn("makeGitLabCli")(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), - createMergeRequest: (input) => - execute({ + createMergeRequest: (input) => { + const sourceProject = sourceProjectIdentifier(input.source); + return execute({ cwd: input.cwd, args: [ "api", @@ -304,15 +320,17 @@ export const make = Effect.fn("makeGitLabCli")(function* () { "POST", "projects/:fullpath/merge_requests", "--raw-field", - `source_branch=${normalizeHeadSelector(input.headSelector)}`, + `source_branch=${sourceRefName(input)}`, "--raw-field", - `target_branch=${input.baseBranch}`, + `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), + }).pipe(Effect.asVoid); + }, getDefaultBranch: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index dc06c5f4c21..d7ea43682d2 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -6,7 +6,7 @@ import { } from "@t3tools/contracts"; import { GitLabCli, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; -import { SourceControlProvider } from "./SourceControlProvider.ts"; +import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { return new SourceControlProviderError({ @@ -39,38 +39,59 @@ function toChangeRequest(summary: GitLabMergeRequestSummary): ChangeRequest { }; } +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) => - 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) => - gitlab + 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))), + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, getRepositoryCloneUrls: (input) => gitlab .getRepositoryCloneUrls(input) diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 1dd2715de38..fbfe8a36892 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -51,7 +51,9 @@ function makeRegistry(input: { }); return SourceControlProviderRegistry.make().pipe( - Effect.provide(Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({}))), + Effect.provide( + Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})), + ), ); } From 759ee5af4917f1681e9eb94b9ce8d37721da3795 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 09:52:24 -0700 Subject: [PATCH 04/11] Fix GitLab provider test expectations --- .../src/sourceControl/GitLabSourceControlProvider.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 440250cbbe0..7773a3b9056 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -94,6 +94,10 @@ it.effect("creates GitLab MRs through provider-neutral input names", () => cwd: "/repo", baseBranch: "main", headSelector: "owner:feature/provider", + source: { + owner: "owner", + refName: "feature/provider", + }, title: "Provider MR", bodyFile: "/tmp/body.md", }); From b343faf5e3038bce358108ecf59ba47c3b91e45f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 12:18:18 -0700 Subject: [PATCH 05/11] Enable GitLab source control discovery --- apps/server/src/sourceControl/SourceControlDiscovery.test.ts | 2 +- apps/server/src/sourceControl/SourceControlDiscovery.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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.", }, From 9bec1e37974960b7faa8cec3e31b5d1cd87eb44b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 16:36:59 -0700 Subject: [PATCH 06/11] Align GitLab source control adapter structure --- apps/server/src/sourceControl/GitLabCli.ts | 14 ++++++++++++-- .../sourceControl/GitLabSourceControlProvider.ts | 8 ++------ .../gitLabMergeRequests.ts} | 0 packages/contracts/src/git.ts | 11 ----------- 4 files changed, 14 insertions(+), 19 deletions(-) rename apps/server/src/{git/gitlabMergeRequests.ts => sourceControl/gitLabMergeRequests.ts} (100%) diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index b1c9a862d33..28c2666fbd3 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -1,17 +1,27 @@ import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; -import { GitLabCliError, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeGitLabMergeRequestJson, decodeGitLabMergeRequestListJson, formatGitLabJsonDecodeError, -} from "../git/gitlabMergeRequests.ts"; +} 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; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index d7ea43682d2..f61f1f5926c 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -1,11 +1,7 @@ import { Effect, Layer, Option } from "effect"; -import { - SourceControlProviderError, - type ChangeRequest, - type GitLabCliError, -} from "@t3tools/contracts"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { GitLabCli, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; +import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts"; import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts"; function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError { diff --git a/apps/server/src/git/gitlabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts similarity index 100% rename from apps/server/src/git/gitlabMergeRequests.ts rename to apps/server/src/sourceControl/gitLabMergeRequests.ts diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 6938c5da4f0..dd12e749da4 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -338,16 +338,6 @@ export class GitHubCliError extends Schema.TaggedErrorClass()("G } } -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 class TextGenerationError extends Schema.TaggedErrorClass()( "TextGenerationError", { @@ -375,7 +365,6 @@ export const GitManagerServiceError = Schema.Union([ GitManagerError, GitCommandError, GitHubCliError, - GitLabCliError, SourceControlProviderError, TextGenerationError, ]); From 53f6992a1263cbc07ab3c9ae4776287039968ecc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 17:27:44 -0700 Subject: [PATCH 07/11] Fix pushing upstream branches to their remote name - Resolve remote branch names before publishing - Ensure push targets refs/heads/ instead of upstream shorthand - Add regression coverage for upstream push behavior --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 39 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 16 ++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 38472fd5b0d..7a47a117ee9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -242,5 +242,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..7a775286fad 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -969,6 +969,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"; @@ -1461,16 +1470,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 +1492,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, From f20747fe446f595af8059a8108ad191bc587a5df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 17:32:28 -0700 Subject: [PATCH 08/11] Allow push with uncommitted ahead changes - Keep Push enabled when the branch is ahead, even if the working tree still has local changes - Add coverage for the Git actions menu state --- .../GitActionsControl.logic.test.ts | 42 +++++++++++++++++++ .../src/components/GitActionsControl.logic.ts | 1 - 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 08c85e3649f..68f9ae89a66 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -444,6 +444,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..982059bc5cb 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -108,7 +108,6 @@ export function buildMenuItems( const canPush = !isBusy && hasBranch && - !hasChanges && !isBehind && gitStatus.aheadCount > 0 && (gitStatus.hasUpstream || canPushWithoutUpstream); From 196c1f1821e8885d0c5141f6ae2b92c7f7b75630 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 18:01:28 -0700 Subject: [PATCH 09/11] Support default-branch deltas in git workflows - track ahead-of-default status through server, shared contracts, and UI - allow push flows to skip committing dirty worktrees when already ahead --- apps/server/src/git/GitManager.test.ts | 37 ++++++++++ apps/server/src/git/GitManager.ts | 8 +-- .../server/src/git/GitWorkflowService.test.ts | 1 + apps/server/src/git/GitWorkflowService.ts | 1 + apps/server/src/vcs/GitVcsDriver.ts | 1 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 23 ++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 19 +++-- .../GitActionsControl.logic.test.ts | 20 ++++++ .../src/components/GitActionsControl.logic.ts | 13 +++- .../settings/SourceControlSettings.tsx | 71 ++++++++++++++++++- apps/web/src/rpc/wsRpcClient.test.ts | 1 + packages/contracts/src/git.ts | 1 + packages/shared/src/git.ts | 4 ++ 13 files changed, 187 insertions(+), 13 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 2d95c5219f7..055a59906aa 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-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index abebc70d02b..ee0de2050ab 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -690,6 +690,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 +749,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 +1595,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/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 0cc73da5e67..2566236b8e1 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 { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7a47a117ee9..902e7ad8d47 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -102,6 +102,29 @@ 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); + }), + ); }); describe("refName operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 7a775286fad..bbd03e920e6 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 = { @@ -1222,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(); @@ -1257,6 +1259,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* behindCount = 0; } + const isDefaultBranch = + refName !== null && + (refName === defaultBranch || + (defaultBranch === null && (refName === "main" || refName === "master"))); + if (refName && !isDefaultBranch) { + aheadOfDefaultCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe( + Effect.catch(() => Effect.succeed(0)), + ); + } + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); const fileStatMap = new Map(); @@ -1286,10 +1298,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, @@ -1301,6 +1310,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* hasUpstream: upstreamRef !== null, aheadCount, behindCount, + aheadOfDefaultCount, }; }); @@ -1332,6 +1342,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, + aheadOfDefaultCount: details.aheadOfDefaultCount, pr: null, })), ); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 68f9ae89a66..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 }), diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 982059bc5cb..6ce9eb9ed37 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -103,6 +103,7 @@ 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 = @@ -116,7 +117,7 @@ export function buildMenuItems( hasBranch && !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + hasDefaultBranchDelta && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -180,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); @@ -285,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/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index c5f7909e865..0170acb904d 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -6,6 +6,7 @@ import type { SourceControlDiscoveryResult, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, + VcsDriverKind, VcsDiscoveryItem, } from "@t3tools/contracts"; @@ -36,6 +37,59 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { sourceControlProviders: [], }; +const GitIcon: Icon = (props) => ( + + + +); + +const JujutsuIcon: Icon = (props) => { + const groupId = `${useId().replaceAll(":", "")}-jj-a`; + + return ( + + + + + + + + + + + + + + + + + ); +}; + const GitLabIcon: Icon = (props) => ( > = { + git: GitIcon, + jj: JujutsuIcon, +}; + function optionLabel(value: Option.Option): string | null { return Option.getOrNull(value); } @@ -211,7 +270,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 +403,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, }; } From 955593c37a9db8d4c5c0cc189b1c9d9bd07287c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 18:44:35 -0700 Subject: [PATCH 10/11] Restore PR upstream tracking and drop unsupported glab force flag - refetch missing remote-tracking branches before setting local PR upstreams - keep same-repo PRs on the originating remote when provider metadata is incomplete - stop passing `--force` to `glab mr checkout` --- apps/server/src/git/GitManager.test.ts | 107 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 21 ++++ .../src/sourceControl/GitLabCli.test.ts | 4 +- apps/server/src/sourceControl/GitLabCli.ts | 2 +- apps/server/src/vcs/GitVcsDriver.ts | 10 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 14 +++ 6 files changed, 155 insertions(+), 3 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 055a59906aa..c9db31837e0 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2454,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 ee0de2050ab..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, diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 81c839d56d9..5eb6276e3a2 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -217,7 +217,7 @@ layer("GitLabCli.layer", (it) => { }), ); - it.effect("passes --force when checking out merge requests with force enabled", () => + it.effect("does not pass unsupported force flags when checking out merge requests", () => Effect.gen(function* () { mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); @@ -232,7 +232,7 @@ layer("GitLabCli.layer", (it) => { expect.objectContaining({ command: "glab", cwd: "/repo", - args: ["mr", "checkout", "42", "--force"], + args: ["mr", "checkout", "42"], }), ); }), diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 28c2666fbd3..04c597b0e7b 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -360,7 +360,7 @@ export const make = Effect.fn("makeGitLabCli")(function* () { checkoutMergeRequest: (input) => execute({ cwd: input.cwd, - args: ["mr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + args: ["mr", "checkout", input.reference], }).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2566236b8e1..661490b71ad 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -134,6 +134,12 @@ export interface GitFetchRemoteBranchInput { localBranch: string; } +export interface GitFetchRemoteTrackingBranchInput { + cwd: string; + remoteName: string; + remoteBranch: string; +} + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -177,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.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index bbd03e920e6..709192f3889 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1915,6 +1915,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", @@ -2096,7 +2108,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* createWorktree, fetchPullRequestBranch, ensureRemote, + resolvePrimaryRemoteName, fetchRemoteBranch, + fetchRemoteTrackingBranch, setBranchUpstream, removeWorktree, renameBranch, From a812dcb0521a684ddb02f1709e40e186b2df3989 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 18:52:10 -0700 Subject: [PATCH 11/11] Reuse fallback ahead count for default-branch status - Reuse the no-upstream ahead count when comparing against the default branch - Add coverage for feature branches without upstreams - Move source control provider icons into shared exports --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 22 +++ apps/server/src/vcs/GitVcsDriverCore.ts | 22 ++- apps/web/src/components/Icons.tsx | 181 +++++++++++++++-- .../settings/SourceControlSettings.tsx | 186 +----------------- 4 files changed, 212 insertions(+), 199 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 902e7ad8d47..aa15bff76b8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -125,6 +125,28 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { 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", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 709192f3889..dd71d53f906 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1252,10 +1252,15 @@ 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; } @@ -1264,9 +1269,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (refName === defaultBranch || (defaultBranch === null && (refName === "main" || refName === "master"))); if (refName && !isDefaultBranch) { - aheadOfDefaultCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe( - Effect.catch(() => Effect.succeed(0)), - ); + aheadOfDefaultCount = + fallbackAheadCount !== null + ? fallbackAheadCount + : yield* computeAheadCountAgainstBase(cwd, refName).pipe( + Effect.catch(() => Effect.succeed(0)), + ); } const stagedEntries = parseNumstatEntries(stagedNumstatStdout); 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 JujutsuIcon: Icon = (props) => { - const groupId = `${useId().replaceAll(":", "")}-jj-a`; - - return ( - - - - - - - - - - - - - - - - - ); -}; - -const GitLabIcon: Icon = (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,