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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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