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/README.md +61 -17
- package/lib/client.js +1269 -489
- package/lib/index.js +473 -103
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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:
|
|
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
|
|
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
|
|
622
|
-
*
|
|
623
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
784
|
-
*
|
|
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
|
|
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)
|
|
803
|
-
|
|
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
|
-
|
|
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
|
|
870
|
-
|
|
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 =
|
|
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
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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,
|