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
@@ -0,0 +1,39 @@
/**
* @file Pins where a workspace git call goes: the workspace's own routes, or —
* for a session's worktree target — the same routes with `?session=`, which
* the server resolves to that session's worktree.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { api, gitTarget } from "../api";
afterEach(() => {
vi.unstubAllGlobals();
});
function stubFetch() {
const spy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => ({}),
});
vi.stubGlobal("fetch", spy);
return spy;
}
describe("git targets", () => {
it("adds session= for a worktree target and nothing for the workspace", async () => {
const spy = stubFetch();
await api.lanes.changes(3, { range: "work" });
await api.lanes.changes(gitTarget(3, "s-1"), { range: "work" });
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
await api.lanes.git(gitTarget(3, null));
const urls = spy.mock.calls.map((c) => String(c[0]));
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
});
});
+52 -23
View File
@@ -565,6 +565,27 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
* JSON-encoding, and error-throwing behavior documented on that helper — the
* per-method docs below focus on the specific route, params, and response shape.
*/
/**
* What a workspace git call works on: the workspace (its id) or, with
* `session`, that session's own worktree (`claude --worktree`) — the server
* resolves the tree from the session row (`?session=`), never from a path.
*/
export type GitTarget = number | { lane: number; session?: string };
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const params = new URLSearchParams(q);
const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session);
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
/** The target for `laneId`, in `session`'s worktree when one is given. */
export function gitTarget(laneId: number, session?: string | null): GitTarget {
return session ? { lane: laneId, session } : laneId;
}
export const api = {
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
@@ -2542,7 +2563,7 @@ export const api = {
* @returns {@link LaneGitFacts} — `available: false` when the cwd is not a
* readable git repository, which is a normal state rather than an error.
*/
git: (id: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
git: (id: GitTarget) => request<LaneGitFacts>(gitUrl(id, "")),
/**
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
* the Changes panel asks for.
@@ -2553,7 +2574,7 @@ export const api = {
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
*/
changes: (
id: number,
id: GitTarget,
opts: {
range?: LaneChangeRange;
path?: string;
@@ -2568,20 +2589,18 @@ export const api = {
if (opts.full) q.set("full", "1");
if (opts.sha) q.set("sha", opts.sha);
if (opts.context != null) q.set("context", String(opts.context));
return request<LaneChanges>(`/lanes/${id}/git/changes?${q.toString()}`);
return request<LaneChanges>(gitUrl(id, "/changes", q));
},
/**
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
* @param id The lane id.
* @param opts `file` narrows the list to the commits touching one path.
*/
gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => {
gitLog: (id: GitTarget, opts: { limit?: number; file?: string } = {}) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
return request<{ available: boolean; commits: LaneCommit[] }>(
`/lanes/${id}/git/log?${q.toString()}`
);
return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
},
/**
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
@@ -2589,17 +2608,15 @@ export const api = {
* @param path Repo-relative path.
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
*/
gitFile: (id: number, path: string, rev = "work") =>
request<LaneFileContent>(
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
),
gitFile: (id: GitTarget, path: string, rev = "work") =>
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
* disk since answers 409 ESTALE rather than being overwritten.
*/
gitSaveFile: (id: number, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(`/lanes/${id}/git/file`, {
gitSaveFile: (id: GitTarget, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(gitUrl(id, "/file"), {
method: "PUT",
body: JSON.stringify({ path, content, baseSha }),
}),
@@ -2609,9 +2626,9 @@ export const api = {
* @param id The lane id.
* @param q The substring typed into the quick-open box.
*/
gitLs: (id: number, q: string) =>
gitLs: (id: GitTarget, q: string) =>
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
`/lanes/${id}/git/ls?q=${encodeURIComponent(q)}`
gitUrl(id, "/ls", new URLSearchParams({ q }))
),
/**
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
@@ -2622,31 +2639,41 @@ export const api = {
* GET /api/lanes/:id/git/tree-all — every level of the tracked tree in one
* read, keyed by directory ("" is the root), each shaped like `gitTree`'s.
*/
gitTreeAll: (id: number) =>
gitTreeAll: (id: GitTarget) =>
request<{
available: boolean;
levels: Record<string, LaneTreeEntry[]>;
truncated: boolean;
}>(`/lanes/${id}/git/tree-all`),
gitTree: (id: number, path = "") =>
}>(gitUrl(id, "/tree-all")),
gitTree: (id: GitTarget, path = "") =>
request<{
available: boolean;
path: string;
entries: LaneTreeEntry[];
truncated: boolean;
}>(`/lanes/${id}/git/tree?path=${encodeURIComponent(path)}`),
}>(gitUrl(id, "/tree", new URLSearchParams({ path }))),
/**
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
* the branch switcher. The repo comes from the lane row, never the query.
* @param id The lane id.
*/
gitBranches: (id: number) =>
/**
* POST /api/lanes/:id/session-worktree/remove — Claude Desktop's archive of a
* worktree session: removes the session's own worktree (never forced; git
* refuses a dirty tree) and its branch when merged (`branchKept` otherwise).
*/
removeSessionWorktree: (id: number, session: string) =>
request<{ removed: true; branch: string | null; branchKept: boolean }>(
`/lanes/${id}/session-worktree/remove`,
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
),
gitBranches: (id: GitTarget) =>
request<{
available: boolean;
branches: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(`/lanes/${id}/git/branches`),
}>(gitUrl(id, "/branches")),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
@@ -2661,7 +2688,7 @@ export const api = {
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
*/
gitAction: (
id: number,
id: GitTarget,
action:
| "fetch"
| "pull"
@@ -2702,7 +2729,7 @@ export const api = {
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
`/lanes/${id}/git/${action}`,
gitUrl(id, `/${action}`),
{ method: "POST", body: JSON.stringify(body) }
),
/**
@@ -3916,6 +3943,8 @@ export interface RunHandle {
resumeSessionId: string | null;
/** Claude Code session id this run created/resumed, once known. */
sessionId: string | null;
/** The pane works in a git worktree of its own (`claude --worktree`). */
worktree?: boolean;
/** ISO timestamp the tmux session was created. */
startedAt: string | null;
/** Initial prompt preview (first 500 chars), null when not provided. */