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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user