@xbghc/warden 0.9.0 → 0.11.0

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,4 +400,16 @@ 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.
406
+
407
+ ### Worktree details and tmux
408
+
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.
410
+
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).
412
+
413
+ `GET /api/tmux/sessions` lists matching sessions. `POST /api/tmux/windows` accepts a registered worktree `path` and a matching `sessionId`; the server revalidates both before creating a window. This optional action creates a terminal window but does not modify repository files.
414
+
415
+ The sidebar uses a view selector for Changes, Commits, and Worktrees. The `Layout/Sidebar` stories demonstrate the selector together with each view’s real sidebar controls and content.
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,12 +4000,57 @@ 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);
3998
- return Promise.all(all.map(async (w) => ({ ...w, dirty: w.prunable || w.bare ? 0 : await dirtyCount(w.path) })));
4019
+ const [all, upstreams] = await Promise.all([listWorktreesAll(ctx), listUpstreams(ctx)]);
4020
+ const main2 = all.find((w) => w.isMain);
4021
+ return Promise.all(
4022
+ all.map(async (w) => {
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;
4039
+ if (w.isMain) return detail;
4040
+ if (!main2?.head || !w.head || /^0+$/.test(main2.head) || /^0+$/.test(w.head)) return { ...detail, comparisonError: "\u5206\u652F\u5C1A\u65E0\u63D0\u4EA4" };
4041
+ try {
4042
+ const result = await runGit(["rev-list", "--left-right", "--count", `${main2.head}...${w.head}`, "--"], { cwd: ctx.commonRoot });
4043
+ const [behind, ahead] = result.stdout.trim().split(/\s+/).map(Number);
4044
+ detail.comparison = { kind: "base", base: main2.branch ?? `HEAD ${main2.head.slice(0, 7)}`, ahead, behind };
4045
+ } catch {
4046
+ detail.comparisonError = "\u65E0\u6CD5\u6BD4\u8F83\u63D0\u4EA4\u5386\u53F2";
4047
+ }
4048
+ return detail;
4049
+ })
4050
+ );
3999
4051
  }
4000
4052
  async function listBranches(ctx, worktrees) {
4001
- 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 });
4002
4054
  const at = /* @__PURE__ */ new Map();
4003
4055
  for (const w of worktrees) if (w.branch) at.set(w.branch, w.path);
4004
4056
  return r.stdout.split("\n").filter(Boolean).map((line) => {
@@ -4007,6 +4059,11 @@ async function listBranches(ctx, worktrees) {
4007
4059
  return { name, sha, ...worktree ? { worktree } : {} };
4008
4060
  });
4009
4061
  }
4062
+ async function lookupRemoteBranches(ctx, name) {
4063
+ const branch = name.trim();
4064
+ await assertBranchName(ctx, branch);
4065
+ return remoteBranches(ctx.commonRoot, branch);
4066
+ }
4010
4067
  async function assertBranchName(ctx, name) {
4011
4068
  if (!isValidRef(name) || name.includes("@{") || name.startsWith("refs/")) throw badRequest(`invalid branch name: ${name}`, "invalid_branch");
4012
4069
  try {
@@ -4015,64 +4072,128 @@ async function assertBranchName(ctx, name) {
4015
4072
  throw badRequest(`invalid branch name: ${name}`, "invalid_branch");
4016
4073
  }
4017
4074
  }
4018
- async function assertNewWorktreePath(ctx, worktrees, p) {
4019
- if (!p || !path3.isAbsolute(p)) throw badRequest("worktree path must be absolute", "invalid_path");
4020
- const target = path3.resolve(p);
4021
- const parent = path3.dirname(ctx.commonRoot);
4022
- if (target === parent || !target.startsWith(parent + path3.sep)) throw badRequest(`worktree path must be under ${parent}`, "invalid_path");
4023
- for (const w of worktrees) {
4024
- if (target === w.path || target.startsWith(w.path + path3.sep)) throw badRequest(`${target} is inside the worktree at ${w.path}`, "invalid_path");
4025
- }
4026
- const st = await stat(target).catch((e) => {
4075
+ async function dirUsable(p) {
4076
+ const st = await stat(p).catch((e) => {
4027
4077
  if (e.code === "ENOENT") return void 0;
4028
4078
  throw e;
4029
4079
  });
4030
- if (!st) return target;
4031
- if (!st.isDirectory() || (await readdir(target)).length > 0) throw badRequest(`${target} already exists`, "path_exists");
4032
- 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 };
4033
4103
  }
4034
- async function addWorktree(ctx, req) {
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);
4107
+ }
4108
+ async function checkoutWorktree(ctx, req) {
4035
4109
  const branch = req.branch.trim();
4036
4110
  const base = req.base?.trim();
4037
4111
  await assertBranchName(ctx, branch);
4038
- const existing = await listWorktreesAll(ctx);
4039
- const target = await assertNewWorktreePath(ctx, existing, req.path.trim());
4040
4112
  const cwd = ctx.commonRoot;
4041
- const branchExists = await refExists(cwd, `refs/heads/${branch}`);
4042
- let args;
4043
4113
  if (base) {
4044
4114
  if (!isValidRef(base)) throw badRequest(`invalid base ref: ${base}`, "invalid_ref");
4045
4115
  if (!await refExists(cwd, base)) throw badRequest(`unknown ref: ${base}`, "unknown_ref");
4046
- if (branchExists) throw new HttpError(409, `branch ${branch} already exists`, "branch_exists");
4047
- args = ["worktree", "add", "-b", branch, target, base];
4048
- } else {
4049
- 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");
4050
4121
  const holder = existing.find((w) => w.branch === branch);
4051
4122
  if (holder) throw new HttpError(409, `${branch} is already checked out at ${holder.path}`, "branch_in_use");
4052
- 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 };
4053
4134
  }
4135
+ const target = await pickSlot(ctx, existing, req.slot);
4136
+ let made = false;
4054
4137
  try {
4055
- 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
+ }
4056
4149
  } catch (e) {
4150
+ if (made) await runGitWrite(["branch", "-D", branch], { cwd }).catch(() => void 0);
4057
4151
  if (e instanceof GitError && /already (checked out|used by worktree)/.test(e.stderr)) throw new HttpError(409, e.message, "branch_in_use");
4058
4152
  throw e;
4059
4153
  }
4060
- const real = await realpath2(target).catch(() => target);
4061
- const made = (await listWorktrees(ctx)).find((w) => w.path === real);
4062
- if (!made) throw new HttpError(500, `git created ${target} but does not list it`, "git_failed");
4063
- 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);
4064
4186
  }
4065
4187
  async function removeWorktree(ctx, req) {
4066
4188
  const p = req.path.trim();
4067
4189
  const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path3.resolve(p));
4068
4190
  if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
4069
4191
  if (wt.isMain) throw badRequest("the main worktree cannot be removed", "main_worktree");
4070
- const cwd = ctx.commonRoot;
4071
4192
  const args = ["worktree", "remove"];
4072
4193
  if (req.force) args.push("--force");
4073
4194
  args.push(wt.path);
4074
4195
  try {
4075
- await runGitWrite(args, { cwd });
4196
+ await runGitWrite(args, { cwd: ctx.commonRoot });
4076
4197
  } catch (e) {
4077
4198
  if (e instanceof GitError && /contains modified or untracked files/.test(e.stderr)) {
4078
4199
  throw new HttpError(409, `${wt.path} has uncommitted changes`, "worktree_dirty");
@@ -4082,16 +4203,7 @@ async function removeWorktree(ctx, req) {
4082
4203
  }
4083
4204
  throw e;
4084
4205
  }
4085
- const res = { ok: true, branchDeleted: false };
4086
- if (req.deleteBranch && wt.branch) {
4087
- try {
4088
- await runGitWrite(["branch", "-d", wt.branch], { cwd });
4089
- res.branchDeleted = true;
4090
- } catch (e) {
4091
- res.branchError = e instanceof Error ? e.message : String(e);
4092
- }
4093
- }
4094
- return res;
4206
+ return dropBranch(ctx, wt, req.deleteBranch);
4095
4207
  }
4096
4208
 
4097
4209
  // packages/server/src/state.ts
@@ -4257,6 +4369,16 @@ var StateStore = class {
4257
4369
  }
4258
4370
  }
4259
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
+ }
4260
4382
 
4261
4383
  // packages/server/src/export.ts
4262
4384
  var LANG_BY_EXT = {
@@ -4449,6 +4571,44 @@ var NvimService = class {
4449
4571
  }
4450
4572
  };
4451
4573
 
4574
+ // packages/server/src/tmux.ts
4575
+ import { execFile as execFile3 } from "child_process";
4576
+ import { realpath as realpath3 } from "fs/promises";
4577
+ var runTmux = (args) => new Promise((resolve, reject) => {
4578
+ execFile3("tmux", args, { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
4579
+ if (!error) return resolve(stdout);
4580
+ const missing = error.code === "ENOENT";
4581
+ reject(
4582
+ new HttpError(503, missing ? "tmux \u4E0D\u53EF\u7528\uFF0C\u8BF7\u5728\u5B89\u88C5\u4E86 tmux \u7684 Linux/macOS \u6216 WSL \u4E2D\u8FD0\u884C warden" : stderr.trim() || "\u65E0\u6CD5\u8FDE\u63A5 tmux", "tmux_unavailable")
4583
+ );
4584
+ });
4585
+ });
4586
+ var TmuxService = class {
4587
+ constructor(run2 = runTmux) {
4588
+ this.run = run2;
4589
+ }
4590
+ run;
4591
+ async sessions(mainRoot) {
4592
+ const root = await realpath3(mainRoot);
4593
+ const output = await this.run(["list-sessions", "-F", "#{session_id} #{session_name} #{session_path}"]);
4594
+ const sessions = [];
4595
+ for (const line of output.trimEnd().split("\n")) {
4596
+ const [id, name, ...parts] = line.split(" ");
4597
+ const path10 = parts.join(" ");
4598
+ if (!id || !/^\$\d+$/.test(id) || !name || !path10) continue;
4599
+ if (await realpath3(path10).catch(() => null) === root) sessions.push({ id, name, path: path10 });
4600
+ }
4601
+ return sessions;
4602
+ }
4603
+ async open(mainRoot, worktreePath, sessionId) {
4604
+ const session = (await this.sessions(mainRoot)).find((s) => s.id === sessionId);
4605
+ if (!session) throw new HttpError(409, "\u4E3B\u4ED3\u5E93\u5BF9\u5E94\u7684 tmux session \u5DF2\u4E0D\u5B58\u5728\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5", "tmux_session_missing");
4606
+ const directory = await realpath3(worktreePath);
4607
+ const window = (await this.run(["new-window", "-d", "-P", "-F", "#{window_id}", "-t", `${session.id}:`, "-c", directory.replaceAll("#", "##")])).trim();
4608
+ return { session: session.name, window };
4609
+ }
4610
+ };
4611
+
4452
4612
  // packages/server/src/watcher.ts
4453
4613
  import path6 from "path";
4454
4614
  import { stat as stat4 } from "fs/promises";
@@ -5217,18 +5377,41 @@ function createApp(opts) {
5217
5377
  const res = { base, sha, ahead: Number(counts[1] ?? 0), behind: Number(counts[0] ?? 0) };
5218
5378
  return c.json(res);
5219
5379
  });
5380
+ const tmux = opts.tmux ?? new TmuxService();
5381
+ api.get("/tmux/sessions", async (c) => c.json({ sessions: await tmux.sessions(repo.commonRoot) }));
5382
+ api.post("/tmux/windows", async (c) => {
5383
+ const body = await c.req.json();
5384
+ if (!body || typeof body.path !== "string" || typeof body.sessionId !== "string") throw badRequest("path and sessionId are required");
5385
+ const wt = (await worktrees(true)).find((w) => w.path === body.path && !w.bare);
5386
+ if (!wt) throw badRequest("unknown worktree", "unknown_worktree");
5387
+ return c.json(await tmux.open(repo.commonRoot, wt.path, body.sessionId), 201);
5388
+ });
5220
5389
  api.get("/worktrees", async (c) => {
5221
5390
  const list = await listWorktreesDetailed(repo);
5222
- 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") ?? "") };
5223
5397
  return c.json(res);
5224
5398
  });
5225
5399
  api.post("/worktrees", async (c) => {
5226
5400
  const body = await c.req.json();
5227
- 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");
5228
5402
  if (body.base !== void 0 && typeof body.base !== "string") throw badRequest("base must be a ref");
5229
- 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);
5230
5413
  worktreeCache = null;
5231
- return c.json(made, 201);
5414
+ return c.json(res);
5232
5415
  });
5233
5416
  api.post("/worktrees/remove", async (c) => {
5234
5417
  const body = await c.req.json();
@@ -5317,7 +5500,7 @@ function createApp(opts) {
5317
5500
  }
5318
5501
 
5319
5502
  // packages/server/src/wsl.ts
5320
- import { execFile as execFile3 } from "child_process";
5503
+ import { execFile as execFile4 } from "child_process";
5321
5504
  import { readFile as readFile4 } from "fs/promises";
5322
5505
  var PROBE_TIMEOUT_MS = 3e3;
5323
5506
  async function isWsl(env = process.env, platform = process.platform) {
@@ -5331,7 +5514,7 @@ async function isWsl(env = process.env, platform = process.platform) {
5331
5514
  }
5332
5515
  function reachableFromWindows(port, token, timeoutMs = PROBE_TIMEOUT_MS) {
5333
5516
  return new Promise((resolve) => {
5334
- execFile3(
5517
+ execFile4(
5335
5518
  "curl.exe",
5336
5519
  ["--silent", "--max-time", String(Math.ceil(timeoutMs / 1e3)), `http://127.0.0.1:${port}/api/ping`],
5337
5520
  { timeout: timeoutMs + 1e3, encoding: "utf8", windowsHide: true },
@@ -5414,7 +5597,7 @@ async function startServer(opts) {
5414
5597
  }
5415
5598
 
5416
5599
  // bin/cli.ts
5417
- var VERSION = true ? "0.9.0" : "dev";
5600
+ var VERSION = true ? "0.11.0" : "dev";
5418
5601
  function parseArgs(argv) {
5419
5602
  const args = { repoPath: process.cwd(), open: true, help: false, version: false };
5420
5603
  for (let i = 0; i < argv.length; i++) {