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);
});
});