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
+93
View File
@@ -1841,3 +1841,96 @@ describe("/api/lanes tool tests", () => {
await request("DELETE", `/api/lanes/${codexId}`);
});
});
describe("a session's own worktree (claude --worktree)", () => {
const { execFileSync } = require("child_process");
const tmux = require("../lib/tmux");
const { db } = require("../db");
// Inside a git hook GIT_DIR / GIT_INDEX_FILE name the repo being committed.
const gitEnv = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
const gitIn = (dir, ...args) =>
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
encoding: "utf8",
env: gitEnv,
});
let repo, wtDir, laneId;
before(async () => {
repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-session-wt-")));
gitIn(repo, "init", "-q", "-b", "main");
fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
gitIn(repo, "add", ".");
gitIn(repo, "commit", "-qm", "init");
gitIn(repo, "worktree", "add", "-q", "-b", "worktree-x", ".claude/worktrees/x");
wtDir = path.join(repo, ".claude", "worktrees", "x");
laneId = (await request("POST", "/api/lanes", { cwd: repo })).body.lane.id;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES ('wt-sess-1', 'w', 'completed', ?, ?, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')"
).run(wtDir, laneId);
});
after(() => fs.rmSync(repo, { recursive: true, force: true }));
it("reads and writes git in the session's worktree, never falling back", async () => {
fs.writeFileSync(path.join(wtDir, "only-in-wt.txt"), "w\n");
const inWt = await request("GET", `/api/lanes/${laneId}/git/changes?session=wt-sess-1`);
assert.ok(inWt.body.files.some((f) => f.path === "only-in-wt.txt"));
const facts = await request("GET", `/api/lanes/${laneId}/git?session=wt-sess-1`);
assert.equal(facts.body.branch, "worktree-x");
assert.equal(facts.body.worktree.branch, "worktree-x");
const main = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.ok(!main.body.files.some((f) => f.path === "only-in-wt.txt"));
const bad = await request("POST", `/api/lanes/${laneId}/git/stage?session=nope`, {
paths: ["only-in-wt.txt"],
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "ENOWORKTREE");
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?session=wt-sess-1`, {
paths: ["only-in-wt.txt"],
});
assert.equal(ok.status, 200);
assert.match(gitIn(wtDir, "diff", "--cached", "--name-only"), /only-in-wt\.txt/);
});
it("resumes a worktree session in its worktree", async () => {
let cwd = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") cwd = args[args.indexOf("-c") + 1];
return "";
});
try {
const r = await request("POST", `/api/lanes/${laneId}/start`, {
slot: 2,
resumeSessionId: "wt-sess-1",
});
assert.equal(r.status, 200);
assert.equal(cwd, wtDir);
} finally {
tmux.__reset();
}
});
it("removes it only when asked, and only when git agrees it is clean", async () => {
const url = `/api/lanes/${laneId}/session-worktree/remove`;
let r = await request("POST", url, { session: "wt-sess-1" });
assert.equal(r.body.error.code, "ECONFIRM");
r = await request("POST", url, { session: "wt-sess-1", confirm: true });
assert.equal(r.status, 409); // a staged file: git refuses
assert.ok(fs.existsSync(wtDir));
gitIn(wtDir, "reset", "-q");
fs.rmSync(path.join(wtDir, "only-in-wt.txt"));
r = await request("POST", url, { session: "wt-sess-1", confirm: true });
assert.equal(r.status, 200);
assert.deepEqual(r.body, { removed: true, branch: "worktree-x", branchKept: false });
assert.equal(fs.existsSync(wtDir), false);
// Its worktree is gone: nothing to resume it in.
r = await request("POST", `/api/lanes/${laneId}/start`, {
slot: 3,
resumeSessionId: "wt-sess-1",
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENOWORKTREE");
});
});
+37
View File
@@ -230,6 +230,43 @@ describe("pty-run", () => {
);
});
it("spawnRun starts a Claude session in its own worktree, Claude's way", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
const repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pty-wt-")));
// Inside a git hook GIT_DIR names the repo being committed; drop it.
const env = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
require("node:child_process").execFileSync("git", ["init", "-q", repo], { env });
try {
pty.spawnRun({ laneId: 41, cwd: repo, worktree: true, initialPrompt: "fix it" });
// `=` form: `-w [name]` takes an optional value and would eat the prompt.
const flag = newSessionArgv.find((a) => a.startsWith("--worktree="));
assert.match(flag, /^--worktree=ah-41-[a-z0-9]+$/);
assert.equal(newSessionArgv.at(-1), "fix it");
// Claude does not exclude its worktrees; the main repo would list them.
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
assert.ok(exclude.split("\n").includes("/.claude/worktrees/"), exclude);
const dashboardRuns = require("../lib/dashboard-runs");
assert.equal(dashboardRuns.getRun("agenthub-lane-41").worktree, 1);
assert.throws(
() => pty.spawnRun({ laneId: 42, cwd: repo, tool: "codex", worktree: true }),
(e) => e.code === "EUNSUPPORTED"
);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
+137
View File
@@ -0,0 +1,137 @@
/**
* @file Tests for a session's own worktree (`server/lib/session-worktree.js`):
* it is resolved only for a session the workspace OWNS whose cwd git registers
* as a worktree of the workspace's repo, and removing it never forces — git
* refuses a dirty tree, `branch -d` keeps unmerged work, a lock held by a live
* process is refused and a stale one cleared first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "session-wt-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
const REPO = path.join(ROOT, "repo");
// A git hook (the pre-commit suite) exports GIT_DIR / GIT_INDEX_FILE for the
// repo being committed; inherited, every git call below would act on THAT repo.
const ENV = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")));
const git = (...args) =>
execFileSync("git", ["-C", REPO, ...args], {
encoding: "utf8",
env: {
...ENV,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@t",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@t",
},
});
let db;
let wt;
let lane;
function addWorktree(name) {
git("worktree", "add", "-q", "-b", `worktree-${name}`, `.claude/worktrees/${name}`);
return path.join(REPO, ".claude", "worktrees", name);
}
function session(id, cwd, laneId) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES (?, ?, 'active', ?, ?, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')"
).run(id, id, cwd, laneId);
}
before(() => {
fs.mkdirSync(REPO);
git("init", "-q", "-b", "main");
fs.writeFileSync(path.join(REPO, "a.txt"), "a\n");
git("add", ".");
git("commit", "-qm", "init");
({ db } = require("../db"));
wt = require("../lib/session-worktree");
const id = db
.prepare("INSERT INTO lanes (cwd, tool) VALUES (?, 'claude')")
.run(REPO).lastInsertRowid;
lane = db.prepare("SELECT * FROM lanes WHERE id = ?").get(id);
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("sessionWorktree", () => {
it("resolves an owned session's registered worktree", async () => {
const dir = addWorktree("one");
session("s-one", dir, lane.id);
const found = await wt.sessionWorktree(lane, "s-one");
assert.equal(found.path, dir);
assert.equal(found.branch, "worktree-one");
});
it("refuses a session it does not own, its own folder, and an unregistered path", async () => {
const dir = addWorktree("two");
session("s-other", dir, lane.id + 100);
session("s-root", REPO, lane.id);
fs.mkdirSync(path.join(REPO, "plain"));
session("s-plain", path.join(REPO, "plain"), lane.id);
for (const id of ["s-other", "s-root", "s-plain", "s-missing"]) {
await assert.rejects(wt.sessionWorktree(lane, id), (e) => e.code === "ENOWORKTREE", id);
}
});
});
describe("removeSessionWorktree", () => {
it("asks for confirmation", async () => {
await assert.rejects(wt.removeSessionWorktree(lane, "s-one", {}), (e) => e.code === "ECONFIRM");
});
it("removes a clean worktree and its merged branch", async () => {
const out = await wt.removeSessionWorktree(lane, "s-one", { confirm: true });
assert.deepEqual(out, { removed: true, branch: "worktree-one", branchKept: false });
assert.equal(fs.existsSync(path.join(REPO, ".claude/worktrees/one")), false);
assert.equal(git("branch", "--list", "worktree-one").trim(), "");
});
it("refuses a dirty worktree and leaves it as it was", async () => {
const dir = addWorktree("dirty");
session("s-dirty", dir, lane.id);
fs.writeFileSync(path.join(dir, "a.txt"), "changed\n");
await assert.rejects(wt.removeSessionWorktree(lane, "s-dirty", { confirm: true }));
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "changed\n");
});
it("keeps a branch that holds unmerged commits", async () => {
const dir = addWorktree("work");
session("s-work", dir, lane.id);
fs.writeFileSync(path.join(dir, "b.txt"), "b\n");
execFileSync("git", ["-C", dir, "add", "."], { env: ENV });
execFileSync(
"git",
["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "b"],
{ env: ENV }
);
const out = await wt.removeSessionWorktree(lane, "s-work", { confirm: true });
assert.equal(out.branchKept, true);
assert.equal(git("branch", "--list", "worktree-work").trim(), "worktree-work");
});
it("refuses a lock held by a live process and clears a stale one", async () => {
const dir = addWorktree("locked");
session("s-locked", dir, lane.id);
git("worktree", "lock", "--reason", `claude session locked (pid ${process.pid} start 1)`, dir);
await assert.rejects(
wt.removeSessionWorktree(lane, "s-locked", { confirm: true }),
(e) => e.code === "ELOCKED"
);
git("worktree", "unlock", dir);
// A pid that cannot be alive: past the kernel's pid_max.
git("worktree", "lock", "--reason", "claude session locked (pid 2147483646 start 1)", dir);
const out = await wt.removeSessionWorktree(lane, "s-locked", { confirm: true });
assert.equal(out.removed, true);
});
});
+9
View File
@@ -575,6 +575,15 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tmux_session TEXT").run();
}
// Migrate: add `worktree` to dashboard_runs — 1 when the run's pane works in a
// git worktree of its own (`claude --worktree=<name>`, or a resume of such a
// session), so the session view points its git panel there.
try {
db.prepare("SELECT worktree FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run();
}
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
// these are additive columns separate from `stage` (the declared stage), so
// `stage`'s meaning is untouched and turning the feature off loses nothing.
+4 -3
View File
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool
lane_id, tool, worktree
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id, @tool
@lane_id, @tool, @worktree
)
`);
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code,
started_at, ended_at, lane_id, tool`;
started_at, ended_at, lane_id, tool, worktree`;
const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
@@ -114,6 +114,7 @@ function recordRun(handle) {
ended_at: endedAt,
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
worktree: handle.worktree ? 1 : 0,
});
} catch (err) {
// Best-effort by design (a live run must never fail because sqlite did),
+22 -1
View File
@@ -58,4 +58,25 @@ function excludeFromGit(laneDir, relPaths) {
}
}
module.exports = { excludeFromGit };
/**
* The same, anchored at the REPOSITORY root rather than `dir` — for paths a
* tool creates at the root whatever subdirectory it was started in (Claude
* Code's `.claude/worktrees/`). Best effort, like `excludeFromGit`.
*/
function excludeFromRepoRoot(dir, relPaths) {
try {
const env = { ...process.env };
for (const name of REPO_VARS) delete env[name];
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: dir,
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (root) excludeFromGit(root, relPaths);
} catch {
/* not a repo */
}
}
module.exports = { excludeFromGit, excludeFromRepoRoot };
+24
View File
@@ -107,6 +107,7 @@ function buildClaudeArgv({
effort,
resumeSessionId,
forkSession = false,
worktreeName = null,
initialPrompt,
addDirs = [],
systemPromptFile = null,
@@ -117,6 +118,11 @@ function buildClaudeArgv({
// always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto");
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
// the prompt as the worktree's name.
if (worktreeName) argv.push(`--worktree=${worktreeName}`);
// From a file: the instructions can be long, and the whole argv has to fit
// in one tmux message. `=` form, so the path is never read as an option.
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
@@ -228,6 +234,10 @@ function buildArgv(args, entry) {
* @param {string} [args.resumeSessionId]
* @param {boolean} [args.forkSession] resume `resumeSessionId` into a new
* session id (`claude --fork-session`); Claude only.
* @param {boolean} [args.worktree] start a NEW Claude session in its own git
* worktree (`claude --worktree=<name>`, name made here); Claude only.
* @param {boolean} [args.inWorktree] `cwd` is a session's worktree (a resume
* into it, resolved by the caller from the session row) — recorded only.
* @param {boolean} [args.resumeLast]
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
@@ -248,6 +258,8 @@ function spawnRun(args) {
approval,
resumeSessionId,
forkSession,
worktree,
inWorktree,
resumeLast,
bypass,
initialPrompt,
@@ -300,6 +312,9 @@ function spawnRun(args) {
) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
@@ -324,6 +339,11 @@ function spawnRun(args) {
const id = runIdForLane(laneId, slot);
const startedAt = Date.now();
// Claude Code does not exclude the worktrees it makes, so the main repo
// would list `.claude/` as untracked; the root's info/exclude hides them.
if (worktree === true) {
require("./git-exclude").excludeFromRepoRoot(cwd, [".claude/worktrees/"]);
}
// The workspace's own folders, context files and instructions — read from
// its rows here, the one place both spawn paths pass through, never from a
// request (server/lib/lane-sources.js).
@@ -344,6 +364,7 @@ function spawnRun(args) {
approval,
resumeSessionId,
forkSession: forkSession === true,
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
resumeLast,
bypass: bypass === true,
initialPrompt,
@@ -372,6 +393,7 @@ function spawnRun(args) {
permissionMode: permissionMode || "auto",
effort: effort || null,
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
prompt: initialPrompt || "",
status: "running",
startedAt,
@@ -488,6 +510,8 @@ function publicRun(id) {
effort: row?.effort || null,
resumeSessionId: row?.resume_session_id || null,
sessionId: row?.session_id || null,
// The pane works in a git worktree of its own (`claude --worktree`).
worktree: !!row?.worktree,
startedAt: row?.started_at || null,
promptPreview: row?.prompt_preview || null,
};
+92
View File
@@ -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 };
+5 -2
View File
@@ -192,7 +192,7 @@ function slugify(text) {
* Records are separated by blank lines, with keys like:
* - worktree <path>
* - branch refs/heads/<name>
* - locked (optional, bare line)
* - locked [<reason>] (optional)
*/
async function listWorktrees(sourceRepo) {
const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]);
@@ -215,8 +215,11 @@ async function listWorktrees(sourceRepo) {
const branchPath = line.slice("branch ".length);
// Strip refs/heads/ prefix
current.branch = branchPath.replace(/^refs\/heads\//, "");
} else if (line === "locked") {
} else if (line === "locked" || line.startsWith("locked ")) {
// `locked <reason>` when one was given (Claude Code locks its session
// worktrees with `claude session <name> (pid …)`).
current.locked = true;
if (line.length > "locked".length) current.lockReason = line.slice("locked ".length);
}
}
+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.