/** * @file The git worktree a workspace session runs in — Claude Code's own * `claude --worktree=` (`/.claude/worktrees/` on branch * `worktree-`), started from a workspace pane. A session's worktree is * resolved ONLY for a session the workspace owns (`sessions.lane_id`) whose * recorded cwd is not the workspace's own folder and is registered by * `git worktree list` of the workspace's repo: that is the whole authorization * for a git route, a resume or a removal to work there, and nothing in a * request is ever read as a path. Removal is Claude Desktop's "archive a * worktree session" and never forces: `git worktree remove` refuses a dirty * tree, `git branch -d` keeps unmerged work, a lock held by a live process is * refused and only a stale one (dead pid) is cleared. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const { db } = require("../db"); const { git, listWorktrees } = require("./worktree"); const getSession = db.prepare("SELECT id, cwd, lane_id FROM sessions WHERE id = ?"); function makeErr(code, message) { return Object.assign(new Error(message), { code }); } const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } }; /** * @returns {Promise<{path: string, branch: string|null, locked: boolean, lockReason: string|null}>} * @throws ENOWORKTREE when the session is not this workspace's or not in a worktree of its repo. */ async function sessionWorktree(lane, sessionId) { const none = () => makeErr("ENOWORKTREE", "that session has no worktree in this workspace"); const s = typeof sessionId === "string" && sessionId ? getSession.get(sessionId) : null; if (!s || s.lane_id !== lane.id || typeof s.cwd !== "string" || !s.cwd) throw none(); const dir = real(s.cwd); if (!dir || dir === real(lane.cwd)) throw none(); const found = (await listWorktrees(lane.cwd).catch(() => [])).find((w) => real(w.path) === dir); if (!found) throw none(); return { path: dir, branch: found.branch || null, locked: !!found.locked, lockReason: found.lockReason || null, }; } /** A lock git can name a holder for (`… (pid N …)`) whose process is gone. */ function staleLock(reason) { const pid = Number(/\bpid (\d+)\b/.exec(reason || "")?.[1]); if (!pid) return false; // no holder to check: treat it as held try { process.kill(pid, 0); return false; } catch (err) { return err.code === "ESRCH"; } } /** * Removes a session's worktree and, when git agrees it is merged, its branch. * @returns {Promise<{removed: true, branch: string|null, branchKept: boolean}>} */ async function removeSessionWorktree(lane, sessionId, { confirm } = {}) { if (confirm !== true) throw makeErr("ECONFIRM", "removing a worktree needs confirm: true"); const wt = await sessionWorktree(lane, sessionId); if (wt.locked) { if (!staleLock(wt.lockReason)) { throw makeErr("ELOCKED", "the worktree is locked by a running session — exit it first"); } await git(lane.cwd, ["worktree", "unlock", wt.path]); } // No --force: git refuses modified or untracked files, which is the point. await git(lane.cwd, ["worktree", "remove", wt.path]); let branchKept = false; if (wt.branch) { // -d, never -D: a branch with commits merged nowhere stays. branchKept = await git(lane.cwd, ["branch", "-d", "--", wt.branch]).then( () => false, () => true ); } return { removed: true, branch: wt.branch, branchKept }; } module.exports = { sessionWorktree, removeSessionWorktree };