@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
|
|
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,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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
4034
|
-
|
|
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
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
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
|
|
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
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4077
|
-
if (!
|
|
4078
|
-
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);
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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++) {
|