diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 334ab519..dd832df4 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -1,8 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; import { describe, expect, it } from "vitest"; -import { copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js"; +import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, @@ -199,4 +200,52 @@ describe("worktree helpers", () => { }), ).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 }); + } + }); }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 4ef43572..e2fa8da8 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1,4 +1,16 @@ -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; @@ -80,6 +92,14 @@ type EmbeddedPostgresHandle = { type GitWorkspaceInfo = { root: string; commonDir: string; + gitDir: string; + hooksPath: string; +}; + +type CopiedGitHooksResult = { + sourceHooksPath: string; + targetHooksPath: string; + copied: boolean; }; type SeedWorktreeDatabaseResult = { @@ -162,15 +182,88 @@ function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); + const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); return { root: path.resolve(root), commonDir: path.resolve(root, commonDirRaw), + gitDir: path.resolve(root, gitDirRaw), + hooksPath: path.resolve(root, hooksPathRaw), }; } catch { return null; } } +function copyDirectoryContents(sourceDir: string, targetDir: string): boolean { + if (!existsSync(sourceDir)) return false; + + const entries = readdirSync(sourceDir, { withFileTypes: true }); + if (entries.length === 0) return false; + + mkdirSync(targetDir, { recursive: true }); + + let copied = false; + for (const entry of entries) { + const sourcePath = path.resolve(sourceDir, entry.name); + const targetPath = path.resolve(targetDir, entry.name); + + if (entry.isDirectory()) { + mkdirSync(targetPath, { recursive: true }); + copyDirectoryContents(sourcePath, targetPath); + copied = true; + continue; + } + + if (entry.isSymbolicLink()) { + rmSync(targetPath, { recursive: true, force: true }); + symlinkSync(readlinkSync(sourcePath), targetPath); + copied = true; + continue; + } + + copyFileSync(sourcePath, targetPath); + try { + chmodSync(targetPath, statSync(sourcePath).mode & 0o777); + } catch { + // best effort + } + copied = true; + } + + return copied; +} + +export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null { + const workspace = detectGitWorkspaceInfo(cwd); + if (!workspace) return null; + + const sourceHooksPath = workspace.hooksPath; + const targetHooksPath = path.resolve(workspace.gitDir, "hooks"); + + if (sourceHooksPath === targetHooksPath) { + return { + sourceHooksPath, + targetHooksPath, + copied: false, + }; + } + + return { + sourceHooksPath, + targetHooksPath, + copied: copyDirectoryContents(sourceHooksPath, targetHooksPath), + }; +} + export function rebindWorkspaceCwd(input: { sourceRepoRoot: string; targetRepoRoot: string; @@ -493,6 +586,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise ${copiedGitHooks.targetHooksPath}`), + ); + } if (seedSummary) { p.log.message(pc.dim(`Seed mode: ${seedMode}`)); p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7d35e542..6b72d3d3 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -138,6 +138,7 @@ This command: - writes repo-local files at `.paperclip/config.json` and `.paperclip/.env` - creates an isolated instance under `~/.paperclip-worktrees/instances//` +- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir - picks a free app port and embedded PostgreSQL port - by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot diff --git a/server/src/__tests__/approvals-service.test.ts b/server/src/__tests__/approvals-service.test.ts index 967fd295..b15298f0 100644 --- a/server/src/__tests__/approvals-service.test.ts +++ b/server/src/__tests__/approvals-service.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { approvalService } from "../services/approvals.js"; +import { approvalService } from "../services/approvals.ts"; const mockAgentService = vi.hoisted(() => ({ activatePendingApproval: vi.fn(), @@ -38,15 +38,12 @@ function createApproval(status: string): ApprovalRecord { } function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) { - const selectWhere = vi.fn(); - for (const result of selectResults) { - selectWhere.mockResolvedValueOnce(result); - } - + const pendingSelectResults = [...selectResults]; + const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []); const from = vi.fn(() => ({ where: selectWhere })); const select = vi.fn(() => ({ from })); - const returning = vi.fn().mockResolvedValue(updateResults); + const returning = vi.fn(async () => updateResults); const updateWhere = vi.fn(() => ({ returning })); const set = vi.fn(() => ({ where: updateWhere })); const update = vi.fn(() => ({ set }));