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:
+52
-23
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user