5084334d81
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
93 lines
3.5 KiB
JavaScript
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 };
|