Skip to content
Open
281 changes: 256 additions & 25 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { expect } from "vite-plus/test";
import type {
GitActionProgressEvent,
GitPreparePullRequestThreadInput,
SourceControlProviderKind,
ThreadId,
} from "@t3tools/contracts";

Expand All @@ -36,8 +37,8 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts";
import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
Expand Down Expand Up @@ -624,13 +625,37 @@ function preparePullRequestThread(
return manager.preparePullRequestThread(input);
}

/**
* Reports the GitHub-backed fake provider as another host. The fake `gh` still answers the
* change-request lookups, so the only thing that changes is the host a change request is
* reported to come from — which is what decides the ref namespace its head is fetched from.
*/
function withProviderKind(
provider: SourceControlProvider.SourceControlProvider["Service"],
kind: SourceControlProviderKind | undefined,
): SourceControlProvider.SourceControlProvider["Service"] {
if (kind === undefined) {
return provider;
}
return {
...provider,
kind,
getChangeRequest: (getInput) =>
provider
.getChangeRequest(getInput)
.pipe(Effect.map((changeRequest) => ({ ...changeRequest, provider: kind }))),
};
}

function makeManager(input?: {
ghScenario?: FakeGhScenario;
sourceControlProvider?: SourceControlProvider["Service"];
providerKind?: SourceControlProviderKind;
sourceControlProvider?: SourceControlProvider.SourceControlProvider["Service"];
textGeneration?: Partial<FakeGitTextGeneration>;
serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
gitConfigReads?: string[];
pullRequestFetches?: string[];
}) {
const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
const textGeneration = createTextGeneration(input?.textGeneration);
Expand All @@ -640,36 +665,42 @@ function makeManager(input?: {

const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);

const vcsDriverLayer = input?.gitConfigReads
? Layer.effect(
GitVcsDriver.GitVcsDriver,
GitVcsDriver.make.pipe(
Effect.map((service) =>
GitVcsDriver.GitVcsDriver.of({
...service,
readConfigValue: (cwd, key) =>
Effect.sync(() => input.gitConfigReads?.push(key)).pipe(
Effect.andThen(service.readConfigValue(cwd, key)),
),
}),
const vcsDriverLayer =
input?.gitConfigReads || input?.pullRequestFetches
? Layer.effect(
GitVcsDriver.GitVcsDriver,
GitVcsDriver.make.pipe(
Effect.map((service) =>
GitVcsDriver.GitVcsDriver.of({
...service,
fetchPullRequestBranch: (fetchInput) =>
Effect.sync(() => input.pullRequestFetches?.push(fetchInput.headRef)).pipe(
Effect.andThen(service.fetchPullRequestBranch(fetchInput)),
),
readConfigValue: (cwd, key) =>
Effect.sync(() => input.gitConfigReads?.push(key)).pipe(
Effect.andThen(service.readConfigValue(cwd, key)),
),
}),
),
),
),
).pipe(
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(serverConfigLayer),
)
: GitVcsDriver.layer.pipe(
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(serverConfigLayer),
);
).pipe(
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(serverConfigLayer),
)
: GitVcsDriver.layer.pipe(
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(serverConfigLayer),
);
const sourceControlRegistryLayer = Layer.effect(
SourceControlProviderRegistry.SourceControlProviderRegistry,
(input?.sourceControlProvider === undefined
? GitHubSourceControlProvider.make
: Effect.succeed(input.sourceControlProvider)
).pipe(
Effect.map((provider) => withProviderKind(provider, input?.providerKind)),
Effect.map((provider) =>
SourceControlProviderRegistry.SourceControlProviderRegistry.of({
get: () => Effect.succeed(provider),
Expand Down Expand Up @@ -4504,6 +4535,166 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("prepares GitLab merge request worktrees from the merge request 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/mr-worktree"]);
NodeFS.writeFileSync(NodePath.join(repoDir, "merge-request.txt"), "merge request\n");
yield* runGit(repoDir, ["add", "merge-request.txt"]);
yield* runGit(repoDir, ["commit", "-m", "MR worktree branch"]);
// GitLab publishes the head at refs/merge-requests/<iid>/head, never under refs/pull, and
// here that ref is the only thing on the remote that names it.
yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/533/head"]);
const mergeRequestHead = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
yield* runGit(repoDir, ["checkout", "main"]);
yield* runGit(repoDir, ["branch", "-D", "feature/mr-worktree"]);

const { manager } = yield* makeManager({
providerKind: "gitlab",
ghScenario: {
pullRequest: {
number: 533,
title: "Worktree MR",
url: "https://gitlab.example.test/group/repo/-/merge_requests/533",
baseRefName: "main",
headRefName: "feature/mr-worktree",
state: "open",
},
},
});

const created = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "533",
mode: "worktree",
});

expect(created.branch).toBe("feature/mr-worktree");
const worktreePath = created.worktreePath as string;
expect(NodeFS.existsSync(worktreePath)).toBe(true);
expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(
mergeRequestHead,
);

// And the same ref again when the reused worktree is refreshed onto a moved head.
yield* runGit(repoDir, ["fetch", "origin", "refs/merge-requests/533/head"]);
yield* runGit(repoDir, ["checkout", "-b", "mr-author", "FETCH_HEAD"]);
NodeFS.writeFileSync(NodePath.join(repoDir, "merge-request.txt"), "merge request again\n");
yield* runGit(repoDir, ["add", "merge-request.txt"]);
yield* runGit(repoDir, ["commit", "-m", "New merge request head"]);
yield* runGit(repoDir, ["push", "origin", "mr-author:refs/merge-requests/533/head"]);
const updatedHead = (yield* runGit(repoDir, ["rev-parse", "mr-author"])).stdout.trim();
yield* runGit(repoDir, ["checkout", "main"]);

const refreshed = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "533",
mode: "worktree",
});

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

it.effect("prepares worktrees from the head branch on hosts that publish no head 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/bitbucket-worktree"]);
NodeFS.writeFileSync(NodePath.join(repoDir, "bitbucket.txt"), "bitbucket\n");
yield* runGit(repoDir, ["add", "bitbucket.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Bitbucket PR branch"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/bitbucket-worktree"]);
const pullRequestHead = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
// Nothing local names the head, and Bitbucket publishes no ref for it either: the branch
// on the remote is all there is.
yield* runGit(repoDir, ["checkout", "main"]);
yield* runGit(repoDir, ["branch", "-D", "feature/bitbucket-worktree"]);
yield* runGit(repoDir, [
"update-ref",
"-d",
"refs/remotes/origin/feature/bitbucket-worktree",
]);

const { manager } = yield* makeManager({
providerKind: "bitbucket",
ghScenario: {
pullRequest: {
number: 612,
title: "Worktree Bitbucket PR",
url: "https://bitbucket.org/workspace/repo/pull-requests/612",
baseRefName: "main",
headRefName: "feature/bitbucket-worktree",
state: "open",
},
},
});

const result = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "612",
mode: "worktree",
});

expect(result.branch).toBe("feature/bitbucket-worktree");
const worktreePath = result.worktreePath as string;
expect(NodeFS.existsSync(worktreePath)).toBe(true);
expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(
pullRequestHead,
);
}),
);

it.effect("does not retry a failed head ref fetch without a resolved head repository", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const originDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);

const pullRequestFetches: string[] = [];
const { manager } = yield* makeManager({
pullRequestFetches,
providerKind: "gitlab",
ghScenario: {
pullRequest: {
number: 533,
title: "Missing merge request ref",
url: "https://gitlab.example.test/group/repo/-/merge_requests/533",
baseRefName: "main",
headRefName: "feature/missing-ref",
state: "open",
},
},
});

const error = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "533",
mode: "worktree",
}).pipe(Effect.flip);

expect(pullRequestFetches).toEqual(["refs/merge-requests/533/head"]);
expect(error).toMatchObject({
_tag: "GitPullRequestMaterializationError",
headRepository: null,
cause: { _tag: "GitCommandError" },
});
}),
);

it.effect("preserves both branch materialization failures when the fallback also fails", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down Expand Up @@ -4563,6 +4754,46 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect(
"surfaces the unresolvable cross-repository head explanation on hosts with no head 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"]);

// A cross-repository head on a host that publishes no change-request ref, where the
// repository it was opened from cannot be resolved either: nothing names the head.
const { manager } = yield* makeManager({
providerKind: "bitbucket",
ghScenario: {
pullRequest: {
number: 613,
title: "Cross-repository Bitbucket PR",
url: "https://bitbucket.org/workspace/repo/pull-requests/613",
baseRefName: "main",
headRefName: "feature/unresolvable-head",
state: "open",
isCrossRepository: true,
},
},
});

const error = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "613",
mode: "worktree",
}).pipe(Effect.flip);

if (error._tag !== "GitManagerError") {
return yield* Effect.die(error);
}
expect(error.message).toContain("This host publishes no ref for the change request head");
}),
);

it.effect("launches setup when creating a new PR worktree", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
Loading
Loading