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
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user