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
+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) => {