dsh-worktree-switch 0.4.0 → 0.5.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/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
- import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
- import { lstat, realpath, stat } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { lstat, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
4
  // src/host/paths.ts
5
5
  /**
6
6
  * Path predicates the host half shares between the verbs, the scope hint, and
@@ -103,6 +103,22 @@ function installSourceCheckoutGuard(ctx, registry) {
103
103
  };
104
104
  });
105
105
  }
106
+ // src/types.ts
107
+ /**
108
+ * Whether a leave that kept a copy left a 保留副本 behind.
109
+ *
110
+ * Not every copy a leave leaves on disk is a leftover: only `dirty` is. A copy
111
+ * kept because this session never owned it (`not-owned`) is the ordinary result
112
+ * of leaving someone else's copy — explained in the switch's hover, never
113
+ * reported as cleanup. The host registers a kept copy for the panel count and
114
+ * the browser half raises the one-shot notice only when this returns true, so
115
+ * the two halves cannot drift apart on the rule.
116
+ * @param reason - why the host kept the copy, or null when it kept nothing.
117
+ * @returns whether the kept copy is a leftover this host has to clean up.
118
+ */
119
+ function isKeptLeftover(reason) {
120
+ return reason === "dirty";
121
+ }
106
122
  /** Per-stream capture cap; worktree listings and status lines are small. */
107
123
  const MAX_OUTPUT_BYTES = 1048576;
108
124
  /**
@@ -280,6 +296,122 @@ async function changedFileCount(ctx, directory, signal) {
280
296
  "--untracked-files=normal"
281
297
  ], { signal })).stdout.split("\n").filter((line) => line.trim() !== "").length;
282
298
  }
299
+ /**
300
+ * Resolve the whereabouts of one checkout's Git administration directory.
301
+ *
302
+ * A linked worktree's `.git` is a file pointing at
303
+ * `<common>/worktrees/<name>`; this is the directory Git owns for that one
304
+ * checkout, which is where the switch records its claim on the copy.
305
+ * @param ctx - Host context carrying the subprocess provider.
306
+ * @param checkout - absolute directory of the checkout to ask.
307
+ * @param signal - caller cancellation.
308
+ * @returns the absolute admin directory Git reports.
309
+ * @throws {GitFailure} when the directory is not inside a Git working tree.
310
+ */
311
+ async function absoluteGitDir(ctx, checkout, signal) {
312
+ const result = await runGit(ctx, checkout, ["rev-parse", "--absolute-git-dir"], { signal });
313
+ const path = result.stdout.replace(/\r?\n$/, "");
314
+ if (path === "") throw new GitFailure(["rev-parse", "--absolute-git-dir"], result);
315
+ return path;
316
+ }
317
+ /**
318
+ * Read the branch one checkout has checked out.
319
+ * @param ctx - Host context carrying the subprocess provider.
320
+ * @param checkout - absolute directory of the checkout to ask.
321
+ * @param signal - caller cancellation.
322
+ * @returns the branch name, or null when the checkout is on a detached HEAD.
323
+ */
324
+ async function currentBranch(ctx, checkout, signal) {
325
+ const result = await runGit(ctx, checkout, [
326
+ "symbolic-ref",
327
+ "--short",
328
+ "--quiet",
329
+ "HEAD"
330
+ ], {
331
+ signal,
332
+ allowedExitCodes: [0, 1]
333
+ });
334
+ const name = result.stdout.replace(/\r?\n$/, "");
335
+ return result.exitCode === 0 && name !== "" ? name : null;
336
+ }
337
+ /**
338
+ * Resolve one revision to the commit id it names.
339
+ * @param ctx - Host context carrying the subprocess provider.
340
+ * @param directory - checkout the command runs in.
341
+ * @param revision - revision to resolve; `HEAD` or a full id are the usual values.
342
+ * @param signal - caller cancellation.
343
+ * @returns the commit id, or null when the revision does not resolve.
344
+ */
345
+ async function revParse(ctx, directory, revision, signal) {
346
+ const result = await runGit(ctx, directory, [
347
+ "rev-parse",
348
+ "--verify",
349
+ "--quiet",
350
+ revision
351
+ ], {
352
+ signal,
353
+ allowedExitCodes: [
354
+ 0,
355
+ 1,
356
+ 128
357
+ ]
358
+ });
359
+ const id = result.stdout.replace(/\r?\n$/, "");
360
+ return result.exitCode === 0 && id !== "" ? id : null;
361
+ }
362
+ /**
363
+ * Count the commits in one revision range.
364
+ * @param ctx - Host context carrying the subprocess provider.
365
+ * @param directory - checkout the command runs in.
366
+ * @param range - the range to count, e.g. `HEAD..refs/heads/<branch>`.
367
+ * @param signal - caller cancellation.
368
+ * @returns the number of commits in the range.
369
+ * @throws {GitFailure} when the range does not resolve.
370
+ */
371
+ async function revListCount(ctx, directory, range, signal) {
372
+ const result = await runGit(ctx, directory, [
373
+ "rev-list",
374
+ "--count",
375
+ range
376
+ ], { signal });
377
+ const count = Number.parseInt(result.stdout.trim(), 10);
378
+ return Number.isFinite(count) ? count : 0;
379
+ }
380
+ /**
381
+ * Parse `git for-each-ref` output produced with the NUL-separated format below.
382
+ * @param stdout - the command's stdout.
383
+ * @returns one entry per local branch, in Git's order.
384
+ */
385
+ function parseBranchRefs(stdout) {
386
+ const refs = [];
387
+ for (const line of stdout.split("\n")) {
388
+ if (line === "") continue;
389
+ const [name, commit, seconds, subject] = line.split("\0");
390
+ if (name === void 0 || commit === void 0 || seconds === void 0 || subject === void 0) continue;
391
+ const committedAt = Number.parseInt(seconds, 10) * 1e3;
392
+ refs.push({
393
+ name,
394
+ commit,
395
+ committedAt: Number.isFinite(committedAt) ? committedAt : 0,
396
+ subject
397
+ });
398
+ }
399
+ return refs;
400
+ }
401
+ /**
402
+ * List the local branches of one repository.
403
+ * @param ctx - Host context carrying the subprocess provider.
404
+ * @param root - repository root the command runs in.
405
+ * @param signal - caller cancellation.
406
+ * @returns every local branch with its tip commit.
407
+ */
408
+ async function listBranchRefs(ctx, root, signal) {
409
+ return parseBranchRefs((await runGit(ctx, root, [
410
+ "for-each-ref",
411
+ "--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)",
412
+ "refs/heads"
413
+ ], { signal })).stdout);
414
+ }
283
415
  // src/host/base.ts
284
416
  /** Whole-command deadline for the plugin's one network call. */
285
417
  const FETCH_TIMEOUT_MS = 5e3;
@@ -488,6 +620,19 @@ function worktreeName(random = Math.random) {
488
620
  }
489
621
  /** A pool entry name is exactly one path segment, as `git worktree` names it. */
490
622
  const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
623
+ /** The shape this plugin generates: two lowercase words and four hex digits. */
624
+ const GENERATED_PATTERN = /^[a-z]+-[a-z]+-[0-9a-f]{4}$/;
625
+ /**
626
+ * Whether a branch name looks like one this plugin generated.
627
+ *
628
+ * Leftover-branch cleanup only offers names of this shape: an ordinary feature
629
+ * branch is never a candidate, even when no checkout holds it.
630
+ * @param value - candidate branch name.
631
+ * @returns true for the generated-name format.
632
+ */
633
+ function isGeneratedName(value) {
634
+ return GENERATED_PATTERN.test(value);
635
+ }
491
636
  /**
492
637
  * Validate a worktree name received from the browser before it reaches Git.
493
638
  * @param value - candidate name.
@@ -496,9 +641,66 @@ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
496
641
  function isWorktreeName(value) {
497
642
  return NAME_PATTERN.test(value) && !value.endsWith(".lock");
498
643
  }
644
+ // src/host/ownership.ts
645
+ /**
646
+ * The switch's claim on a copy.
647
+ *
648
+ * "This copy was created by the switch for this session" used to live only in
649
+ * host memory, so a host restart turned every copy into a stranger and the
650
+ * leave path could never prove it was safe to undo. The claim is now stored in
651
+ * the checkout's own Git administration directory: it travels with the copy,
652
+ * vanishes when the copy is deleted, never appears in the working tree, and
653
+ * survives a restart. A missing or unparseable marker means "not owned" — the
654
+ * plugin does not guess, because guessing wrong deletes work.
655
+ * @module dsh-worktree-switch/host/ownership
656
+ */
657
+ /** File name inside the checkout's admin directory. */
658
+ const MARKER_FILE = "dsh-worktree-switch.json";
659
+ /**
660
+ * Read one checkout's ownership marker.
661
+ *
662
+ * Every failure — no admin directory, no marker, invalid JSON, a shape this
663
+ * version does not understand — reads as "no claim". The leave path treats
664
+ * that as a reason to keep the copy, never as a reason to take it.
665
+ * @param ctx - Host context carrying the subprocess provider.
666
+ * @param checkout - absolute directory of the copy.
667
+ * @param signal - caller cancellation.
668
+ * @returns the recorded claim, or null when there is none.
669
+ */
670
+ async function readOwnership(ctx, checkout, signal) {
671
+ try {
672
+ const directory = await absoluteGitDir(ctx, checkout, signal);
673
+ const raw = await readFile(join(directory, MARKER_FILE), "utf8");
674
+ const parsed = JSON.parse(raw);
675
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
676
+ const { sessionId, createdAt, baseCommit } = parsed;
677
+ if (typeof sessionId !== "string" || sessionId === "") return null;
678
+ if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return null;
679
+ if (typeof baseCommit !== "string" || baseCommit === "") return null;
680
+ return {
681
+ sessionId,
682
+ createdAt,
683
+ baseCommit
684
+ };
685
+ } catch {
686
+ return null;
687
+ }
688
+ }
689
+ /**
690
+ * Record the switch's claim on a freshly created copy.
691
+ * @param ctx - Host context carrying the subprocess provider.
692
+ * @param checkout - absolute directory of the copy.
693
+ * @param ownership - the claim to write.
694
+ * @param signal - caller cancellation.
695
+ * @throws when the admin directory cannot be resolved or the marker cannot be written.
696
+ */
697
+ async function writeOwnership(ctx, checkout, ownership, signal) {
698
+ const directory = await absoluteGitDir(ctx, checkout, signal);
699
+ await writeFile(join(directory, MARKER_FILE), `${JSON.stringify(ownership)}\n`, "utf8");
700
+ }
499
701
  // src/host/handlers.ts
500
702
  /**
501
- * The plugin's host Remote face: the five verbs the browser half can call,
703
+ * The plugin's host Remote face: the verbs the browser half can call,
502
704
  * expressed against a live session and the checkouts on disk.
503
705
  *
504
706
  * This module is the seam the host tests drive directly. Everything below it —
@@ -525,6 +727,16 @@ var WorktreeToggleError = class extends Error {
525
727
  this.name = "WorktreeToggleError";
526
728
  }
527
729
  };
730
+ /**
731
+ * The failure one refused branch delete reports.
732
+ * @param branch - branch the verb tried to delete.
733
+ * @param args - the Git command that refused, for the message.
734
+ * @param result - its captured outcome.
735
+ * @returns the error the browser half switches on.
736
+ */
737
+ function branchDeleteFailure(branch, args, result) {
738
+ return new WorktreeToggleError("branch-delete-failed", new GitFailure(args, result).message, { branch });
739
+ }
528
740
  /** Normalize a browser-supplied path before any filesystem work. */
529
741
  function absolutePath(value) {
530
742
  const trimmed = value.trim();
@@ -539,11 +751,13 @@ function absolutePath(value) {
539
751
  * from any configured pool path. The pool belongs to the harness worktree
540
752
  * service, so this plugin never needs a second copy of that setting: moving the
541
753
  * pool in the service's row config keeps every verb working.
754
+ * @param ctx - host context carrying `subprocess`.
542
755
  * @param current - the session's committed working directory.
543
756
  * @param repositoryRoot - canonical root of the project's repository.
757
+ * @param signal - caller cancellation.
544
758
  * @returns the copy's identity, or null when the directory is not one.
545
759
  */
546
- async function worktreeAt(current, repositoryRoot) {
760
+ async function worktreeAt(ctx, current, repositoryRoot, signal) {
547
761
  const path = await canonicalPath(current);
548
762
  if (path === repositoryRoot) return null;
549
763
  const inside = relative(repositoryRoot, path);
@@ -558,7 +772,7 @@ async function worktreeAt(current, repositoryRoot) {
558
772
  return {
559
773
  name,
560
774
  path,
561
- branch: name
775
+ branch: await currentBranch(ctx, path, signal)
562
776
  };
563
777
  }
564
778
  /**
@@ -598,7 +812,7 @@ async function detectSessionScope(ctx, agent, signal) {
598
812
  signal?.throwIfAborted();
599
813
  let probe = current;
600
814
  for (;;) {
601
- const copy = await worktreeAt(probe, root);
815
+ const copy = await worktreeAt(ctx, probe, root, signal);
602
816
  if (copy !== null) return {
603
817
  worktree: copy,
604
818
  repositoryRoot: root
@@ -609,21 +823,114 @@ async function detectSessionScope(ctx, agent, signal) {
609
823
  }
610
824
  }
611
825
  /**
826
+ * The copy rows of one repository, newest first.
827
+ *
828
+ * A row whose checkout directory is gone is still a copy: Git keeps the
829
+ * registration until it is pruned, and the panel offers the same discard for
830
+ * it, marked as missing. A detached copy keeps `branch: null` so no verb can
831
+ * ever mistake the directory name for a branch to delete.
832
+ * @param ctx - host context carrying `subprocess`.
833
+ * @param root - canonical repository root.
834
+ * @param records - `git worktree list` records for the repository.
835
+ * @param signal - caller cancellation.
836
+ * @returns one row per copy under the project.
837
+ */
838
+ async function copyItems(ctx, root, records, signal) {
839
+ const items = [];
840
+ for (const record of records) {
841
+ signal?.throwIfAborted();
842
+ const path = await canonicalPath(record.path);
843
+ if (path === root) continue;
844
+ const name = basename(path);
845
+ if (!isWorktreeName(name)) continue;
846
+ let createdAt = 0;
847
+ let prunable = true;
848
+ try {
849
+ const info = await stat(path);
850
+ createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs);
851
+ prunable = false;
852
+ } catch {}
853
+ const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal);
854
+ items.push({
855
+ name,
856
+ path,
857
+ branch: record.branch ?? null,
858
+ createdAt,
859
+ changedFiles,
860
+ prunable
861
+ });
862
+ }
863
+ items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name));
864
+ return items;
865
+ }
866
+ /**
867
+ * Generated-name branches that no checkout holds.
868
+ *
869
+ * Deliberately narrow: only the names this plugin generates qualify, so an
870
+ * ordinary feature branch never appears in a list whose only action is delete.
871
+ * The commit count is what the source checkout's HEAD does not contain — a
872
+ * branch whose work is fully merged reads as 0, which is the one safe signal.
873
+ * @param ctx - host context carrying `subprocess`.
874
+ * @param root - canonical repository root.
875
+ * @param records - `git worktree list` records for the repository.
876
+ * @param signal - caller cancellation.
877
+ * @returns the leftover branches, newest commit first.
878
+ */
879
+ async function orphanBranches(ctx, root, records, signal) {
880
+ const checkedOut = /* @__PURE__ */ new Set();
881
+ for (const record of records) if (record.branch !== void 0) checkedOut.add(record.branch);
882
+ const orphans = [];
883
+ for (const ref of await listBranchRefs(ctx, root, signal)) {
884
+ signal?.throwIfAborted();
885
+ if (!isGeneratedName(ref.name) || checkedOut.has(ref.name)) continue;
886
+ let commits = 0;
887
+ try {
888
+ commits = await revListCount(ctx, root, `HEAD..${ref.commit}`, signal);
889
+ } catch {}
890
+ orphans.push({
891
+ branch: ref.name,
892
+ commits,
893
+ subject: ref.subject,
894
+ committedAt: ref.committedAt
895
+ });
896
+ }
897
+ orphans.sort((left, right) => right.committedAt - left.committedAt || left.branch.localeCompare(right.branch));
898
+ return orphans;
899
+ }
900
+ /** The wire identity of one listed copy; a detached copy keeps its null branch. */
901
+ function copyRef(item) {
902
+ return {
903
+ name: item.name,
904
+ path: item.path,
905
+ branch: item.branch
906
+ };
907
+ }
908
+ /**
612
909
  * Build the host face.
613
910
  * @param ctx - Host context with `agents`, `worktrees`, `workingDirectory`, and `subprocess`.
614
911
  * @param options - resolved row config.
615
- * @returns the five verbs.
912
+ * @returns the verbs above.
616
913
  */
617
914
  function createWorktreeToggle(ctx, options) {
618
915
  /** One in-flight preparation per session, so a double mount cannot create twice. */
619
916
  const pending = /* @__PURE__ */ new Map();
620
917
  /**
621
- * Copies this host instance created for a session, by session id. Turning the
622
- * switch off may only delete what the switch itself just made: a copy that
623
- * existed before this instance started is left for the panel's explicit
624
- * discard, because it may carry work the switch never saw.
918
+ * Copies this host instance kept when the switch was turned off because they
919
+ * had content (or because deleting them failed), by repository root. A copy
920
+ * left only because another session owns it is not one of these: it is still
921
+ * listed as a copy, but it is not this host's leftover to clean up. A host
922
+ * restart forgets the set; the panel's count then honestly drops back to
923
+ * leftovers only.
625
924
  */
626
- const createdBySession = /* @__PURE__ */ new Map();
925
+ const keptCopies = /* @__PURE__ */ new Map();
926
+ const keep = (root, name) => {
927
+ const names = keptCopies.get(root) ?? /* @__PURE__ */ new Set();
928
+ names.add(name);
929
+ keptCopies.set(root, names);
930
+ };
931
+ const forget = (root, name) => {
932
+ keptCopies.get(root)?.delete(name);
933
+ };
627
934
  const sessionOf = async (sessionId, signal) => {
628
935
  signal?.throwIfAborted();
629
936
  if (sessionId === "") throw new WorktreeToggleError("bad-request", "a session id is required");
@@ -654,7 +961,7 @@ function createWorktreeToggle(ctx, options) {
654
961
  const current = ctx.workingDirectory.get(agent.session);
655
962
  signal?.throwIfAborted();
656
963
  const sourceDirty = await changedFileCount(ctx, root, signal);
657
- const copy = await worktreeAt(current, root);
964
+ const copy = await worktreeAt(ctx, current, root, signal);
658
965
  options.onScope?.(request.sessionId, copy === null ? null : {
659
966
  worktree: copy,
660
967
  repositoryRoot: root
@@ -703,7 +1010,14 @@ function createWorktreeToggle(ctx, options) {
703
1010
  path: created.path,
704
1011
  branch: created.branch
705
1012
  };
706
- createdBySession.set(request.sessionId, worktree);
1013
+ const baseCommit = await revParse(ctx, created.path, "HEAD");
1014
+ if (baseCommit !== null) try {
1015
+ await writeOwnership(ctx, created.path, {
1016
+ sessionId: request.sessionId,
1017
+ createdAt: Date.now(),
1018
+ baseCommit
1019
+ });
1020
+ } catch {}
707
1021
  options.onScope?.(request.sessionId, {
708
1022
  worktree,
709
1023
  repositoryRoot: created.repositoryRoot
@@ -734,6 +1048,22 @@ function createWorktreeToggle(ctx, options) {
734
1048
  if (pending.get(request.sessionId) === operation) pending.delete(request.sessionId);
735
1049
  }
736
1050
  };
1051
+ const scope = async (request, signal) => {
1052
+ const agent = await sessionOf(request.sessionId, signal);
1053
+ const current = ctx.workingDirectory.get(agent.session);
1054
+ const known = options.readScope?.(request.sessionId) ?? null;
1055
+ const found = known !== null && contains(known.worktree.path, current) ? known : await detectSessionScope(ctx, agent, signal);
1056
+ if (found === null) return { inWorktree: false };
1057
+ signal?.throwIfAborted();
1058
+ const changedFiles = await changedFileCount(ctx, found.worktree.path, signal);
1059
+ options.onScope?.(request.sessionId, found);
1060
+ return {
1061
+ inWorktree: true,
1062
+ worktree: found.worktree,
1063
+ repositoryRoot: found.repositoryRoot,
1064
+ changedFiles
1065
+ };
1066
+ };
737
1067
  const list = async (request, signal) => {
738
1068
  const directory = absolutePath(request.path);
739
1069
  let root;
@@ -747,50 +1077,84 @@ function createWorktreeToggle(ctx, options) {
747
1077
  throw error;
748
1078
  }
749
1079
  const records = await listWorktreeRecords(ctx, root, signal);
750
- const items = [];
751
- for (const record of records) {
752
- signal?.throwIfAborted();
753
- const path = await canonicalPath(record.path);
754
- if (path === root) continue;
755
- const name = basename(path);
756
- if (!isWorktreeName(name)) continue;
757
- const branch = record.branch ?? name;
758
- let createdAt = 0;
759
- let prunable = true;
760
- try {
761
- const info = await stat(path);
762
- createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs);
763
- prunable = false;
764
- } catch {}
765
- const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal);
766
- items.push({
767
- name,
768
- path,
769
- branch,
770
- createdAt,
771
- changedFiles,
772
- prunable
773
- });
774
- }
775
- items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name));
1080
+ const items = await copyItems(ctx, root, records, signal);
1081
+ const orphans = await orphanBranches(ctx, root, records, signal);
1082
+ const kept = keptCopies.get(root);
1083
+ const keptCopiesStillHere = kept === void 0 ? 0 : items.filter((item) => kept.has(item.name)).length;
776
1084
  return {
777
1085
  supported: true,
778
1086
  repositoryRoot: root,
779
- items
1087
+ items,
1088
+ orphans,
1089
+ keptCopies: keptCopiesStillHere
780
1090
  };
781
1091
  };
782
1092
  /**
783
- * Delete one clean copy and its branch. Both the panel's discard and the
784
- * switch's own undo go through here, so the dirty guard has one home.
1093
+ * Delete one branch by name.
1094
+ * @param root - canonical repository root.
1095
+ * @param branch - branch to delete; already validated by the caller.
1096
+ * @param signal - caller cancellation.
1097
+ * @throws {WorktreeToggleError} when Git refused.
1098
+ */
1099
+ const deleteBranch = async (root, branch, signal) => {
1100
+ const deleted = await runGit(ctx, root, [
1101
+ "branch",
1102
+ "-D",
1103
+ "--",
1104
+ branch
1105
+ ], {
1106
+ signal,
1107
+ allowedExitCodes: [0, 1]
1108
+ });
1109
+ const gone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr);
1110
+ if (deleted.exitCode !== 0 && !gone) throw branchDeleteFailure(branch, [
1111
+ "branch",
1112
+ "-D",
1113
+ branch
1114
+ ], deleted);
1115
+ };
1116
+ /**
1117
+ * Delete a branch a prunable copy still holds.
1118
+ *
1119
+ * `git branch -D` refuses while any worktree registration names the branch,
1120
+ * even a registration whose directory is gone, so the branch is removed
1121
+ * through the ref itself. The registration still pins Git's bookkeeping, but
1122
+ * nothing can have the branch checked out any more: the directory is missing.
1123
+ * @param root - canonical repository root.
1124
+ * @param branch - branch to delete; already validated by the caller.
1125
+ * @param signal - caller cancellation.
1126
+ * @throws {WorktreeToggleError} when Git refused (for example a locked ref).
1127
+ */
1128
+ const deleteBranchByRef = async (root, branch, signal) => {
1129
+ const deleted = await runGit(ctx, root, [
1130
+ "update-ref",
1131
+ "-d",
1132
+ `refs/heads/${branch}`
1133
+ ], {
1134
+ signal,
1135
+ allowedExitCodes: [0, 1]
1136
+ });
1137
+ if (deleted.exitCode !== 0) throw branchDeleteFailure(branch, [
1138
+ "update-ref",
1139
+ "-d",
1140
+ branch
1141
+ ], deleted);
1142
+ };
1143
+ /**
1144
+ * Delete one clean copy and, unless it is detached, its branch.
1145
+ *
1146
+ * Both the panel's discard and the switch's own undo go through here, so the
1147
+ * dirty guard and the delete order have one home. A prunable copy — its
1148
+ * directory was deleted by hand — deletes the branch first and prunes the
1149
+ * registration only once that succeeded, so a refused delete cannot leave the
1150
+ * registration already gone.
785
1151
  * @param root - canonical repository root.
786
1152
  * @param name - copy name; already validated by the caller.
787
1153
  * @param signal - caller cancellation.
788
1154
  * @returns the removed copy's identity.
789
1155
  */
790
1156
  const removeCopy = async (root, name, signal) => {
791
- const listed = await list({ path: root }, signal);
792
- if (!listed.supported) throw new WorktreeToggleError("not-a-git-repository", "the project is not inside a Git working tree");
793
- const item = listed.items.find((entry) => entry.name === name);
1157
+ const item = (await copyItems(ctx, root, await listWorktreeRecords(ctx, root, signal), signal)).find((entry) => entry.name === name);
794
1158
  if (item === void 0) throw new WorktreeToggleError("worktree-not-found", `no worktree named ${name} in ${root}`, {
795
1159
  name,
796
1160
  repositoryRoot: root
@@ -799,8 +1163,10 @@ function createWorktreeToggle(ctx, options) {
799
1163
  name: item.name,
800
1164
  changedFiles: item.changedFiles
801
1165
  });
802
- if (item.prunable) await runGit(ctx, root, ["worktree", "prune"], { signal });
803
- else {
1166
+ if (item.prunable) {
1167
+ if (item.branch !== null) await deleteBranchByRef(root, item.branch, signal);
1168
+ await runGit(ctx, root, ["worktree", "prune"], { signal });
1169
+ } else {
804
1170
  const removed = await runGit(ctx, root, [
805
1171
  "worktree",
806
1172
  "remove",
@@ -817,26 +1183,9 @@ function createWorktreeToggle(ctx, options) {
817
1183
  name: item.name,
818
1184
  path: item.path
819
1185
  });
1186
+ if (item.branch !== null) await deleteBranch(root, item.branch, signal);
820
1187
  }
821
- const deleted = await runGit(ctx, root, [
822
- "branch",
823
- "-D",
824
- "--",
825
- item.branch
826
- ], {
827
- signal,
828
- allowedExitCodes: [0, 1]
829
- });
830
- const branchGone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr);
831
- if (deleted.exitCode !== 0 && !branchGone) throw new WorktreeToggleError("worktree-remove-failed", new GitFailure([
832
- "branch",
833
- "-D",
834
- item.branch
835
- ], deleted).message, {
836
- name: item.name,
837
- branch: item.branch
838
- });
839
- for (const [sessionId, copy] of createdBySession) if (copy.path === item.path) createdBySession.delete(sessionId);
1188
+ forget(root, item.name);
840
1189
  return {
841
1190
  removed: true,
842
1191
  name: item.name,
@@ -850,6 +1199,17 @@ function createWorktreeToggle(ctx, options) {
850
1199
  const root = await repositoryOf(ctx, directory, signal);
851
1200
  return await removeCopy(root, request.name, signal);
852
1201
  };
1202
+ const discardBranch = async (request, signal) => {
1203
+ const directory = absolutePath(request.path);
1204
+ if (!isGeneratedName(request.branch)) throw new WorktreeToggleError("branch-name-invalid", `not a branch this plugin generated: ${request.branch}`);
1205
+ const root = await repositoryOf(ctx, directory, signal);
1206
+ if ((await listWorktreeRecords(ctx, root, signal)).some((record) => record.branch === request.branch)) throw new WorktreeToggleError("branch-in-use", `${request.branch} is checked out by a worktree; discard that copy instead`, { branch: request.branch });
1207
+ if (new Set((await listBranchRefs(ctx, root, signal)).map((ref) => ref.name)).has(request.branch)) await deleteBranch(root, request.branch, signal);
1208
+ return {
1209
+ removed: true,
1210
+ branch: request.branch
1211
+ };
1212
+ };
853
1213
  const release = async (request, signal) => {
854
1214
  const running = pending.get(request.sessionId);
855
1215
  if (running !== void 0) try {
@@ -866,27 +1226,20 @@ function createWorktreeToggle(ctx, options) {
866
1226
  if (error instanceof GitFailure) throw new WorktreeToggleError("not-a-git-repository", "the session project is not inside a Git working tree");
867
1227
  throw error;
868
1228
  }
869
- const listed = await list({ path: anchor }, signal);
870
- if (!listed.supported) return {
871
- directory: current,
872
- removed: null,
873
- kept: null
874
- };
1229
+ const records = await listWorktreeRecords(ctx, root, signal);
1230
+ const items = await copyItems(ctx, root, records, signal);
875
1231
  const path = await canonicalPath(current);
876
- const item = listed.items.find((entry) => contains(entry.path, path));
1232
+ const item = items.find((entry) => contains(entry.path, path));
877
1233
  if (item === void 0) {
878
1234
  options.onScope?.(request.sessionId, null);
879
1235
  return {
880
1236
  directory: current,
881
1237
  removed: null,
882
- kept: null
1238
+ kept: null,
1239
+ keptReason: null
883
1240
  };
884
1241
  }
885
- const copy = {
886
- name: item.name,
887
- path: item.path,
888
- branch: item.branch
889
- };
1242
+ const copy = copyRef(item);
890
1243
  signal?.throwIfAborted();
891
1244
  let directory;
892
1245
  try {
@@ -898,38 +1251,58 @@ function createWorktreeToggle(ctx, options) {
898
1251
  });
899
1252
  }
900
1253
  options.onScope?.(request.sessionId, null);
901
- const mine = createdBySession.get(request.sessionId);
902
- if (mine === void 0 || mine.path !== copy.path) return {
903
- directory,
904
- removed: null,
905
- kept: copy
906
- };
907
- try {
908
- const removed = await removeCopy(root, copy.name, signal);
909
- return {
1254
+ const keepCopy = (reason, error) => {
1255
+ if (isKeptLeftover(reason)) keep(root, item.name);
1256
+ return error === void 0 ? {
910
1257
  directory,
911
- removed: {
912
- name: removed.name,
913
- path: removed.path,
914
- branch: removed.branch
915
- },
916
- kept: null
917
- };
918
- } catch (error) {
919
- if (signal?.aborted === true) throw error;
920
- return {
1258
+ removed: null,
1259
+ kept: copy,
1260
+ keptReason: reason
1261
+ } : {
921
1262
  directory,
922
1263
  removed: null,
923
- kept: copy
1264
+ kept: copy,
1265
+ keptReason: reason,
1266
+ error
924
1267
  };
1268
+ };
1269
+ const ownership = await readOwnership(ctx, item.path, signal);
1270
+ if (ownership === null || ownership.sessionId !== request.sessionId) return keepCopy("not-owned");
1271
+ if (item.changedFiles > 0) return keepCopy("dirty", `${item.changedFiles} uncommitted change(s)`);
1272
+ const tip = await revParse(ctx, item.path, "HEAD", signal);
1273
+ if (tip === null || tip !== ownership.baseCommit) {
1274
+ let commits = 0;
1275
+ try {
1276
+ commits = await revListCount(ctx, item.path, `${ownership.baseCommit}..HEAD`, signal);
1277
+ } catch {}
1278
+ return keepCopy("dirty", `${commits} new commit(s)`);
1279
+ }
1280
+ let removed;
1281
+ try {
1282
+ removed = await removeCopy(root, item.name, signal);
1283
+ } catch (error) {
1284
+ keep(root, item.name);
1285
+ throw error;
925
1286
  }
1287
+ return {
1288
+ directory,
1289
+ removed: {
1290
+ name: removed.name,
1291
+ path: removed.path,
1292
+ branch: removed.branch
1293
+ },
1294
+ kept: null,
1295
+ keptReason: null
1296
+ };
926
1297
  };
927
1298
  return {
928
1299
  status,
929
1300
  prepare,
930
1301
  release,
1302
+ scope,
931
1303
  list,
932
- discard
1304
+ discard,
1305
+ discardBranch
933
1306
  };
934
1307
  }
935
1308
  // src/host/routes.ts
@@ -942,8 +1315,10 @@ const ENDPOINTS = [
942
1315
  "status",
943
1316
  "prepare",
944
1317
  "release",
1318
+ "scope",
945
1319
  "list",
946
- "discard"
1320
+ "discard",
1321
+ "discardBranch"
947
1322
  ];
948
1323
  /** A payload object with the named string field, or the failure to send back. */
949
1324
  function requiredString(payload, field) {
@@ -1013,6 +1388,11 @@ async function dispatch(toggle, endpoint, payload, signal) {
1013
1388
  if ("failure" in sessionId) throw sessionId.failure;
1014
1389
  return ok(await toggle.release({ sessionId: sessionId.value }, signal));
1015
1390
  }
1391
+ if (endpoint === "scope") {
1392
+ const sessionId = requiredString(payload, "sessionId");
1393
+ if ("failure" in sessionId) throw sessionId.failure;
1394
+ return ok(await toggle.scope({ sessionId: sessionId.value }, signal));
1395
+ }
1016
1396
  if (endpoint === "list") {
1017
1397
  const path = requiredString(payload, "path");
1018
1398
  if ("failure" in path) throw path.failure;
@@ -1020,6 +1400,14 @@ async function dispatch(toggle, endpoint, payload, signal) {
1020
1400
  }
1021
1401
  const path = requiredString(payload, "path");
1022
1402
  if ("failure" in path) throw path.failure;
1403
+ if (endpoint === "discardBranch") {
1404
+ const branch = requiredString(payload, "branch");
1405
+ if ("failure" in branch) throw branch.failure;
1406
+ return ok(await toggle.discardBranch({
1407
+ path: path.value,
1408
+ branch: branch.value
1409
+ }, signal));
1410
+ }
1023
1411
  const name = requiredString(payload, "name");
1024
1412
  if ("failure" in name) throw name.failure;
1025
1413
  return ok(await toggle.discard({
@@ -1202,7 +1590,8 @@ function apply(ctx, config) {
1202
1590
  onScope: (sessionId, scope) => {
1203
1591
  if (scope === null) scopes.forget(sessionId);
1204
1592
  else scopes.remember(sessionId, scope);
1205
- }
1593
+ },
1594
+ readScope: (sessionId) => scopes.get(sessionId)
1206
1595
  });
1207
1596
  installWorktreeScope(ctx, {
1208
1597
  registry: scopes,