Spaces:
Build error
Build error
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { execFileSync } from "node:child_process"; | |
| import { afterEach, describe, expect, it, vi } from "vitest"; | |
| import { | |
| copyGitHooksToWorktreeGitDir, | |
| copySeededSecretsKey, | |
| rebindWorkspaceCwd, | |
| resolveSourceConfigPath, | |
| resolveGitWorktreeAddArgs, | |
| resolveWorktreeMakeTargetPath, | |
| worktreeInitCommand, | |
| worktreeMakeCommand, | |
| } from "../commands/worktree.js"; | |
| import { | |
| buildWorktreeConfig, | |
| buildWorktreeEnvEntries, | |
| formatShellExports, | |
| generateWorktreeColor, | |
| resolveWorktreeSeedPlan, | |
| resolveWorktreeLocalPaths, | |
| rewriteLocalUrlPort, | |
| sanitizeWorktreeInstanceId, | |
| } from "../commands/worktree-lib.js"; | |
| import type { PaperclipConfig } from "../config/schema.js"; | |
| const ORIGINAL_CWD = process.cwd(); | |
| const ORIGINAL_ENV = { ...process.env }; | |
| afterEach(() => { | |
| process.chdir(ORIGINAL_CWD); | |
| for (const key of Object.keys(process.env)) { | |
| if (!(key in ORIGINAL_ENV)) delete process.env[key]; | |
| } | |
| for (const [key, value] of Object.entries(ORIGINAL_ENV)) { | |
| if (value === undefined) delete process.env[key]; | |
| else process.env[key] = value; | |
| } | |
| }); | |
| function buildSourceConfig(): PaperclipConfig { | |
| return { | |
| $meta: { | |
| version: 1, | |
| updatedAt: "2026-03-09T00:00:00.000Z", | |
| source: "configure", | |
| }, | |
| database: { | |
| mode: "embedded-postgres", | |
| embeddedPostgresDataDir: "/tmp/main/db", | |
| embeddedPostgresPort: 54329, | |
| backup: { | |
| enabled: true, | |
| intervalMinutes: 60, | |
| retentionDays: 30, | |
| dir: "/tmp/main/backups", | |
| }, | |
| }, | |
| logging: { | |
| mode: "file", | |
| logDir: "/tmp/main/logs", | |
| }, | |
| server: { | |
| deploymentMode: "authenticated", | |
| exposure: "private", | |
| host: "127.0.0.1", | |
| port: 3100, | |
| allowedHostnames: ["localhost"], | |
| serveUi: true, | |
| }, | |
| auth: { | |
| baseUrlMode: "explicit", | |
| publicBaseUrl: "http://127.0.0.1:3100", | |
| disableSignUp: false, | |
| }, | |
| storage: { | |
| provider: "local_disk", | |
| localDisk: { | |
| baseDir: "/tmp/main/storage", | |
| }, | |
| s3: { | |
| bucket: "paperclip", | |
| region: "us-east-1", | |
| prefix: "", | |
| forcePathStyle: false, | |
| }, | |
| }, | |
| secrets: { | |
| provider: "local_encrypted", | |
| strictMode: false, | |
| localEncrypted: { | |
| keyFilePath: "/tmp/main/secrets/master.key", | |
| }, | |
| }, | |
| }; | |
| } | |
| describe("worktree helpers", () => { | |
| it("sanitizes instance ids", () => { | |
| expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support"); | |
| expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree"); | |
| }); | |
| it("resolves worktree:make target paths under the user home directory", () => { | |
| expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe( | |
| path.resolve(os.homedir(), "paperclip-pr-432"), | |
| ); | |
| }); | |
| it("rejects worktree:make names that are not safe directory/branch names", () => { | |
| expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow( | |
| "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", | |
| ); | |
| }); | |
| it("builds git worktree add args for new and existing branches", () => { | |
| expect( | |
| resolveGitWorktreeAddArgs({ | |
| branchName: "feature-branch", | |
| targetPath: "/tmp/feature-branch", | |
| branchExists: false, | |
| }), | |
| ).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]); | |
| expect( | |
| resolveGitWorktreeAddArgs({ | |
| branchName: "feature-branch", | |
| targetPath: "/tmp/feature-branch", | |
| branchExists: true, | |
| }), | |
| ).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]); | |
| }); | |
| it("builds git worktree add args with a start point", () => { | |
| expect( | |
| resolveGitWorktreeAddArgs({ | |
| branchName: "my-worktree", | |
| targetPath: "/tmp/my-worktree", | |
| branchExists: false, | |
| startPoint: "public-gh/master", | |
| }), | |
| ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]); | |
| }); | |
| it("uses start point even when a local branch with the same name exists", () => { | |
| expect( | |
| resolveGitWorktreeAddArgs({ | |
| branchName: "my-worktree", | |
| targetPath: "/tmp/my-worktree", | |
| branchExists: true, | |
| startPoint: "origin/main", | |
| }), | |
| ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]); | |
| }); | |
| it("rewrites loopback auth URLs to the new port only", () => { | |
| expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); | |
| expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example"); | |
| }); | |
| it("builds isolated config and env paths for a worktree", () => { | |
| const paths = resolveWorktreeLocalPaths({ | |
| cwd: "/tmp/paperclip-feature", | |
| homeDir: "/tmp/paperclip-worktrees", | |
| instanceId: "feature-worktree-support", | |
| }); | |
| const config = buildWorktreeConfig({ | |
| sourceConfig: buildSourceConfig(), | |
| paths, | |
| serverPort: 3110, | |
| databasePort: 54339, | |
| now: new Date("2026-03-09T12:00:00.000Z"), | |
| }); | |
| expect(config.database.embeddedPostgresDataDir).toBe( | |
| path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"), | |
| ); | |
| expect(config.database.embeddedPostgresPort).toBe(54339); | |
| expect(config.server.port).toBe(3110); | |
| expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/"); | |
| expect(config.storage.localDisk.baseDir).toBe( | |
| path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"), | |
| ); | |
| const env = buildWorktreeEnvEntries(paths, { | |
| name: "feature-worktree-support", | |
| color: "#3abf7a", | |
| }); | |
| expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); | |
| expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); | |
| expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); | |
| expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support"); | |
| expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a"); | |
| expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); | |
| }); | |
| it("generates vivid worktree colors as hex", () => { | |
| expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/); | |
| }); | |
| it("uses minimal seed mode to keep app state but drop heavy runtime history", () => { | |
| const minimal = resolveWorktreeSeedPlan("minimal"); | |
| const full = resolveWorktreeSeedPlan("full"); | |
| expect(minimal.excludedTables).toContain("heartbeat_runs"); | |
| expect(minimal.excludedTables).toContain("heartbeat_run_events"); | |
| expect(minimal.excludedTables).toContain("workspace_runtime_services"); | |
| expect(minimal.excludedTables).toContain("agent_task_sessions"); | |
| expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]); | |
| expect(full.excludedTables).toEqual([]); | |
| expect(full.nullifyColumns).toEqual({}); | |
| }); | |
| it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { | |
| const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); | |
| const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; | |
| const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; | |
| try { | |
| delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; | |
| delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; | |
| const sourceConfigPath = path.join(tempRoot, "source", "config.json"); | |
| const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key"); | |
| const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); | |
| fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); | |
| fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); | |
| const sourceConfig = buildSourceConfig(); | |
| sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; | |
| copySeededSecretsKey({ | |
| sourceConfigPath, | |
| sourceConfig, | |
| sourceEnvEntries: {}, | |
| targetKeyFilePath: targetKeyPath, | |
| }); | |
| expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key"); | |
| } finally { | |
| if (originalInlineMasterKey === undefined) { | |
| delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; | |
| } else { | |
| process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey; | |
| } | |
| if (originalKeyFile === undefined) { | |
| delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; | |
| } else { | |
| process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile; | |
| } | |
| fs.rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("writes the source inline secrets master key into the seeded worktree instance", () => { | |
| const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); | |
| try { | |
| const sourceConfigPath = path.join(tempRoot, "source", "config.json"); | |
| const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); | |
| copySeededSecretsKey({ | |
| sourceConfigPath, | |
| sourceConfig: buildSourceConfig(), | |
| sourceEnvEntries: { | |
| PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key", | |
| }, | |
| targetKeyFilePath: targetKeyPath, | |
| }); | |
| expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key"); | |
| } finally { | |
| fs.rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("persists the current agent jwt secret into the worktree env file", async () => { | |
| const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-")); | |
| const repoRoot = path.join(tempRoot, "repo"); | |
| const originalCwd = process.cwd(); | |
| const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; | |
| try { | |
| fs.mkdirSync(repoRoot, { recursive: true }); | |
| process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret"; | |
| process.chdir(repoRoot); | |
| await worktreeInitCommand({ | |
| seed: false, | |
| fromConfig: path.join(tempRoot, "missing", "config.json"), | |
| home: path.join(tempRoot, ".paperclip-worktrees"), | |
| }); | |
| const envPath = path.join(repoRoot, ".paperclip", ".env"); | |
| const envContents = fs.readFileSync(envPath, "utf8"); | |
| expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); | |
| expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo"); | |
| expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/); | |
| } finally { | |
| process.chdir(originalCwd); | |
| if (originalJwtSecret === undefined) { | |
| delete process.env.PAPERCLIP_AGENT_JWT_SECRET; | |
| } else { | |
| process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret; | |
| } | |
| fs.rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("defaults the seed source config to the current repo-local Paperclip config", () => { | |
| const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); | |
| const repoRoot = path.join(tempRoot, "repo"); | |
| const localConfigPath = path.join(repoRoot, ".paperclip", "config.json"); | |
| const originalCwd = process.cwd(); | |
| const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; | |
| try { | |
| fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); | |
| fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); | |
| delete process.env.PAPERCLIP_CONFIG; | |
| process.chdir(repoRoot); | |
| expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath)); | |
| } finally { | |
| process.chdir(originalCwd); | |
| if (originalPaperclipConfig === undefined) { | |
| delete process.env.PAPERCLIP_CONFIG; | |
| } else { | |
| process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; | |
| } | |
| fs.rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("preserves the source config path across worktree:make cwd changes", () => { | |
| const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-")); | |
| const sourceConfigPath = path.join(tempRoot, "source", "config.json"); | |
| const targetRoot = path.join(tempRoot, "target"); | |
| const originalCwd = process.cwd(); | |
| const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; | |
| try { | |
| fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); | |
| fs.mkdirSync(targetRoot, { recursive: true }); | |
| fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); | |
| delete process.env.PAPERCLIP_CONFIG; | |
| process.chdir(targetRoot); | |
| expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe( | |
| path.resolve(sourceConfigPath), | |
| ); | |
| } finally { | |
| process.chdir(originalCwd); | |
| if (originalPaperclipConfig === undefined) { | |
| delete process.env.PAPERCLIP_CONFIG; | |
| } else { | |
| process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; | |
| } | |
| fs.rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("rebinds same-repo workspace paths onto the current worktree root", () => { | |
| expect( | |
| rebindWorkspaceCwd({ | |
| sourceRepoRoot: "/Users/example/paperclip", | |
| targetRepoRoot: "/Users/example/paperclip-pr-432", | |
| workspaceCwd: "/Users/example/paperclip", | |
| }), | |
| ).toBe("/Users/example/paperclip-pr-432"); | |
| expect( | |
| rebindWorkspaceCwd({ | |
| sourceRepoRoot: "/Users/example/paperclip", | |
| targetRepoRoot: "/Users/example/paperclip-pr-432", | |
| workspaceCwd: "/Users/example/paperclip/packages/db", | |
| }), | |
| ).toBe("/Users/example/paperclip-pr-432/packages/db"); | |
| }); | |
| it("does not rebind paths outside the source repo root", () => { | |
| expect( | |
| rebindWorkspaceCwd({ | |
| sourceRepoRoot: "/Users/example/paperclip", | |
| targetRepoRoot: "/Users/example/paperclip-pr-432", | |
| workspaceCwd: "/Users/example/other-project", | |
| }), | |
| ).toBeNull(); | |
| }); | |
| it("copies shared git hooks into a linked worktree git dir", () => { | |
| const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-")); | |
| const repoRoot = path.join(tempRoot, "repo"); | |
| const worktreePath = path.join(tempRoot, "repo-feature"); | |
| try { | |
| fs.mkdirSync(repoRoot, { recursive: true }); | |
| execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); | |
| execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); | |
| execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); | |
| fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); | |
| execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); | |
| execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); | |
| const sourceHooksDir = path.join(repoRoot, ".git", "hooks"); | |
| const sourceHookPath = path.join(sourceHooksDir, "pre-commit"); | |
| const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt"); | |
| fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 }); | |
| fs.chmodSync(sourceHookPath, 0o755); | |
| fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8"); | |
| execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" }); | |
| const copied = copyGitHooksToWorktreeGitDir(worktreePath); | |
| const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], { | |
| cwd: worktreePath, | |
| encoding: "utf8", | |
| stdio: ["ignore", "pipe", "ignore"], | |
| }).trim(); | |
| const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir); | |
| const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks")); | |
| const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit"); | |
| const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt"); | |
| expect(copied).toMatchObject({ | |
| sourceHooksPath: resolvedSourceHooksDir, | |
| targetHooksPath: resolvedTargetHooksDir, | |
| copied: true, | |
| }); | |
| expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n"); | |
| expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0); | |
| expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n"); | |
| } finally { | |
| execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" }); | |
| fs.rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("creates and initializes a worktree from the top-level worktree:make command", async () => { | |
| const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-")); | |
| const repoRoot = path.join(tempRoot, "repo"); | |
| const fakeHome = path.join(tempRoot, "home"); | |
| const worktreePath = path.join(fakeHome, "paperclip-make-test"); | |
| const originalCwd = process.cwd(); | |
| const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| try { | |
| fs.mkdirSync(repoRoot, { recursive: true }); | |
| fs.mkdirSync(fakeHome, { recursive: true }); | |
| execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); | |
| execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); | |
| execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); | |
| fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); | |
| execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); | |
| execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); | |
| process.chdir(repoRoot); | |
| await worktreeMakeCommand("paperclip-make-test", { | |
| seed: false, | |
| home: path.join(tempRoot, ".paperclip-worktrees"), | |
| }); | |
| expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true); | |
| expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true); | |
| expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true); | |
| } finally { | |
| process.chdir(originalCwd); | |
| homedirSpy.mockRestore(); | |
| fs.rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }, 20_000); | |
| }); | |