diff --git a/apps/api/src/git-provider/workspace.test.ts b/apps/api/src/git-provider/workspace.test.ts new file mode 100644 index 00000000..4f603e90 --- /dev/null +++ b/apps/api/src/git-provider/workspace.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { MockProvider } from "../../mock/provider.js"; +import type { Workspace } from "./workspace.js"; + +async function git(dir: string, argList: string[]): Promise { + const proc = Bun.spawn(["git", ...argList], { cwd: dir, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) { + throw new Error(stderr || stdout); + } + return stdout.trim(); +} + +// Isolate the real logger and service dependency from module mocks in other test files +if (process.env.PROVAL_WORKSPACE_TEST_CHILD !== "1") { + it("passes workspace integration in an isolated process", () => { + const result = Bun.spawnSync([process.execPath, "test", import.meta.path], { + env: { ...process.env, PROVAL_WORKSPACE_TEST_CHILD: "1", DB_FILE_NAME: ":memory:" }, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error(result.stdout.toString() + result.stderr.toString()); + } + expect(result.exitCode).toBe(0); + }, 30000); +} else { + const { Workspace } = await import("./workspace.js"); + + async function withFixture( + branch: string | null, + prRef: string, + run: (fixture: { + dir: string; + targetBranch: string; + workspace: Workspace; + startSha: string; + previousSha: string; + headSha: string; + }) => Promise, + ): Promise { + const dir = await mkdtemp(join(tmpdir(), "provalworkspace")); + const provider = new MockProvider({ + detail: { + title: "Test", + description: null, + sourceBranch: "feature", + targetBranch: branch ?? "master", + author: "dev", + state: "opened", + }, + diffs: [], + }); + provider.fetchGitRepositoryUrl = async () => pathToFileURL(dir).href; + provider.getPullRequestHeadFetchRef = () => prRef; + const workspace = new Workspace(provider); + + try { + await git(dir, ["init"]); + const targetBranch = branch ?? (await git(dir, ["symbolic-ref", "--short", "HEAD"])); + await git(dir, ["symbolic-ref", "HEAD", `refs/heads/${targetBranch}`]); + await git(dir, ["config", "user.name", "Test"]); + await git(dir, ["config", "user.email", "test@example.com"]); + await git(dir, ["config", "commit.gpgsign", "false"]); + await writeFile(join(dir, "sample.txt"), "start\n"); + await git(dir, ["add", "sample.txt"]); + await git(dir, ["commit", "-m", "start"]); + const startSha = await git(dir, ["rev-parse", "HEAD"]); + await git(dir, ["checkout", "-b", "feature"]); + await writeFile(join(dir, "sample.txt"), "previous\n"); + await git(dir, ["commit", "-am", "previous"]); + const previousSha = await git(dir, ["rev-parse", "HEAD"]); + await git(dir, ["update-ref", "refs/heads/previous", previousSha]); + await writeFile(join(dir, "sample.txt"), "head\n"); + await git(dir, ["commit", "-am", "head"]); + const headSha = await git(dir, ["rev-parse", "HEAD"]); + await git(dir, ["update-ref", prRef, headSha]); + + await run({ dir, targetBranch, workspace, startSha, previousSha, headSha }); + } finally { + await workspace.clean(); + await rm(dir, { recursive: true, force: true }); + } + } + + describe("Workspace fetch", () => { + for (const prRef of ["refs/pull/21/head", "refs/merge-requests/21/head"]) { + it(`loads ${prRef} when the target matches the initial branch`, async () => { + await withFixture(null, prRef, async ({ targetBranch, workspace, startSha, previousSha, headSha }) => { + await workspace.loadFromPullRequest({ + prIid: 21, + targetBranch, + headSha, + startSha, + baseSha: startSha, + previousSha, + }); + + expect((await workspace.read("sample.txt")).trim()).toBe("head"); + expect((await workspace.getFileDiff("sample.txt")).diff).toContain("-start"); + expect((await workspace.getPushFileDiff("sample.txt")).diff).toContain("-previous"); + }); + }); + } + + for (const branch of [null, "master", "main", "release/stable"]) { + it(`loads branch ${branch ?? "matching the initial default"}`, async () => { + await withFixture(branch, "refs/pull/21/head", async ({ targetBranch, workspace }) => { + await workspace.loadFromBranch(targetBranch); + expect((await workspace.read("sample.txt")).trim()).toBe("start"); + }); + }); + } + + it("loads the local branch in an adopted repository and preserves the directory", async () => { + await withFixture("master", "refs/pull/21/head", async ({ dir, workspace, startSha }) => { + await workspace.adopt(dir); + await workspace.loadFromBranch("master"); + + expect((await workspace.read("sample.txt")).trim()).toBe("start"); + expect(await git(dir, ["rev-parse", "HEAD"])).toBe(startSha); + expect(await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).toBe("HEAD"); + await workspace.clean(); + expect((await readFile(join(dir, "sample.txt"), "utf8")).trim()).toBe("start"); + }); + }); + }); +} diff --git a/apps/api/src/git-provider/workspace.ts b/apps/api/src/git-provider/workspace.ts index d6ab98cc..a4f33968 100644 --- a/apps/api/src/git-provider/workspace.ts +++ b/apps/api/src/git-provider/workspace.ts @@ -100,7 +100,7 @@ export class Workspace { this.authHeader = null; } - public async fetch(ref: string, options: { depth?: number } = {}): Promise { + public async fetch(ref: string, options: { depth?: number } = {}): Promise { if (!this.rootDir || !this.authHeader) { throw new Error("Workspace is not initialized. Call init() first."); } @@ -111,7 +111,8 @@ export class Workspace { } const depth = options.depth ?? 1; - const spec = SHA_PATTERN.test(want) ? want : `${want}:${want}`; + const target = want.replace(/^refs\/heads\//, "refs/remotes/origin/"); + const spec = SHA_PATTERN.test(want) ? want : `${want}:${target}`; await runCommand( ["git", "-c", `http.extraHeader=${this.authHeader}`, "fetch", `--depth=${depth}`, "origin", spec], { @@ -119,6 +120,9 @@ export class Workspace { env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, }, ); + + // Returns the fetched remote ref name (refs/remotes/origin/...) + return target; } public async checkout(sha: string): Promise { @@ -180,10 +184,8 @@ export class Workspace { await this.init(); } const branchRef = this.provider.getBranchFetchRef(branch); - if (this.authHeader) { - await this.fetch(branchRef); - } - await this.checkout(branchRef); + const target = this.authHeader ? await this.fetch(branchRef) : branchRef; + await this.checkout(target); } public setVersion(version: WorkspaceVersion): void {