|
| 1 | +import { spawnSync } from "node:child_process" |
| 2 | +import { mkdtempSync, rmSync } from "node:fs" |
| 3 | +import { tmpdir } from "node:os" |
| 4 | +import { resolve } from "node:path" |
| 5 | +import process from "node:process" |
| 6 | + |
| 7 | +const root = resolve(import.meta.dirname, "../..") |
| 8 | +const pnpm = process.platform === "win32" ? process.env.npm_execpath : "pnpm" |
| 9 | +if (!pnpm) throw new Error("pnpm executable path is unavailable") |
| 10 | +const command = process.platform === "win32" ? process.execPath : pnpm |
| 11 | +const args = process.platform === "win32" ? [pnpm] : [] |
| 12 | + |
| 13 | +const gitError = (result) => { |
| 14 | + const details = [result.error?.message, result.signal, result.stderr, result.stdout].filter(Boolean).join("\n") |
| 15 | + return new Error(details || `git exited with status ${result.status ?? "unknown"}`) |
| 16 | +} |
| 17 | + |
| 18 | +const git = (gitArgs) => { |
| 19 | + const result = spawnSync("git", gitArgs, { cwd: root, encoding: "utf8" }) |
| 20 | + if (result.status !== 0) throw gitError(result) |
| 21 | +} |
| 22 | + |
| 23 | +const trackedTreeStatus = () => |
| 24 | + spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=no"], { cwd: root, encoding: "utf8" }) |
| 25 | + |
| 26 | +// Probe the working tree so local runs validate uncommitted task edits. CI |
| 27 | +// runs on a clean tree, where stash create exits without a SHA and this |
| 28 | +// falls back to HEAD. git stash create can exit 1 with empty output when |
| 29 | +// stale index metadata refreshes to a clean tracked tree. Confirm a clean |
| 30 | +// tracked tree before trusting that exit. |
| 31 | +export const stashCommit = (result, trackedTree = trackedTreeStatus) => { |
| 32 | + if (result.status === 0) return result.stdout.trim() || "HEAD" |
| 33 | + const quietExit = result.status === 1 && !result.error && !result.signal && !result.stdout && !result.stderr |
| 34 | + if (!quietExit) throw gitError(result) |
| 35 | + const status = trackedTree() |
| 36 | + if (status.status !== 0) throw gitError(status) |
| 37 | + if (status.stdout.trim() !== "") { |
| 38 | + const changes = status.stdout.trim() |
| 39 | + throw new Error(`git stash create found no changes, but git status reports tracked changes:\n${changes}`) |
| 40 | + } |
| 41 | + return "HEAD" |
| 42 | +} |
| 43 | + |
| 44 | +const worktreeCommit = () => stashCommit(spawnSync("git", ["stash", "create"], { cwd: root, encoding: "utf8" })) |
| 45 | + |
| 46 | +export const turboGraph = (probeRoot, taskArgs, env = process.env) => { |
| 47 | + const result = spawnSync( |
| 48 | + command, |
| 49 | + [...args, "turbo", "--cwd", probeRoot, "run", ...taskArgs, "--dry=json", "--no-daemon"], |
| 50 | + { cwd: root, encoding: "utf8", env }, |
| 51 | + ) |
| 52 | + if (result.status !== 0) |
| 53 | + throw new Error( |
| 54 | + [result.error?.message, result.signal, result.stderr, result.stdout].filter(Boolean).join("\n") || |
| 55 | + `pnpm exited with status ${result.status ?? "unknown"}`, |
| 56 | + ) |
| 57 | + const lines = result.stdout.split("\n") |
| 58 | + let jsonStart = -1 |
| 59 | + for (let i = 0; i < lines.length; i++) { |
| 60 | + if (lines[i].startsWith("{")) jsonStart = i |
| 61 | + } |
| 62 | + if (jsonStart < 0) { |
| 63 | + const details = [result.error?.message, result.stderr, result.stdout].filter(Boolean).join("\n") |
| 64 | + throw new Error(details || "turbo dry run produced no JSON graph") |
| 65 | + } |
| 66 | + return JSON.parse(lines.slice(jsonStart).join("\n")) |
| 67 | +} |
| 68 | + |
| 69 | +export const createProbe = () => { |
| 70 | + const probeRoot = mkdtempSync(resolve(tmpdir(), "zoo-build-cache-probe-")) |
| 71 | + let worktreeAdded = false |
| 72 | + let cleaned = false |
| 73 | + const cleanup = () => { |
| 74 | + if (cleaned) return |
| 75 | + cleaned = true |
| 76 | + try { |
| 77 | + if (worktreeAdded) git(["worktree", "remove", "--force", probeRoot]) |
| 78 | + } finally { |
| 79 | + rmSync(probeRoot, { recursive: true, force: true }) |
| 80 | + } |
| 81 | + } |
| 82 | + const terminate = (signal) => { |
| 83 | + cleanup() |
| 84 | + process.kill(process.pid, signal) |
| 85 | + } |
| 86 | + const onSigint = () => terminate("SIGINT") |
| 87 | + const onSigterm = () => terminate("SIGTERM") |
| 88 | + process.once("SIGINT", onSigint) |
| 89 | + process.once("SIGTERM", onSigterm) |
| 90 | + |
| 91 | + try { |
| 92 | + git(["worktree", "add", "--detach", probeRoot, worktreeCommit()]) |
| 93 | + worktreeAdded = true |
| 94 | + |
| 95 | + return { |
| 96 | + probeRoot, |
| 97 | + cleanup: () => { |
| 98 | + process.off("SIGINT", onSigint) |
| 99 | + process.off("SIGTERM", onSigterm) |
| 100 | + cleanup() |
| 101 | + }, |
| 102 | + } |
| 103 | + } catch (error) { |
| 104 | + process.off("SIGINT", onSigint) |
| 105 | + process.off("SIGTERM", onSigterm) |
| 106 | + cleanup() |
| 107 | + throw error |
| 108 | + } |
| 109 | +} |
0 commit comments