From 6a303e3a09faed84fb076629632d2dce6c1c3edc Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Fri, 9 Oct 2026 10:23:21 +0800 Subject: [PATCH 1/3] Fix workspace fetch into the current branch (#66) Store fetched branch refs under `refs/remotes/origin/xxx` and checkout the returned ref --- apps/api/src/git-provider/workspace.test.ts | 118 ++++++++++++++++++++ apps/api/src/git-provider/workspace.ts | 14 ++- 2 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/git-provider/workspace.test.ts 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..8b0ba23f --- /dev/null +++ b/apps/api/src/git-provider/workspace.test.ts @@ -0,0 +1,118 @@ +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 { 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(); +} + +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 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 { From 00de5c3dbb0bf46281e344ac82b8256b321fa055 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Fri, 9 Oct 2026 10:31:06 +0800 Subject: [PATCH 2/3] Isolate workspace tests to prevent module mock interference --- apps/api/src/git-provider/workspace.test.ts | 189 +++++++++++--------- 1 file changed, 103 insertions(+), 86 deletions(-) diff --git a/apps/api/src/git-provider/workspace.test.ts b/apps/api/src/git-provider/workspace.test.ts index 8b0ba23f..27e6cdcc 100644 --- a/apps/api/src/git-provider/workspace.test.ts +++ b/apps/api/src/git-provider/workspace.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { MockProvider } from "../../mock/provider.js"; -import { Workspace } from "./workspace.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" }); @@ -19,100 +19,117 @@ async function git(dir: string, argList: string[]): Promise { return stdout.trim(); } -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); +// 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"); - 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 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]); + 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); - await run({ dir, targetBranch, workspace, startSha, previousSha, headSha }); - } finally { - await workspace.clean(); - await rm(dir, { recursive: true, force: true }); + 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 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, - }); + 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"); + 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"); + 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"); + 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"); + 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"); + }); }); }); -}); +} From 840a0c15fb364d3557205d23de165ce628bfab78 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Fri, 9 Oct 2026 11:30:11 +0800 Subject: [PATCH 3/3] Expose previous commit through a fixture branch --- apps/api/src/git-provider/workspace.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/git-provider/workspace.test.ts b/apps/api/src/git-provider/workspace.test.ts index 27e6cdcc..4f603e90 100644 --- a/apps/api/src/git-provider/workspace.test.ts +++ b/apps/api/src/git-provider/workspace.test.ts @@ -78,6 +78,7 @@ if (process.env.PROVAL_WORKSPACE_TEST_CHILD !== "1") { 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"]);