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:
+137
-36
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user