Merge pull request #3386 from paperclipai/pap-1347-dev-runner-worktree-env

fix: isolate dev runner worktree env
This commit is contained in:
Dotta
2026-04-11 08:45:16 -05:00
committed by GitHub
4 changed files with 173 additions and 0 deletions
@@ -0,0 +1,75 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
bootstrapDevRunnerWorktreeEnv,
isLinkedGitWorktreeCheckout,
resolveWorktreeEnvFilePath,
} from "../dev-runner-worktree.ts";
const tempRoots = new Set<string>();
afterEach(() => {
for (const root of tempRoots) {
fs.rmSync(root, { recursive: true, force: true });
}
tempRoots.clear();
});
function createTempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempRoots.add(root);
return root;
}
describe("dev-runner worktree env bootstrap", () => {
it("detects linked git worktrees from .git files", () => {
const root = createTempRoot("paperclip-dev-runner-worktree-");
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8");
expect(isLinkedGitWorktreeCheckout(root)).toBe(true);
});
it("loads repo-local Paperclip env for initialized worktrees without overriding explicit env", () => {
const root = createTempRoot("paperclip-dev-runner-worktree-env-");
fs.mkdirSync(path.join(root, ".paperclip"), { recursive: true });
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8");
fs.writeFileSync(
resolveWorktreeEnvFilePath(root),
[
"PAPERCLIP_HOME=/tmp/paperclip-worktrees",
"PAPERCLIP_INSTANCE_ID=feature-worktree",
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=feature-worktree",
"PAPERCLIP_OPTIONAL= # comment-only value",
"",
].join("\n"),
"utf8",
);
const env: NodeJS.ProcessEnv = {
PAPERCLIP_INSTANCE_ID: "already-set",
};
const result = bootstrapDevRunnerWorktreeEnv(root, env);
expect(result).toEqual({
envPath: resolveWorktreeEnvFilePath(root),
missingEnv: false,
});
expect(env.PAPERCLIP_HOME).toBe("/tmp/paperclip-worktrees");
expect(env.PAPERCLIP_INSTANCE_ID).toBe("already-set");
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
expect(env.PAPERCLIP_OPTIONAL).toBe("");
});
it("reports uninitialized linked worktrees so dev runner can fail fast", () => {
const root = createTempRoot("paperclip-dev-runner-worktree-missing-");
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8");
expect(bootstrapDevRunnerWorktreeEnv(root, {})).toEqual({
envPath: resolveWorktreeEnvFilePath(root),
missingEnv: true,
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import { existsSync, lstatSync, readFileSync } from "node:fs";
import path from "node:path";
function parseEnvFile(contents: string): Record<string, string> {
const entries: Record<string, string> = {};
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (!match) continue;
const [, key, rawValue] = match;
const value = rawValue.trim();
if (!value) {
entries[key] = "";
continue;
}
if (value.startsWith("#")) {
entries[key] = "";
continue;
}
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
entries[key] = value.slice(1, -1);
continue;
}
entries[key] = value.replace(/\s+#.*$/, "").trim();
}
return entries;
}
type WorktreeEnvBootstrapResult =
| { envPath: null; missingEnv: false }
| { envPath: string; missingEnv: true }
| { envPath: string; missingEnv: false };
export function isLinkedGitWorktreeCheckout(rootDir: string): boolean {
const gitMetadataPath = path.join(rootDir, ".git");
if (!existsSync(gitMetadataPath)) return false;
const stat = lstatSync(gitMetadataPath);
if (!stat.isFile()) return false;
return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:");
}
export function resolveWorktreeEnvFilePath(rootDir: string): string {
return path.resolve(rootDir, ".paperclip", ".env");
}
export function bootstrapDevRunnerWorktreeEnv(
rootDir: string,
env: NodeJS.ProcessEnv = process.env,
): WorktreeEnvBootstrapResult {
if (!isLinkedGitWorktreeCheckout(rootDir)) {
return {
envPath: null,
missingEnv: false,
};
}
const envPath = resolveWorktreeEnvFilePath(rootDir);
if (!existsSync(envPath)) {
return {
envPath,
missingEnv: true,
};
}
const entries = parseEnvFile(readFileSync(envPath, "utf8"));
for (const [key, value] of Object.entries(entries)) {
if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue;
env[key] = value;
}
return {
envPath,
missingEnv: false,
};
}