@xbghc/warden 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,8 @@ with line comments you can copy back to the agent as a prompt.
7
7
  - Diff sources: working tree, staged, working tree vs HEAD, a branch since it forked off its base (commits and
8
8
  uncommitted work together), any commit, any two refs, and git worktrees.
9
9
  - Worktrees are made and taken down from the page, one per agent branch, with the path ready to paste.
10
+ Their directories are numbered slots beside the repository that get reused rather than remade:
11
+ releasing one keeps the directory, installed dependencies included, for the next branch.
10
12
  - Side-by-side **Unstaged** and **Staged** file lists: staged means reviewed. Stage from the UI by dragging
11
13
  over the lines you have read (or a hunk, or a file), and unstage the same way from the Staged view.
12
14
  - GitHub-style unified / side-by-side diff with syntax highlighting, collapsed file tree, lazy per-file loading, context expansion, virtual scrolling.
@@ -23,8 +25,10 @@ with line comments you can copy back to the agent as a prompt.
23
25
  path (each a `git log` on the server, not a filter over the rows already loaded), and a
24
26
  first-parent view that folds merged branches into their merge commits.
25
27
  - Click a line number to jump to that line in a running nvim instance (WSL2 friendly).
26
- - Git is touched through whitelisted read sub-commands plus exactly one write, `git apply --cached`, which is
27
- what the stage / unstage controls run. The working tree and HEAD are never written.
28
+ - Git is touched through whitelisted read sub-commands plus a few writes the server composes itself:
29
+ `git apply --cached`, which is what the stage / unstage controls run, and the worktree slot
30
+ operations (`worktree add` / `remove`, `branch`, `switch`). Reviewing and staging never write the
31
+ working tree or HEAD; only checking a branch out into a free slot, or releasing one, does.
28
32
 
29
33
  ## Install / run
30
34
 
@@ -65,8 +69,8 @@ Worktrees are discovered with `git worktree list` (and made in the *Worktrees* t
65
69
  share the review state of the main repository.
66
70
  One whose directory is gone (git lists it as *prunable*) is not offered, and a remembered target
67
71
  inside a removed worktree falls back to the working tree on the next load. The comments and viewed
68
- flags kept under that worktree's key stay in the state file and come back if a worktree is created
69
- at the same path again.
72
+ flags kept under a worktree's key stay in the state file until a branch is checked out into that
73
+ slot again, which drops them: they were about another branch.
70
74
 
71
75
  The sidebar is the navigation, and the only navigation: its three tabs — *变更*, *提交*, *Worktree* —
72
76
  each fill the left column with the controls for what the middle is showing, and the top bar carries
@@ -101,25 +105,50 @@ the fix and delete or re-attach it.
101
105
  ## Worktrees
102
106
 
103
107
  Agents do their best work each in a worktree of its own, and the sidebar's *Worktree* tab is
104
- where those are made and taken down without a trip to the terminal:
105
-
106
- - *新建 worktree*, the form in the sidebar, takes a branch and a base. A name that is not a branch yet becomes one from the
107
- base (`main` or `master` unless you say otherwise; any ref goes) — `git worktree add -b <branch>
108
- <path> <base>`; an existing branch is checked out as it is, unless another worktree already has it.
109
- The path is suggested as a sibling of the main worktree named `<repo>-<branch>` (slashes become
110
- dashes) and can be edited, within limits: it has to sit under the main worktree's parent directory,
111
- outside every existing worktree, and be new or an empty directory.
112
- - Each row names the checkout, its branch, and whether it is clean or how many paths `git status`
113
- reports. *查看* switches the review to it (the kind of target carries over, as with the selector
114
- in the top bar), *复制路径* is for the agent's prompt.
115
- - *删除* always asks first, in the row it would remove, and runs `git worktree remove` without
116
- `--force`: a worktree with uncommitted changes is not removed until you confirm again, since those
117
- changes go with it, and whatever else git refuses without `--force` is put to you the same way.
118
- *一并删除分支* is offered in that same confirmation, ticked by default, and takes the branch by
119
- `git branch -d`: one that is not merged is kept and the toast says why. The comments and viewed
120
- flags kept under the worktree's key stay in the state file, as before.
108
+ where those are made and taken down without a trip to the terminal. The directories are numbered
109
+ **slots** beside the main worktree — `<repo>-1`, `<repo>-2`, … (工位 in the UI) — rather than one
110
+ directory per branch: a directory is where the dependencies get installed, and a fresh one for every
111
+ branch meant installing them for every branch. A slot outlives its branch. Releasing it leaves the
112
+ directory behind, `node_modules` and all, and the next branch is checked out into it.
113
+
114
+ - *检出分支*, the form in the sidebar, takes a branch, a base and a slot. An existing branch is
115
+ checked out as it is, unless another worktree already has it. A branch only a remote has is
116
+ checked out tracking the remote one (what `git worktree add` itself guesses) rather than started
117
+ afresh; when several remotes have it, the base says which. The branch list offers local branches
118
+ only, since a remote can carry thousands: the name typed is looked up on its own
119
+ (`GET /api/worktrees/remotes`), so the form says it is a remote branch before you submit. Any
120
+ other name becomes a branch from the base (`main` or `master` unless you say otherwise; any ref
121
+ goes). The server looks the name up when the request arrives, so a branch fetched or made after the page
122
+ loaded its list is still found, and a base typed for a name that already exists is refused.
123
+ - The slot picker lists the free slots, lowest first, then *新目录*: the next number, which is made
124
+ with `git worktree add [--track] [-b <branch>] <repo>-<n> [<base>]`. Into a free slot the checkout
125
+ is `git branch [--track] <branch> <base>` in the main worktree — so a base such as `HEAD` means what
126
+ it would to `worktree add` — followed by `git switch <branch>` in the slot, which rewrites the
127
+ tracked files and leaves the ignored ones alone; the toast says which of the two happened. Nothing
128
+ in the request names a directory: the server composes the path from the number, so a checkout
129
+ cannot land anywhere but beside the main worktree. Whatever review state was kept under the slot's
130
+ key (comments, viewed flags, its `base` pool included) is dropped: it was about another branch.
131
+ - Each row names the branch, its directory, and whether it is clean or how many paths `git status`
132
+ reports. *查看* switches the review to it (the kind of target carries over), *复制路径* is for the
133
+ agent's prompt. A free slot is a row of its own, marked *空闲*, and its *检出到这里* points the form
134
+ at it.
135
+ - *释放* is what a slot's row offers, and it always asks first, in the row: `git switch --detach` in
136
+ the slot leaves the last commit checked out with no branch on it and touches no file, and the slot
137
+ is free for the next checkout. A slot with uncommitted changes is not released until you confirm
138
+ again, since those changes go: only that second request discards them — `git reset --hard`, then
139
+ `git clean -fd` for the untracked files, never `-x`, so the ignored ones, the installed
140
+ dependencies above all, stay. *一并删除分支* is offered in the same confirmation,
141
+ ticked by default, and takes the branch by `git branch -d`: one that is not merged is kept and the
142
+ toast says why. *连目录一起删除* turns the release into a removal.
143
+ - *删除* is for a worktree that is not a slot (one made by hand, wherever it is) and for a free slot
144
+ whose directory you no longer want; a slot on a branch gets it through *连目录一起删除*. It runs
145
+ `git worktree remove` without `--force`: uncommitted changes are put to you first, as with a
146
+ release, and so is whatever else git refuses without `--force`.
121
147
  - A worktree whose directory was deleted behind git's back is listed struck through; *清理* drops that
122
- one entry (`git worktree remove` handles it; nothing is pruned wholesale).
148
+ one entry (`git worktree remove` handles it; nothing is pruned wholesale). A slot in that state
149
+ cannot be released or checked out into, and its number is skipped until the entry is gone.
150
+
151
+ `git switch` needs git 2.23 or newer.
123
152
 
124
153
  ## Keyboard
125
154
 
@@ -319,11 +348,17 @@ flag; requests that would need anything else get HTTP 400. The writes are few an
319
348
  own arguments. `POST /api/targets/:key/stage` runs `git apply --cached` (with `--reverse` for the
320
349
  Staged view) on a patch the server itself builds from the diff it just produced — the patch is never
321
350
  taken from the request, only the line indices are, and they are checked against the diff's hash
322
- first. `POST /api/worktrees` and `POST /api/worktrees/remove` run `git worktree add`, `git worktree
323
- remove` and `git branch -d` with a branch name git has validated, a ref that resolves, and a
324
- path confined to the main worktree's parent directory. Nothing writes to the working tree or HEAD of
325
- an existing checkout; refs change only when a worktree is made (its new branch) or removed (its merged
326
- branch, on request), and the review state lives outside the repository. A mutating request the
351
+ first. `POST /api/worktrees`, `POST /api/worktrees/release` and `POST /api/worktrees/remove` run
352
+ `git worktree add`, `git branch`, `git switch`, `git worktree remove` and `git branch -d` with a
353
+ branch name git has validated, a ref that resolves, and a slot path the server composes from a
354
+ number — `<repo>-<n>` beside the main worktree; no request names a directory. The working tree and
355
+ HEAD of an existing checkout are written only inside such a slot: `git switch <branch>` into a free
356
+ one (detached, clean) when a branch is checked out there, `git switch --detach` when it is released,
357
+ and `git reset --hard` plus `git clean -fd` only on the forced release the reviewer confirmed after
358
+ a 409. Refs change only when a branch is made for a checkout (plus its upstream setting when it comes
359
+ from a remote) — undone with `branch -D` should the switch into the slot then fail — or deleted on
360
+ request after a release or removal (`-d`, so only a merged one), and the review state lives outside
361
+ the repository. A mutating request the
327
362
  browser labels as coming from another site (`Sec-Fetch-Site: cross-site`) is refused with 403, so a
328
363
  page from elsewhere cannot drive the server through the browser it is open in.
329
364
 
@@ -365,11 +400,13 @@ MIT
365
400
 
366
401
  ## Storybook
367
402
 
403
+ [Browse the hosted Storybook](https://xbghc.github.io/warden/). Every push to `main` builds and deploys it through `.github/workflows/storybook-pages.yml`; the workflow can also be run manually. Repository Settings → Pages must use **GitHub Actions** as the publishing source.
404
+
368
405
  Run `pnpm storybook` for isolated review and layout components at http://127.0.0.1:6006. Stories use in-memory fixtures and include interaction checks. Run `pnpm build:storybook` to build the static preview.
369
406
 
370
407
  ### Worktree details and tmux
371
408
 
372
- Each checkout reports commits ahead/behind the branch currently checked out in the main repository. “Merged” means its HEAD is reachable from the main checkout HEAD; squash merges and cherry-picks do not imply this. Expand a row to browse its paginated commit history (including shared commits) or view that branch’s todos.
409
+ Each checkout reports how many commits it is ahead of and behind a base, named beside the counts. A branch with an upstream, the main checkout's included, is counted against it (`origin/topic`): what is not pushed and what is not pulled yet, as `git status` counts them. Any other checkout, or one whose upstream is gone (`[origin/topic: gone]`, which the row notes), is counted against the branch currently checked out in the main repository; “已合并” there means its HEAD is reachable from the main checkout HEAD, which squash merges and cherry-picks do not imply. Either way the base is the ref `git branch -d` checks, so a branch with nothing ahead is one *一并删除分支* will take. Expand a row to browse its paginated commit history (including shared commits) or view that branch’s todos.
373
410
 
374
411
  The optional tmux integration discovers sessions whose `session_path` resolves to the main repository directory. Click tmux to create a window immediately when exactly one session matches. With multiple matches, choose a session first. The new window starts in the worktree directory. It uses `tmux new-window -d -c` without sending a shell command; existing windows stay selected. No session is created automatically. Run warden alongside tmux in Linux, macOS, or WSL, using the same user/server environment. See the [tmux manual](https://man.openbsd.org/tmux.1).
375
412
 
package/dist/cli.js CHANGED
@@ -3982,8 +3982,15 @@ function buildStagePatch(diff, mode, selection) {
3982
3982
  // packages/server/src/worktrees.ts
3983
3983
  import path3 from "path";
3984
3984
  import { readdir, realpath as realpath2, stat } from "fs/promises";
3985
- function worktreePathPrefix(ctx) {
3986
- return path3.join(path3.dirname(ctx.commonRoot), `${path3.basename(ctx.commonRoot)}-`);
3985
+ function slotPath(ctx, slot) {
3986
+ return path3.join(path3.dirname(ctx.commonRoot), `${path3.basename(ctx.commonRoot)}-${slot}`);
3987
+ }
3988
+ function slotOf(ctx, p) {
3989
+ if (path3.dirname(p) !== path3.dirname(ctx.commonRoot)) return void 0;
3990
+ const prefix = `${path3.basename(ctx.commonRoot)}-`;
3991
+ const name = path3.basename(p);
3992
+ const n = name.startsWith(prefix) ? name.slice(prefix.length) : "";
3993
+ return /^[1-9]\d*$/.test(n) ? Number(n) : void 0;
3987
3994
  }
3988
3995
  async function dirtyCount(cwd) {
3989
3996
  try {
@@ -3993,18 +4000,48 @@ async function dirtyCount(cwd) {
3993
4000
  return 0;
3994
4001
  }
3995
4002
  }
4003
+ async function listUpstreams(ctx) {
4004
+ const r = await runGit(["for-each-ref", "--format=%(refname)%09%(upstream:short)%09%(upstream:track)", "refs/heads"], { cwd: ctx.commonRoot });
4005
+ const out = /* @__PURE__ */ new Map();
4006
+ for (const line of r.stdout.split("\n")) {
4007
+ const [ref = "", name = "", track = ""] = line.split(" ");
4008
+ if (!name) continue;
4009
+ out.set(ref.replace(/^refs\/heads\//, ""), {
4010
+ name,
4011
+ gone: track === "[gone]",
4012
+ ahead: Number(/ahead (\d+)/.exec(track)?.[1] ?? 0),
4013
+ behind: Number(/behind (\d+)/.exec(track)?.[1] ?? 0)
4014
+ });
4015
+ }
4016
+ return out;
4017
+ }
3996
4018
  async function listWorktreesDetailed(ctx) {
3997
- const all = await listWorktreesAll(ctx);
4019
+ const [all, upstreams] = await Promise.all([listWorktreesAll(ctx), listUpstreams(ctx)]);
3998
4020
  const main2 = all.find((w) => w.isMain);
3999
4021
  return Promise.all(
4000
4022
  all.map(async (w) => {
4001
- const detail = { ...w, dirty: w.prunable || w.bare ? 0 : await dirtyCount(w.path) };
4023
+ const slot = w.isMain || w.bare ? void 0 : slotOf(ctx, w.path);
4024
+ const dirty = w.prunable || w.bare ? 0 : await dirtyCount(w.path);
4025
+ const detail = {
4026
+ ...w,
4027
+ dirty,
4028
+ ...slot === void 0 ? {} : { slot },
4029
+ // Free is what the next checkout may take over: a detached HEAD and nothing that would be lost.
4030
+ free: slot !== void 0 && !w.prunable && w.detached && dirty === 0
4031
+ };
4032
+ if (detail.free) return detail;
4033
+ const upstream = w.branch ? upstreams.get(w.branch) : void 0;
4034
+ if (upstream && !upstream.gone) {
4035
+ detail.comparison = { kind: "upstream", base: upstream.name, ahead: upstream.ahead, behind: upstream.behind };
4036
+ return detail;
4037
+ }
4038
+ if (upstream) detail.upstreamGone = upstream.name;
4002
4039
  if (w.isMain) return detail;
4003
4040
  if (!main2?.head || !w.head || /^0+$/.test(main2.head) || /^0+$/.test(w.head)) return { ...detail, comparisonError: "\u5206\u652F\u5C1A\u65E0\u63D0\u4EA4" };
4004
4041
  try {
4005
4042
  const result = await runGit(["rev-list", "--left-right", "--count", `${main2.head}...${w.head}`, "--"], { cwd: ctx.commonRoot });
4006
4043
  const [behind, ahead] = result.stdout.trim().split(/\s+/).map(Number);
4007
- detail.comparison = { base: main2.branch ?? `HEAD ${main2.head.slice(0, 7)}`, ahead, behind, merged: ahead === 0 };
4044
+ detail.comparison = { kind: "base", base: main2.branch ?? `HEAD ${main2.head.slice(0, 7)}`, ahead, behind };
4008
4045
  } catch {
4009
4046
  detail.comparisonError = "\u65E0\u6CD5\u6BD4\u8F83\u63D0\u4EA4\u5386\u53F2";
4010
4047
  }
@@ -4013,7 +4050,7 @@ async function listWorktreesDetailed(ctx) {
4013
4050
  );
4014
4051
  }
4015
4052
  async function listBranches(ctx, worktrees) {
4016
- const r = await runGit(["for-each-ref", "--format=%(refname:short)%09%(objectname:short)", "refs/heads"], { cwd: ctx.commonRoot });
4053
+ const r = await runGit(["for-each-ref", "--format=%(refname:lstrip=2)%09%(objectname:short)", "refs/heads"], { cwd: ctx.commonRoot });
4017
4054
  const at = /* @__PURE__ */ new Map();
4018
4055
  for (const w of worktrees) if (w.branch) at.set(w.branch, w.path);
4019
4056
  return r.stdout.split("\n").filter(Boolean).map((line) => {
@@ -4022,6 +4059,11 @@ async function listBranches(ctx, worktrees) {
4022
4059
  return { name, sha, ...worktree ? { worktree } : {} };
4023
4060
  });
4024
4061
  }
4062
+ async function lookupRemoteBranches(ctx, name) {
4063
+ const branch = name.trim();
4064
+ await assertBranchName(ctx, branch);
4065
+ return remoteBranches(ctx.commonRoot, branch);
4066
+ }
4025
4067
  async function assertBranchName(ctx, name) {
4026
4068
  if (!isValidRef(name) || name.includes("@{") || name.startsWith("refs/")) throw badRequest(`invalid branch name: ${name}`, "invalid_branch");
4027
4069
  try {
@@ -4030,64 +4072,128 @@ async function assertBranchName(ctx, name) {
4030
4072
  throw badRequest(`invalid branch name: ${name}`, "invalid_branch");
4031
4073
  }
4032
4074
  }
4033
- async function assertNewWorktreePath(ctx, worktrees, p) {
4034
- if (!p || !path3.isAbsolute(p)) throw badRequest("worktree path must be absolute", "invalid_path");
4035
- const target = path3.resolve(p);
4036
- const parent = path3.dirname(ctx.commonRoot);
4037
- if (target === parent || !target.startsWith(parent + path3.sep)) throw badRequest(`worktree path must be under ${parent}`, "invalid_path");
4038
- for (const w of worktrees) {
4039
- if (target === w.path || target.startsWith(w.path + path3.sep)) throw badRequest(`${target} is inside the worktree at ${w.path}`, "invalid_path");
4040
- }
4041
- const st = await stat(target).catch((e) => {
4075
+ async function dirUsable(p) {
4076
+ const st = await stat(p).catch((e) => {
4042
4077
  if (e.code === "ENOENT") return void 0;
4043
4078
  throw e;
4044
4079
  });
4045
- if (!st) return target;
4046
- if (!st.isDirectory() || (await readdir(target)).length > 0) throw badRequest(`${target} already exists`, "path_exists");
4047
- return target;
4080
+ return !st || st.isDirectory() && (await readdir(p)).length === 0;
4081
+ }
4082
+ async function nextSlot(ctx, worktrees) {
4083
+ const listed = new Set(worktrees.map((w) => w.path));
4084
+ for (let slot = 1; ; slot++) {
4085
+ const p = slotPath(ctx, slot);
4086
+ if (!listed.has(p) && await dirUsable(p)) return { slot, path: p };
4087
+ }
4088
+ }
4089
+ async function pickSlot(ctx, worktrees, wanted) {
4090
+ const slots = worktrees.filter((w) => w.slot !== void 0);
4091
+ if (wanted === void 0) {
4092
+ const free = slots.filter((w) => w.free).sort((a, b) => a.slot - b.slot)[0];
4093
+ if (free) return { slot: free.slot, path: free.path, reuse: true };
4094
+ return { ...await nextSlot(ctx, worktrees), reuse: false };
4095
+ }
4096
+ const have = slots.find((w) => w.slot === wanted);
4097
+ if (have?.prunable) throw new HttpError(409, `the directory of slot ${wanted} is gone; remove its entry first`, "slot_gone");
4098
+ if (have && !have.free) throw new HttpError(409, `slot ${wanted} is in use${have.branch ? ` by ${have.branch}` : ""}`, "slot_in_use");
4099
+ if (have) return { slot: wanted, path: have.path, reuse: true };
4100
+ const p = slotPath(ctx, wanted);
4101
+ if (!await dirUsable(p)) throw badRequest(`${p} already exists`, "path_exists");
4102
+ return { slot: wanted, path: p, reuse: false };
4103
+ }
4104
+ async function remoteBranches(cwd, branch) {
4105
+ const r = await runGit(["for-each-ref", "--format=%(refname:lstrip=2)", `refs/remotes/*/${branch}`], { cwd });
4106
+ return r.stdout.split("\n").filter(Boolean);
4048
4107
  }
4049
- async function addWorktree(ctx, req) {
4108
+ async function checkoutWorktree(ctx, req) {
4050
4109
  const branch = req.branch.trim();
4051
4110
  const base = req.base?.trim();
4052
4111
  await assertBranchName(ctx, branch);
4053
- const existing = await listWorktreesAll(ctx);
4054
- const target = await assertNewWorktreePath(ctx, existing, req.path.trim());
4055
4112
  const cwd = ctx.commonRoot;
4056
- const branchExists = await refExists(cwd, `refs/heads/${branch}`);
4057
- let args;
4058
4113
  if (base) {
4059
4114
  if (!isValidRef(base)) throw badRequest(`invalid base ref: ${base}`, "invalid_ref");
4060
4115
  if (!await refExists(cwd, base)) throw badRequest(`unknown ref: ${base}`, "unknown_ref");
4061
- if (branchExists) throw new HttpError(409, `branch ${branch} already exists`, "branch_exists");
4062
- args = ["worktree", "add", "-b", branch, target, base];
4063
- } else {
4064
- if (!branchExists) throw badRequest(`unknown branch: ${branch}`, "unknown_ref");
4116
+ }
4117
+ const existing = await listWorktreesDetailed(ctx);
4118
+ let create;
4119
+ if (await refExists(cwd, `refs/heads/${branch}`)) {
4120
+ if (base) throw new HttpError(409, `branch ${branch} already exists`, "branch_exists");
4065
4121
  const holder = existing.find((w) => w.branch === branch);
4066
4122
  if (holder) throw new HttpError(409, `${branch} is already checked out at ${holder.path}`, "branch_in_use");
4067
- args = ["worktree", "add", target, branch];
4123
+ } else if (await refExists(cwd, `refs/remotes/${branch}`)) {
4124
+ throw new HttpError(409, `${branch} is a remote branch; enter ${branch.slice(branch.indexOf("/") + 1)} to check it out`, "branch_exists");
4125
+ } else {
4126
+ const remote = await remoteBranches(cwd, branch);
4127
+ if (remote.length > 1 && !remote.includes(base ?? "")) {
4128
+ throw new HttpError(409, `branch ${branch} exists on several remotes; enter ${remote.join(" or ")} as the base`, "ambiguous_branch");
4129
+ }
4130
+ if (remote.length === 1 && base && base !== remote[0]) {
4131
+ throw new HttpError(409, `branch ${branch} already exists as ${remote[0]}; leave the base empty to check it out`, "branch_exists");
4132
+ }
4133
+ create = remote.length ? { start: base || remote[0], track: true } : { start: base || await detectDefaultBase(cwd) || "HEAD", track: false };
4068
4134
  }
4135
+ const target = await pickSlot(ctx, existing, req.slot);
4136
+ let made = false;
4069
4137
  try {
4070
- await runGitWrite(args, { cwd });
4138
+ if (target.reuse) {
4139
+ if (create) {
4140
+ await runGitWrite(["branch", ...create.track ? ["--track"] : [], branch, create.start], { cwd });
4141
+ made = true;
4142
+ }
4143
+ await runGitWrite(["switch", "--no-guess", branch], { cwd: target.path });
4144
+ } else if (create) {
4145
+ await runGitWrite(["worktree", "add", ...create.track ? ["--track"] : [], "-b", branch, target.path, create.start], { cwd });
4146
+ } else {
4147
+ await runGitWrite(["worktree", "add", target.path, branch], { cwd });
4148
+ }
4071
4149
  } catch (e) {
4150
+ if (made) await runGitWrite(["branch", "-D", branch], { cwd }).catch(() => void 0);
4072
4151
  if (e instanceof GitError && /already (checked out|used by worktree)/.test(e.stderr)) throw new HttpError(409, e.message, "branch_in_use");
4073
4152
  throw e;
4074
4153
  }
4075
- const real = await realpath2(target).catch(() => target);
4076
- const made = (await listWorktrees(ctx)).find((w) => w.path === real);
4077
- if (!made) throw new HttpError(500, `git created ${target} but does not list it`, "git_failed");
4078
- return made;
4154
+ const real = await realpath2(target.path).catch(() => target.path);
4155
+ const worktree = (await listWorktrees(ctx)).find((w) => w.path === real);
4156
+ if (!worktree) throw new HttpError(500, `git checked ${branch} out at ${target.path} but does not list it`, "git_failed");
4157
+ return { worktree, slot: target.slot, reused: target.reuse };
4158
+ }
4159
+ async function dropBranch(ctx, wt, asked) {
4160
+ const res = { ok: true, branchDeleted: false };
4161
+ if (asked && wt.branch) {
4162
+ try {
4163
+ await runGitWrite(["branch", "-d", wt.branch], { cwd: ctx.commonRoot });
4164
+ res.branchDeleted = true;
4165
+ } catch (e) {
4166
+ res.branchError = e instanceof Error ? e.message : String(e);
4167
+ }
4168
+ }
4169
+ return res;
4170
+ }
4171
+ async function releaseWorktree(ctx, req) {
4172
+ const p = req.path.trim();
4173
+ const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path3.resolve(p));
4174
+ if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4175
+ if (wt.isMain) throw badRequest("the main worktree cannot be released", "main_worktree");
4176
+ if (wt.bare || slotOf(ctx, wt.path) === void 0) throw badRequest(`${wt.path} is not a slot; remove it instead`, "not_a_slot");
4177
+ if (wt.prunable) throw badRequest(`the directory of ${wt.path} is gone; remove its entry instead`, "worktree_gone");
4178
+ const dirty = await dirtyCount(wt.path);
4179
+ if (dirty > 0 && !req.force) throw new HttpError(409, `${wt.path} has uncommitted changes`, "worktree_dirty");
4180
+ if (dirty > 0) {
4181
+ await runGitWrite(["reset", "-q", "--hard"], { cwd: wt.path });
4182
+ await runGitWrite(["clean", "-fdq"], { cwd: wt.path });
4183
+ }
4184
+ if (!wt.detached) await runGitWrite(["switch", "--detach"], { cwd: wt.path });
4185
+ return dropBranch(ctx, wt, req.deleteBranch);
4079
4186
  }
4080
4187
  async function removeWorktree(ctx, req) {
4081
4188
  const p = req.path.trim();
4082
4189
  const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path3.resolve(p));
4083
4190
  if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4084
4191
  if (wt.isMain) throw badRequest("the main worktree cannot be removed", "main_worktree");
4085
- const cwd = ctx.commonRoot;
4086
4192
  const args = ["worktree", "remove"];
4087
4193
  if (req.force) args.push("--force");
4088
4194
  args.push(wt.path);
4089
4195
  try {
4090
- await runGitWrite(args, { cwd });
4196
+ await runGitWrite(args, { cwd: ctx.commonRoot });
4091
4197
  } catch (e) {
4092
4198
  if (e instanceof GitError && /contains modified or untracked files/.test(e.stderr)) {
4093
4199
  throw new HttpError(409, `${wt.path} has uncommitted changes`, "worktree_dirty");
@@ -4097,16 +4203,7 @@ async function removeWorktree(ctx, req) {
4097
4203
  }
4098
4204
  throw e;
4099
4205
  }
4100
- const res = { ok: true, branchDeleted: false };
4101
- if (req.deleteBranch && wt.branch) {
4102
- try {
4103
- await runGitWrite(["branch", "-d", wt.branch], { cwd });
4104
- res.branchDeleted = true;
4105
- } catch (e) {
4106
- res.branchError = e instanceof Error ? e.message : String(e);
4107
- }
4108
- }
4109
- return res;
4206
+ return dropBranch(ctx, wt, req.deleteBranch);
4110
4207
  }
4111
4208
 
4112
4209
  // packages/server/src/state.ts
@@ -4272,6 +4369,16 @@ var StateStore = class {
4272
4369
  }
4273
4370
  }
4274
4371
  };
4372
+ function forgetWorktreeTargets(state, worktreePath) {
4373
+ const prefix = `worktree:${worktreePath}:`;
4374
+ const gone = /* @__PURE__ */ new Set();
4375
+ for (const key of Object.keys(state.targets)) {
4376
+ if (!key.startsWith(prefix)) continue;
4377
+ for (const c of state.targets[key].comments) gone.add(c.id);
4378
+ delete state.targets[key];
4379
+ }
4380
+ if (gone.size) for (const issue of state.issues) issue.commentIds = issue.commentIds.filter((id) => !gone.has(id));
4381
+ }
4275
4382
 
4276
4383
  // packages/server/src/export.ts
4277
4384
  var LANG_BY_EXT = {
@@ -5281,16 +5388,30 @@ function createApp(opts) {
5281
5388
  });
5282
5389
  api.get("/worktrees", async (c) => {
5283
5390
  const list = await listWorktreesDetailed(repo);
5284
- const res = { worktrees: list, branches: await listBranches(repo, list), pathPrefix: worktreePathPrefix(repo) };
5391
+ const [branches, newSlot] = await Promise.all([listBranches(repo, list), nextSlot(repo, list)]);
5392
+ const res = { worktrees: list, branches, newSlot };
5393
+ return c.json(res);
5394
+ });
5395
+ api.get("/worktrees/remotes", async (c) => {
5396
+ const res = { remotes: await lookupRemoteBranches(repo, c.req.query("branch") ?? "") };
5285
5397
  return c.json(res);
5286
5398
  });
5287
5399
  api.post("/worktrees", async (c) => {
5288
5400
  const body = await c.req.json();
5289
- if (typeof body.path !== "string" || typeof body.branch !== "string") throw badRequest("path and branch are required");
5401
+ if (typeof body.branch !== "string") throw badRequest("branch is required");
5290
5402
  if (body.base !== void 0 && typeof body.base !== "string") throw badRequest("base must be a ref");
5291
- const made = await addWorktree(repo, body);
5403
+ if (body.slot !== void 0 && (!Number.isInteger(body.slot) || body.slot < 1)) throw badRequest("slot must be a positive integer", "invalid_slot");
5404
+ const res = await checkoutWorktree(repo, body);
5405
+ worktreeCache = null;
5406
+ await store.update((s) => forgetWorktreeTargets(s, res.worktree.path));
5407
+ return c.json(res, 201);
5408
+ });
5409
+ api.post("/worktrees/release", async (c) => {
5410
+ const body = await c.req.json();
5411
+ if (typeof body.path !== "string") throw badRequest("path is required");
5412
+ const res = await releaseWorktree(repo, body);
5292
5413
  worktreeCache = null;
5293
- return c.json(made, 201);
5414
+ return c.json(res);
5294
5415
  });
5295
5416
  api.post("/worktrees/remove", async (c) => {
5296
5417
  const body = await c.req.json();
@@ -5476,7 +5597,7 @@ async function startServer(opts) {
5476
5597
  }
5477
5598
 
5478
5599
  // bin/cli.ts
5479
- var VERSION = true ? "0.10.0" : "dev";
5600
+ var VERSION = true ? "0.11.1" : "dev";
5480
5601
  function parseArgs(argv) {
5481
5602
  const args = { repoPath: process.cwd(), open: true, help: false, version: false };
5482
5603
  for (let i = 0; i < argv.length; i++) {