dsh-worktree-switch 0.4.0 → 0.5.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/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
@@ -280,6 +280,122 @@ async function changedFileCount(ctx, directory, signal) {
280
280
  "--untracked-files=normal"
281
281
  ], { signal })).stdout.split("\n").filter((line) => line.trim() !== "").length;
282
282
  }
283
+ /**
284
+ * Resolve the whereabouts of one checkout's Git administration directory.
285
+ *
286
+ * A linked worktree's `.git` is a file pointing at
287
+ * `<common>/worktrees/<name>`; this is the directory Git owns for that one
288
+ * checkout, which is where the switch records its claim on the copy.
289
+ * @param ctx - Host context carrying the subprocess provider.
290
+ * @param checkout - absolute directory of the checkout to ask.
291
+ * @param signal - caller cancellation.
292
+ * @returns the absolute admin directory Git reports.
293
+ * @throws {GitFailure} when the directory is not inside a Git working tree.
294
+ */
295
+ async function absoluteGitDir(ctx, checkout, signal) {
296
+ const result = await runGit(ctx, checkout, ["rev-parse", "--absolute-git-dir"], { signal });
297
+ const path = result.stdout.replace(/\r?\n$/, "");
298
+ if (path === "") throw new GitFailure(["rev-parse", "--absolute-git-dir"], result);
299
+ return path;
300
+ }
301
+ /**
302
+ * Read the branch one checkout has checked out.
303
+ * @param ctx - Host context carrying the subprocess provider.
304
+ * @param checkout - absolute directory of the checkout to ask.
305
+ * @param signal - caller cancellation.
306
+ * @returns the branch name, or null when the checkout is on a detached HEAD.
307
+ */
308
+ async function currentBranch(ctx, checkout, signal) {
309
+ const result = await runGit(ctx, checkout, [
310
+ "symbolic-ref",
311
+ "--short",
312
+ "--quiet",
313
+ "HEAD"
314
+ ], {
315
+ signal,
316
+ allowedExitCodes: [0, 1]
317
+ });
318
+ const name = result.stdout.replace(/\r?\n$/, "");
319
+ return result.exitCode === 0 && name !== "" ? name : null;
320
+ }
321
+ /**
322
+ * Resolve one revision to the commit id it names.
323
+ * @param ctx - Host context carrying the subprocess provider.
324
+ * @param directory - checkout the command runs in.
325
+ * @param revision - revision to resolve; `HEAD` or a full id are the usual values.
326
+ * @param signal - caller cancellation.
327
+ * @returns the commit id, or null when the revision does not resolve.
328
+ */
329
+ async function revParse(ctx, directory, revision, signal) {
330
+ const result = await runGit(ctx, directory, [
331
+ "rev-parse",
332
+ "--verify",
333
+ "--quiet",
334
+ revision
335
+ ], {
336
+ signal,
337
+ allowedExitCodes: [
338
+ 0,
339
+ 1,
340
+ 128
341
+ ]
342
+ });
343
+ const id = result.stdout.replace(/\r?\n$/, "");
344
+ return result.exitCode === 0 && id !== "" ? id : null;
345
+ }
346
+ /**
347
+ * Count the commits in one revision range.
348
+ * @param ctx - Host context carrying the subprocess provider.
349
+ * @param directory - checkout the command runs in.
350
+ * @param range - the range to count, e.g. `HEAD..refs/heads/<branch>`.
351
+ * @param signal - caller cancellation.
352
+ * @returns the number of commits in the range.
353
+ * @throws {GitFailure} when the range does not resolve.
354
+ */
355
+ async function revListCount(ctx, directory, range, signal) {
356
+ const result = await runGit(ctx, directory, [
357
+ "rev-list",
358
+ "--count",
359
+ range
360
+ ], { signal });
361
+ const count = Number.parseInt(result.stdout.trim(), 10);
362
+ return Number.isFinite(count) ? count : 0;
363
+ }
364
+ /**
365
+ * Parse `git for-each-ref` output produced with the NUL-separated format below.
366
+ * @param stdout - the command's stdout.
367
+ * @returns one entry per local branch, in Git's order.
368
+ */
369
+ function parseBranchRefs(stdout) {
370
+ const refs = [];
371
+ for (const line of stdout.split("\n")) {
372
+ if (line === "") continue;
373
+ const [name, commit, seconds, subject] = line.split("\0");
374
+ if (name === void 0 || commit === void 0 || seconds === void 0 || subject === void 0) continue;
375
+ const committedAt = Number.parseInt(seconds, 10) * 1e3;
376
+ refs.push({
377
+ name,
378
+ commit,
379
+ committedAt: Number.isFinite(committedAt) ? committedAt : 0,
380
+ subject
381
+ });
382
+ }
383
+ return refs;
384
+ }
385
+ /**
386
+ * List the local branches of one repository.
387
+ * @param ctx - Host context carrying the subprocess provider.
388
+ * @param root - repository root the command runs in.
389
+ * @param signal - caller cancellation.
390
+ * @returns every local branch with its tip commit.
391
+ */
392
+ async function listBranchRefs(ctx, root, signal) {
393
+ return parseBranchRefs((await runGit(ctx, root, [
394
+ "for-each-ref",
395
+ "--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)",
396
+ "refs/heads"
397
+ ], { signal })).stdout);
398
+ }
283
399
  // src/host/base.ts
284
400
  /** Whole-command deadline for the plugin's one network call. */
285
401
  const FETCH_TIMEOUT_MS = 5e3;
@@ -488,6 +604,19 @@ function worktreeName(random = Math.random) {
488
604
  }
489
605
  /** A pool entry name is exactly one path segment, as `git worktree` names it. */
490
606
  const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
607
+ /** The shape this plugin generates: two lowercase words and four hex digits. */
608
+ const GENERATED_PATTERN = /^[a-z]+-[a-z]+-[0-9a-f]{4}$/;
609
+ /**
610
+ * Whether a branch name looks like one this plugin generated.
611
+ *
612
+ * Leftover-branch cleanup only offers names of this shape: an ordinary feature
613
+ * branch is never a candidate, even when no checkout holds it.
614
+ * @param value - candidate branch name.
615
+ * @returns true for the generated-name format.
616
+ */
617
+ function isGeneratedName(value) {
618
+ return GENERATED_PATTERN.test(value);
619
+ }
491
620
  /**
492
621
  * Validate a worktree name received from the browser before it reaches Git.
493
622
  * @param value - candidate name.
@@ -496,9 +625,66 @@ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
496
625
  function isWorktreeName(value) {
497
626
  return NAME_PATTERN.test(value) && !value.endsWith(".lock");
498
627
  }
628
+ // src/host/ownership.ts
629
+ /**
630
+ * The switch's claim on a copy.
631
+ *
632
+ * "This copy was created by the switch for this session" used to live only in
633
+ * host memory, so a host restart turned every copy into a stranger and the
634
+ * leave path could never prove it was safe to undo. The claim is now stored in
635
+ * the checkout's own Git administration directory: it travels with the copy,
636
+ * vanishes when the copy is deleted, never appears in the working tree, and
637
+ * survives a restart. A missing or unparseable marker means "not owned" — the
638
+ * plugin does not guess, because guessing wrong deletes work.
639
+ * @module dsh-worktree-switch/host/ownership
640
+ */
641
+ /** File name inside the checkout's admin directory. */
642
+ const MARKER_FILE = "dsh-worktree-switch.json";
643
+ /**
644
+ * Read one checkout's ownership marker.
645
+ *
646
+ * Every failure — no admin directory, no marker, invalid JSON, a shape this
647
+ * version does not understand — reads as "no claim". The leave path treats
648
+ * that as a reason to keep the copy, never as a reason to take it.
649
+ * @param ctx - Host context carrying the subprocess provider.
650
+ * @param checkout - absolute directory of the copy.
651
+ * @param signal - caller cancellation.
652
+ * @returns the recorded claim, or null when there is none.
653
+ */
654
+ async function readOwnership(ctx, checkout, signal) {
655
+ try {
656
+ const directory = await absoluteGitDir(ctx, checkout, signal);
657
+ const raw = await readFile(join(directory, MARKER_FILE), "utf8");
658
+ const parsed = JSON.parse(raw);
659
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
660
+ const { sessionId, createdAt, baseCommit } = parsed;
661
+ if (typeof sessionId !== "string" || sessionId === "") return null;
662
+ if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return null;
663
+ if (typeof baseCommit !== "string" || baseCommit === "") return null;
664
+ return {
665
+ sessionId,
666
+ createdAt,
667
+ baseCommit
668
+ };
669
+ } catch {
670
+ return null;
671
+ }
672
+ }
673
+ /**
674
+ * Record the switch's claim on a freshly created copy.
675
+ * @param ctx - Host context carrying the subprocess provider.
676
+ * @param checkout - absolute directory of the copy.
677
+ * @param ownership - the claim to write.
678
+ * @param signal - caller cancellation.
679
+ * @throws when the admin directory cannot be resolved or the marker cannot be written.
680
+ */
681
+ async function writeOwnership(ctx, checkout, ownership, signal) {
682
+ const directory = await absoluteGitDir(ctx, checkout, signal);
683
+ await writeFile(join(directory, MARKER_FILE), `${JSON.stringify(ownership)}\n`, "utf8");
684
+ }
499
685
  // src/host/handlers.ts
500
686
  /**
501
- * The plugin's host Remote face: the five verbs the browser half can call,
687
+ * The plugin's host Remote face: the verbs the browser half can call,
502
688
  * expressed against a live session and the checkouts on disk.
503
689
  *
504
690
  * This module is the seam the host tests drive directly. Everything below it —
@@ -525,6 +711,16 @@ var WorktreeToggleError = class extends Error {
525
711
  this.name = "WorktreeToggleError";
526
712
  }
527
713
  };
714
+ /**
715
+ * The failure one refused branch delete reports.
716
+ * @param branch - branch the verb tried to delete.
717
+ * @param args - the Git command that refused, for the message.
718
+ * @param result - its captured outcome.
719
+ * @returns the error the browser half switches on.
720
+ */
721
+ function branchDeleteFailure(branch, args, result) {
722
+ return new WorktreeToggleError("branch-delete-failed", new GitFailure(args, result).message, { branch });
723
+ }
528
724
  /** Normalize a browser-supplied path before any filesystem work. */
529
725
  function absolutePath(value) {
530
726
  const trimmed = value.trim();
@@ -539,11 +735,13 @@ function absolutePath(value) {
539
735
  * from any configured pool path. The pool belongs to the harness worktree
540
736
  * service, so this plugin never needs a second copy of that setting: moving the
541
737
  * pool in the service's row config keeps every verb working.
738
+ * @param ctx - host context carrying `subprocess`.
542
739
  * @param current - the session's committed working directory.
543
740
  * @param repositoryRoot - canonical root of the project's repository.
741
+ * @param signal - caller cancellation.
544
742
  * @returns the copy's identity, or null when the directory is not one.
545
743
  */
546
- async function worktreeAt(current, repositoryRoot) {
744
+ async function worktreeAt(ctx, current, repositoryRoot, signal) {
547
745
  const path = await canonicalPath(current);
548
746
  if (path === repositoryRoot) return null;
549
747
  const inside = relative(repositoryRoot, path);
@@ -558,7 +756,7 @@ async function worktreeAt(current, repositoryRoot) {
558
756
  return {
559
757
  name,
560
758
  path,
561
- branch: name
759
+ branch: await currentBranch(ctx, path, signal)
562
760
  };
563
761
  }
564
762
  /**
@@ -598,7 +796,7 @@ async function detectSessionScope(ctx, agent, signal) {
598
796
  signal?.throwIfAborted();
599
797
  let probe = current;
600
798
  for (;;) {
601
- const copy = await worktreeAt(probe, root);
799
+ const copy = await worktreeAt(ctx, probe, root, signal);
602
800
  if (copy !== null) return {
603
801
  worktree: copy,
604
802
  repositoryRoot: root
@@ -609,21 +807,111 @@ async function detectSessionScope(ctx, agent, signal) {
609
807
  }
610
808
  }
611
809
  /**
810
+ * The copy rows of one repository, newest first.
811
+ *
812
+ * A row whose checkout directory is gone is still a copy: Git keeps the
813
+ * registration until it is pruned, and the panel offers the same discard for
814
+ * it, marked as missing. A detached copy keeps `branch: null` so no verb can
815
+ * ever mistake the directory name for a branch to delete.
816
+ * @param ctx - host context carrying `subprocess`.
817
+ * @param root - canonical repository root.
818
+ * @param records - `git worktree list` records for the repository.
819
+ * @param signal - caller cancellation.
820
+ * @returns one row per copy under the project.
821
+ */
822
+ async function copyItems(ctx, root, records, signal) {
823
+ const items = [];
824
+ for (const record of records) {
825
+ signal?.throwIfAborted();
826
+ const path = await canonicalPath(record.path);
827
+ if (path === root) continue;
828
+ const name = basename(path);
829
+ if (!isWorktreeName(name)) continue;
830
+ let createdAt = 0;
831
+ let prunable = true;
832
+ try {
833
+ const info = await stat(path);
834
+ createdAt = Math.trunc(info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs);
835
+ prunable = false;
836
+ } catch {}
837
+ const changedFiles = prunable ? 0 : await changedFileCount(ctx, path, signal);
838
+ items.push({
839
+ name,
840
+ path,
841
+ branch: record.branch ?? null,
842
+ createdAt,
843
+ changedFiles,
844
+ prunable
845
+ });
846
+ }
847
+ items.sort((left, right) => right.createdAt - left.createdAt || left.name.localeCompare(right.name));
848
+ return items;
849
+ }
850
+ /**
851
+ * Generated-name branches that no checkout holds.
852
+ *
853
+ * Deliberately narrow: only the names this plugin generates qualify, so an
854
+ * ordinary feature branch never appears in a list whose only action is delete.
855
+ * The commit count is what the source checkout's HEAD does not contain — a
856
+ * branch whose work is fully merged reads as 0, which is the one safe signal.
857
+ * @param ctx - host context carrying `subprocess`.
858
+ * @param root - canonical repository root.
859
+ * @param records - `git worktree list` records for the repository.
860
+ * @param signal - caller cancellation.
861
+ * @returns the leftover branches, newest commit first.
862
+ */
863
+ async function orphanBranches(ctx, root, records, signal) {
864
+ const checkedOut = /* @__PURE__ */ new Set();
865
+ for (const record of records) if (record.branch !== void 0) checkedOut.add(record.branch);
866
+ const orphans = [];
867
+ for (const ref of await listBranchRefs(ctx, root, signal)) {
868
+ signal?.throwIfAborted();
869
+ if (!isGeneratedName(ref.name) || checkedOut.has(ref.name)) continue;
870
+ let commits = 0;
871
+ try {
872
+ commits = await revListCount(ctx, root, `HEAD..${ref.commit}`, signal);
873
+ } catch {}
874
+ orphans.push({
875
+ branch: ref.name,
876
+ commits,
877
+ subject: ref.subject,
878
+ committedAt: ref.committedAt
879
+ });
880
+ }
881
+ orphans.sort((left, right) => right.committedAt - left.committedAt || left.branch.localeCompare(right.branch));
882
+ return orphans;
883
+ }
884
+ /** The wire identity of one listed copy; a detached copy keeps its null branch. */
885
+ function copyRef(item) {
886
+ return {
887
+ name: item.name,
888
+ path: item.path,
889
+ branch: item.branch
890
+ };
891
+ }
892
+ /**
612
893
  * Build the host face.
613
894
  * @param ctx - Host context with `agents`, `worktrees`, `workingDirectory`, and `subprocess`.
614
895
  * @param options - resolved row config.
615
- * @returns the five verbs.
896
+ * @returns the verbs above.
616
897
  */
617
898
  function createWorktreeToggle(ctx, options) {
618
899
  /** One in-flight preparation per session, so a double mount cannot create twice. */
619
900
  const pending = /* @__PURE__ */ new Map();
620
901
  /**
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.
902
+ * Copies this host instance kept when the switch was turned off, by
903
+ * repository root. A host restart forgets them; the panel's count then
904
+ * honestly drops back to leftovers only.
625
905
  */
626
- const createdBySession = /* @__PURE__ */ new Map();
906
+ const keptCopies = /* @__PURE__ */ new Map();
907
+ const keep = (root, name) => {
908
+ const names = keptCopies.get(root) ?? /* @__PURE__ */ new Set();
909
+ names.add(name);
910
+ keptCopies.set(root, names);
911
+ };
912
+ const forget = (root, name) => {
913
+ keptCopies.get(root)?.delete(name);
914
+ };
627
915
  const sessionOf = async (sessionId, signal) => {
628
916
  signal?.throwIfAborted();
629
917
  if (sessionId === "") throw new WorktreeToggleError("bad-request", "a session id is required");
@@ -654,7 +942,7 @@ function createWorktreeToggle(ctx, options) {
654
942
  const current = ctx.workingDirectory.get(agent.session);
655
943
  signal?.throwIfAborted();
656
944
  const sourceDirty = await changedFileCount(ctx, root, signal);
657
- const copy = await worktreeAt(current, root);
945
+ const copy = await worktreeAt(ctx, current, root, signal);
658
946
  options.onScope?.(request.sessionId, copy === null ? null : {
659
947
  worktree: copy,
660
948
  repositoryRoot: root
@@ -703,7 +991,14 @@ function createWorktreeToggle(ctx, options) {
703
991
  path: created.path,
704
992
  branch: created.branch
705
993
  };
706
- createdBySession.set(request.sessionId, worktree);
994
+ const baseCommit = await revParse(ctx, created.path, "HEAD");
995
+ if (baseCommit !== null) try {
996
+ await writeOwnership(ctx, created.path, {
997
+ sessionId: request.sessionId,
998
+ createdAt: Date.now(),
999
+ baseCommit
1000
+ });
1001
+ } catch {}
707
1002
  options.onScope?.(request.sessionId, {
708
1003
  worktree,
709
1004
  repositoryRoot: created.repositoryRoot
@@ -734,6 +1029,22 @@ function createWorktreeToggle(ctx, options) {
734
1029
  if (pending.get(request.sessionId) === operation) pending.delete(request.sessionId);
735
1030
  }
736
1031
  };
1032
+ const scope = async (request, signal) => {
1033
+ const agent = await sessionOf(request.sessionId, signal);
1034
+ const current = ctx.workingDirectory.get(agent.session);
1035
+ const known = options.readScope?.(request.sessionId) ?? null;
1036
+ const found = known !== null && contains(known.worktree.path, current) ? known : await detectSessionScope(ctx, agent, signal);
1037
+ if (found === null) return { inWorktree: false };
1038
+ signal?.throwIfAborted();
1039
+ const changedFiles = await changedFileCount(ctx, found.worktree.path, signal);
1040
+ options.onScope?.(request.sessionId, found);
1041
+ return {
1042
+ inWorktree: true,
1043
+ worktree: found.worktree,
1044
+ repositoryRoot: found.repositoryRoot,
1045
+ changedFiles
1046
+ };
1047
+ };
737
1048
  const list = async (request, signal) => {
738
1049
  const directory = absolutePath(request.path);
739
1050
  let root;
@@ -747,50 +1058,84 @@ function createWorktreeToggle(ctx, options) {
747
1058
  throw error;
748
1059
  }
749
1060
  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));
1061
+ const items = await copyItems(ctx, root, records, signal);
1062
+ const orphans = await orphanBranches(ctx, root, records, signal);
1063
+ const kept = keptCopies.get(root);
1064
+ const keptCopiesStillHere = kept === void 0 ? 0 : items.filter((item) => kept.has(item.name)).length;
776
1065
  return {
777
1066
  supported: true,
778
1067
  repositoryRoot: root,
779
- items
1068
+ items,
1069
+ orphans,
1070
+ keptCopies: keptCopiesStillHere
780
1071
  };
781
1072
  };
782
1073
  /**
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.
1074
+ * Delete one branch by name.
1075
+ * @param root - canonical repository root.
1076
+ * @param branch - branch to delete; already validated by the caller.
1077
+ * @param signal - caller cancellation.
1078
+ * @throws {WorktreeToggleError} when Git refused.
1079
+ */
1080
+ const deleteBranch = async (root, branch, signal) => {
1081
+ const deleted = await runGit(ctx, root, [
1082
+ "branch",
1083
+ "-D",
1084
+ "--",
1085
+ branch
1086
+ ], {
1087
+ signal,
1088
+ allowedExitCodes: [0, 1]
1089
+ });
1090
+ const gone = deleted.exitCode === 1 && /not found/i.test(deleted.stderr);
1091
+ if (deleted.exitCode !== 0 && !gone) throw branchDeleteFailure(branch, [
1092
+ "branch",
1093
+ "-D",
1094
+ branch
1095
+ ], deleted);
1096
+ };
1097
+ /**
1098
+ * Delete a branch a prunable copy still holds.
1099
+ *
1100
+ * `git branch -D` refuses while any worktree registration names the branch,
1101
+ * even a registration whose directory is gone, so the branch is removed
1102
+ * through the ref itself. The registration still pins Git's bookkeeping, but
1103
+ * nothing can have the branch checked out any more: the directory is missing.
1104
+ * @param root - canonical repository root.
1105
+ * @param branch - branch to delete; already validated by the caller.
1106
+ * @param signal - caller cancellation.
1107
+ * @throws {WorktreeToggleError} when Git refused (for example a locked ref).
1108
+ */
1109
+ const deleteBranchByRef = async (root, branch, signal) => {
1110
+ const deleted = await runGit(ctx, root, [
1111
+ "update-ref",
1112
+ "-d",
1113
+ `refs/heads/${branch}`
1114
+ ], {
1115
+ signal,
1116
+ allowedExitCodes: [0, 1]
1117
+ });
1118
+ if (deleted.exitCode !== 0) throw branchDeleteFailure(branch, [
1119
+ "update-ref",
1120
+ "-d",
1121
+ branch
1122
+ ], deleted);
1123
+ };
1124
+ /**
1125
+ * Delete one clean copy and, unless it is detached, its branch.
1126
+ *
1127
+ * Both the panel's discard and the switch's own undo go through here, so the
1128
+ * dirty guard and the delete order have one home. A prunable copy — its
1129
+ * directory was deleted by hand — deletes the branch first and prunes the
1130
+ * registration only once that succeeded, so a refused delete cannot leave the
1131
+ * registration already gone.
785
1132
  * @param root - canonical repository root.
786
1133
  * @param name - copy name; already validated by the caller.
787
1134
  * @param signal - caller cancellation.
788
1135
  * @returns the removed copy's identity.
789
1136
  */
790
1137
  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);
1138
+ const item = (await copyItems(ctx, root, await listWorktreeRecords(ctx, root, signal), signal)).find((entry) => entry.name === name);
794
1139
  if (item === void 0) throw new WorktreeToggleError("worktree-not-found", `no worktree named ${name} in ${root}`, {
795
1140
  name,
796
1141
  repositoryRoot: root
@@ -799,8 +1144,10 @@ function createWorktreeToggle(ctx, options) {
799
1144
  name: item.name,
800
1145
  changedFiles: item.changedFiles
801
1146
  });
802
- if (item.prunable) await runGit(ctx, root, ["worktree", "prune"], { signal });
803
- else {
1147
+ if (item.prunable) {
1148
+ if (item.branch !== null) await deleteBranchByRef(root, item.branch, signal);
1149
+ await runGit(ctx, root, ["worktree", "prune"], { signal });
1150
+ } else {
804
1151
  const removed = await runGit(ctx, root, [
805
1152
  "worktree",
806
1153
  "remove",
@@ -817,26 +1164,9 @@ function createWorktreeToggle(ctx, options) {
817
1164
  name: item.name,
818
1165
  path: item.path
819
1166
  });
1167
+ if (item.branch !== null) await deleteBranch(root, item.branch, signal);
820
1168
  }
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);
1169
+ forget(root, item.name);
840
1170
  return {
841
1171
  removed: true,
842
1172
  name: item.name,
@@ -850,6 +1180,17 @@ function createWorktreeToggle(ctx, options) {
850
1180
  const root = await repositoryOf(ctx, directory, signal);
851
1181
  return await removeCopy(root, request.name, signal);
852
1182
  };
1183
+ const discardBranch = async (request, signal) => {
1184
+ const directory = absolutePath(request.path);
1185
+ if (!isGeneratedName(request.branch)) throw new WorktreeToggleError("branch-name-invalid", `not a branch this plugin generated: ${request.branch}`);
1186
+ const root = await repositoryOf(ctx, directory, signal);
1187
+ 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 });
1188
+ if (new Set((await listBranchRefs(ctx, root, signal)).map((ref) => ref.name)).has(request.branch)) await deleteBranch(root, request.branch, signal);
1189
+ return {
1190
+ removed: true,
1191
+ branch: request.branch
1192
+ };
1193
+ };
853
1194
  const release = async (request, signal) => {
854
1195
  const running = pending.get(request.sessionId);
855
1196
  if (running !== void 0) try {
@@ -866,27 +1207,20 @@ function createWorktreeToggle(ctx, options) {
866
1207
  if (error instanceof GitFailure) throw new WorktreeToggleError("not-a-git-repository", "the session project is not inside a Git working tree");
867
1208
  throw error;
868
1209
  }
869
- const listed = await list({ path: anchor }, signal);
870
- if (!listed.supported) return {
871
- directory: current,
872
- removed: null,
873
- kept: null
874
- };
1210
+ const records = await listWorktreeRecords(ctx, root, signal);
1211
+ const items = await copyItems(ctx, root, records, signal);
875
1212
  const path = await canonicalPath(current);
876
- const item = listed.items.find((entry) => contains(entry.path, path));
1213
+ const item = items.find((entry) => contains(entry.path, path));
877
1214
  if (item === void 0) {
878
1215
  options.onScope?.(request.sessionId, null);
879
1216
  return {
880
1217
  directory: current,
881
1218
  removed: null,
882
- kept: null
1219
+ kept: null,
1220
+ keptReason: null
883
1221
  };
884
1222
  }
885
- const copy = {
886
- name: item.name,
887
- path: item.path,
888
- branch: item.branch
889
- };
1223
+ const copy = copyRef(item);
890
1224
  signal?.throwIfAborted();
891
1225
  let directory;
892
1226
  try {
@@ -898,38 +1232,58 @@ function createWorktreeToggle(ctx, options) {
898
1232
  });
899
1233
  }
900
1234
  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 {
1235
+ const keepCopy = (reason, error) => {
1236
+ keep(root, item.name);
1237
+ return error === void 0 ? {
910
1238
  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 {
1239
+ removed: null,
1240
+ kept: copy,
1241
+ keptReason: reason
1242
+ } : {
921
1243
  directory,
922
1244
  removed: null,
923
- kept: copy
1245
+ kept: copy,
1246
+ keptReason: reason,
1247
+ error
924
1248
  };
1249
+ };
1250
+ const ownership = await readOwnership(ctx, item.path, signal);
1251
+ if (ownership === null || ownership.sessionId !== request.sessionId) return keepCopy("not-owned");
1252
+ if (item.changedFiles > 0) return keepCopy("dirty", `${item.changedFiles} uncommitted change(s)`);
1253
+ const tip = await revParse(ctx, item.path, "HEAD", signal);
1254
+ if (tip === null || tip !== ownership.baseCommit) {
1255
+ let commits = 0;
1256
+ try {
1257
+ commits = await revListCount(ctx, item.path, `${ownership.baseCommit}..HEAD`, signal);
1258
+ } catch {}
1259
+ return keepCopy("dirty", `${commits} new commit(s)`);
1260
+ }
1261
+ let removed;
1262
+ try {
1263
+ removed = await removeCopy(root, item.name, signal);
1264
+ } catch (error) {
1265
+ keep(root, item.name);
1266
+ throw error;
925
1267
  }
1268
+ return {
1269
+ directory,
1270
+ removed: {
1271
+ name: removed.name,
1272
+ path: removed.path,
1273
+ branch: removed.branch
1274
+ },
1275
+ kept: null,
1276
+ keptReason: null
1277
+ };
926
1278
  };
927
1279
  return {
928
1280
  status,
929
1281
  prepare,
930
1282
  release,
1283
+ scope,
931
1284
  list,
932
- discard
1285
+ discard,
1286
+ discardBranch
933
1287
  };
934
1288
  }
935
1289
  // src/host/routes.ts
@@ -942,8 +1296,10 @@ const ENDPOINTS = [
942
1296
  "status",
943
1297
  "prepare",
944
1298
  "release",
1299
+ "scope",
945
1300
  "list",
946
- "discard"
1301
+ "discard",
1302
+ "discardBranch"
947
1303
  ];
948
1304
  /** A payload object with the named string field, or the failure to send back. */
949
1305
  function requiredString(payload, field) {
@@ -1013,6 +1369,11 @@ async function dispatch(toggle, endpoint, payload, signal) {
1013
1369
  if ("failure" in sessionId) throw sessionId.failure;
1014
1370
  return ok(await toggle.release({ sessionId: sessionId.value }, signal));
1015
1371
  }
1372
+ if (endpoint === "scope") {
1373
+ const sessionId = requiredString(payload, "sessionId");
1374
+ if ("failure" in sessionId) throw sessionId.failure;
1375
+ return ok(await toggle.scope({ sessionId: sessionId.value }, signal));
1376
+ }
1016
1377
  if (endpoint === "list") {
1017
1378
  const path = requiredString(payload, "path");
1018
1379
  if ("failure" in path) throw path.failure;
@@ -1020,6 +1381,14 @@ async function dispatch(toggle, endpoint, payload, signal) {
1020
1381
  }
1021
1382
  const path = requiredString(payload, "path");
1022
1383
  if ("failure" in path) throw path.failure;
1384
+ if (endpoint === "discardBranch") {
1385
+ const branch = requiredString(payload, "branch");
1386
+ if ("failure" in branch) throw branch.failure;
1387
+ return ok(await toggle.discardBranch({
1388
+ path: path.value,
1389
+ branch: branch.value
1390
+ }, signal));
1391
+ }
1023
1392
  const name = requiredString(payload, "name");
1024
1393
  if ("failure" in name) throw name.failure;
1025
1394
  return ok(await toggle.discard({
@@ -1202,7 +1571,8 @@ function apply(ctx, config) {
1202
1571
  onScope: (sessionId, scope) => {
1203
1572
  if (scope === null) scopes.forget(sessionId);
1204
1573
  else scopes.remember(sessionId, scope);
1205
- }
1574
+ },
1575
+ readScope: (sessionId) => scopes.get(sessionId)
1206
1576
  });
1207
1577
  installWorktreeScope(ctx, {
1208
1578
  registry: scopes,