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:
2026-10-02 13:22:29 +07:00
parent a6bd33bcff
commit 5084334d81
35 changed files with 1041 additions and 119 deletions
+137 -36
View File
@@ -14,6 +14,7 @@ const os = require("node:os");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
const aiTools = require("../lib/ai-tools");
const {
listPipelines,
@@ -834,11 +835,14 @@ router.get("/:id/integrations/:name", (req, res) => {
* registration after it: the suite stayed green.
*/
router.get("/:id/git", async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({ available: true, ...(await gitFacts(lane.cwd)) });
res.json({
available: true,
...(await gitFacts(t.cwd)),
...(t.worktree ? { worktree: t.worktree } : {}),
});
} catch {
res.json({ available: false });
}
@@ -860,13 +864,14 @@ router.get("/:id/git", async (req, res) => {
* GET /:id/git. See server/lib/git-diff.js for the caps.
*/
router.get("/:id/git/changes", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work";
const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null;
const full = req.query.full === "1" || req.query.full === "true";
try {
const changes = await collectChanges(lane.cwd, {
const changes = await collectChanges(cwd, {
range,
base: lane.base_branch,
sha: typeof req.query.sha === "string" ? req.query.sha : null,
@@ -891,10 +896,11 @@ router.get("/:id/git/changes", async (req, res) => {
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
*/
router.get("/:id/git/log", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const commits = await listCommits(lane.cwd, {
const commits = await listCommits(cwd, {
limit: req.query.limit,
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
});
@@ -915,11 +921,12 @@ router.get("/:id/git/log", async (req, res) => {
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
*/
router.get("/:id/git/file", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const file = await readFileAt(
lane.cwd,
cwd,
typeof req.query.path === "string" ? req.query.path : "",
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
);
@@ -942,12 +949,13 @@ router.get("/:id/git/file", async (req, res) => {
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
*/
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
const body = req.body || {};
try {
await writeLaneFile(lane.cwd, body.path, body.content, body.baseSha);
res.json({ available: true, ...(await readFileAt(lane.cwd, body.path, "work")) });
await writeLaneFile(cwd, body.path, body.content, body.baseSha);
res.json({ available: true, ...(await readFileAt(cwd, body.path, "work")) });
} catch (err) {
const status =
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
@@ -958,10 +966,11 @@ router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
/** Tracked paths matching `?q=`, for quick-open. */
router.get("/:id/git/ls", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
try {
res.json({ available: true, ...(await listPaths(lane.cwd, { q: req.query.q })) });
res.json({ available: true, ...(await listPaths(cwd, { q: req.query.q })) });
} catch {
res.json({ available: false, paths: [], truncated: false, total: 0 });
}
@@ -969,10 +978,11 @@ router.get("/:id/git/ls", async (req, res) => {
/** Every level of the tracked tree in one read, for "expand all folders". */
router.get("/:id/git/tree-all", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
try {
res.json({ available: true, ...(await listTreeAll(lane.cwd)) });
res.json({ available: true, ...(await listTreeAll(cwd)) });
} catch {
res.json({ available: false, levels: {}, truncated: false });
}
@@ -980,11 +990,12 @@ router.get("/:id/git/tree-all", async (req, res) => {
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
router.get("/:id/git/tree", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const subdir = typeof req.query.path === "string" ? req.query.path : "";
res.json({ available: true, ...(await listTree(lane.cwd, { subdir })) });
res.json({ available: true, ...(await listTree(cwd, { subdir })) });
} catch (err) {
if (err.code === "EBADPATH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
@@ -1000,11 +1011,12 @@ router.get("/:id/git/tree", async (req, res) => {
* so no path ever comes from the request.
*/
router.get("/:id/git/branches", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const { branches, current } = await listBranches(lane.cwd);
const worktrees = await listWorktrees(lane.cwd).catch(() => []);
const { branches, current } = await listBranches(cwd);
const worktrees = await listWorktrees(cwd).catch(() => []);
res.json({ available: true, branches, current, worktrees });
} catch {
res.json({ available: false, branches: [], current: null, worktrees: [] });
@@ -1022,19 +1034,20 @@ router.get("/:id/git/branches", async (req, res) => {
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
*/
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
// cwd — or a root lane and a subdirectory lane of one repo — share one
// index, and their writes would otherwise race on index.lock. The git dir
// is per worktree, so sibling worktree lanes still run in parallel; a
// cwd that is not a repo falls back to its real path.
const gitDir = await git(lane.cwd, ["rev-parse", "--absolute-git-dir"])
const gitDir = await git(cwd, ["rev-parse", "--absolute-git-dir"])
.then((r) => r.stdout.trim())
.catch(() => fs.promises.realpath(lane.cwd).catch(() => lane.cwd));
.catch(() => fs.promises.realpath(cwd).catch(() => cwd));
const result = await withLaneLock(`git:${gitDir}`, () =>
runGitAction(lane.cwd, req.params.action, req.body || {})
runGitAction(cwd, req.params.action, req.body || {})
);
res.json(result);
} catch (err) {
@@ -1090,6 +1103,46 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
}
});
/**
* Claude Desktop's "archive a worktree session": remove the git worktree a
* session of this workspace ran in (`claude --worktree`), then its branch when
* git agrees it is merged. The session is named, never a path; the tree comes
* from `sessionWorktree`. Refused while a live pane holds the session
* (`ELIVE`), and every refusal git itself makes — a dirty tree, an unmerged
* branch (kept and reported) — stands: nothing here forces.
*/
router.post("/:id/session-worktree/remove", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const sessionId = typeof req.body?.session === "string" ? req.body.session : "";
const held = runs
.listRuns()
.some((r) => r.laneId === lane.id && r.status === "running" && r.sessionId === sessionId);
if (held) {
return res.status(409).json({
error: { code: "ELIVE", message: "a pane is still running that session — exit it first" },
});
}
try {
res.json(await removeSessionWorktree(lane, sessionId, { confirm: req.body?.confirm }));
} catch (err) {
// git ran and refused (a dirty tree): the repository answering, a 409.
const refused = !!err.git;
const status = ["ECONFIRM", "ENOWORKTREE"].includes(err.code)
? 400
: err.code === "ELOCKED" || refused
? 409
: 500;
const detail = refused ? (err.git.stderr || "").trim() : "";
res.status(status).json({
error: {
code: refused ? "EGIT" : err.code || "EGIT",
message: detail ? `${err.message}\n${detail}` : err.message,
},
});
}
});
/**
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
@@ -1422,6 +1475,28 @@ function gitErrorCode(err) {
}
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
/**
* Which tree a git route works in: the workspace's own folder, or — with
* `?session=<id>` — that session's worktree (`claude --worktree`), resolved by
* `sessionWorktree` from the session ROW (owned by this workspace, registered
* by `git worktree list`), never from a path in the request. An unresolvable
* session is a 400, not a silent fall back to the main folder: a commit or a
* discard must never land in the wrong tree.
*/
async function gitTargetOr404(req, res) {
const lane = laneOr404(req, res);
if (!lane) return null;
const session = req.query.session;
if (session === undefined) return { lane, cwd: lane.cwd, worktree: null };
try {
const wt = await sessionWorktree(lane, String(session));
return { lane, cwd: wt.path, worktree: { path: wt.path, branch: wt.branch } };
} catch (err) {
res.status(400).json({ error: { code: err.code, message: err.message } });
return null;
}
}
function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id);
if (!lane) {
@@ -1752,6 +1827,29 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
});
}
// A session that ran in a worktree of its own resumes (or forks) THERE:
// Claude finds a session by the folder it ran in. Resolved from the
// session row, before the lock; anything else starts as it always did.
let resumeWorktree = null;
if (typeof body.resumeSessionId === "string" && body.resumeSessionId) {
resumeWorktree = await sessionWorktree(lane, body.resumeSessionId).catch(() => null);
const row = resumeWorktree
? null
: db
.prepare("SELECT cwd, lane_id FROM sessions WHERE id = ?")
.get(body.resumeSessionId);
// One of this workspace's worktree sessions whose worktree is gone
// (Claude removed it on exit): there is no folder to resume it in.
if (
row &&
row.lane_id === lane.id &&
/\/\.claude\/worktrees\/[^/]+\/?$/.test(row.cwd || "")
) {
return res.status(400).json({
error: { code: "ENOWORKTREE", message: "that session's worktree no longer exists" },
});
}
}
// Overwriting run_id while its child is alive orphans that child: a later
// reset would kill and await only the RECORDED run, then `git clean -fd`
// the directory the orphan is still writing into. Stop the first run before starting a
@@ -1774,7 +1872,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
laneId: current.id,
slot,
initialPrompt: body.prompt || "",
cwd: current.cwd,
cwd: resumeWorktree ? resumeWorktree.path : current.cwd,
inWorktree: !!resumeWorktree,
// A NEW session in a worktree of its own (`claude --worktree`).
worktree: body.worktree === true && !body.resumeSessionId,
// The tool is the LANE's fact, never the request's: a client that
// asked for the other CLI would otherwise get a pane that doesn't
// match the lane's badge, its history, or its binary probe.