@xbghc/warden 0.4.0 → 0.6.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
@@ -4,7 +4,8 @@ Local web UI for reviewing git diffs — built for reviewing changes that a codi
4
4
  with line comments you can copy back to the agent as a prompt.
5
5
 
6
6
  - Runs as a single local process per repository (`127.0.0.1` only, no auth, no database).
7
- - Diff sources: working tree, staged, working tree vs HEAD, any commit, any two refs, and git worktrees.
7
+ - Diff sources: working tree, staged, working tree vs HEAD, a branch since it forked off its base (commits and
8
+ uncommitted work together), any commit, any two refs, and git worktrees.
8
9
  - Side-by-side **Unstaged** and **Staged** file lists, so staging a hunk in your editor is what marks it reviewed.
9
10
  - GitHub-style unified / side-by-side diff with syntax highlighting, collapsed file tree, lazy per-file loading, context expansion, virtual scrolling.
10
11
  - Line comments (single line or a dragged range), Markdown, edit / delete.
@@ -13,8 +14,10 @@ with line comments you can copy back to the agent as a prompt.
13
14
  - Comments follow the code: they move with a hunk that gets staged, re-attach after the agent edits the file, and are cleaned up once the change is committed.
14
15
  - Auto-refresh — warden watches the repository and reloads itself when you edit, stage, commit or switch branches.
15
16
  - Local issues (title, Markdown body, open/closed) that link comments.
16
- - Branch-scoped todos, exportable as a numbered checklist.
17
- - Commit history browser.
17
+ - Branch-scoped todos, each copied to the clipboard on its own — one task at a time for the agent.
18
+ - Commit history browser: grouped by day, branch / tag labels, search by message, sha, author or
19
+ path (each a `git log` on the server, not a filter over the rows already loaded), and a
20
+ first-parent view that folds merged branches into their merge commits.
18
21
  - Click a line number to jump to that line in a running nvim instance (WSL2 friendly).
19
22
  - Read-only with respect to git: only whitelisted read sub-commands are ever executed.
20
23
 
@@ -45,19 +48,38 @@ Several instances on the same repository can run at the same time.
45
48
  | Working tree, uncommitted (incl. untracked) | `working` | `git diff` + `git diff --no-index /dev/null <file>` |
46
49
  | Staged | `staged` | `git diff --cached` |
47
50
  | Working tree, everything vs HEAD | `all` | `git diff HEAD` (+ untracked) |
51
+ | Branch, everything since it forked off a base (committed or not) | `base:<ref>` | `git diff $(git merge-base <ref> HEAD)` (+ untracked) |
48
52
  | One commit | `commit:<sha>` | `git diff <sha>^ <sha>` |
49
53
  | Two refs | `range:<base>..<head>` | `git diff <base>...<head>` |
50
54
  | Worktree, working tree | `worktree:<path>:working` | same, run inside the worktree |
51
55
  | Worktree, branch vs base | `worktree:<path>:range:<base>..<head>` | same, run inside the worktree |
56
+ | Worktree, everything since base | `worktree:<path>:base:<ref>` | same, run inside the worktree |
52
57
 
53
58
  Refs accept anything git can resolve (`main`, `v1.2`, `HEAD~3`, a sha). `@` is `HEAD`.
54
59
  Worktrees are discovered with `git worktree list` and share the review state of the main repository.
55
60
 
61
+ There is no target switcher in the UI: the sidebar's two blocks *are* the working tree, a commit is
62
+ picked from the *Commits* panel, and the *Branch vs* and *Range* fields at the top of that panel open
63
+ the other two. The sidebar names whatever is under review in its block header and puts a
64
+ *返回工作区* link above it.
65
+
56
66
  `working`, `staged` and `all` are the three **local views** of one worktree. While any of them is
57
67
  selected the sidebar shows two blocks — Unstaged (`working`) and Staged (`staged`) — instead of a
58
68
  single tree, and clicking a file switches to the view it belongs to. A file that is only partly
59
69
  staged appears in both. Switching between the three keeps your comments, the draft you are typing
60
- and the current selection; only the diff is reloaded. Commit and range targets keep the single tree.
70
+ and the current selection; only the diff is reloaded. `all` has no control of its own (a saved
71
+ `lastTarget` can still restore it; *Branch vs* `@` shows the same diff). Commit, range and `base`
72
+ targets keep the single tree.
73
+
74
+ `base:<ref>` is for a branch a coding agent has been working on, committing as it goes: one tree with
75
+ everything since the branch forked off `<ref>` — the commits plus whatever is still uncommitted or
76
+ untracked. The diff runs against the merge base, so commits that landed on `<ref>` after the fork are
77
+ not listed as reverted (which is what a plain `git diff <ref>` would do). The *Branch vs* field in the
78
+ Commits panel suggests the base for you — the main worktree's branch when a sibling worktree is under
79
+ review, otherwise `main` or `master` — and takes any ref. Unlike the three local views, `base` keeps its own
80
+ pool of comments and a commit never deletes them: the round under review is not over when the agent
81
+ commits, so a comment whose lines changed stays *orphaned*, snippet and all, until you have checked
82
+ the fix and delete or re-attach it.
61
83
 
62
84
  ## Keyboard
63
85
 
@@ -120,6 +142,9 @@ them — the comment's current view first, then `working`, `staged`, `all`. So:
120
142
  **deleted** (and unlinked from any issue), because the code they were about is now history.
121
143
  Anything still visible in Unstaged or Staged survives, including as a context line.
122
144
 
145
+ Commit, range and `base` targets each keep their own pool and only ever search themselves, and none of
146
+ them deletes on a moved HEAD.
147
+
123
148
  Comment markers in the diff belong to one view; the rail's *全部* tab lists the whole pool and
124
149
  *此文件* lists every comment on the open file regardless of which view it currently sits in.
125
150
 
@@ -143,19 +168,16 @@ Notes attached to a branch rather than to a line of code, for the things you not
143
168
  that do not belong in a comment. They are not deleted when you commit. They sit in the right-hand
144
169
  rail next to the comments, under the *Todo* tab: type a title and press Enter to add one, expand a
145
170
  card to read or edit its Markdown body, filter by status, and switch between the current branch
146
- and all branches; *复制 Todo* copies the open ones
147
- (optionally including the done ones, marked ` (done)`) as:
171
+ and all branches.
172
+
173
+ Each card has its own *复制* button, and there is deliberately no "copy all": a todo is one task to
174
+ hand to an agent, and the agent it goes to is already working in that branch. So the clipboard gets
175
+ the title and the body — no branch, repo, count or numbering:
148
176
 
149
177
  ```markdown
150
- # TODO
151
- Branch: feature/x
152
- Repo: /home/user/project
153
- Count: 2
178
+ 补充 OrderList 的空状态
154
179
 
155
- ## 1. 补充 OrderList 的空状态
156
180
  描述正文(Markdown)…
157
-
158
- ## 2. useOrder 的依赖数组缺少 id
159
181
  ```
160
182
 
161
183
  ## nvim integration
@@ -201,7 +223,7 @@ move into the matching scope the first time the file is read.
201
223
 
202
224
  Single user, local only. The server binds to `127.0.0.1`, executes git only through
203
225
  `execFile('git', [...])` with an argument whitelist (`rev-parse`, `diff`, `show`, `log`, `worktree list`,
204
- `ls-files`, `status`), refuses option-looking refs and any write-capable flag, and never writes into the
226
+ `ls-files`, `status`, `merge-base`), refuses option-looking refs and any write-capable flag, and never writes into the
205
227
  repository. Requests that would need anything else get HTTP 400.
206
228
 
207
229
  ## Development
package/dist/cli.js CHANGED
@@ -2897,6 +2897,11 @@ function parseSuffix(rest) {
2897
2897
  if (!isValidRef(sha)) throw new TargetKeyError(`invalid commit ref: ${sha}`);
2898
2898
  return { kind: "commit", sha };
2899
2899
  }
2900
+ if (rest.startsWith("base:")) {
2901
+ const ref = rest.slice("base:".length);
2902
+ if (!isValidRef(ref)) throw new TargetKeyError(`invalid base ref: ${ref}`);
2903
+ return { kind: "base", ref };
2904
+ }
2900
2905
  if (rest.startsWith("range:")) {
2901
2906
  const spec = rest.slice("range:".length);
2902
2907
  const idx = spec.indexOf("..");
@@ -2912,7 +2917,7 @@ function parseTargetKey(key) {
2912
2917
  if (typeof key !== "string" || !key) throw new TargetKeyError("empty target key");
2913
2918
  if (key.startsWith("worktree:")) {
2914
2919
  const rest = key.slice("worktree:".length);
2915
- const m = /^(.+):(working|staged|all|commit:.+|range:.+)$/.exec(rest);
2920
+ const m = /^(.+):(working|staged|all|commit:.+|range:.+|base:.+)$/.exec(rest);
2916
2921
  if (!m || !m[1] || !m[2]) throw new TargetKeyError(`invalid worktree target key: ${key}`);
2917
2922
  const worktree = m[1];
2918
2923
  if (!worktree.startsWith("/")) throw new TargetKeyError(`worktree path must be absolute: ${worktree}`);
@@ -2934,6 +2939,9 @@ function formatTargetKey(t) {
2934
2939
  case "range":
2935
2940
  suffix = `range:${t.base}..${t.head}`;
2936
2941
  break;
2942
+ case "base":
2943
+ suffix = `base:${t.ref}`;
2944
+ break;
2937
2945
  }
2938
2946
  return t.worktree ? `worktree:${t.worktree}:${suffix}` : suffix;
2939
2947
  }
@@ -2980,9 +2988,44 @@ function notFound(message, code = "not_found") {
2980
2988
  return new HttpError(404, message, code);
2981
2989
  }
2982
2990
 
2991
+ // packages/server/src/commits.ts
2992
+ var SEP = "";
2993
+ var COMMIT_FORMAT = `%H${SEP}%h${SEP}%an${SEP}%ae${SEP}%aI${SEP}%P${SEP}%D${SEP}%s`;
2994
+ function parseDecorations(raw2) {
2995
+ const refs = [];
2996
+ let head = false;
2997
+ for (const part of raw2.split(", ")) {
2998
+ const p = part.trim();
2999
+ if (!p || p === "grafted" || p === "replaced") continue;
3000
+ if (p.endsWith("/HEAD")) continue;
3001
+ if (p === "HEAD") head = true;
3002
+ else if (p.startsWith("HEAD -> ")) {
3003
+ head = true;
3004
+ refs.unshift({ name: p.slice("HEAD -> ".length), kind: "branch" });
3005
+ } else if (p.startsWith("tag: ")) refs.push({ name: p.slice("tag: ".length), kind: "tag" });
3006
+ else refs.push({ name: p, kind: "branch" });
3007
+ }
3008
+ return { refs, head };
3009
+ }
3010
+ function parseCommitLog(stdout) {
3011
+ return stdout.split("\n").filter(Boolean).map((line) => {
3012
+ const [sha = "", shortSha = "", author = "", email = "", date = "", parents = "", decorations = "", ...rest] = line.split(SEP);
3013
+ return {
3014
+ sha,
3015
+ shortSha,
3016
+ author,
3017
+ email,
3018
+ date,
3019
+ parents: parents.split(" ").filter(Boolean),
3020
+ ...parseDecorations(decorations),
3021
+ subject: rest.join(SEP)
3022
+ };
3023
+ });
3024
+ }
3025
+
2983
3026
  // packages/server/src/git.ts
2984
3027
  import { execFile } from "child_process";
2985
- var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status"]);
3028
+ var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status", "merge-base"]);
2986
3029
  var FORBIDDEN_OPTION_PREFIXES = ["--output", "--ext-diff", "--textconv", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree"];
2987
3030
  var DEFAULT_GIT_TIMEOUT_MS = 3e4;
2988
3031
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
@@ -3077,6 +3120,14 @@ async function revParse(cwd, ref) {
3077
3120
  return void 0;
3078
3121
  }
3079
3122
  }
3123
+ async function mergeBase(cwd, a, b) {
3124
+ try {
3125
+ const r = await runGit(["merge-base", a, b], { cwd });
3126
+ return r.stdout.trim() || void 0;
3127
+ } catch {
3128
+ return void 0;
3129
+ }
3130
+ }
3080
3131
 
3081
3132
  // packages/server/src/repo.ts
3082
3133
  import path from "path";
@@ -3150,11 +3201,16 @@ async function currentBranch(cwd) {
3150
3201
  const sha = head.stdout.trim();
3151
3202
  return sha ? sha.slice(0, 7) : "HEAD";
3152
3203
  }
3204
+ async function detectDefaultBase(cwd) {
3205
+ for (const ref of ["main", "master"]) if (await refExists(cwd, ref)) return ref;
3206
+ return void 0;
3207
+ }
3153
3208
  async function getRepoInfo(ctx, defaultTarget) {
3154
- const [branch, headRes, worktrees] = await Promise.all([
3209
+ const [branch, headRes, worktrees, defaultBase] = await Promise.all([
3155
3210
  currentBranch(ctx.root),
3156
3211
  runGit(["rev-parse", "HEAD"], { cwd: ctx.root }).catch(() => ({ stdout: "" })),
3157
- listWorktrees(ctx)
3212
+ listWorktrees(ctx),
3213
+ detectDefaultBase(ctx.root)
3158
3214
  ]);
3159
3215
  return {
3160
3216
  root: ctx.root,
@@ -3162,7 +3218,8 @@ async function getRepoInfo(ctx, defaultTarget) {
3162
3218
  branch,
3163
3219
  head: headRes.stdout.trim(),
3164
3220
  worktrees,
3165
- defaultTarget
3221
+ defaultTarget,
3222
+ ...defaultBase ? { defaultBase } : {}
3166
3223
  };
3167
3224
  }
3168
3225
 
@@ -3410,6 +3467,13 @@ function resolveTargetContext(repo, worktrees, key) {
3410
3467
  async function hasHead(cwd) {
3411
3468
  return refExists(cwd, "HEAD");
3412
3469
  }
3470
+ async function baseSha(ctx, ref) {
3471
+ if (!await refExists(ctx.cwd, ref)) throw badRequest(`unknown ref: ${ref}`, "unknown_ref");
3472
+ if (!await hasHead(ctx.cwd)) return EMPTY_TREE_SHA;
3473
+ const sha = await mergeBase(ctx.cwd, ref, "HEAD");
3474
+ if (!sha) throw badRequest(`${ref} and HEAD share no history`, "no_merge_base");
3475
+ return sha;
3476
+ }
3413
3477
  async function diffArgs(ctx) {
3414
3478
  const t = ctx.target;
3415
3479
  switch (t.kind) {
@@ -3429,10 +3493,12 @@ async function diffArgs(ctx) {
3429
3493
  if (!await refExists(ctx.cwd, t.head)) throw badRequest(`unknown ref: ${t.head}`, "unknown_ref");
3430
3494
  return [...DIFF_BASE_ARGS, `${t.base}...${t.head}`];
3431
3495
  }
3496
+ case "base":
3497
+ return [...DIFF_BASE_ARGS, await baseSha(ctx, t.ref)];
3432
3498
  }
3433
3499
  }
3434
3500
  function includesUntracked(t) {
3435
- return t.kind === "working" || t.kind === "all";
3501
+ return t.kind === "working" || t.kind === "all" || t.kind === "base";
3436
3502
  }
3437
3503
  async function listUntracked(cwd) {
3438
3504
  const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z"], { cwd });
@@ -3512,6 +3578,8 @@ async function refForSide(ctx, side) {
3512
3578
  return await revParse(ctx.cwd, `${t.sha}^`) ?? EMPTY_TREE_SHA;
3513
3579
  case "range":
3514
3580
  return side === "new" ? t.head : t.base;
3581
+ case "base":
3582
+ return side === "new" ? void 0 : baseSha(ctx, t.ref);
3515
3583
  }
3516
3584
  }
3517
3585
  async function getFullFile(ctx, filePath, side) {
@@ -3892,16 +3960,6 @@ function formatIssueExport({ repoRoot, issue, comments }) {
3892
3960
  parts.push(...comments.map(formatCommentSection));
3893
3961
  return parts.join("\n\n") + "\n";
3894
3962
  }
3895
- function formatTodosExport({ repoRoot, branch, todos }) {
3896
- const head = ["# TODO", `Branch: ${branch}`, `Repo: ${repoRoot}`, `Count: ${todos.length}`];
3897
- const sections = todos.map((t, i) => {
3898
- const title = `## ${i + 1}. ${t.title}${t.status === "done" ? " (done)" : ""}`;
3899
- const body = t.body.replace(/\r\n/g, "\n").trim();
3900
- return body ? `${title}
3901
- ${body}` : title;
3902
- });
3903
- return [head.join("\n"), ...sections].join("\n\n") + "\n";
3904
- }
3905
3963
 
3906
3964
  // packages/server/src/nvim.ts
3907
3965
  import os2 from "os";
@@ -4653,15 +4711,6 @@ function createApp(opts) {
4653
4711
  });
4654
4712
  return c.json(todo, 201);
4655
4713
  });
4656
- api.post("/todos/export", async (c) => {
4657
- const body = await c.req.json();
4658
- const branch = typeof body.branch === "string" ? body.branch.trim() : "";
4659
- if (!branch) throw badRequest("branch is required");
4660
- const s = await store.load();
4661
- const todos = s.todos.filter((t) => t.branch === branch && (body.includeDone === true || t.status === "open"));
4662
- const res = { text: formatTodosExport({ repoRoot: repo.root, branch, todos }), count: todos.length };
4663
- return c.json(res);
4664
- });
4665
4714
  api.patch("/todos/:id", async (c) => {
4666
4715
  const id = c.req.param("id");
4667
4716
  const body = await c.req.json();
@@ -4689,23 +4738,29 @@ function createApp(opts) {
4689
4738
  api.get("/commits", async (c) => {
4690
4739
  const limitRaw = Number(c.req.query("limit") ?? 200);
4691
4740
  const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? Math.floor(limitRaw) : 200, 1), 1e3);
4692
- const before = c.req.query("before") || void 0;
4741
+ const offsetRaw = Number(c.req.query("offset") ?? 0);
4742
+ const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
4693
4743
  const filePath = c.req.query("path") || void 0;
4694
4744
  const ref = c.req.query("ref") || void 0;
4695
4745
  const rootParam = c.req.query("root") || void 0;
4746
+ const q = (c.req.query("q") ?? "").trim();
4747
+ const author = (c.req.query("author") ?? "").trim();
4748
+ const firstParent = c.req.query("firstParent") === "1";
4696
4749
  let cwd = repo.root;
4697
4750
  if (rootParam) {
4698
4751
  const wt = (await worktrees()).find((w) => w.path === rootParam);
4699
4752
  if (!wt && rootParam !== repo.root) throw badRequest("unknown root", "unknown_worktree");
4700
4753
  cwd = rootParam;
4701
4754
  }
4702
- if (before && !isValidRef(before)) throw badRequest("invalid before ref");
4703
4755
  if (ref && !isValidRef(ref)) throw badRequest("invalid ref");
4704
4756
  if (filePath && (filePath.startsWith("/") || filePath.split("/").includes(".."))) throw badRequest("invalid path");
4705
- const SEP = "";
4706
- const args = ["log", `--max-count=${limit + 1}`, `--format=%H${SEP}%h${SEP}%an${SEP}%ae${SEP}%aI${SEP}%P${SEP}%s`];
4707
- if (before) args.push(before);
4708
- else if (ref) args.push(ref);
4757
+ if (q.length > 200 || author.length > 200) throw badRequest("search text too long");
4758
+ const args = ["log", `--max-count=${limit + 1}`, `--skip=${offset}`, `--format=${COMMIT_FORMAT}`];
4759
+ if (firstParent) args.push("--first-parent");
4760
+ if (q || author) args.push("--fixed-strings", "--regexp-ignore-case");
4761
+ if (q) args.push(`--grep=${q}`);
4762
+ if (author) args.push(`--author=${author}`);
4763
+ if (ref) args.push(ref);
4709
4764
  if (filePath) args.push("--", filePath);
4710
4765
  let stdout = "";
4711
4766
  try {
@@ -4714,13 +4769,17 @@ function createApp(opts) {
4714
4769
  if (e instanceof HttpError && e.code === "git_failed") stdout = "";
4715
4770
  else throw e;
4716
4771
  }
4717
- let commits = stdout.split("\n").filter(Boolean).map((line) => {
4718
- const [sha = "", shortSha = "", author = "", email = "", date = "", parents = "", ...rest] = line.split(SEP);
4719
- return { sha, shortSha, author, email, date, parents: parents.split(" ").filter(Boolean), subject: rest.join(SEP) };
4720
- });
4721
- if (before) commits = commits.filter((x) => x.sha !== before && !x.sha.startsWith(before));
4722
- const hasMore = commits.length > limit;
4723
- const res = { commits: commits.slice(0, limit), hasMore };
4772
+ const rows = parseCommitLog(stdout);
4773
+ const hasMore = rows.length > limit;
4774
+ let commits = rows.slice(0, limit);
4775
+ if (q && offset === 0 && /^[0-9a-f]{4,40}$/i.test(q)) {
4776
+ const sha = await revParse(cwd, `${q}^{commit}`);
4777
+ if (sha && !commits.some((x) => x.sha === sha)) {
4778
+ const hit = parseCommitLog((await runGit(["log", "--max-count=1", `--format=${COMMIT_FORMAT}`, sha], { cwd })).stdout)[0];
4779
+ if (hit) commits = [hit, ...commits];
4780
+ }
4781
+ }
4782
+ const res = { commits, hasMore };
4724
4783
  return c.json(res);
4725
4784
  });
4726
4785
  api.get("/events", async (c) => {
@@ -4900,7 +4959,7 @@ async function startServer(opts) {
4900
4959
  }
4901
4960
 
4902
4961
  // bin/cli.ts
4903
- var VERSION = true ? "0.4.0" : "dev";
4962
+ var VERSION = true ? "0.6.0" : "dev";
4904
4963
  function parseArgs(argv) {
4905
4964
  const args = { repoPath: process.cwd(), open: true, help: false, version: false };
4906
4965
  for (let i = 0; i < argv.length; i++) {