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 @@ -6403,6 +6403,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
31 changes: 22 additions & 9 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ interface ResolvedPullRequest {
}

interface PullRequestHeadRemoteInfo {
/** The remote ref the host publishes the pull request's head under. */
headRef?: string | undefined;
isCrossRepository?: boolean | undefined;
headRepositoryNameWithOwner?: string | null | undefined;
headRepositoryOwnerLogin?: string | null | undefined;
Expand Down Expand Up @@ -947,12 +949,13 @@ export const make = Effect.gen(function* () {
.fetchPullRequestBranch({
cwd,
prNumber: pullRequest.number,
headRef: pullRequest.headRef,
branch: localBranch,
})
.pipe(
// Azure DevOps, GitLab and Bitbucket publish no `refs/pull/<n>/head`. A head in the
// same repository is a branch on the primary remote, so it is fetched by name instead,
// but only when that remote is the pull request's own repository: Azure finds a pull
// Azure DevOps and Bitbucket publish no pull request head ref. A head in the same
// repository is a branch on the primary remote, so it is fetched by name instead, but
// only when that remote is the pull request's own repository: Azure finds a pull
// request by number anywhere in the organization. Only while it is open, too: the
// branch of a closed one may have moved on past the head it was closed with.
Effect.catch((cause) =>
Expand Down Expand Up @@ -1027,6 +1030,7 @@ export const make = Effect.gen(function* () {
.fetchPullRequestBranch({
cwd,
prNumber: pullRequest.number,
headRef: pullRequest.headRef,
branch: localBranch,
})
.pipe(
Expand Down Expand Up @@ -2600,11 +2604,16 @@ export const make = Effect.gen(function* () {
return yield* Effect.gen(function* () {
const normalizedReference = normalizePullRequestReference(input.reference);
const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd);
const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({
const provider = yield* sourceControlProvider(input.cwd);
const pullRequestSummary = yield* provider.getChangeRequest({
cwd: input.cwd,
reference: normalizedReference,
});
const pullRequest = toResolvedPullRequest(pullRequestSummary);
const headRemoteInfo = {
...toPullRequestHeadRemoteInfo(pullRequestSummary),
headRef: provider.changeRequestHeadRef?.(pullRequestSummary.number),
};

if (input.mode === "local") {
yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({
Expand All @@ -2617,7 +2626,7 @@ export const make = Effect.gen(function* () {
input.cwd,
{
...pullRequest,
...toPullRequestHeadRemoteInfo(pullRequestSummary),
...headRemoteInfo,
},
details.branch ?? pullRequest.headBranch,
);
Expand All @@ -2637,15 +2646,15 @@ export const make = Effect.gen(function* () {
worktreePath,
{
...pullRequest,
...toPullRequestHeadRemoteInfo(pullRequestSummary),
...headRemoteInfo,
},
details.branch ?? pullRequest.headBranch,
);
});

const pullRequestWithRemoteInfo = {
...pullRequest,
...toPullRequestHeadRemoteInfo(pullRequestSummary),
...headRemoteInfo,
} as const;
const localPullRequestBranch =
resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
Expand Down Expand Up @@ -2687,9 +2696,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,
headRef: headRemoteInfo.headRef,
})
.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
4 changes: 4 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,15 @@ export interface GitFetchPullRequestBranchInput {
cwd: string;
prNumber: number;
branch: string;
/** The remote ref holding the head; `refs/pull/<prNumber>/head` when absent. */
headRef?: string | undefined;
}

export interface GitFetchPullRequestHeadCommitInput {
cwd: string;
prNumber: number;
/** The remote ref holding the head; `refs/pull/<prNumber>/head` when absent. */
headRef?: string | undefined;
}

export interface GitResolveCommitInput {
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 @@ -4271,3 +4271,32 @@ describe("Windows long path configuration", () => {
}
});
});

describe("pull request head refs", () => {
it.effect.each([
{ name: "a host's own head ref", headRef: "refs/merge-requests/42/head" },
{ name: "refs/pull by default", headRef: undefined },
])("fetches pull request heads from $name without moving the checkout", ({ headRef }) =>
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"]);
yield* git(remote, ["update-ref", headRef ?? "refs/pull/42/head", 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", headRef });
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, headRef });
assert.strictEqual(result.commitSha, head);
assert.strictEqual(yield* git(cwd, ["rev-parse", "HEAD"]), original);
}).pipe(Effect.provide(layerTest)),
);
});
6 changes: 4 additions & 2 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
import * as ServerConfig from "../config.ts";

const DEFAULT_TIMEOUT_MS = 30_000;
const pullRequestHeadRef = (input: { prNumber: number; headRef?: string | undefined }) =>
input.headRef ?? `refs/pull/${input.prNumber}/head`;
const gitProcesses = Semaphore.makeUnsafe(8);
const WORKTREE_ADD_TIMEOUT_MS = 300_000;
const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5));
Expand Down Expand Up @@ -3562,7 +3564,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
"--quiet",
"--no-tags",
remoteName,
`+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
`+${pullRequestHeadRef(input)}:refs/heads/${input.branch}`,
],
{
fallbackErrorDetail: "git fetch pull request branch failed",
Expand Down Expand Up @@ -3590,7 +3592,7 @@ 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)],
{
fallbackErrorDetail: "git fetch pull request head failed",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export class SourceControlProvider extends Context.Service<
* more than that (Forgejo's HTTP installation mount). Absent means the whole path.
*/
readonly repositoryNameFromRemoteUrl?: (url: string) => string | null;
readonly changeRequestHeadRef?: (number: number) => string;
readonly refineRepositoryIdentity?: (input: {
readonly identity: RepositoryIdentity;
readonly resolveContext: (input: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export const make = Effect.gen(function* () {

return SourceControlProvider.SourceControlProvider.of({
kind: SourceControlProviderKind.make("gitlab"),
changeRequestHeadRef: (number) => `refs/merge-requests/${number}/head`,
resolveLink: (input) => {
// Automatic enrichment must not send ambient CLI credentials to a host from message text.
if (input.url.host !== "gitlab.com") return undefined;
Expand Down
Loading