Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5607,6 +5607,58 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

it.effect("materializes and refreshes GitLab worktrees without source 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", "author"]);
NodeFS.writeFileSync(NodePath.join(repoDir, "mr.txt"), "first head\n");
yield* runGit(repoDir, ["add", "mr.txt"]);
yield* runGit(repoDir, ["commit", "-m", "First MR head"]);
yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/90/head"]);
const firstHead = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
yield* runGit(repoDir, ["checkout", "main"]);

const provider = yield* GitLabSourceControlProvider.make.pipe(
Effect.provide(
Layer.mock(GitLabCli.GitLabCli)({
getMergeRequest: () =>
Effect.succeed({
number: 90,
title: "Fork MR",
url: "https://gitlab.com/group/project/-/merge_requests/90",
baseRefName: "main",
headRefName: "feature/mr",
isCrossRepository: true,
}),
}),
),
);
const { manager } = yield* makeManager({ sourceControlProvider: provider });
const input = { cwd: repoDir, reference: "90", mode: "worktree" } as const;
const created = yield* preparePullRequestThread(manager, input);
const worktreePath = created.worktreePath!;
expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(firstHead);

yield* runGit(repoDir, ["checkout", "author"]);
NodeFS.writeFileSync(NodePath.join(repoDir, "mr.txt"), "updated head\n");
yield* runGit(repoDir, ["add", "mr.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Updated MR head"]);
yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/90/head"]);
const updatedHead = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
const result = yield* preparePullRequestThread(manager, input);

expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe(
NodeFS.realpathSync.native(worktreePath),
);
expect(result.isOnPullRequestHead).toBe(true);
expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead);
}),
);

it.effect("never moves an unrelated local branch that shares the fork head branch name", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ interface ResolvedPullRequest {
}

interface PullRequestHeadRemoteInfo {
provider?: SourceControlProviderKind | undefined;
isCrossRepository?: boolean | undefined;
headRepositoryNameWithOwner?: string | null | undefined;
headRepositoryOwnerLogin?: string | null | undefined;
Expand Down Expand Up @@ -686,11 +687,13 @@ function shouldPreferSshRemote(url: string | null): boolean {
}

function toPullRequestHeadRemoteInfo(pr: {
provider: SourceControlProviderKind;
isCrossRepository?: boolean | undefined;
headRepositoryNameWithOwner?: string | null | undefined;
headRepositoryOwnerLogin?: string | null | undefined;
}): PullRequestHeadRemoteInfo {
return {
provider: pr.provider,
...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}),
...(pr.headRepositoryNameWithOwner !== undefined
? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner }
Expand Down Expand Up @@ -953,6 +956,7 @@ export const make = Effect.gen(function* () {
yield* gitCore.fetchPullRequestBranch({
cwd,
prNumber: pullRequest.number,
provider: pullRequest.provider,
branch: localBranch,
});
return;
Expand Down Expand Up @@ -1000,6 +1004,7 @@ export const make = Effect.gen(function* () {
.fetchPullRequestBranch({
cwd,
prNumber: pullRequest.number,
provider: pullRequest.provider,
branch: localBranch,
})
.pipe(
Expand Down Expand Up @@ -2475,9 +2480,13 @@ export const make = Effect.gen(function* () {
// head. The branch's upstream does not: configuring it is best-effort, so a branch cut
// from `origin/main` whose head branch has since been deleted still resolves — and
// following it would move the checkout onto main and call that the pull request.
.fetchPullRequestHeadCommit({ cwd: worktreePath, prNumber: pullRequest.number })
.fetchPullRequestHeadCommit({
cwd: worktreePath,
prNumber: pullRequest.number,
provider: pullRequestSummary.provider,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
.pipe(
// A host that publishes no `refs/pull/<n>/head` leaves the remote-tracking branch,
// A host that publishes no pull request head ref leaves the remote-tracking branch,
// taken only where it is the head branch's own rather than whatever the checkout
// happened to be cut from.
Effect.catch(() =>
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ChildProcessSpawner } from "effect/process";

import {
GitCommandError,
type SourceControlProviderKind,
VcsProcessExitError,
type VcsSwitchRefInput,
type VcsSwitchRefResult,
Expand Down Expand Up @@ -215,11 +216,13 @@ export interface GitFetchPullRequestBranchInput {
cwd: string;
prNumber: number;
branch: string;
provider?: SourceControlProviderKind | undefined;
}

export interface GitFetchPullRequestHeadCommitInput {
cwd: string;
prNumber: number;
provider?: SourceControlProviderKind | undefined;
}

export interface GitResolveCommitInput {
Expand Down Expand Up @@ -357,7 +360,7 @@ export class GitVcsDriver extends Context.Service<
readonly fetchPullRequestBranch: (
input: GitFetchPullRequestBranchInput,
) => Effect.Effect<void, GitCommandError>;
/** Fetches `refs/pull/<n>/head` without writing a branch, for heads that exist nowhere else. */
/** Fetches the host's pull/merge request head ref without writing a branch. */
readonly fetchPullRequestHeadCommit: (
input: GitFetchPullRequestHeadCommitInput,
) => Effect.Effect<GitResolveCommitResult, GitCommandError>;
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3906,3 +3906,32 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
);
});
});

describe("pull request head refs", () => {
it.effect.each(["gitlab", "github"] as const)(
"fetches %s pull request heads without moving the checkout",
(provider) =>
Effect.gen(function* () {
const remote = yield* makeTmpDir();
yield* initRepoWithCommit(remote);
yield* writeTextFile(remote, "mr-head.txt", "remote head\n");
yield* git(remote, ["add", "mr-head.txt"]);
yield* git(remote, ["commit", "-m", "MR head"]);
const head = yield* git(remote, ["rev-parse", "HEAD"]);
const ref = provider === "gitlab" ? "refs/merge-requests/42/head" : "refs/pull/42/head";
yield* git(remote, ["update-ref", ref, head]);
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
yield* git(cwd, ["remote", "add", "origin", remote]);
const original = yield* git(cwd, ["rev-parse", "HEAD"]);
assert.notStrictEqual(original, head);
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.fetchPullRequestBranch({ cwd, prNumber: 42, branch: "pr-42", provider });
assert.strictEqual(yield* git(cwd, ["rev-parse", "pr-42"]), head);
assert.strictEqual(yield* git(cwd, ["rev-parse", "HEAD"]), original);
const result = yield* driver.fetchPullRequestHeadCommit({ cwd, prNumber: 42, provider });
assert.strictEqual(result.commitSha, head);
assert.strictEqual(yield* git(cwd, ["rev-parse", "HEAD"]), original);
}).pipe(Effect.provide(layerTest)),
);
});
13 changes: 11 additions & 2 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
import {
GitCommandError,
type GitCommandFailureReason,
type SourceControlProviderKind,
T3_PROJECT_FILE_NAME,
type ReviewDiffFileContentsInput,
type ReviewDiffPreviewInput,
Expand All @@ -45,6 +46,8 @@ import {
import * as ServerConfig from "../config.ts";

const DEFAULT_TIMEOUT_MS = 30_000;
const pullRequestHeadRef = (provider: SourceControlProviderKind | undefined, number: number) =>
provider === "gitlab" ? `refs/merge-requests/${number}/head` : `refs/pull/${number}/head`;
const gitProcesses = Semaphore.makeUnsafe(8);
// `git worktree add` checks out the full tree, so on large repositories it can
// take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle
Expand Down Expand Up @@ -3507,7 +3510,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
"--quiet",
"--no-tags",
remoteName,
`+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
`+${pullRequestHeadRef(input.provider, input.prNumber)}:refs/heads/${input.branch}`,
],
{
fallbackErrorDetail: "git fetch pull request branch failed",
Expand Down Expand Up @@ -3535,7 +3538,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
yield* executeGit(
"GitVcsDriver.fetchPullRequestHeadCommit",
input.cwd,
["fetch", "--quiet", "--no-tags", remoteName, `refs/pull/${input.prNumber}/head`],
[
"fetch",
"--quiet",
"--no-tags",
remoteName,
pullRequestHeadRef(input.provider, input.prNumber),
],
{
fallbackErrorDetail: "git fetch pull request head failed",
},
Expand Down
Loading