Files
AgentHub/server/lib/session-worktree.js
T
nntrivi2001 5084334d81 feat(workspace): a session in its own worktree, Claude's way
Desktop-parity phase 6, following Claude Desktop:
- the workspace home's Worktree checkbox starts the pane with
  `claude --worktree=<name>`, so Claude Code makes, populates
  (.worktreeinclude, WorktreeCreate hook) and cleans up
  <repo>/.claude/worktrees/<name>; AgentHub only excludes
  /.claude/worktrees/ at the repo root (Claude does not)
- server/lib/session-worktree.js resolves a session's worktree from the
  session row only (owned by the workspace, registered by git worktree
  list); every lane git route takes ?session= and answers ENOWORKTREE
  instead of falling back to the main folder
- the session view's Changes panel works in the opened run's worktree
  (dashboard_runs.worktree, GitTarget in the client api)
- Resume/Fork start in the session's worktree; a gone one is ENOWORKTREE
- Archive of a worktree session removes the worktree after asking:
  git worktree remove without --force, git branch -d (unmerged work keeps
  its branch), a lock held by a live pid refused, a stale one cleared
- listWorktrees reads `locked <reason>` locks
- tests scrub GIT_* env so the pre-commit suite cannot act on this repo
2026-10-02 13:22:29 +07:00

93 lines
3.5 KiB
JavaScript

/**
* @file The git worktree a workspace session runs in — Claude Code's own
* `claude --worktree=<name>` (`<repo>/.claude/worktrees/<name>` on branch
* `worktree-<name>`), 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ĩ <vinnt@smartgift.vn>
*/
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 };