@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
|
|
27
|
-
what the stage / unstage controls run
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
4019
|
-
|
|
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
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
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
|
|
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
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4062
|
-
if (!
|
|
4063
|
-
return
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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++) {
|