diff --git a/packages/server/package.json b/packages/server/package.json index fee28237ab..b80941d56f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 64ba85eb48..be225f171d 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -1,5 +1,14 @@ import { test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + existsSync, + rmSync, + readFileSync, + realpathSync, + mkdirSync, +} from "fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -152,6 +161,78 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); +interface GitCommandOptions { + cwd: string; + args: string[]; +} + +async function mergeFeatureIntoBaseWorktree(baseName: string): Promise { + const directory = realpathSync(tmpCwd()); + const base = path.join(directory, baseName); + const feature = path.join(directory, "feature"); + function git({ cwd, args }: GitCommandOptions): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + mkdirSync(base); + git({ cwd: base, args: ["init", "-b", "main"] }); + git({ cwd: base, args: ["config", "user.name", "Paseo Test"] }); + git({ cwd: base, args: ["config", "user.email", "test@paseo.local"] }); + git({ cwd: base, args: ["config", "commit.gpgsign", "false"] }); + git({ cwd: base, args: ["config", "core.autocrlf", "false"] }); + git({ cwd: base, args: ["commit", "--allow-empty", "-m", "base"] }); + git({ cwd: base, args: ["worktree", "add", "-b", "feature", feature] }); + writeFileSync(path.join(feature, "feature.txt"), "feature payload\n"); + git({ cwd: feature, args: ["add", "feature.txt"] }); + git({ cwd: feature, args: ["commit", "-m", "feature"] }); + const featureCommit = git({ cwd: feature, args: ["rev-parse", "HEAD"] }).trim(); + + const merged = await ctx.client.checkoutMerge(feature, { baseRef: "main" }); + expect(merged).toMatchObject({ success: true, error: null }); + expect(git({ cwd: base, args: ["rev-parse", "HEAD"] }).trim()).toBe(featureCommit); + expect(readFileSync(path.join(base, "feature.txt"), "utf8")).toBe("feature payload\n"); + expect(git({ cwd: feature, args: ["symbolic-ref", "--short", "HEAD"] }).trim()).toBe("feature"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test.skipIf(isPlatform("win32")).each(["base\ncheckout", "base "])( + "merges into a base worktree whose path preserves %j", + mergeFeatureIntoBaseWorktree, +); + +test.each(["base checkout", "中文 checkout"])( + "merges into a base worktree named %j", + mergeFeatureIntoBaseWorktree, +); + +test("merges into a local base branch without another worktree", async () => { + const cwd = realpathSync(tmpCwd()); + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Paseo Test"]); + git(["config", "user.email", "test@paseo.local"]); + git(["config", "commit.gpgsign", "false"]); + git(["commit", "--allow-empty", "-m", "base"]); + git(["checkout", "-b", "feature"]); + writeFileSync(path.join(cwd, "feature.txt"), "feature payload\n"); + git(["add", "feature.txt"]); + git(["commit", "-m", "feature"]); + const featureCommit = git(["rev-parse", "HEAD"]).trim(); + + const merged = await ctx.client.checkoutMerge(cwd, { baseRef: "main" }); + expect(merged).toMatchObject({ success: true, error: null }); + expect(git(["rev-parse", "main"]).trim()).toBe(featureCommit); + expect(git(["symbolic-ref", "--short", "HEAD"]).trim()).toBe("feature"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + test("returns diff for modified file in git repo", async () => { const cwd = tmpCwd(); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 2e317810ba..ee3571504d 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1187,13 +1187,16 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] { async function getWorktreePathForBranch(cwd: string, branchName: string): Promise { try { - const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - const entries = parseWorktreeList(stdout); const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`; - return entries.find((entry) => entry.branchRef === ref)?.path ?? null; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname)%00%(worktreepath)%00", ref], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + for (const record of stdout.split("\0\n")) { + const [reference, worktreePath] = record.split("\0"); + if (reference === ref) return worktreePath || null; + } + return null; } catch { return null; }