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