import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { execFile } from "node:child_process"; const execFileAsync = promisify(execFile); function workspaceName(targetPath) { const digest = createHash("sha256").update(path.resolve(targetPath)).digest("hex").slice(0, 16); const base = path.basename(targetPath).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 20) || "workspace"; return `codex_${digest}_${base}`; } function optionTakesValue(arg) { return arg === "-C" || arg === "-c" || arg === "--git-dir" || arg === "--work-tree" || arg === "--namespace" || arg === "--super-prefix" || arg === "--config-env"; } export function parseWorktreeCommand(args, initialCwd = process.cwd()) { let cwd = path.resolve(initialCwd); let commandIndex = -1; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "worktree") { commandIndex = index; break; } if (arg === "-C" && args[index + 1]) { cwd = path.resolve(cwd, args[index + 1]); index += 1; continue; } if (optionTakesValue(arg)) index += 1; } if (commandIndex < 0) return null; const subcommand = args[commandIndex + 1]; if (subcommand !== "add" && subcommand !== "remove") return null; const positional = []; const worktreeArgs = args.slice(commandIndex + 2); for (let index = 0; index < worktreeArgs.length; index += 1) { const arg = worktreeArgs[index]; if (arg === "--") { positional.push(...worktreeArgs.slice(index + 1)); break; } if (arg === "-b" || arg === "-B" || arg === "--reason") { index += 1; continue; } if (arg.startsWith("--reason=")) continue; if (arg.startsWith("-")) continue; positional.push(arg); } return { cwd, subcommand, force: worktreeArgs.includes("-f") || worktreeArgs.includes("--force"), targetPath: positional[0] ? path.resolve(cwd, positional[0]) : null, revision: subcommand === "add" ? positional[1] || "HEAD" : null, }; } async function commandOutput(command, args, options = {}) { const { stdout } = await execFileAsync(command, args, { cwd: options.cwd, env: options.env || process.env, timeout: options.timeout || 10_000, maxBuffer: 4 * 1024 * 1024, }); return stdout.trim(); } async function findJjRoot(jj, cwd) { try { const root = await commandOutput(jj, ["root", "-R", cwd], { timeout: 3_000 }); return root ? path.resolve(root) : null; } catch { return null; } } async function appendGitExclude(commonDir) { const excludePath = path.join(commonDir, "info", "exclude"); await fs.mkdir(path.dirname(excludePath), { recursive: true }); const current = await fs.readFile(excludePath, "utf8").catch(() => ""); const lines = new Set(current.split(/\r?\n/)); if (lines.has(".jj") && lines.has(".jj/")) return; const separator = current.length > 0 && !current.endsWith("\n") ? "\n" : ""; await fs.appendFile(excludePath, `${separator}.jj\n.jj/\n`); } async function resolveGitCommonDir(realGit, cwd) { const raw = await commandOutput(realGit, ["-C", cwd, "rev-parse", "--git-common-dir"]); return path.resolve(cwd, raw); } async function resolveRevision(realGit, cwd, revision) { return commandOutput(realGit, ["-C", cwd, "rev-parse", "--verify", `${revision}^{commit}`]); } async function createJjWorkspace({ jj, realGit, jjRoot, cwd, targetPath, revision }) { if (!targetPath) throw new Error("git worktree add did not specify a destination"); if (path.resolve(targetPath) === path.resolve(jjRoot)) throw new Error("refusing to replace the source Jujutsu workspace"); const targetStat = await fs.stat(targetPath).catch(() => null); if (targetStat) { if (!targetStat.isDirectory()) throw new Error(`worktree destination is not a directory: ${targetPath}`); const entries = await fs.readdir(targetPath); if (entries.length > 0) throw new Error(`worktree destination is not empty: ${targetPath}`); await fs.rmdir(targetPath); } await fs.mkdir(path.dirname(targetPath), { recursive: true }); const commit = await resolveRevision(realGit, cwd, revision); const name = workspaceName(targetPath); const commonDir = await resolveGitCommonDir(realGit, jjRoot); const gitDir = path.join(commonDir, "worktrees", name); if (await fs.stat(gitDir).catch(() => null)) { throw new Error(`Git compatibility directory already exists: ${gitDir}`); } let workspaceCreated = false; try { await execFileAsync(jj, ["workspace", "add", "--name", name, "-r", commit, targetPath, "-R", jjRoot], { cwd: jjRoot, timeout: 15_000, }); workspaceCreated = true; await fs.mkdir(path.dirname(gitDir), { recursive: true }); await fs.mkdir(gitDir); await fs.writeFile(path.join(gitDir, "gitdir"), `${path.join(targetPath, ".git")}\n`); await fs.writeFile(path.join(gitDir, "commondir"), "../../\n"); await fs.writeFile(path.join(gitDir, "HEAD"), `${commit}\n`); await fs.writeFile(path.join(gitDir, "omp-codex-jj-workspace"), `${name}\n`); await fs.writeFile(path.join(targetPath, ".git"), `gitdir: ${gitDir}\n`); await appendGitExclude(commonDir); await execFileAsync(realGit, ["-C", targetPath, "reset", "--mixed", "HEAD"], { timeout: 10_000 }); process.stdout.write(`Preparing Jujutsu workspace (detached HEAD ${commit.slice(0, 12)})\n`); } catch (error) { await fs.rm(gitDir, { recursive: true, force: true }).catch(() => {}); if (workspaceCreated) { await execFileAsync(jj, ["workspace", "forget", name, "-R", jjRoot], { cwd: jjRoot, timeout: 5_000 }).catch(() => {}); await fs.rm(targetPath, { recursive: true, force: true }).catch(() => {}); } throw error; } } async function removeJjWorkspace({ jj, realGit, jjRoot, targetPath, force }) { if (!targetPath) throw new Error("git worktree remove did not specify a destination"); if (path.resolve(targetPath) === path.resolve(jjRoot)) throw new Error("refusing to remove the source Jujutsu workspace"); const name = workspaceName(targetPath); const commonDir = await resolveGitCommonDir(realGit, jjRoot); const gitDir = path.join(commonDir, "worktrees", name); const registeredGitFile = await fs.readFile(path.join(gitDir, "gitdir"), "utf8").catch(() => ""); const expectedGitFile = path.join(targetPath, ".git"); if (path.resolve(registeredGitFile.trim()) !== path.resolve(expectedGitFile)) return false; const markerExists = await fs.stat(path.join(gitDir, "omp-codex-jj-workspace")).then(() => true).catch(() => false); const jjPointerExists = await fs.stat(path.join(targetPath, ".jj")).then((stat) => stat.isDirectory()).catch(() => false); const gitPointer = await fs.readFile(expectedGitFile, "utf8").catch(() => ""); const ownsTarget = markerExists || (jjPointerExists && gitPointer.trim() === `gitdir: ${gitDir}`); if (!ownsTarget) return false; if (!force && await fs.stat(targetPath).then(() => true).catch(() => false)) { const status = await commandOutput(realGit, ["-C", targetPath, "status", "--porcelain"]); if (status) throw new Error(`'${targetPath}' contains modified or untracked files; use --force to delete it`); } await execFileAsync(jj, ["workspace", "forget", name, "-R", jjRoot], { cwd: jjRoot, timeout: 5_000 }); await fs.rm(gitDir, { recursive: true, force: true }); await fs.rm(targetPath, { recursive: true, force: true }); return true; } function delegate(realGit, args) { return new Promise((resolve, reject) => { const child = spawn(realGit, args, { stdio: "inherit", env: process.env }); child.on("error", reject); child.on("exit", (code, signal) => { if (signal) { process.kill(process.pid, signal); return; } resolve(code ?? 1); }); }); } export async function runGitWorktreeProxy(args, options = {}) { const realGit = options.realGit || process.env.OMP_CODEX_REAL_GIT || "/usr/bin/git"; const jj = options.jj || process.env.OMP_CODEX_JJ || "jj"; const parsed = parseWorktreeCommand(args, options.cwd || process.cwd()); if (!parsed) return delegate(realGit, args); const jjRoot = await findJjRoot(jj, parsed.cwd); if (!jjRoot) return delegate(realGit, args); if (parsed.subcommand === "add") { await createJjWorkspace({ jj, realGit, jjRoot, ...parsed }); } else if (!await removeJjWorkspace({ jj, realGit, jjRoot, ...parsed })) { return delegate(realGit, args); } return 0; }