@xbghc/warden 0.6.0 → 0.6.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
@@ -57,6 +57,10 @@ Several instances on the same repository can run at the same time.
57
57
 
58
58
  Refs accept anything git can resolve (`main`, `v1.2`, `HEAD~3`, a sha). `@` is `HEAD`.
59
59
  Worktrees are discovered with `git worktree list` and share the review state of the main repository.
60
+ One whose directory is gone (git lists it as *prunable*) is not offered, and a remembered target
61
+ inside a removed worktree falls back to the working tree on the next load. The comments and viewed
62
+ flags kept under that worktree's key stay in the state file and come back if a worktree is created
63
+ at the same path again.
60
64
 
61
65
  There is no target switcher in the UI: the sidebar's two blocks *are* the working tree, a commit is
62
66
  picked from the *Commits* panel, and the *Branch vs* and *Range* fields at the top of that panel open
@@ -154,8 +158,8 @@ bound to a hash of the file's diff; when the diff changes the flag is dropped an
154
158
 
155
159
  ## Auto-refresh
156
160
 
157
- warden polls the worktree every 1.5 s while the page is open (`git status --porcelain`, HEAD, the
158
- branch name, plus mtime and size of the paths status reports — enough to notice a second edit to an
161
+ warden polls the worktree every 1.5 s while the page is open (`git status --porcelain
162
+ --untracked-files=all`, HEAD, the branch name, plus mtime and size of the paths status reports — enough to notice a second edit to an
159
163
  already-modified file) and pushes a change event over `GET /api/events` (SSE). The page then re-runs
160
164
  the normal refresh: re-anchor, reload both file lists, reload the open diff. Your draft comment,
161
165
  selection, current view and current file are left alone, and the diff is scrolled back to where you
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // bin/cli.ts
4
4
  import path8 from "path";
5
5
  import { spawn } from "child_process";
6
- import { existsSync } from "fs";
6
+ import { existsSync as existsSync2 } from "fs";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
9
  // packages/server/src/index.ts
@@ -3025,6 +3025,7 @@ function parseCommitLog(stdout) {
3025
3025
 
3026
3026
  // packages/server/src/git.ts
3027
3027
  import { execFile } from "child_process";
3028
+ import { existsSync } from "fs";
3028
3029
  var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status", "merge-base"]);
3029
3030
  var FORBIDDEN_OPTION_PREFIXES = ["--output", "--ext-diff", "--textconv", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree"];
3030
3031
  var DEFAULT_GIT_TIMEOUT_MS = 3e4;
@@ -3087,6 +3088,10 @@ async function runGit(args, opts) {
3087
3088
  return;
3088
3089
  }
3089
3090
  if (err.code === "ENOENT") {
3091
+ if (opts.cwd && !existsSync(opts.cwd)) {
3092
+ reject(new GitError(`working directory no longer exists: ${opts.cwd}`, null, "", 400, "unknown_worktree"));
3093
+ return;
3094
+ }
3090
3095
  reject(new GitError("git executable not found in PATH", null, "", 500, "git_missing"));
3091
3096
  return;
3092
3097
  }
@@ -3159,7 +3164,7 @@ async function listWorktrees(ctx) {
3159
3164
  const out = [];
3160
3165
  let cur = null;
3161
3166
  const flush = () => {
3162
- if (cur && cur.path) {
3167
+ if (cur && cur.path && !cur.prunable) {
3163
3168
  out.push({
3164
3169
  path: cur.path,
3165
3170
  head: cur.head ?? "",
@@ -3185,6 +3190,7 @@ async function listWorktrees(ctx) {
3185
3190
  else if (line.startsWith("branch ")) cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
3186
3191
  else if (line === "detached") cur.detached = true;
3187
3192
  else if (line === "bare") cur.bare = true;
3193
+ else if (line.startsWith("prunable")) cur.prunable = true;
3188
3194
  }
3189
3195
  flush();
3190
3196
  for (const wt of out) {
@@ -3387,11 +3393,11 @@ function parseUnifiedDiff(text) {
3387
3393
  continue;
3388
3394
  }
3389
3395
  if (line.startsWith("--- ")) {
3390
- c.fromMinus = stripPrefix(line.slice(4), "a/");
3396
+ c.fromMinus = stripPrefix(line.slice(4).replace(/\t$/, ""), "a/");
3391
3397
  continue;
3392
3398
  }
3393
3399
  if (line.startsWith("+++ ")) {
3394
- c.fromPlus = stripPrefix(line.slice(4), "b/");
3400
+ c.fromPlus = stripPrefix(line.slice(4).replace(/\t$/, ""), "b/");
3395
3401
  continue;
3396
3402
  }
3397
3403
  if (line.startsWith("new file mode ")) {
@@ -3464,6 +3470,10 @@ function resolveTargetContext(repo, worktrees, key) {
3464
3470
  }
3465
3471
  return { key, target, cwd };
3466
3472
  }
3473
+ function assertRepoPath(p) {
3474
+ if (!p || p.startsWith("/") || p.split("/").includes("..")) throw badRequest(`invalid path: ${p}`, "invalid_path");
3475
+ }
3476
+ var literal = (p) => `:(literal)${p}`;
3467
3477
  async function hasHead(cwd) {
3468
3478
  return refExists(cwd, "HEAD");
3469
3479
  }
@@ -3491,6 +3501,7 @@ async function diffArgs(ctx) {
3491
3501
  case "range": {
3492
3502
  if (!await refExists(ctx.cwd, t.base)) throw badRequest(`unknown ref: ${t.base}`, "unknown_ref");
3493
3503
  if (!await refExists(ctx.cwd, t.head)) throw badRequest(`unknown ref: ${t.head}`, "unknown_ref");
3504
+ if (!await mergeBase(ctx.cwd, t.base, t.head)) throw badRequest(`${t.base} and ${t.head} share no history`, "no_merge_base");
3494
3505
  return [...DIFF_BASE_ARGS, `${t.base}...${t.head}`];
3495
3506
  }
3496
3507
  case "base":
@@ -3505,7 +3516,7 @@ async function listUntracked(cwd) {
3505
3516
  return r.stdout.split("\0").filter(Boolean);
3506
3517
  }
3507
3518
  async function isUntracked(cwd, file) {
3508
- const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z", "--", file], { cwd });
3519
+ const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z", "--", literal(file)], { cwd });
3509
3520
  return r.stdout.split("\0").filter(Boolean).includes(file);
3510
3521
  }
3511
3522
  async function untrackedDiff(cwd, file) {
@@ -3551,15 +3562,14 @@ function toSummary(f) {
3551
3562
  return rest;
3552
3563
  }
3553
3564
  async function getFileDiff(ctx, filePath, hints = {}) {
3554
- if (!filePath || filePath.startsWith("/") || filePath.split("/").includes("..")) {
3555
- throw badRequest(`invalid path: ${filePath}`, "invalid_path");
3556
- }
3565
+ assertRepoPath(filePath);
3566
+ if (hints.oldPath) assertRepoPath(hints.oldPath);
3557
3567
  if (includesUntracked(ctx.target) && (hints.untracked || await isUntracked(ctx.cwd, filePath))) {
3558
3568
  return untrackedDiff(ctx.cwd, filePath);
3559
3569
  }
3560
3570
  const args = await diffArgs(ctx);
3561
- const pathspec = [filePath];
3562
- if (hints.oldPath && hints.oldPath !== filePath) pathspec.push(hints.oldPath);
3571
+ const pathspec = [literal(filePath)];
3572
+ if (hints.oldPath && hints.oldPath !== filePath) pathspec.push(literal(hints.oldPath));
3563
3573
  const r = await runGit([...args, "--", ...pathspec], { cwd: ctx.cwd });
3564
3574
  const files = parseUnifiedDiff(r.stdout);
3565
3575
  return files.find((f) => f.path === filePath) ?? files[0];
@@ -3583,9 +3593,7 @@ async function refForSide(ctx, side) {
3583
3593
  }
3584
3594
  }
3585
3595
  async function getFullFile(ctx, filePath, side) {
3586
- if (!filePath || filePath.startsWith("/") || filePath.split("/").includes("..")) {
3587
- throw badRequest(`invalid path: ${filePath}`, "invalid_path");
3588
- }
3596
+ assertRepoPath(filePath);
3589
3597
  const ref = await refForSide(ctx, side);
3590
3598
  if (ref === void 0) {
3591
3599
  try {
@@ -3751,6 +3759,8 @@ function ensureTarget(state, key) {
3751
3759
  }
3752
3760
  return t;
3753
3761
  }
3762
+ var usable = (v) => !!v && typeof v === "object" && typeof v.id === "string";
3763
+ var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
3754
3764
  function normalise(raw2, repoRoot) {
3755
3765
  const base = defaultState(repoRoot);
3756
3766
  if (!raw2 || typeof raw2 !== "object") return base;
@@ -3761,7 +3771,7 @@ function normalise(raw2, repoRoot) {
3761
3771
  if (!v || typeof v !== "object") continue;
3762
3772
  targets[k] = {
3763
3773
  viewed: typeof v.viewed === "object" && v.viewed ? v.viewed : {},
3764
- comments: Array.isArray(v.comments) ? v.comments : [],
3774
+ comments: Array.isArray(v.comments) ? v.comments.filter(usableComment) : [],
3765
3775
  ...typeof v.head === "string" ? { head: v.head } : {}
3766
3776
  };
3767
3777
  }
@@ -3770,8 +3780,8 @@ function normalise(raw2, repoRoot) {
3770
3780
  schemaVersion: 1,
3771
3781
  repoRoot: r.repoRoot ?? repoRoot,
3772
3782
  targets,
3773
- issues: Array.isArray(r.issues) ? r.issues : [],
3774
- todos: Array.isArray(r.todos) ? r.todos : [],
3783
+ issues: Array.isArray(r.issues) ? r.issues.filter(usable) : [],
3784
+ todos: Array.isArray(r.todos) ? r.todos.filter(usable) : [],
3775
3785
  prefs: {
3776
3786
  ...defaultPrefs(),
3777
3787
  ...r.prefs ?? {},
@@ -4166,7 +4176,9 @@ var RepoWatcher = class {
4166
4176
  }
4167
4177
  async snapshot() {
4168
4178
  const [statusRes, headRes, branch] = await Promise.all([
4169
- runGit(["status", "--porcelain", "-z"], { cwd: this.root }),
4179
+ // Untracked files one by one: folded into their directory, an edit inside a directory the
4180
+ // agent just created would leave the directory's own mtime and size untouched.
4181
+ runGit(["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: this.root }),
4170
4182
  runGit(["rev-parse", "HEAD"], { cwd: this.root }).catch(() => ({ stdout: "" })),
4171
4183
  // The same label getRepoInfo reports, so the branch identity the client filters todos by
4172
4184
  // never flips between `HEAD` and a short sha depending on which of the two spoke last.
@@ -4275,13 +4287,6 @@ function createApp(opts) {
4275
4287
  throw e;
4276
4288
  }
4277
4289
  };
4278
- const decodeKey = (raw2) => {
4279
- try {
4280
- return decodeURIComponent(raw2);
4281
- } catch {
4282
- throw badRequest("malformed target key", "invalid_target");
4283
- }
4284
- };
4285
4290
  const fileDiffWithHints = async (ctx, filePath, explicit) => {
4286
4291
  const cached = listings.get(ctx.key);
4287
4292
  const hint = cached && Date.now() - cached.at < LISTING_HINT_TTL_MS ? cached.files.find((f) => f.path === filePath) : void 0;
@@ -4308,8 +4313,13 @@ function createApp(opts) {
4308
4313
  api.get("/ping", (c) => c.text(opts.instanceToken ?? ""));
4309
4314
  api.get("/repo", async (c) => {
4310
4315
  const state = await store.load();
4311
- const info = await getRepoInfo(repo, state.prefs.lastTarget ?? "working");
4316
+ const info = await getRepoInfo(repo, "working");
4312
4317
  worktreeCache = { at: Date.now(), list: info.worktrees };
4318
+ const last = state.prefs.lastTarget;
4319
+ const target = last ? tryParseTargetKey(last) : void 0;
4320
+ const usable2 = !!target && (!target.worktree || info.worktrees.some((w) => w.path === target.worktree));
4321
+ if (last && usable2) info.defaultTarget = last;
4322
+ else if (last) await store.update((s) => void (s.prefs.lastTarget = "working"));
4313
4323
  return c.json(info);
4314
4324
  });
4315
4325
  api.get("/state", async (c) => c.json(await store.load()));
@@ -4327,7 +4337,7 @@ function createApp(opts) {
4327
4337
  return c.json(prefs);
4328
4338
  });
4329
4339
  api.get("/targets/:key/files", async (c) => {
4330
- const key = decodeKey(c.req.param("key"));
4340
+ const key = c.req.param("key");
4331
4341
  const ctx = await targetCtx(key);
4332
4342
  const diffs = await listTargetDiffs(ctx);
4333
4343
  listings.set(key, { at: Date.now(), files: diffs });
@@ -4363,7 +4373,7 @@ function createApp(opts) {
4363
4373
  return c.json(res);
4364
4374
  });
4365
4375
  api.get("/targets/:key/file", async (c) => {
4366
- const key = decodeKey(c.req.param("key"));
4376
+ const key = c.req.param("key");
4367
4377
  const filePath = c.req.query("path");
4368
4378
  if (!filePath) throw badRequest("missing path");
4369
4379
  const ctx = await targetCtx(key);
@@ -4374,7 +4384,7 @@ function createApp(opts) {
4374
4384
  return c.json(diff);
4375
4385
  });
4376
4386
  api.get("/targets/:key/file/full", async (c) => {
4377
- const key = decodeKey(c.req.param("key"));
4387
+ const key = c.req.param("key");
4378
4388
  const filePath = c.req.query("path");
4379
4389
  if (!filePath) throw badRequest("missing path");
4380
4390
  const side = c.req.query("side") === "old" ? "old" : "new";
@@ -4384,7 +4394,7 @@ function createApp(opts) {
4384
4394
  return c.json(res);
4385
4395
  });
4386
4396
  api.put("/targets/:key/viewed", async (c) => {
4387
- const key = decodeKey(c.req.param("key"));
4397
+ const key = c.req.param("key");
4388
4398
  await targetCtx(key);
4389
4399
  const body = await c.req.json();
4390
4400
  if (!body.path) throw badRequest("missing path");
@@ -4398,12 +4408,12 @@ function createApp(opts) {
4398
4408
  return c.json({ viewed });
4399
4409
  });
4400
4410
  api.get("/targets/:key/comments", async (c) => {
4401
- const key = decodeKey(c.req.param("key"));
4411
+ const key = c.req.param("key");
4402
4412
  const state = await store.load();
4403
4413
  return c.json({ comments: state.targets[commentScopeKey(key)]?.comments ?? [] });
4404
4414
  });
4405
4415
  api.post("/targets/:key/comments", async (c) => {
4406
- const key = decodeKey(c.req.param("key"));
4416
+ const key = c.req.param("key");
4407
4417
  const ctx = await targetCtx(key);
4408
4418
  const body = await c.req.json();
4409
4419
  if (!body.filePath || typeof body.body !== "string" || !body.body.trim()) throw badRequest("filePath and body are required");
@@ -4436,7 +4446,7 @@ function createApp(opts) {
4436
4446
  return c.json(comment, 201);
4437
4447
  });
4438
4448
  api.patch("/targets/:key/comments/:id", async (c) => {
4439
- const key = decodeKey(c.req.param("key"));
4449
+ const key = c.req.param("key");
4440
4450
  const id = c.req.param("id");
4441
4451
  const ctx = await targetCtx(key);
4442
4452
  const body = await c.req.json();
@@ -4481,7 +4491,7 @@ function createApp(opts) {
4481
4491
  return c.json(updated);
4482
4492
  });
4483
4493
  api.delete("/targets/:key/comments/:id", async (c) => {
4484
- const key = decodeKey(c.req.param("key"));
4494
+ const key = c.req.param("key");
4485
4495
  const id = c.req.param("id");
4486
4496
  const removed = await store.update((s) => {
4487
4497
  const t = s.targets[commentScopeKey(key)];
@@ -4495,7 +4505,7 @@ function createApp(opts) {
4495
4505
  return c.json({ ok: true });
4496
4506
  });
4497
4507
  api.post("/targets/:key/comments/reanchor", async (c) => {
4498
- const key = decodeKey(c.req.param("key"));
4508
+ const key = c.req.param("key");
4499
4509
  const ctx = await targetCtx(key);
4500
4510
  const scope = commentScopeKey(key);
4501
4511
  const local = isLocalTarget(ctx.target);
@@ -4959,7 +4969,7 @@ async function startServer(opts) {
4959
4969
  }
4960
4970
 
4961
4971
  // bin/cli.ts
4962
- var VERSION = true ? "0.6.0" : "dev";
4972
+ var VERSION = true ? "0.6.1" : "dev";
4963
4973
  function parseArgs(argv) {
4964
4974
  const args = { repoPath: process.cwd(), open: true, help: false, version: false };
4965
4975
  for (let i = 0; i < argv.length; i++) {
@@ -5029,7 +5039,7 @@ function resolveWebDir() {
5029
5039
  // bin/cli.ts (tsx dev) -> dist/web
5030
5040
  ].filter((p) => !!p);
5031
5041
  for (const dir of candidates) {
5032
- if (existsSync(path8.join(dir, "index.html"))) return dir;
5042
+ if (existsSync2(path8.join(dir, "index.html"))) return dir;
5033
5043
  }
5034
5044
  return void 0;
5035
5045
  }