// @generated by scripts/build-runtime.mjs; do not edit. // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader. // src/command.ts import { existsSync as existsSync4 } from "node:fs"; import { resolve as resolve2 } from "node:path"; // src/git.ts import { spawn } from "node:child_process"; import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; var GIT_TIMEOUT_MS = 15e3; var GIT_MUTATION_TIMEOUT_MS = 6e4; var LOCAL_BRANCH_PREFIX = "refs/heads/"; var GitWorktreeError = class extends Error { args; constructor(message, args) { super(message); this.name = "GitWorktreeError"; this.args = args; } }; function parseWorktreePorcelain(output) { const records = []; let current; const finish = () => { if (!current) return; records.push({ ...current, isMain: records.length === 0 }); current = void 0; }; for (const field of output.split("\0")) { if (field === "") { finish(); continue; } const separator = field.indexOf(" "); const key = separator < 0 ? field : field.slice(0, separator); const value = separator < 0 ? "" : field.slice(separator + 1); if (key === "worktree") { finish(); if (!value) throw new GitWorktreeError("Worktree porcelain record is missing path."); current = { path: value, bare: false, detached: false }; continue; } if (!current) { throw new GitWorktreeError( `Worktree porcelain field ${JSON.stringify(key)} appears before worktree.` ); } switch (key) { case "HEAD": current.head = value; break; case "branch": current.branchRef = value; current.branch = value.startsWith(LOCAL_BRANCH_PREFIX) ? value.slice(LOCAL_BRANCH_PREFIX.length) : void 0; break; case "bare": current.bare = true; break; case "detached": current.detached = true; break; case "locked": current.lockedReason = value; break; case "prunable": current.prunableReason = value; break; } } finish(); return records; } function worktreeForBranch(records, branch) { const branchRef = `${LOCAL_BRANCH_PREFIX}${branch}`; return records.find((record) => record.branchRef === branchRef); } function defaultWorktreePath(mainWorktreePath, branch, worktreeRoot) { return resolve(worktreeRoot, basename(mainWorktreePath), branch.replaceAll("/", "-")); } function buildAddArguments(input) { return input.startOid ? ["worktree", "add", "-b", input.branch, input.path, input.startOid] : ["worktree", "add", input.path, input.branch]; } function pathIdentity(path) { const absolute = resolve(path); if (!existsSync(absolute)) return absolute; try { return realpathSync.native(absolute); } catch { return absolute; } } function pathEntryExists(path) { try { lstatSync(path); return true; } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return false; throw new GitWorktreeError(`Cannot inspect filesystem path ${path}: ${formatError(error)}`); } } function unresolvableSymlinkAncestor(path) { let current = dirname(resolve(path)); while (true) { try { const stat = lstatSync(current); if (!stat.isSymbolicLink()) return void 0; try { realpathSync.native(current); return void 0; } catch (error) { if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ELOOP")) { return current; } throw new GitWorktreeError( `Cannot resolve filesystem ancestor ${current}: ${formatError(error)}` ); } } catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") { if (error instanceof GitWorktreeError) throw error; throw new GitWorktreeError( `Cannot inspect filesystem ancestor ${current}: ${formatError(error)}` ); } const parent = dirname(current); if (parent === current) return void 0; current = parent; } } } function pathsEqual(left, right) { return pathIdentity(left) === pathIdentity(right); } function sameWorktreeIdentity(left, right) { return pathsEqual(left.path, right.path) && left.head === right.head && left.branchRef === right.branchRef && left.detached === right.detached && left.isMain === right.isMain && left.bare === right.bare; } async function listWorktrees(pi, cwd, signal) { const result = await runGit(pi, ["worktree", "list", "--porcelain", "-z"], cwd, signal); return parseWorktreePorcelain(result.stdout); } async function currentWorktreePath(pi, cwd, signal) { const result = await runGit(pi, ["rev-parse", "--show-toplevel"], cwd, signal); const path = removeLineEnding(result.stdout); if (!path) throw new GitWorktreeError("Git did not return the current worktree path."); return pathIdentity(path); } async function symbolicBranch(pi, cwd, signal) { const result = await runGitAllowFailure( pi, ["symbolic-ref", "--quiet", "--short", "HEAD"], cwd, signal ); if (result.killed) throw killedError(["symbolic-ref", "--quiet", "--short", "HEAD"]); if (result.code !== 0) return void 0; return result.stdout.trim() || void 0; } async function validateBranch(pi, cwd, branch, signal) { const result = await runGit(pi, ["check-ref-format", "--branch", branch], cwd, signal); const normalized = result.stdout.trim(); if (!normalized) throw new GitWorktreeError("Git returned an empty branch name."); return normalized; } async function localBranchExists(pi, cwd, branch, signal) { const result = await runGitAllowFailure( pi, ["show-ref", "--verify", "--quiet", `${LOCAL_BRANCH_PREFIX}${branch}`], cwd, signal ); if (result.killed) throw killedError(["show-ref", "--verify", "--quiet"]); if (result.code === 0) return true; if (result.code === 1) return false; throw gitFailure(["show-ref", "--verify", "--quiet"], result); } async function resolveCommit(pi, cwd, startPoint, signal) { const result = await runGit( pi, ["rev-parse", "--verify", "--end-of-options", `${startPoint}^{commit}`], cwd, signal ); const oid = result.stdout.trim(); if (!/^[0-9a-fA-F]{40,64}$/u.test(oid)) { throw new GitWorktreeError(`Git returned an invalid commit object for ${startPoint}.`); } return oid; } async function addWorktree(pi, cwd, input, signal) { await runGit(pi, buildAddArguments(input), cwd, signal, GIT_MUTATION_TIMEOUT_MS); } async function removeWorktree(pi, cwd, path, signal) { await runGit(pi, ["worktree", "remove", path], cwd, signal, GIT_MUTATION_TIMEOUT_MS); } async function worktreeInventory(pi, path, signal) { const statusArgs = [ "status", "--porcelain=v1", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none" ]; const status = await runGit(pi, statusArgs, path, signal); const indexFlags = await runGit(pi, ["ls-files", "-v", "-z"], path, signal); const indexInventory = await indexFlagInventory(pi, indexFlags.stdout, path, signal); const submoduleStatus = await runGit(pi, ["submodule", "status", "--recursive"], path, signal); const initializedSubmodules = nonEmptyLines(submoduleStatus.stdout).filter((line) => !line.startsWith("-")).map((line) => `initialized submodule: ${line.slice(1).trimStart()}`); const submodules = await runGit( pi, [ "submodule", "foreach", "--recursive", "--quiet", "git status --porcelain=v1 --untracked-files=all --ignored=matching --ignore-submodules=none" ], path, signal ); return [ ...nonEmptyLines(status.stdout), ...indexInventory, ...initializedSubmodules, ...nonEmptyLines(submodules.stdout) ]; } async function worktreeAdministrativeDirectory(pi, cwd, signal) { const result = await runGit( pi, ["rev-parse", "--path-format=absolute", "--git-dir"], cwd, signal ); const value = removeLineEnding(result.stdout); if (!value) throw new GitWorktreeError("Git did not return its worktree administrative path."); return resolve(cwd, value); } async function administrativeHistoryOids(pi, cwd, administrativePath, signal) { const gitDirArgument = `--git-dir=${administrativePath}`; const values = readAdministrativeReflogOids(resolve(administrativePath, "logs")); const refs = await runGit( pi, [gitDirArgument, "for-each-ref", "--format=%(objectname)", "refs/worktree", "refs/bisect"], cwd, signal ); values.push(...splitAdministrativeOids(refs.stdout, "per-worktree refs")); for (const name of [ "ORIG_HEAD", "MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_HEAD" ]) { const contents = readAdministrativeFile(administrativePath, name); if (contents === void 0) continue; values.push(...splitAdministrativeOids(contents, name)); } const fetchHead = readAdministrativeFile(administrativePath, "FETCH_HEAD"); if (fetchHead !== void 0) { values.push(...splitFetchHeadOids(fetchHead)); } return [...new Set(values)]; } async function administrativePruneCandidates(pi, cwd, signal) { const commonResult = await runGit( pi, ["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd, signal ); const commonValue = removeLineEnding(commonResult.stdout); if (!commonValue) throw new GitWorktreeError("Git did not return its common directory."); const commonDirectory = resolve(cwd, commonValue); const administrativeRoot = resolve(commonDirectory, "worktrees"); if (!existsSync(administrativeRoot)) return []; const candidates = []; for (const entry of readdirSync(administrativeRoot, { withFileTypes: true })) { const administrativePath = resolve(administrativeRoot, entry.name); if (!entry.isDirectory() || entry.isSymbolicLink()) { throw new GitWorktreeError( `Unexpected Git worktree administrative entry: ${administrativePath}.` ); } if (existsSync(resolve(administrativePath, "locked"))) continue; const gitdirPath = resolve(administrativePath, "gitdir"); let registeredGitFile; try { registeredGitFile = removeLineEnding(readFileSync(gitdirPath, "utf8")); } catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") throw error; } if (registeredGitFile) { const targetGitFile = resolve(administrativePath, registeredGitFile); if (existsSync(targetGitFile)) continue; } const headPath = resolve(administrativePath, "HEAD"); let headValue; try { if (!lstatSync(headPath).isFile()) { throw new GitWorktreeError(`Git worktree administrative HEAD is not a file: ${headPath}.`); } headValue = removeLineEnding(readFileSync(headPath, "utf8")); } catch (error) { if (error instanceof GitWorktreeError) throw error; throw new GitWorktreeError( `Cannot inspect Git worktree administrative HEAD ${headPath}: ${formatError(error)}` ); } const indexDirty = await administrativeIndexIsDirty(pi, cwd, administrativePath, signal); if (headValue.startsWith("ref: ")) { const branchRef = headValue.slice("ref: ".length); if (!branchRef) { throw new GitWorktreeError( `Git worktree administrative HEAD has an empty ref: ${headPath}.` ); } candidates.push({ id: entry.name, administrativePath, branchRef, indexDirty }); continue; } if (!/^[0-9a-fA-F]{40,64}$/u.test(headValue)) { throw new GitWorktreeError(`Git worktree administrative HEAD is malformed: ${headPath}.`); } candidates.push({ id: entry.name, administrativePath, head: headValue, indexDirty }); } return candidates; } async function administrativeIndexIsDirty(pi, cwd, administrativePath, signal) { const args = [ `--git-dir=${administrativePath}`, "diff", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--" ]; const result = await runGitAllowFailure(pi, args, cwd, signal); if (result.killed) throw killedError(args); if (result.code === 0) return false; if (result.code === 1) return true; throw gitFailure(args, result); } async function durableRefExists(pi, cwd, ref, signal) { if (!ref.startsWith("refs/") || ref.includes("\0")) { throw new GitWorktreeError("Git worktree administrative HEAD contains an invalid ref."); } const result = await runGitAllowFailure( pi, ["show-ref", "--verify", "--quiet", ref], cwd, signal ); if (result.killed) throw killedError(["show-ref", "--verify", "--quiet"]); if (result.code === 0) return true; if (result.code === 1) return false; throw gitFailure(["show-ref", "--verify", "--quiet"], result); } async function durableRefsContaining(pi, cwd, head, signal) { if (!/^[0-9a-fA-F]{40,64}$/u.test(head)) { throw new GitWorktreeError("Detached worktree has an invalid HEAD object."); } const result = await runGit( pi, [ "for-each-ref", "--format=%(refname)", `--contains=${head}`, "refs/heads", "refs/tags", "refs/remotes" ], cwd, signal ); return nonEmptyLines(result.stdout); } async function prunePreview(pi, cwd, signal) { const result = await runGit(pi, ["worktree", "prune", "--dry-run", "--verbose"], cwd, signal); return combineOutput(result); } async function pruneWorktrees(pi, cwd, signal) { const result = await runGit( pi, ["worktree", "prune", "--verbose"], cwd, signal, GIT_MUTATION_TIMEOUT_MS ); return combineOutput(result); } function formatWorktree(record, currentPath) { const labels = [ currentPath && pathsEqual(record.path, currentPath) ? "current" : void 0, record.isMain ? "main" : void 0, record.bare ? "bare" : void 0, record.detached ? "detached" : record.branch, record.lockedReason !== void 0 ? `locked${record.lockedReason ? `: ${record.lockedReason}` : ""}` : void 0, record.prunableReason !== void 0 ? `prunable${record.prunableReason ? `: ${record.prunableReason}` : ""}` : void 0 ].filter((label) => Boolean(label)); const head = record.head ? record.head.slice(0, 8) : "no HEAD"; return stripTerminalControls(`${record.path} [${labels.join(", ") || "unknown"}] ${head}`); } function stripTerminalControls(value) { return [...value].filter((character) => { const code = character.codePointAt(0) ?? 0; return code > 31 && (code < 127 || code > 159); }).join(""); } async function runGit(pi, args, cwd, signal, timeout = GIT_TIMEOUT_MS) { const result = await runGitAllowFailure(pi, args, cwd, signal, timeout); if (result.killed) throw killedError(args); if (result.code !== 0) throw gitFailure(args, result); return result; } async function runGitAllowFailure(pi, args, cwd, signal, timeout = GIT_TIMEOUT_MS) { try { return await pi.exec("git", args, { cwd, signal, timeout }); } catch (error) { const message = formatError(error); if (/\bENOENT\b|not found/i.test(message)) { throw new GitWorktreeError("Git executable was not found. Install Git and retry.", args); } throw new GitWorktreeError( `Could not start git ${args.slice(0, 2).join(" ")}: ${message}`, args ); } } function gitFailure(args, result) { const detail = stripTerminalControls( [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n") ); const hint = /not a git repository/i.test(detail) ? "The current Pi workspace is not inside a Git repository." : detail || `Git exited with code ${result.code}.`; return new GitWorktreeError(`git ${args.slice(0, 2).join(" ")} failed: ${hint}`, args); } function killedError(args) { return new GitWorktreeError( `git ${args.slice(0, 2).join(" ")} timed out or was cancelled.`, args ); } function nonEmptyLines(value) { return value.split(/\r?\n/u).filter((line) => line.length > 0); } async function indexFlagInventory(pi, value, cwd, signal) { const entries = parseIndexFlagEntries(value); const sparseManagedPaths = await sparseManagedSkipWorktreePaths( pi, cwd, entries.filter((entry) => entry.skipWorktree).map((entry) => entry.path), signal ); const inventory = []; for (const entry of entries) { const flags = [ entry.skipWorktree && !sparseManagedPaths.has(entry.path) ? "skip-worktree" : void 0, entry.assumeUnchanged ? "assume-unchanged" : void 0 ].filter((flag) => flag !== void 0); if (flags.length > 0) inventory.push(`index flag ${flags.join("+")}: ${entry.path}`); } return inventory; } function parseIndexFlagEntries(value) { const entries = []; for (const entry of value.split("\0")) { if (!entry) continue; if (entry.length < 3 || entry[1] !== " ") { throw new GitWorktreeError("Git returned malformed ls-files index-flag output."); } const tag = entry[0] ?? ""; const skipWorktree = tag.toUpperCase() === "S"; const assumeUnchanged = /[a-z]/u.test(tag); if (skipWorktree || assumeUnchanged) { entries.push({ path: entry.slice(2), skipWorktree, assumeUnchanged }); } } return entries; } async function sparseManagedSkipWorktreePaths(pi, cwd, paths, signal) { if (paths.length === 0) return /* @__PURE__ */ new Set(); const configArgs = ["config", "--bool", "--get", "core.sparseCheckout"]; const config = await runGitAllowFailure(pi, configArgs, cwd, signal); if (config.killed) throw killedError(configArgs); if (config.code !== 0 || config.stdout.trim() !== "true") return /* @__PURE__ */ new Set(); const candidates = new Set(paths); const checkArgs = ["sparse-checkout", "check-rules", "-z"]; const checked = await runGitWithInputAllowFailure( checkArgs, cwd, `${[...candidates].join("\0")}\0`, signal ); if (checked.killed) throw killedError(checkArgs); if (checked.code !== 0) return /* @__PURE__ */ new Set(); const included = new Set(nulSeparatedPaths(checked.stdout, "sparse-checkout rules")); if ([...included].some((path) => !candidates.has(path))) { throw new GitWorktreeError("Git returned an unexpected sparse-checkout path."); } return new Set([...candidates].filter((path) => !included.has(path))); } function runGitWithInputAllowFailure(args, cwd, input, signal, timeout = GIT_TIMEOUT_MS) { if (signal?.aborted) { return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true }); } return new Promise((resolveResult, reject) => { const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); let stdout = ""; let stderr = ""; let killed = false; let settled = false; const finish = (result) => { if (settled) return; settled = true; clearTimeout(timeoutHandle); signal?.removeEventListener("abort", stop); resolveResult(result); }; const fail = (error) => { if (settled) return; settled = true; clearTimeout(timeoutHandle); signal?.removeEventListener("abort", stop); child.kill(); const message = formatError(error); reject( /\bENOENT\b|not found/i.test(message) ? new GitWorktreeError("Git executable was not found. Install Git and retry.", args) : new GitWorktreeError( `Could not start git ${args.slice(0, 2).join(" ")}: ${message}`, args ) ); }; const stop = () => { killed = true; child.kill(); }; const timeoutHandle = setTimeout(stop, timeout); child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.stdin.on("error", (error) => { if (!isNodeError(error) || error.code !== "EPIPE") fail(error); }); child.once("error", fail); child.once("close", (code, closeSignal) => { finish({ stdout, stderr, code: code ?? 1, killed: killed || closeSignal !== null }); }); signal?.addEventListener("abort", stop, { once: true }); if (signal?.aborted) stop(); child.stdin.end(input); }); } function nulSeparatedPaths(value, source) { if (value && !value.endsWith("\0")) { throw new GitWorktreeError(`Git returned malformed ${source} output.`); } return value.split("\0").filter(Boolean); } function combineOutput(result) { return [result.stdout.trimEnd(), result.stderr.trimEnd()].filter(Boolean).join("\n"); } function removeLineEnding(value) { if (value.endsWith("\r\n")) return value.slice(0, -2); if (value.endsWith("\n")) return value.slice(0, -1); return value; } function splitAdministrativeOids(value, source) { const normalized = value.endsWith("\n") ? value.slice(0, -1) : value; if (!normalized) return []; const values = normalized.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line); if (values.some((oid) => !/^[0-9a-fA-F]{40,64}$/u.test(oid))) { throw new GitWorktreeError(`Git returned malformed object IDs for ${source}.`); } return values; } function splitFetchHeadOids(value) { const normalized = value.endsWith("\n") ? value.slice(0, -1) : value; if (!normalized) return []; return normalized.split("\n").map((line) => { const match = /^([0-9a-fA-F]{40,64})\t/u.exec(line); if (!match?.[1]) { throw new GitWorktreeError("Git worktree administrative FETCH_HEAD is malformed."); } return match[1]; }); } function readAdministrativeFile(administrativePath, name) { const path = resolve(administrativePath, name); let stat; try { stat = lstatSync(path); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return void 0; throw new GitWorktreeError( `Cannot inspect Git worktree administrative ${name}: ${formatError(error)}` ); } if (stat.isSymbolicLink() || !stat.isFile()) { throw new GitWorktreeError( `Git worktree administrative ${name} must be a regular file: ${path}.` ); } try { return readFileSync(path, "utf8"); } catch (error) { throw new GitWorktreeError( `Cannot inspect Git worktree administrative ${name}: ${formatError(error)}` ); } } function readAdministrativeReflogOids(logPath) { if (!existsSync(logPath)) return []; let stat; try { stat = lstatSync(logPath); } catch (error) { throw new GitWorktreeError(`Cannot inspect Git reflog path ${logPath}: ${formatError(error)}`); } if (stat.isSymbolicLink()) { throw new GitWorktreeError(`Git reflog path must not be a symbolic link: ${logPath}.`); } if (stat.isDirectory()) { const values2 = []; for (const entry of readdirSync(logPath, { withFileTypes: true })) { values2.push(...readAdministrativeReflogOids(resolve(logPath, entry.name))); } return values2; } if (!stat.isFile()) { throw new GitWorktreeError(`Unexpected Git reflog entry type: ${logPath}.`); } let contents; try { contents = readFileSync(logPath, "utf8"); } catch (error) { throw new GitWorktreeError(`Cannot read Git reflog ${logPath}: ${formatError(error)}`); } const normalized = contents.endsWith("\n") ? contents.slice(0, -1) : contents; if (!normalized) return []; const values = []; for (const line of normalized.split("\n")) { const match = /^([0-9a-fA-F]{40,64}) ([0-9a-fA-F]{40,64}) /u.exec(line); if (!match?.[1] || !match[2] || match[1].length !== match[2].length) { throw new GitWorktreeError(`Git worktree reflog is malformed: ${logPath}.`); } for (const oid of [match[1], match[2]]) { if (!/^0+$/u.test(oid)) values.push(oid); } } return values; } function isNodeError(error) { return error instanceof Error && "code" in error; } function formatError(error) { return error instanceof Error ? error.message : String(error); } // src/session.ts import { existsSync as existsSync2, writeFileSync } from "node:fs"; import { SessionManager } from "@earendil-works/pi-coding-agent"; async function switchToWorktree(ctx, targetPath) { let sessionPath; try { sessionPath = createTargetSession(ctx, targetPath); const result = await ctx.switchSession(sessionPath, { withSession: async (replacementCtx) => { replacementCtx.ui.notify( stripTerminalControls(`Switched Pi workspace to ${targetPath}.`), "info" ); } }); if (result.cancelled) { ctx.ui.notify( stripTerminalControls( `Workspace switch was cancelled. The prepared target session was retained at ${sessionPath}.` ), "info" ); return "cancelled"; } return "switched"; } catch (error) { const retained = sessionPath ? " The prepared target session was retained." : ""; const message = stripTerminalControls( `Could not switch Pi workspace to ${targetPath}.${retained} The worktree was retained. Retry from /worktree. ${formatError2(error)}` ); try { ctx.ui.notify(message, "error"); } catch { console.error(message); } return "failed"; } } function createTargetSession(ctx, targetPath) { const sourceFile = ctx.sessionManager.getSessionFile(); if (sourceFile && existsSync2(sourceFile)) { const persisted = SessionManager.open(sourceFile); const activeLeaf = ctx.sessionManager.getLeafId(); if (persisted.getLeafId() === activeLeaf) { const forked = SessionManager.forkFrom(sourceFile, targetPath); const targetFile = forked.getSessionFile(); if (!targetFile || !existsSync2(targetFile)) { throw new Error("Pi did not create the target worktree session file."); } return targetFile; } if (activeLeaf !== null && !persisted.getEntry(activeLeaf)) { throw new Error("The active Pi session branch is not present in the persisted source file."); } return writeTargetSession(targetPath, ctx.sessionManager.getBranch(), sourceFile, activeLeaf); } if (ctx.sessionManager.getEntries().length > 0) { return writeTargetSession( targetPath, ctx.sessionManager.getBranch(), void 0, ctx.sessionManager.getLeafId() ); } return writeTargetSession(targetPath, [], void 0, null); } function writeTargetSession(targetPath, entries, parentSession, expectedLeaf) { const target = SessionManager.create(targetPath, void 0, { parentSession }); const targetFile = target.getSessionFile(); const header = target.getHeader(); if (!targetFile || !header) throw new Error("Pi could not prepare a target session."); const document = [header, ...entries].map((entry) => JSON.stringify(entry)).join("\n"); writeFileSync(targetFile, `${document} `, { encoding: "utf8", flag: "wx", mode: 384 }); const verified = SessionManager.open(targetFile); if (verified.getCwd() !== targetPath || verified.getLeafId() !== expectedLeaf) { throw new Error("Pi could not verify the target session cwd and active branch."); } return targetFile; } function formatError2(error) { return error instanceof Error ? error.message : String(error); } // src/status.ts import { existsSync as existsSync3 } from "node:fs"; var GIT_STATUS_TIMEOUT_MS = 15e3; var DEFAULT_STATUS_CONCURRENCY = 4; var OID_PATTERN = /^[0-9a-fA-F]{40,64}$/u; var STATUS_XY_PATTERN = /^[.MADRCUT]{2}$/u; function parseWorktreeStatusPorcelain(output) { if (output && !output.endsWith("\0")) { throw new Error("Git status porcelain output is not NUL-terminated."); } const entries = output ? output.slice(0, -1).split("\0") : []; const snapshot = { detached: false, staged: 0, unstaged: 0, untracked: 0, conflicts: 0 }; for (let index = 0; index < entries.length; index += 1) { const entry = entries[index] ?? ""; if (entry.startsWith("# ")) { parseBranchHeader(snapshot, entry); continue; } if (entry.startsWith("1 ")) { assertFieldCount(entry, 9, "ordinary"); countTrackedState(snapshot, statusCode(entry)); continue; } if (entry.startsWith("2 ")) { assertFieldCount(entry, 10, "renamed"); countTrackedState(snapshot, statusCode(entry)); const originalPath = entries[index + 1]; if (!originalPath) throw new Error("Git status returned a malformed renamed record."); index += 1; continue; } if (entry.startsWith("u ")) { assertFieldCount(entry, 11, "unmerged"); statusCode(entry); snapshot.conflicts += 1; continue; } if (entry.startsWith("? ")) { if (entry.length === 2) throw new Error("Git status returned a malformed untracked record."); snapshot.untracked += 1; continue; } if (entry.startsWith("! ")) continue; throw new Error("Git status returned an unknown porcelain-v2 record."); } return snapshot; } function formatWorktreeStatusCard(record, currentPath, result) { const path = safeDisplay(record.path); const identity = [ pathsEqual(record.path, currentPath) ? "current" : void 0, record.isMain ? "main" : void 0, record.bare ? "bare" : void 0, record.detached ? "detached" : void 0 ].filter((value) => value !== void 0); const label = safeDisplay( record.branch ?? (record.detached ? `Detached ${record.head?.slice(0, 8) ?? "HEAD"}` : record.bare ? "Bare worktree" : "Unknown worktree") ); const statePrefix = identity.length > 0 ? `${identity.join(" \xB7 ")} \xB7 ` : ""; const baseDetails = [ `Path: ${path}`, `Identity: ${identity.join(" \xB7 ") || "linked"}`, `Registered HEAD: ${safeDisplay(record.head ?? "unavailable")}` ]; if (record.lockedReason !== void 0) { baseDetails.push( record.lockedReason ? `Locked: ${safeDisplay(record.lockedReason)}` : "Locked: no reason provided" ); } if (record.prunableReason !== void 0) { baseDetails.push( record.prunableReason ? `Prunable: ${safeDisplay(record.prunableReason)}` : "Prunable: no reason provided" ); } if (result.kind === "unavailable") { const reason = safeDisplay(result.reason); return { id: record.path, label, description: path, statusText: `${statePrefix}unavailable`, searchText: `${label} ${path} ${identity.join(" ")} unavailable ${reason}`, details: [ ...baseDetails, `Status: ${reason}`, "Snapshot: local Git state; no fetch performed." ] }; } const { snapshot, lastCommit } = result; const workingState = formatWorkingState(snapshot); const upstream = snapshot.upstream ? snapshot.ahead !== void 0 && snapshot.behind !== void 0 ? `${safeDisplay(snapshot.upstream)} \xB7 ahead ${snapshot.ahead} \xB7 behind ${snapshot.behind}` : `${safeDisplay(snapshot.upstream)} \xB7 ahead/behind unavailable` : "not configured; ahead/behind unavailable"; const details = [ ...baseDetails, `Snapshot HEAD: ${safeDisplay(snapshot.headOid ?? "unborn")}`, `Working tree: ${workingState}`, `Upstream: ${upstream}`, lastCommit ? `Last commit: ${safeDisplay(lastCommit.committedAt)} \xB7 ${safeDisplay(lastCommit.subject || "(no subject)")}` : "Last commit: unavailable", "Snapshot: local Git state; no fetch performed; removal uses stricter checks." ]; return { id: record.path, label, description: path, statusText: `${statePrefix}${workingState}`, searchText: `${label} ${path} ${identity.join(" ")} ${workingState} ${safeDisplay(snapshot.upstream ?? "")}`, details }; } async function loadWorktreeStatusCards(pi, records, currentPath, signal, options = {}) { const concurrency = Math.max( 1, Math.min(options.concurrency ?? DEFAULT_STATUS_CONCURRENCY, records.length || 1) ); const cards = new Array(records.length); let nextIndex = 0; const worker = async () => { while (true) { throwIfAborted(signal); const index = nextIndex; if (index >= records.length) return; nextIndex += 1; const record = records[index]; if (!record) return; const unavailable = unavailableReason(record); if (unavailable) { cards[index] = formatWorktreeStatusCard(record, currentPath, { kind: "unavailable", reason: unavailable }); continue; } try { const status = await runGit2( pi, [ "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all", "--ignore-submodules=none" ], record.path, signal ); const snapshot = parseWorktreeStatusPorcelain(status.stdout); const lastCommit = snapshot.headOid ? parseLastCommit( (await runGit2( pi, ["show", "-s", "--format=%aI%x00%s", "--end-of-options", snapshot.headOid], record.path, signal )).stdout ) : void 0; cards[index] = formatWorktreeStatusCard(record, currentPath, { kind: "available", snapshot, lastCommit }); } catch (error) { throwIfAborted(signal); cards[index] = formatWorktreeStatusCard(record, currentPath, { kind: "unavailable", reason: `Status failed: ${formatError3(error)}` }); } } }; await Promise.all(Array.from({ length: concurrency }, worker)); throwIfAborted(signal); return cards; } function parseBranchHeader(snapshot, entry) { if (entry.startsWith("# branch.oid ")) { const oid = entry.slice("# branch.oid ".length); if (oid === "(initial)") { snapshot.headOid = void 0; return; } if (!OID_PATTERN.test(oid)) throw new Error("Git status returned a malformed branch OID."); snapshot.headOid = oid; return; } if (entry.startsWith("# branch.head ")) { const branch = entry.slice("# branch.head ".length); if (!branch) throw new Error("Git status returned a malformed branch head."); snapshot.detached = branch === "(detached)"; snapshot.branch = snapshot.detached || branch === "(unknown)" ? void 0 : branch; return; } if (entry.startsWith("# branch.upstream ")) { const upstream = entry.slice("# branch.upstream ".length); if (!upstream) throw new Error("Git status returned a malformed upstream."); snapshot.upstream = upstream; return; } if (entry.startsWith("# branch.ab ")) { const match = /^# branch\.ab \+(\d+) -(\d+)$/u.exec(entry); if (!match?.[1] || !match[2]) { throw new Error("Git status returned malformed ahead/behind counts."); } snapshot.ahead = Number.parseInt(match[1], 10); snapshot.behind = Number.parseInt(match[2], 10); } } function statusCode(entry) { const code = entry.slice(2, 4); if (!STATUS_XY_PATTERN.test(code)) { throw new Error("Git status returned a malformed tracked status code."); } return code; } function countTrackedState(snapshot, code) { if (code[0] !== ".") snapshot.staged += 1; if (code[1] !== ".") snapshot.unstaged += 1; } function assertFieldCount(entry, minimum, kind) { if (entry.split(" ").length < minimum) { throw new Error(`Git status returned a malformed ${kind} record.`); } } function formatWorkingState(snapshot) { const values = [ snapshot.conflicts > 0 ? `${snapshot.conflicts} conflict${snapshot.conflicts === 1 ? "" : "s"}` : void 0, snapshot.staged > 0 ? `${snapshot.staged} staged` : void 0, snapshot.unstaged > 0 ? `${snapshot.unstaged} unstaged` : void 0, snapshot.untracked > 0 ? `${snapshot.untracked} untracked` : void 0 ].filter((value) => value !== void 0); return values.join(" \xB7 ") || "clean"; } function unavailableReason(record) { if (record.bare) return "Bare worktree cannot be inspected"; if (record.prunableReason !== void 0) { return record.prunableReason ? `Prunable: ${record.prunableReason}` : "Prunable worktree metadata"; } if (!existsSync3(record.path)) return "Worktree path is missing"; return void 0; } async function runGit2(pi, args, cwd, signal) { throwIfAborted(signal); const result = await pi.exec("git", args, { cwd, signal, timeout: GIT_STATUS_TIMEOUT_MS }); if (result.killed) { throwIfAborted(signal); throw new Error(`git ${args.slice(0, 2).join(" ")} timed out.`); } if (result.code !== 0) { const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" "); throw new Error(detail || `git ${args.slice(0, 2).join(" ")} exited with code ${result.code}.`); } return result; } function parseLastCommit(output) { const separator = output.indexOf("\0"); if (separator <= 0 || output.indexOf("\0", separator + 1) >= 0) { throw new Error("Git returned malformed last-commit details."); } const committedAt = output.slice(0, separator); const subject = removeLineEnding2(output.slice(separator + 1)); if (!committedAt) throw new Error("Git returned an empty last-commit timestamp."); return { committedAt, subject }; } function safeDisplay(value) { return stripTerminalControls(value); } function throwIfAborted(signal) { if (signal?.aborted) { throw new DOMException("Worktree status loading was aborted.", "AbortError"); } } function removeLineEnding2(value) { if (value.endsWith("\r\n")) return value.slice(0, -2); if (value.endsWith("\n")) return value.slice(0, -1); return value; } function formatError3(error) { const message = error instanceof Error ? error.message : String(error); return safeDisplay(message).slice(0, 500); } // src/command.ts var ACTION_STATUS = "Worktree status"; var ACTION_ADD = "Add worktree"; var ACTION_SWITCH = "Switch worktree"; var ACTION_REMOVE = "Remove worktree"; var ACTION_PRUNE = "Prune stale metadata"; var ACTION_CONFIGURE_ROOT = "Configure worktree root"; var ACTIONS = { status: ACTION_STATUS, add: ACTION_ADD, switch: ACTION_SWITCH, remove: ACTION_REMOVE, prune: ACTION_PRUNE, configure: ACTION_CONFIGURE_ROOT }; function registerWorktreeCommand(pi, settings, getMenuOwner) { pi.registerCommand("worktree", { description: "Interactively manage Git worktrees and their default root", handler: async (args, ctx) => { if (args.trim()) { safeNotify( ctx, "/worktree does not accept arguments; run it without arguments to open the menu.", "warning" ); return; } if (!ctx.hasUI) { safeNotify(ctx, "/worktree requires TUI or RPC mode.", "error"); return; } try { await ctx.waitForIdle(); const records = await listWorktrees(pi, ctx.cwd, ctx.signal); const currentPath = await currentWorktreePath(pi, ctx.cwd, ctx.signal); const root = settings.get(); const warning = root.warning ? " \u2014 settings warning" : ""; const owner = getMenuOwner(); const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit"); if (owner.signal.aborted || !owner.isCurrent()) return; const runFlow = async (flow) => { try { await flow(); } catch (error) { safeNotify(ctx, formatError4(error), "error"); } return { kind: "close" }; }; const state = { statusCards: [] }; const menu = defineMenu({ start: "main", screens: { main: () => ({ kind: "actions", title: "Git worktrees", lines: [ `Registered: ${records.length}`, `Current: ${currentPath}`, `Worktree root: ${root.effectiveRoot} (${root.source})${warning}` ], items: Object.entries(ACTIONS).map(([id, label]) => ({ id, label, action: id, busyLabel: id === "status" ? "Inspecting worktrees\u2026" : void 0 })), hint: "close" }), status: ({ state: currentState }) => ({ kind: "browse", title: "Worktree status", lines: ["Local Git snapshot; no fetch performed."], items: currentState.statusCards, viewportSize: "adaptive", hint: "back" }) }, actions: { status: async ({ signal }) => { try { const statusRecords = await listWorktrees(pi, ctx.cwd, signal); state.statusCards = await loadWorktreeStatusCards( pi, statusRecords, currentPath, signal ); if (signal.aborted || !owner.isCurrent()) return { kind: "close" }; return { kind: "to", screen: "status" }; } catch (error) { if (signal.aborted || !owner.isCurrent()) return { kind: "close" }; safeNotify(ctx, formatError4(error), "error"); return { kind: "stay" }; } }, add: async () => runFlow(() => addFlow(pi, ctx, records, root.effectiveRoot)), switch: async ({ signal }) => runFlow(() => switchFlow(pi, ctx, records, currentPath, signal)), remove: async ({ signal }) => runFlow(() => removeFlow(pi, ctx, records, currentPath, signal)), prune: async () => runFlow(() => pruneFlow(pi, ctx, records)), configure: async () => runFlow(() => configureRootFlow(ctx, settings)) } }); await runMenu(ctx, menu, { getState: () => state, signal: owner.signal, isCurrent: owner.isCurrent }); } catch (error) { safeNotify(ctx, formatError4(error), "error"); } } }); } async function configureRootFlow(ctx, settings) { const current = await settings.reload(); if (!current.canSave) { throw new Error( current.warning ?? `Fix ${settings.getPath()} before changing pi-worktree settings.` ); } const requested = await ctx.ui.input( "Worktree root (blank restores ~/.worktrees)", stripTerminalControls(current.configuredRoot ?? current.effectiveRoot) ); if (requested === void 0) return; const configuredRoot = requested.trim() || void 0; const updated = await settings.save(configuredRoot); safeNotify( ctx, configuredRoot === void 0 ? `Worktree root reset to ${updated.effectiveRoot}.` : `Worktree root saved as ${updated.effectiveRoot}.`, "info" ); } async function addFlow(pi, ctx, records, worktreeRoot) { const main = records[0]; if (!main) throw new Error("Git returned no registered worktrees."); if (main.bare) { throw new Error("The main worktree is bare; pi-worktree cannot derive a safe default path."); } if (!existsSync4(main.path)) { throw new Error( `The registered main worktree path is stale: ${main.path}. Repair it with Git first.` ); } const requestedBranch = await ctx.ui.input("Branch for the new worktree", "feat/my-change"); if (requestedBranch === void 0) return; const branchInput = requestedBranch.trim(); if (!branchInput) throw new Error("Branch name is required."); const branch = await validateBranch(pi, ctx.cwd, branchInput, ctx.signal); const branchExists = await localBranchExists(pi, ctx.cwd, branch, ctx.signal); const occupied = worktreeForBranch(records, branch); if (occupied) { throw new Error(`Branch ${branch} is already checked out at ${occupied.path}.`); } let startOid; let provenance; if (branchExists) { provenance = { kind: "existing-local-branch", label: branch, oid: await resolveCommit(pi, ctx.cwd, `refs/heads/${branch}`, ctx.signal) }; } else { const defaultStart = await symbolicBranch(pi, ctx.cwd, ctx.signal); const requestedStart = await ctx.ui.input( stripTerminalControls( defaultStart ? `Start point for ${branch} (blank uses ${defaultStart})` : `Start point for ${branch} (required because HEAD is detached)` ), stripTerminalControls(defaultStart ?? "commit-ish") ); if (requestedStart === void 0) return; const explicitStart = requestedStart.trim(); const startLabel = explicitStart || defaultStart; if (!startLabel) throw new Error("An explicit start point is required from detached HEAD."); startOid = await resolveCommit(pi, ctx.cwd, startLabel, ctx.signal); provenance = { kind: explicitStart ? "explicit-commit-ish" : "current-branch", label: startLabel, oid: startOid }; } const suggestedPath = defaultWorktreePath(main.path, branch, worktreeRoot); const requestedPath = await ctx.ui.input( stripTerminalControls(`Worktree path (blank uses ${suggestedPath})`), stripTerminalControls(suggestedPath) ); if (requestedPath === void 0) return; const targetPath = pathIdentity( requestedPath.trim() ? resolve2(ctx.cwd, requestedPath.trim()) : suggestedPath ); assertTargetFilesystemAvailable(targetPath); const pathCollision = records.find((record) => pathsEqual(record.path, targetPath)); if (pathCollision) { throw new Error(`The target path is already registered as a worktree: ${pathCollision.path}.`); } const summary = formatAddPreview(branch, branchExists, provenance, targetPath); if (!await ctx.ui.confirm("Create Git worktree", summary)) return; assertTargetFilesystemAvailable(targetPath); const latestRecords = await listWorktrees(pi, ctx.cwd, ctx.signal); const latestOccupied = worktreeForBranch(latestRecords, branch); if (latestOccupied) { throw new Error( `Branch ${branch} is now checked out at ${latestOccupied.path}; select it again.` ); } const latestPathCollision = latestRecords.find((record) => pathsEqual(record.path, targetPath)); if (latestPathCollision) { throw new Error( `The target path is now registered as a worktree: ${latestPathCollision.path}. Select it again.` ); } const branchStillExists = await localBranchExists(pi, ctx.cwd, branch, ctx.signal); if (branchStillExists !== branchExists) { throw new Error(`Branch ${branch} changed after confirmation; select it again.`); } if (branchExists) { const latestOid = await resolveCommit(pi, ctx.cwd, `refs/heads/${branch}`, ctx.signal); if (latestOid !== provenance.oid) { throw new Error(`Branch ${branch} moved after confirmation; select it again.`); } } assertTargetFilesystemAvailable(targetPath); await addWorktree(pi, ctx.cwd, { path: targetPath, branch, startOid }, ctx.signal); let created; try { const updated = await listWorktrees(pi, ctx.cwd, ctx.signal); const verified = updated.find((record) => pathsEqual(record.path, targetPath)); if (!verified || verified.branch !== branch || verified.head !== provenance.oid) { throw new Error( "the expected path, branch, and approved HEAD were not present in Git porcelain output" ); } created = verified; } catch (error) { throw new Error( `Git add completed, so the worktree was retained at ${targetPath}, but verification failed: ${formatError4(error)}. Inspect git worktree list before retrying.` ); } safeNotify(ctx, `Created worktree ${targetPath} on branch ${branch}.`, "info"); if (await ctx.ui.confirm( "Switch Pi workspace?", stripTerminalControls(`Continue this conversation in ${targetPath}?`) )) { const latest = await revalidateWorktreeIdentity(pi, ctx, created); if (latest.prunableReason !== void 0 || !existsSync4(latest.path)) { throw new Error("The newly created worktree became unavailable; select it again."); } await switchToWorktree(ctx, latest.path); } } function formatAddPreview(branch, branchExists, provenance, targetPath) { const base = provenance.kind === "existing-local-branch" ? `existing local branch ${quoteTerminalValue(provenance.label)}` : provenance.kind === "current-branch" ? `current branch ${quoteTerminalValue(provenance.label)}` : `explicit commit-ish ${quoteTerminalValue(provenance.label)}`; return [ `Branch: ${quoteTerminalValue(branch)} (${branchExists ? "existing" : "new"} local branch)`, `Base: ${base}`, `Base commit: ${provenance.oid}`, `Path: ${quoteTerminalValue(targetPath)}` ].join("; "); } function quoteTerminalValue(value) { let quoted = ""; for (const character of value) { const code = character.codePointAt(0) ?? 0; if (code <= 31 || code >= 127 && code <= 159) { quoted += `\\u${code.toString(16).padStart(4, "0")}`; } else if (character === "\\" || character === '"') { quoted += `\\${character}`; } else { quoted += character; } } return `"${quoted}"`; } function assertTargetFilesystemAvailable(targetPath) { if (pathEntryExists(targetPath)) { throw new Error(`The target path already exists: ${targetPath}.`); } const unsafeAncestor = unresolvableSymlinkAncestor(targetPath); if (unsafeAncestor) { throw new Error( `The target path has an unresolvable symbolic-link ancestor: ${unsafeAncestor}.` ); } } async function switchFlow(pi, ctx, records, currentPath, signal) { const candidates = records.filter( (record) => !record.bare && record.prunableReason === void 0 && existsSync4(record.path) && !pathsEqual(record.path, currentPath) ); const selected = await selectWorktree(ctx, "Switch to worktree", candidates, currentPath, signal); if (!selected) return; const latest = await revalidateWorktreeIdentity(pi, ctx, selected); if (latest.bare || latest.prunableReason !== void 0 || !existsSync4(latest.path) || pathsEqual(latest.path, currentPath)) { throw new Error("The selected worktree changed state; select it again."); } await switchToWorktree(ctx, latest.path); } async function removeFlow(pi, ctx, records, currentPath, signal) { const candidates = records.filter( (record) => !record.isMain && !record.bare && !pathsEqual(record.path, currentPath) ); const selected = await selectWorktree( ctx, "Remove linked worktree", candidates, currentPath, signal ); if (!selected) return; if (selected.lockedReason !== void 0) { throw new Error( `Worktree is locked${selected.lockedReason ? `: ${selected.lockedReason}` : "."} Unlock it explicitly with Git before removal.` ); } if (selected.prunableReason !== void 0 || !existsSync4(selected.path)) { throw new Error("The selected worktree path is stale. Use prune instead of remove."); } const inventory = classifyRemovalInventory( await worktreeInventory(pi, selected.path, ctx.signal) ); if (inventory.protected.length > 0) { throw new Error( `Removal refused because ${selected.path} contains tracked, untracked, index-flagged, or submodule data: ${inventory.protected.join("\n")}` ); } await assertDetachedHeadIsDurable(pi, ctx, selected); const administrativePath = await worktreeAdministrativeDirectory(pi, selected.path, ctx.signal); const approvedHistoryRisks = historyRisks( selected.path, await unreachableAdministrativeHistoryOids(pi, ctx, administrativePath) ); const recoveryWarning = formatAdministrativeRecoveryWarning(approvedHistoryRisks); const ignoredWarning = formatIgnoredDataWarning(inventory.ignored); const removalWarning = ignoredWarning && recoveryWarning ? `${ignoredWarning} ${recoveryWarning.trimStart()}` : `${ignoredWarning}${recoveryWarning}`; const confirmationTitle = inventory.ignored.length > 0 ? recoveryWarning ? "Remove worktree and discard local/recovery data" : "Remove worktree and delete ignored files" : recoveryWarning ? "Remove worktree and discard recovery history" : "Remove Git worktree"; if (!await ctx.ui.confirm( confirmationTitle, `Delete the worktree directory ${stripTerminalControls(selected.path)}? The branch will be preserved.${removalWarning}` )) { return; } await assertAdministrativeHistoryUnchanged( pi, ctx, selected.path, administrativePath, approvedHistoryRisks ); const beforeRemoval = await listWorktrees(pi, ctx.cwd, ctx.signal); const latest = beforeRemoval.find((record) => pathsEqual(record.path, selected.path)); if (!latest) throw new Error(`Worktree ${selected.path} is no longer registered.`); if (!sameWorktreeIdentity(selected, latest)) { throw new Error(`Worktree ${selected.path} changed identity; select it again.`); } if (latest.isMain || latest.lockedReason !== void 0 || latest.prunableReason !== void 0) { throw new Error( `Worktree ${selected.path} changed state after confirmation; removal was refused.` ); } const latestInventory = classifyRemovalInventory( await worktreeInventory(pi, latest.path, ctx.signal) ); if (latestInventory.protected.length > 0) { throw new Error( `Removal refused because new protected local data appeared after confirmation: ${latestInventory.protected.join("\n")}` ); } if (!sameInventory(inventory.ignored, latestInventory.ignored)) { throw new Error( `Removal refused because ignored data changed after confirmation: ${latestInventory.ignored.join("\n") || "(none)"}` ); } await assertDetachedHeadIsDurable(pi, ctx, latest); await assertAdministrativeHistoryUnchanged( pi, ctx, latest.path, administrativePath, approvedHistoryRisks ); await removeWorktree(pi, ctx.cwd, latest.path, ctx.signal); const updated = await listWorktrees(pi, ctx.cwd, ctx.signal); if (updated.some((record) => pathsEqual(record.path, selected.path))) { throw new Error(`Git remove returned success, but ${selected.path} is still registered.`); } safeNotify(ctx, `Removed worktree ${selected.path}. Its branch was preserved.`, "info"); } async function pruneFlow(pi, ctx, records) { for (const record of records.filter( (candidate) => candidate.prunableReason !== void 0 && candidate.detached )) { await assertDetachedHeadIsDurable(pi, ctx, record); } const preview = await prunePreview(pi, ctx.cwd, ctx.signal); if (!preview) { ctx.ui.notify("Git found no stale worktree metadata to prune.", "info"); return; } const approvedHistoryRisks = await inspectAdministrativePruneCandidates(pi, ctx); const safePreview = stripTerminalControls(preview); const recoveryWarning = formatAdministrativeRecoveryWarning(approvedHistoryRisks); ctx.ui.notify(`git worktree prune --dry-run --verbose ${safePreview}`, "warning"); if (!await ctx.ui.confirm( recoveryWarning ? "Prune metadata and discard recovery history" : "Prune stale worktree metadata", stripTerminalControls(`${safePreview}${recoveryWarning}`) )) { return; } const latest = await listWorktrees(pi, ctx.cwd, ctx.signal); for (const record of latest.filter( (candidate) => candidate.prunableReason !== void 0 && candidate.detached )) { await assertDetachedHeadIsDurable(pi, ctx, record); } const beforePreviewHistoryRisks = await inspectAdministrativePruneCandidates(pi, ctx); if (!sameAdministrativeHistoryRisks(approvedHistoryRisks, beforePreviewHistoryRisks)) { throw new Error("Stale worktree metadata changed after confirmation; run prune again."); } const latestPreview = await prunePreview(pi, ctx.cwd, ctx.signal); const finalHistoryRisks = await inspectAdministrativePruneCandidates(pi, ctx); if (latestPreview !== preview || !sameAdministrativeHistoryRisks(approvedHistoryRisks, finalHistoryRisks)) { throw new Error("Stale worktree metadata changed after confirmation; run prune again."); } const output = await pruneWorktrees(pi, ctx.cwd, ctx.signal); safeNotify( ctx, output ? `Pruned stale worktree metadata: ${output}` : "Pruned stale worktree metadata.", "info" ); } async function assertAdministrativeHistoryUnchanged(pi, ctx, selectedPath, approvedAdministrativePath, approvedHistoryRisks) { const latestAdministrativePath = await worktreeAdministrativeDirectory( pi, selectedPath, ctx.signal ); const latestHistoryRisks = historyRisks( selectedPath, await unreachableAdministrativeHistoryOids(pi, ctx, latestAdministrativePath) ); if (!pathsEqual(approvedAdministrativePath, latestAdministrativePath) || !sameAdministrativeHistoryRisks(approvedHistoryRisks, latestHistoryRisks)) { throw new Error( `Worktree ${selectedPath} administrative recovery history changed after confirmation; select it again.` ); } } async function inspectAdministrativePruneCandidates(pi, ctx) { const risks = []; for (const candidate of await administrativePruneCandidates(pi, ctx.cwd, ctx.signal)) { if (candidate.indexDirty) { throw new Error( `Prune refused because administrative worktree ${candidate.id} contains staged-only index changes.` ); } if (candidate.head) { const refs = await durableRefsContaining(pi, ctx.cwd, candidate.head, ctx.signal); if (refs.length === 0) { throw new Error( `Prune refused because administrative worktree ${candidate.id} has detached HEAD ${candidate.head}, which is not reachable from a durable ref.` ); } } else if (!candidate.branchRef || !await durableRefExists(pi, ctx.cwd, candidate.branchRef, ctx.signal)) { throw new Error( `Prune refused because administrative worktree ${candidate.id} does not resolve to a durable ref.` ); } risks.push( ...historyRisks( candidate.id, await unreachableAdministrativeHistoryOids(pi, ctx, candidate.administrativePath) ) ); } return normalizeAdministrativeHistoryRisks(risks); } async function unreachableAdministrativeHistoryOids(pi, ctx, administrativePath) { const unreachable = []; for (const oid of await administrativeHistoryOids(pi, ctx.cwd, administrativePath, ctx.signal)) { const refs = await durableRefsContaining(pi, ctx.cwd, oid, ctx.signal); if (refs.length === 0) unreachable.push(oid); } return [...new Set(unreachable)].sort(); } function historyRisks(label, oids) { return oids.length > 0 ? [{ label, oids }] : []; } function normalizeAdministrativeHistoryRisks(risks) { return risks.map((risk) => ({ label: risk.label, oids: [...new Set(risk.oids)].sort() })).filter((risk) => risk.oids.length > 0).sort((left, right) => left.label.localeCompare(right.label)); } function sameAdministrativeHistoryRisks(left, right) { return JSON.stringify(normalizeAdministrativeHistoryRisks(left)) === JSON.stringify(normalizeAdministrativeHistoryRisks(right)); } function formatAdministrativeRecoveryWarning(risks) { if (risks.length === 0) return ""; const entries = risks.map( (risk) => `${stripTerminalControls(risk.label)}: ${risk.oids.map(stripTerminalControls).join(", ")}` ).join("; "); return ` Administrative recovery warning: these commits are not reachable from a branch, tag, or remote ref: ${entries}. Discarding their recovery pointers means they may later be garbage-collected.`; } function classifyRemovalInventory(lines) { const ignored = []; const protectedData = []; for (const line of lines) { (line.startsWith("!! ") ? ignored : protectedData).push(line); } return { ignored: normalizeInventory(ignored), protected: normalizeInventory(protectedData) }; } function normalizeInventory(lines) { return [...new Set(lines)].sort(); } function sameInventory(left, right) { return JSON.stringify(normalizeInventory(left)) === JSON.stringify(normalizeInventory(right)); } function formatIgnoredDataWarning(ignored) { if (ignored.length === 0) return ""; return ` Ignored files and directories that will be deleted: ${ignored.map(stripTerminalControls).join("\n")}`; } async function assertDetachedHeadIsDurable(pi, ctx, record) { if (!record.detached) return; if (!record.head) throw new Error(`Detached worktree ${record.path} has no HEAD object; refusing.`); const refs = await durableRefsContaining(pi, ctx.cwd, record.head, ctx.signal); if (refs.length === 0) { throw new Error( `Detached HEAD ${record.head} at ${record.path} is not reachable from a local branch, tag, or remote ref. Preserve it before continuing.` ); } } async function revalidateWorktreeIdentity(pi, ctx, selected) { const latest = (await listWorktrees(pi, ctx.cwd, ctx.signal)).find( (record) => pathsEqual(record.path, selected.path) ); if (!latest) throw new Error(`Worktree ${selected.path} is no longer registered.`); if (!sameWorktreeIdentity(selected, latest)) { throw new Error(`Worktree ${selected.path} changed identity; select it again.`); } return latest; } async function selectWorktree(ctx, title, records, currentPath, signal) { if (records.length === 0) { ctx.ui.notify("No eligible worktrees are available for this action.", "info"); return void 0; } const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit"); if (signal?.aborted || ctx.signal?.aborted) return void 0; let selected; const menu = defineMenu({ start: "worktrees", screens: { worktrees: () => ({ kind: "choice", title, enableSearch: true, items: records.map((record, index) => ({ id: record.path, label: `${index + 1}. ${formatWorktree(record, currentPath)}`, searchText: [record.path, record.branch, record.head].filter(Boolean).join(" ") })), action: "choose", hint: "close" }) }, actions: { choose: async ({ itemId }) => { selected = records.find((record) => record.path === itemId); return selected ? { kind: "close" } : { kind: "rejected" }; } } }); await runMenu(ctx, menu, { getState: () => void 0, signal, isCurrent: () => !signal?.aborted }); return selected; } function safeNotify(ctx, message, level) { try { ctx.ui.notify(stripTerminalControls(message), level); } catch { console.error(message); } } function formatError4(error) { return error instanceof Error ? error.message : String(error); } // src/settings.ts import { lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname as dirname2, join, posix, win32 } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; var SETTINGS_FILE = "pi-worktree.json"; var DEFAULT_FILE_OPERATIONS = { write: (path, data) => writeFile(path, data, { encoding: "utf8", flag: "wx", mode: 384 }).then(() => void 0), rename }; function settingsFilePath() { return join(getAgentDir(), SETTINGS_FILE); } function defaultWorktreeRoot(home = homedir(), platform = process.platform) { return platformPath(platform).join(home, ".worktrees"); } function resolveWorktreeRoot(value, home = homedir(), platform = process.platform) { if (!value || value.includes("\0")) { throw new Error("worktreeRoot must be a non-empty path without NUL characters."); } if (hasShellVariableSyntax(value)) { throw new Error("worktreeRoot must not contain shell variable syntax."); } const path = platformPath(platform); let candidate = value; if (value === "~") { candidate = home; } else if (value.startsWith("~/") || platform === "win32" && value.startsWith("~\\")) { candidate = path.resolve(home, value.slice(2)); } else if (value.startsWith("~")) { throw new Error("worktreeRoot supports only ~ itself or a path beginning with ~/."); } if (!path.isAbsolute(candidate)) { throw new Error("worktreeRoot must be an absolute path or begin with ~/."); } try { const normalized = path.normalize(candidate); if (!normalized || !path.isAbsolute(normalized)) { throw new Error("normalization did not produce an absolute path"); } return normalized; } catch (error) { throw new Error(`worktreeRoot could not be normalized: ${formatError5(error)}`); } } async function loadWorktreeSettings(path = settingsFilePath(), home = homedir(), platform = process.platform) { const fallback = defaultWorktreeRoot(home, platform); let text; try { const stats = await lstat(path); if (stats.isSymbolicLink()) return invalid(path, fallback, "symbolic links are not accepted"); if (!stats.isFile()) return invalid(path, fallback, "settings path is not a regular file"); text = await readFile(path, "utf8"); } catch (error) { if (isNodeError2(error) && error.code === "ENOENT") { return { kind: "missing", path, effectiveRoot: fallback, source: "default", document: {} }; } return invalid(path, fallback, formatError5(error)); } try { const document = JSON.parse(text); if (!isRecord(document)) return invalid(path, fallback, "the top level must be a JSON object"); if (!Object.hasOwn(document, "worktreeRoot")) { return { kind: "loaded", path, effectiveRoot: fallback, source: "default", document }; } if (typeof document.worktreeRoot !== "string") { return invalid(path, fallback, "worktreeRoot must be a string"); } const effectiveRoot = resolveWorktreeRoot(document.worktreeRoot, home, platform); return { kind: "loaded", path, effectiveRoot, source: "user", configuredRoot: document.worktreeRoot, document }; } catch (error) { return invalid(path, fallback, formatError5(error)); } } async function saveWorktreeSettings(document, configuredRoot, path = settingsFilePath(), operations = {}) { const nextDocument = { ...document }; if (configuredRoot === void 0) delete nextDocument.worktreeRoot; else nextDocument.worktreeRoot = configuredRoot; await mkdir(dirname2(path), { recursive: true }); const temporaryPath = temporaryFilePath(path); try { await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)( temporaryPath, `${JSON.stringify(nextDocument, null, 2)} ` ); await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(temporaryPath, path); return nextDocument; } catch (error) { await unlink(temporaryPath).catch(() => void 0); throw error; } } function createWorktreeSettingsRuntime(options = {}) { const home = options.home ?? homedir(); const platform = options.platform ?? process.platform; let resolvedPath; const getPath = () => { resolvedPath ??= typeof options.path === "function" ? options.path() : options.path ?? settingsFilePath(); return resolvedPath; }; let operationQueue = Promise.resolve(); const enqueue = (operation) => { const result = operationQueue.then(operation, operation); operationQueue = result.then( () => void 0, () => void 0 ); return result; }; let state = { effectiveRoot: defaultWorktreeRoot(home, platform), source: "default", canSave: true }; return { get: () => Object.freeze({ ...state }), getPath, async flush() { await operationQueue; }, reload() { return enqueue(async () => { const loaded = await loadWorktreeSettings(getPath(), home, platform); if (loaded.kind === "invalid") { state = { ...state, warning: loaded.warning, canSave: false }; return Object.freeze({ ...state }); } state = stateFromLoaded(loaded); return Object.freeze({ ...state }); }); }, save(configuredRoot) { return enqueue(async () => { if (!state.canSave) { throw new Error(`Fix the pi-worktree settings file at ${getPath()} before changing it.`); } const effectiveRoot = configuredRoot === void 0 ? defaultWorktreeRoot(home, platform) : resolveWorktreeRoot(configuredRoot, home, platform); const latest = await loadWorktreeSettings(getPath(), home, platform); if (latest.kind === "invalid") { state = { ...state, warning: latest.warning, canSave: false }; throw new Error(`Fix the pi-worktree settings file at ${getPath()} before changing it.`); } await saveWorktreeSettings( latest.document ?? {}, configuredRoot, getPath(), options.operations ); state = { effectiveRoot, source: configuredRoot === void 0 ? "default" : "user", ...configuredRoot === void 0 ? {} : { configuredRoot }, canSave: true }; return Object.freeze({ ...state }); }); } }; } function stateFromLoaded(loaded) { return { effectiveRoot: loaded.effectiveRoot, source: loaded.source, ...loaded.configuredRoot === void 0 ? {} : { configuredRoot: loaded.configuredRoot }, ...loaded.warning === void 0 ? {} : { warning: loaded.warning }, canSave: true }; } function invalid(path, fallback, reason) { return { kind: "invalid", path, effectiveRoot: fallback, source: "default", warning: `${SETTINGS_FILE} ignored (${path}: ${reason}); using the safe default or last valid root without overwriting the file.` }; } function platformPath(platform) { return platform === "win32" ? win32 : posix; } function hasShellVariableSyntax(value) { return /\$|%[^%]+%/u.test(value); } function temporaryFilePath(path) { return `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function isNodeError2(error) { return error instanceof Error && "code" in error; } function formatError5(error) { return error instanceof Error ? error.message : String(error); } // src/worktree.ts function worktreeExtension(pi, options = {}) { const settings = options.settings ?? createWorktreeSettingsRuntime({ path: settingsFilePath }); let sessionGeneration = 0; let menuController = new AbortController(); registerWorktreeCommand(pi, settings, () => { const generation = sessionGeneration; return { signal: menuController.signal, isCurrent: () => generation === sessionGeneration && !menuController.signal.aborted }; }); pi.on("session_start", async (_event, ctx) => { const generation = ++sessionGeneration; menuController.abort(new DOMException("Worktree session replaced", "AbortError")); menuController = new AbortController(); const loaded = await settings.reload(); if (generation !== sessionGeneration || !loaded.warning || !ctx.hasUI) return; ctx.ui.notify(loaded.warning, "warning"); }); pi.on("session_shutdown", async () => { sessionGeneration += 1; menuController.abort(new DOMException("Worktree session shut down", "AbortError")); await settings.flush?.(); }); } export { worktreeExtension as default }; //# sourceMappingURL=index.ts.map