@solaqua/gji 0.13.0 → 0.14.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.
Files changed (39) hide show
  1. package/dist/application/worktree/go-resolution.js +5 -3
  2. package/dist/application/worktree/sources.d.ts +13 -4
  3. package/dist/application/worktree/sources.js +34 -13
  4. package/dist/cli/commands/clean.d.ts +3 -2
  5. package/dist/cli/commands/clean.js +299 -65
  6. package/dist/cli/commands/go.d.ts +2 -1
  7. package/dist/cli/commands/go.js +17 -13
  8. package/dist/cli/commands/hub.js +4 -1
  9. package/dist/cli/commands/warp.js +6 -4
  10. package/dist/domain/shared/concurrency.d.ts +1 -1
  11. package/dist/domain/shared/concurrency.js +6 -1
  12. package/dist/gji-bundle.mjs +492 -225
  13. package/dist/infrastructure/git/refs.js +3 -11
  14. package/dist/presentation/worktree/picker.d.ts +9 -6
  15. package/dist/presentation/worktree/picker.js +19 -2
  16. package/man/man1/gji-back.1 +1 -1
  17. package/man/man1/gji-clean.1 +1 -1
  18. package/man/man1/gji-completion.1 +1 -1
  19. package/man/man1/gji-config.1 +1 -1
  20. package/man/man1/gji-doctor.1 +1 -1
  21. package/man/man1/gji-done.1 +1 -1
  22. package/man/man1/gji-go.1 +1 -1
  23. package/man/man1/gji-history.1 +1 -1
  24. package/man/man1/gji-init.1 +1 -1
  25. package/man/man1/gji-ls.1 +1 -1
  26. package/man/man1/gji-new.1 +1 -1
  27. package/man/man1/gji-open.1 +1 -1
  28. package/man/man1/gji-pr.1 +1 -1
  29. package/man/man1/gji-remove.1 +1 -1
  30. package/man/man1/gji-root.1 +1 -1
  31. package/man/man1/gji-run-hook.1 +1 -1
  32. package/man/man1/gji-status.1 +1 -1
  33. package/man/man1/gji-sync-files.1 +1 -1
  34. package/man/man1/gji-sync.1 +1 -1
  35. package/man/man1/gji-task.1 +1 -1
  36. package/man/man1/gji-undo.1 +1 -1
  37. package/man/man1/gji-warp.1 +1 -1
  38. package/man/man1/gji.1 +1 -1
  39. package/package.json +1 -1
@@ -12810,7 +12810,7 @@ function updateNotifier(options) {
12810
12810
  // package.json
12811
12811
  var package_default = {
12812
12812
  name: "@solaqua/gji",
12813
- version: "0.13.0",
12813
+ version: "0.14.0",
12814
12814
  description: "Git worktree CLI for fast context switching.",
12815
12815
  license: "MIT",
12816
12816
  author: "sjquant",
@@ -13931,16 +13931,7 @@ async function resolveCachedRemoteDefaultBranch(cwd, remote) {
13931
13931
  const prefix = `${remote}/`;
13932
13932
  return ref.startsWith(prefix) ? ref.slice(prefix.length) : void 0;
13933
13933
  } catch {
13934
- try {
13935
- const refs = await runGit(cwd, [
13936
- "for-each-ref",
13937
- "--format=%(refname:strip=3)",
13938
- `refs/remotes/${remote}`
13939
- ]);
13940
- return refs.split("\n").find((ref) => ref !== "HEAD");
13941
- } catch {
13942
- return void 0;
13943
- }
13934
+ return void 0;
13944
13935
  }
13945
13936
  }
13946
13937
  async function readBranchLastCommitTimestamp(cwd, branch) {
@@ -15294,11 +15285,12 @@ function toErrorMessage2(error) {
15294
15285
  }
15295
15286
 
15296
15287
  // src/domain/shared/concurrency.ts
15297
- async function mapWithConcurrency(items, limit, mapper) {
15288
+ async function mapWithConcurrency(items, limit, mapper, signal) {
15298
15289
  const results = new Array(items.length);
15299
15290
  let nextIndex = 0;
15300
15291
  async function readNext() {
15301
15292
  for (; ; ) {
15293
+ throwIfAborted(signal);
15302
15294
  const index = nextIndex;
15303
15295
  nextIndex += 1;
15304
15296
  if (index >= items.length) return;
@@ -15310,6 +15302,9 @@ async function mapWithConcurrency(items, limit, mapper) {
15310
15302
  );
15311
15303
  return results;
15312
15304
  }
15305
+ function throwIfAborted(signal) {
15306
+ if (signal?.aborted) throw new Error("Operation cancelled");
15307
+ }
15313
15308
 
15314
15309
  // src/infrastructure/worktree/info.ts
15315
15310
  var MAX_WORKTREE_INFO_READ_CONCURRENCY = 8;
@@ -16401,6 +16396,90 @@ function sortPullRequests2(pullRequests) {
16401
16396
  );
16402
16397
  }
16403
16398
 
16399
+ // src/application/worktree/sources.ts
16400
+ var MAX_REPOSITORY_DISCOVERY_CONCURRENCY = 4;
16401
+ async function listRegisteredWorktreeSources(options) {
16402
+ const { cwd, repositoryPort, signal } = options;
16403
+ throwIfAborted2(signal);
16404
+ const registry = await repositoryPort.loadRegistry();
16405
+ const currentRoot = await repositoryPort.detectRepository(cwd).then((repository) => repository.currentRoot).catch(() => null);
16406
+ throwIfAborted2(signal);
16407
+ const results = await mapWithConcurrency(
16408
+ registry,
16409
+ MAX_REPOSITORY_DISCOVERY_CONCURRENCY,
16410
+ async (entry) => {
16411
+ throwIfAborted2(signal);
16412
+ try {
16413
+ const worktrees = await repositoryPort.listWorktrees(entry.path);
16414
+ return { entry, skipped: false, worktrees };
16415
+ } catch {
16416
+ return { entry, skipped: true, worktrees: [] };
16417
+ }
16418
+ },
16419
+ signal
16420
+ );
16421
+ const allItems = [];
16422
+ const skipped = [];
16423
+ for (const result of results) {
16424
+ if (result.skipped) {
16425
+ skipped.push(result.entry);
16426
+ continue;
16427
+ }
16428
+ const { entry, worktrees } = result;
16429
+ for (const worktree of worktrees) {
16430
+ allItems.push({
16431
+ repoRoot: entry.path,
16432
+ repoName: entry.name,
16433
+ worktree: {
16434
+ ...worktree,
16435
+ isCurrent: currentRoot !== null && worktree.path === currentRoot
16436
+ }
16437
+ });
16438
+ }
16439
+ }
16440
+ return { skipped, sources: allItems };
16441
+ }
16442
+ async function listDiscoverableWorktreeSources(options) {
16443
+ const { cwd, repositoryPort } = options;
16444
+ const currentRepository = await repositoryPort.detectRepository(cwd).catch(() => null);
16445
+ const registered = await listRegisteredWorktreeSources(options);
16446
+ if (currentRepository === null) {
16447
+ return {
16448
+ skipped: registered.skipped,
16449
+ sources: deduplicateWorktreeSources(registered.sources)
16450
+ };
16451
+ }
16452
+ let currentSources = [];
16453
+ try {
16454
+ currentSources = (await repositoryPort.listWorktrees(cwd)).map(
16455
+ (worktree) => ({
16456
+ repoName: currentRepository.repoName,
16457
+ repoRoot: currentRepository.repoRoot,
16458
+ worktree
16459
+ })
16460
+ );
16461
+ } catch {
16462
+ }
16463
+ return {
16464
+ skipped: registered.skipped,
16465
+ sources: deduplicateWorktreeSources([
16466
+ ...currentSources,
16467
+ ...registered.sources
16468
+ ])
16469
+ };
16470
+ }
16471
+ function deduplicateWorktreeSources(sources) {
16472
+ const seen = /* @__PURE__ */ new Set();
16473
+ return sources.filter((source) => {
16474
+ if (seen.has(source.worktree.path)) return false;
16475
+ seen.add(source.worktree.path);
16476
+ return true;
16477
+ });
16478
+ }
16479
+ function throwIfAborted2(signal) {
16480
+ if (signal?.aborted) throw new Error("Operation cancelled");
16481
+ }
16482
+
16404
16483
  // src/presentation/worktree/picker.ts
16405
16484
  import { env as env4, platform as platform2, stdin, stdout } from "node:process";
16406
16485
 
@@ -16501,6 +16580,7 @@ async function promptForSingleWorktree(message, worktrees, io = {}) {
16501
16580
  message,
16502
16581
  multiple: false,
16503
16582
  output: io.output,
16583
+ catalog: io.catalog,
16504
16584
  scope: io.scope
16505
16585
  });
16506
16586
  return typeof choice === "string" ? choice : null;
@@ -16511,7 +16591,9 @@ async function promptForMultipleWorktrees(message, worktrees, io = {}) {
16511
16591
  input: io.input,
16512
16592
  message,
16513
16593
  multiple: true,
16514
- output: io.output
16594
+ output: io.output,
16595
+ catalog: io.catalog,
16596
+ scope: io.scope
16515
16597
  });
16516
16598
  return Array.isArray(choice) ? choice : null;
16517
16599
  }
@@ -16600,6 +16682,7 @@ var SearchablePrompt = class {
16600
16682
  scope;
16601
16683
  scopeTogglePending = false;
16602
16684
  promptClosed = false;
16685
+ cancellation = new AbortController();
16603
16686
  prompt;
16604
16687
  run() {
16605
16688
  return this.prompt.prompt();
@@ -16650,9 +16733,14 @@ var SearchablePrompt = class {
16650
16733
  this.scopeTogglePending = true;
16651
16734
  renderPrompt(prompt);
16652
16735
  try {
16653
- const nextScope = await currentScope.toggle();
16736
+ const nextScope = await currentScope.toggle(this.cancellation.signal);
16654
16737
  if (this.promptClosed) return;
16655
- this.entries = nextScope.entries.map(buildSearchableWorktreeEntry);
16738
+ const nextEntries = await buildWorktreePromptEntries(nextScope.sources, {
16739
+ catalog: this.options.catalog,
16740
+ metadata: nextScope.metadata
16741
+ });
16742
+ this.entries = nextEntries.map(buildSearchableWorktreeEntry);
16743
+ this.pruneHiddenSelections();
16656
16744
  this.scope = {
16657
16745
  label: nextScope.label,
16658
16746
  toggle: currentScope.toggle,
@@ -16683,6 +16771,7 @@ var SearchablePrompt = class {
16683
16771
  }
16684
16772
  cancel(prompt) {
16685
16773
  this.promptClosed = true;
16774
+ this.cancellation.abort();
16686
16775
  prompt.state = "cancel";
16687
16776
  renderPrompt(prompt);
16688
16777
  closePrompt(prompt);
@@ -16773,6 +16862,14 @@ var SearchablePrompt = class {
16773
16862
  }
16774
16863
  this.selected.add(entry.value);
16775
16864
  }
16865
+ pruneHiddenSelections() {
16866
+ const visibleValues = new Set(
16867
+ this.entries.filter(isSelectableEntry).map((entry) => entry.value)
16868
+ );
16869
+ for (const value of this.selected) {
16870
+ if (!visibleValues.has(value)) this.selected.delete(value);
16871
+ }
16872
+ }
16776
16873
  render(state, error) {
16777
16874
  const entries = this.visibleEntries();
16778
16875
  const visibleEntries = windowPromptEntries(
@@ -17790,6 +17887,15 @@ function emitUndoError(options, message) {
17790
17887
  }
17791
17888
 
17792
17889
  // src/cli/commands/clean.ts
17890
+ function toPromptSource(candidate) {
17891
+ return {
17892
+ repoName: candidate.repoName,
17893
+ repoRoot: candidate.repoRoot,
17894
+ worktree: candidate.worktree
17895
+ };
17896
+ }
17897
+ var MAX_CLEAN_REPOSITORY_CONCURRENCY = 4;
17898
+ var MAX_CLEAN_WORKTREE_CONCURRENCY = 8;
17793
17899
  function createCleanCommand(dependencies = {}) {
17794
17900
  const promptForWorktrees = dependencies.promptForWorktrees ?? defaultPromptForWorktrees;
17795
17901
  const confirmRemoval = dependencies.confirmRemoval ?? defaultConfirmRemoval;
@@ -17800,7 +17906,7 @@ function createCleanCommand(dependencies = {}) {
17800
17906
  const {
17801
17907
  readWorktreeHealth: readWorktreeHealth2,
17802
17908
  isBranchMergedInto: isBranchMergedInto2,
17803
- resolveRemoteDefaultBranch: resolveRemoteDefaultBranch2,
17909
+ resolveRemoteBase: resolveRemoteBase2,
17804
17910
  runGit: runGit2
17805
17911
  } = runtime.git;
17806
17912
  const { loadEffectiveConfig: loadEffectiveConfig2 } = runtime.config;
@@ -17830,24 +17936,98 @@ function createCleanCommand(dependencies = {}) {
17830
17936
  options.cwd,
17831
17937
  sourceDependencies
17832
17938
  );
17833
- const linkedCleanupCandidates = linkedWorktrees.filter(
17834
- (worktree) => worktree.path !== repository.currentRoot
17939
+ const currentSources = linkedWorktrees.filter((worktree) => worktree.path !== repository.currentRoot).map((worktree) => ({
17940
+ repoName: repository.repoName,
17941
+ repoRoot: repository.repoRoot,
17942
+ worktree
17943
+ }));
17944
+ const cleanupDependencies = {
17945
+ isBranchMergedInto: isBranchMergedInto2,
17946
+ loadEffectiveConfig: loadEffectiveConfig2,
17947
+ readWorktreeHealth: readWorktreeHealth2,
17948
+ resolveRemoteBase: resolveRemoteBase2,
17949
+ runGit: runGit2
17950
+ };
17951
+ const currentCandidates = await resolveCleanupCandidates(
17952
+ {
17953
+ sources: currentSources,
17954
+ stale: options.stale,
17955
+ stderr: options.stderr
17956
+ },
17957
+ cleanupDependencies
17835
17958
  );
17836
- const staleBaseRef = options.stale ? await resolveStaleBaseRef(
17837
- repository.repoRoot,
17838
- options.stderr,
17839
- loadEffectiveConfig2,
17840
- resolveRemoteDefaultBranch2,
17841
- runGit2
17842
- ) : null;
17843
- const cleanupCandidates = options.stale ? await filterStaleCleanupCandidates(
17844
- repository.repoRoot,
17845
- linkedCleanupCandidates,
17846
- staleBaseRef,
17847
- readWorktreeHealth2,
17848
- isBranchMergedInto2
17849
- ) : linkedCleanupCandidates;
17850
- if (cleanupCandidates.length === 0) {
17959
+ let activeCandidates = currentCandidates;
17960
+ let allCandidates = null;
17961
+ let currentScope = true;
17962
+ const interactiveSelection = !options.force && !options.json && !isHeadless();
17963
+ const skippedRepositories = [];
17964
+ const loadAllCandidates = async (signal) => {
17965
+ throwIfAborted3(signal);
17966
+ if (allCandidates !== null) return allCandidates;
17967
+ const registered = await listRegisteredWorktreeSources({
17968
+ cwd: options.cwd,
17969
+ repositoryPort: sourceDependencies,
17970
+ signal
17971
+ });
17972
+ skippedRepositories.push(...registered.skipped);
17973
+ if (registered.skipped.length > 0) {
17974
+ reportSkippedRepositories(skippedRepositories, options.stderr);
17975
+ }
17976
+ const sources = deduplicateWorktreeSources([
17977
+ ...currentSources,
17978
+ ...registered.sources
17979
+ ]).filter(
17980
+ (source) => source.repoRoot !== void 0 && source.worktree.path !== source.repoRoot && source.worktree.path !== repository.currentRoot
17981
+ );
17982
+ const otherRepositorySources = sources.filter(
17983
+ (source) => source.repoRoot !== repository.repoRoot
17984
+ );
17985
+ const otherRepositoryCandidates = await resolveCleanupCandidates(
17986
+ {
17987
+ sources: otherRepositorySources,
17988
+ stale: options.stale,
17989
+ stderr: options.stderr,
17990
+ signal
17991
+ },
17992
+ cleanupDependencies
17993
+ );
17994
+ allCandidates = [...currentCandidates, ...otherRepositoryCandidates];
17995
+ throwIfAborted3(signal);
17996
+ return allCandidates;
17997
+ };
17998
+ const scope = {
17999
+ label: "current repository",
18000
+ toggleLabel: "all repositories",
18001
+ toggle: async (signal) => {
18002
+ const nextCurrentScope = !currentScope;
18003
+ const nextCandidates = nextCurrentScope ? currentCandidates : await loadAllCandidates(signal);
18004
+ throwIfAborted3(signal);
18005
+ currentScope = nextCurrentScope;
18006
+ activeCandidates = nextCandidates;
18007
+ return {
18008
+ sources: nextCandidates.map(toPromptSource),
18009
+ metadata: nextCurrentScope ? "full" : "fast",
18010
+ label: nextCurrentScope ? "current repository" : "all repositories",
18011
+ toggleLabel: nextCurrentScope ? "all repositories" : "current repository"
18012
+ };
18013
+ }
18014
+ };
18015
+ if (currentCandidates.length === 0 && interactiveSelection) {
18016
+ const loadedAllCandidates = await loadAllCandidates();
18017
+ if (loadedAllCandidates.length === 0) {
18018
+ if (options.stale) {
18019
+ emitNoStaleCandidates(options);
18020
+ return 0;
18021
+ }
18022
+ emitError(options, "No linked worktrees to clean");
18023
+ return 1;
18024
+ }
18025
+ currentScope = false;
18026
+ activeCandidates = loadedAllCandidates;
18027
+ scope.label = "all repositories";
18028
+ scope.toggleLabel = "current repository";
18029
+ }
18030
+ if (currentCandidates.length === 0 && !interactiveSelection) {
17851
18031
  if (options.stale) {
17852
18032
  emitNoStaleCandidates(options);
17853
18033
  return 0;
@@ -17868,27 +18048,29 @@ function createCleanCommand(dependencies = {}) {
17868
18048
  return 1;
17869
18049
  }
17870
18050
  const shouldSelectAll = options.force || options.dryRun && (options.stale || options.json || isHeadless());
17871
- const selections = shouldSelectAll ? cleanupCandidates.map((w2) => w2.path) : await promptForWorktrees(
17872
- await buildWorktreePromptEntries(
17873
- cleanupCandidates.map((worktree) => ({
17874
- repoName: repository.repoName,
17875
- worktree
17876
- })),
17877
- { catalog: runtime.worktreeCatalog }
17878
- )
18051
+ const selections = shouldSelectAll ? activeCandidates.map(({ worktree }) => worktree.path) : await promptForWorktrees(
18052
+ await buildWorktreePromptEntries(activeCandidates, {
18053
+ metadata: currentScope ? "full" : "fast",
18054
+ catalog: runtime.worktreeCatalog
18055
+ }),
18056
+ scope,
18057
+ runtime.worktreeCatalog
17879
18058
  );
17880
18059
  if (!selections || selections.length === 0) {
17881
18060
  options.stderr("Aborted\n");
17882
18061
  return 1;
17883
18062
  }
17884
- const selectedWorktrees = resolveSelectedWorktrees(
17885
- cleanupCandidates,
18063
+ const selectedCandidates = resolveSelectedCandidates(
18064
+ activeCandidates,
17886
18065
  selections
17887
18066
  );
17888
- if (selectedWorktrees.length !== selections.length) {
18067
+ if (selectedCandidates.length !== selections.length) {
17889
18068
  options.stderr("Selected worktree no longer exists\n");
17890
18069
  return 1;
17891
18070
  }
18071
+ const selectedWorktrees = selectedCandidates.map(
18072
+ ({ worktree }) => worktree
18073
+ );
17892
18074
  const selectedWorktreeInfos = await readWorktreeInfos2(selectedWorktrees);
17893
18075
  const selectedInfoByPath = new Map(
17894
18076
  selectedWorktreeInfos.map((info) => [info.path, info])
@@ -17916,38 +18098,48 @@ function createCleanCommand(dependencies = {}) {
17916
18098
  }
17917
18099
  return 0;
17918
18100
  }
17919
- const removedWorktrees = [];
18101
+ const candidatesByRepository = groupCandidatesByRepository(selectedCandidates);
18102
+ const journals = /* @__PURE__ */ new Map();
17920
18103
  const failures = [];
17921
- let journal;
17922
18104
  try {
17923
- journal = await recordUndoOperation(
17924
- "clean",
17925
- repository.repoRoot,
17926
- selectedWorktrees,
17927
- void 0,
17928
- runtime
17929
- );
18105
+ for (const [repoRoot, candidates] of candidatesByRepository) {
18106
+ const journal = await recordUndoOperation(
18107
+ "clean",
18108
+ repoRoot,
18109
+ candidates.map(({ worktree }) => worktree),
18110
+ void 0,
18111
+ runtime
18112
+ );
18113
+ if (!journal) {
18114
+ await discardUndoRecords(
18115
+ journals.values(),
18116
+ runtime.configStore.GLOBAL_CONFIG_DIRECTORY
18117
+ );
18118
+ emitError(
18119
+ options,
18120
+ "could not capture undo state; no worktrees were removed"
18121
+ );
18122
+ return 1;
18123
+ }
18124
+ journals.set(repoRoot, journal);
18125
+ }
17930
18126
  } catch (error) {
17931
- emitError(
17932
- options,
17933
- `could not write undo journal; no worktrees were removed: ${toMessage2(error)}`
18127
+ await discardUndoRecords(
18128
+ journals.values(),
18129
+ runtime.configStore.GLOBAL_CONFIG_DIRECTORY
17934
18130
  );
17935
- return 1;
17936
- }
17937
- if (!journal) {
17938
18131
  emitError(
17939
18132
  options,
17940
- "could not capture undo state; no worktrees were removed"
18133
+ `could not write undo journal; no worktrees were removed: ${toMessage2(error)}`
17941
18134
  );
17942
18135
  return 1;
17943
18136
  }
17944
- for (const worktree of selectedWorktrees) {
18137
+ const removedCandidates = [];
18138
+ for (const candidate of selectedCandidates) {
18139
+ const { repoRoot, staleBaseRef, worktree } = candidate;
17945
18140
  if (options.stale && !await isStaleCleanupCandidate(
17946
- repository.repoRoot,
17947
- worktree,
17948
- staleBaseRef,
17949
- readWorktreeHealth2,
17950
- isBranchMergedInto2
18141
+ { repoRoot, worktree, baseBranch: staleBaseRef },
18142
+ cleanupDependencies
17951
18143
  )) {
17952
18144
  options.stderr(
17953
18145
  `Skipped ${worktree.path}: no longer a safe stale cleanup candidate
@@ -17956,7 +18148,7 @@ function createCleanCommand(dependencies = {}) {
17956
18148
  continue;
17957
18149
  }
17958
18150
  try {
17959
- await removeWorktree2(repository.repoRoot, worktree.path);
18151
+ await removeWorktree2(repoRoot, worktree.path);
17960
18152
  } catch (error) {
17961
18153
  if (!isWorktreeForceRemovalError2(error)) {
17962
18154
  failures.push({
@@ -17982,7 +18174,7 @@ function createCleanCommand(dependencies = {}) {
17982
18174
  continue;
17983
18175
  }
17984
18176
  try {
17985
- await forceRemoveWorktree2(repository.repoRoot, worktree.path);
18177
+ await forceRemoveWorktree2(repoRoot, worktree.path);
17986
18178
  } catch (forceError) {
17987
18179
  failures.push({
17988
18180
  branch: worktree.branch,
@@ -17992,10 +18184,10 @@ function createCleanCommand(dependencies = {}) {
17992
18184
  continue;
17993
18185
  }
17994
18186
  }
17995
- removedWorktrees.push(worktree);
18187
+ removedCandidates.push(candidate);
17996
18188
  if (worktree.branch) {
17997
18189
  try {
17998
- await deleteBranch2(repository.repoRoot, worktree.branch);
18190
+ await deleteBranch2(repoRoot, worktree.branch);
17999
18191
  } catch (error) {
18000
18192
  if (!isBranchUnmergedError2(error)) {
18001
18193
  failures.push({
@@ -18007,7 +18199,7 @@ function createCleanCommand(dependencies = {}) {
18007
18199
  }
18008
18200
  if (options.force || await confirmForceDeleteBranch(worktree.branch)) {
18009
18201
  try {
18010
- await forceDeleteBranch2(repository.repoRoot, worktree.branch);
18202
+ await forceDeleteBranch2(repoRoot, worktree.branch);
18011
18203
  } catch (forceError) {
18012
18204
  options.stderr(
18013
18205
  `Failed to delete branch ${worktree.branch}: ${toMessage2(forceError)}
@@ -18023,83 +18215,180 @@ function createCleanCommand(dependencies = {}) {
18023
18215
  }
18024
18216
  }
18025
18217
  }
18026
- await finalizeUndoOperation(
18027
- journal,
18028
- removedWorktrees,
18029
- void 0,
18030
- runtime.configStore.GLOBAL_CONFIG_DIRECTORY
18218
+ for (const [repoRoot, journal] of journals) {
18219
+ await finalizeUndoOperation(
18220
+ journal,
18221
+ removedCandidates.filter((candidate) => candidate.repoRoot === repoRoot).map(({ worktree }) => worktree),
18222
+ void 0,
18223
+ runtime.configStore.GLOBAL_CONFIG_DIRECTORY
18224
+ );
18225
+ }
18226
+ const undoRecords = remainingUndoRecords(
18227
+ journals.values(),
18228
+ removedCandidates
18031
18229
  );
18032
18230
  await Promise.all(
18033
- removedWorktrees.map((worktree) => releaseWorktreeSlot2(worktree.path))
18231
+ removedCandidates.map(
18232
+ ({ worktree }) => releaseWorktreeSlot2(worktree.path)
18233
+ )
18034
18234
  );
18035
18235
  if (options.json) {
18036
- const removed = removedWorktrees.map((worktree) => {
18236
+ const removed = removedCandidates.map(({ worktree }) => {
18037
18237
  const info = selectedInfoByPath.get(worktree.path);
18038
18238
  return info === void 0 ? { branch: worktree.branch, path: worktree.path } : serializeWorktreeInfo2(info);
18039
18239
  });
18040
- const payload = failures.length === 0 ? { removed } : { removed, failed: failures };
18240
+ const payload = {
18241
+ removed,
18242
+ ...failures.length === 0 ? {} : { failed: failures },
18243
+ ...needsExplicitUndoIds(undoRecords, repository.repoRoot) ? {
18244
+ undo: undoRecords.map(({ id, repoRoot }) => ({ id, repoRoot }))
18245
+ } : {}
18246
+ };
18041
18247
  options.stdout(`${JSON.stringify(payload, null, 2)}
18042
18248
  `);
18043
18249
  } else if (failures.length > 0) {
18044
18250
  reportCleanFailures(failures, options.stderr);
18251
+ emitUndoHints(undoRecords, repository.repoRoot, options.stderr);
18045
18252
  } else {
18046
- options.stderr("undo: gji undo\n");
18047
- options.stdout(`${repository.repoRoot}
18048
- `);
18253
+ emitUndoHints(undoRecords, repository.repoRoot, options.stderr);
18254
+ options.stdout(
18255
+ `${[...new Set(selectedCandidates.map(({ repoRoot }) => repoRoot))].join("\n")}
18256
+ `
18257
+ );
18049
18258
  }
18050
18259
  return failures.length === 0 ? 0 : 1;
18051
18260
  };
18052
18261
  }
18053
18262
  var runCleanCommand = createCleanCommand();
18054
- async function filterStaleCleanupCandidates(repoRoot, worktrees, baseBranch, readWorktreeHealth2, isBranchMergedInto2) {
18055
- if (baseBranch === null) {
18056
- return [];
18263
+ async function resolveCleanupCandidates(options, dependencies) {
18264
+ const { sources, stale, stderr, signal } = options;
18265
+ const grouped = /* @__PURE__ */ new Map();
18266
+ for (const source of sources) {
18267
+ if (source.repoRoot === void 0) continue;
18268
+ const existing = grouped.get(source.repoRoot);
18269
+ if (existing === void 0) {
18270
+ grouped.set(source.repoRoot, {
18271
+ repoName: source.repoName,
18272
+ worktrees: [source.worktree]
18273
+ });
18274
+ } else {
18275
+ existing.worktrees.push(source.worktree);
18276
+ }
18057
18277
  }
18058
- const results = await Promise.all(
18059
- worktrees.map(
18060
- (worktree) => isStaleCleanupCandidate(
18278
+ const results = await mapWithConcurrency(
18279
+ [...grouped.entries()],
18280
+ MAX_CLEAN_REPOSITORY_CONCURRENCY,
18281
+ async ([repoRoot, group]) => {
18282
+ throwIfAborted3(signal);
18283
+ const staleBaseRef = stale ? await resolveStaleBaseRef({ repoRoot, stderr }, dependencies) : null;
18284
+ const worktrees = stale ? await filterStaleCleanupCandidates(
18285
+ {
18286
+ repoRoot,
18287
+ worktrees: group.worktrees,
18288
+ baseBranch: staleBaseRef,
18289
+ signal
18290
+ },
18291
+ dependencies
18292
+ ) : group.worktrees;
18293
+ return worktrees.map((worktree) => ({
18294
+ repoName: group.repoName,
18061
18295
  repoRoot,
18062
- worktree,
18063
- baseBranch,
18064
- readWorktreeHealth2,
18065
- isBranchMergedInto2
18066
- )
18296
+ staleBaseRef,
18297
+ worktree
18298
+ }));
18299
+ },
18300
+ signal
18301
+ );
18302
+ return results.flat();
18303
+ }
18304
+ function resolveSelectedCandidates(candidates, selections) {
18305
+ const selected = [];
18306
+ const seenPaths = /* @__PURE__ */ new Set();
18307
+ for (const selection of selections) {
18308
+ const candidate = candidates.find(
18309
+ (entry) => entry.worktree.path === selection || entry.worktree.branch === selection
18310
+ );
18311
+ if (candidate === void 0 || seenPaths.has(candidate.worktree.path)) {
18312
+ continue;
18313
+ }
18314
+ selected.push(candidate);
18315
+ seenPaths.add(candidate.worktree.path);
18316
+ }
18317
+ return selected;
18318
+ }
18319
+ function groupCandidatesByRepository(candidates) {
18320
+ const grouped = /* @__PURE__ */ new Map();
18321
+ for (const candidate of candidates) {
18322
+ const existing = grouped.get(candidate.repoRoot);
18323
+ if (existing === void 0) grouped.set(candidate.repoRoot, [candidate]);
18324
+ else existing.push(candidate);
18325
+ }
18326
+ return grouped;
18327
+ }
18328
+ function remainingUndoRecords(records, removedCandidates) {
18329
+ return [...records].filter(
18330
+ (record) => removedCandidates.some(
18331
+ (candidate) => candidate.repoRoot === record.repoRoot && record.entries.some((entry) => entry.path === candidate.worktree.path)
18332
+ )
18333
+ );
18334
+ }
18335
+ async function discardUndoRecords(records, globalConfigDirectory) {
18336
+ await Promise.all(
18337
+ [...records].map(
18338
+ (record) => finalizeUndoOperation(record, [], void 0, globalConfigDirectory)
18067
18339
  )
18068
18340
  );
18341
+ }
18342
+ async function filterStaleCleanupCandidates(options, dependencies) {
18343
+ const { repoRoot, worktrees, baseBranch, signal } = options;
18344
+ throwIfAborted3(signal);
18345
+ if (baseBranch === null) {
18346
+ return [];
18347
+ }
18348
+ const results = await mapWithConcurrency(
18349
+ worktrees,
18350
+ MAX_CLEAN_WORKTREE_CONCURRENCY,
18351
+ (worktree) => isStaleCleanupCandidate({ repoRoot, worktree, baseBranch }, dependencies),
18352
+ signal
18353
+ );
18354
+ throwIfAborted3(signal);
18069
18355
  return worktrees.filter((_3, index) => results[index]);
18070
18356
  }
18071
- async function resolveStaleBaseRef(repoRoot, stderr, loadEffectiveConfig2, resolveRemoteDefaultBranch2, runGit2) {
18072
- const config = await loadEffectiveConfig2(repoRoot, void 0, stderr);
18357
+ async function resolveStaleBaseRef(options, dependencies) {
18358
+ const { repoRoot, stderr } = options;
18359
+ const config = await dependencies.loadEffectiveConfig(
18360
+ repoRoot,
18361
+ void 0,
18362
+ stderr
18363
+ );
18073
18364
  const remote = resolveConfiguredString(config.syncRemote) ?? "origin";
18074
18365
  const configuredDefaultBranch = resolveConfiguredString(
18075
18366
  config.syncDefaultBranch
18076
18367
  );
18077
- if (configuredDefaultBranch) {
18078
- return await resolveFetchedRemoteRef(
18079
- repoRoot,
18080
- remote,
18081
- configuredDefaultBranch,
18082
- runGit2
18083
- );
18084
- }
18085
18368
  try {
18086
- const remoteDefaultBranch = await resolveRemoteDefaultBranch2(
18087
- repoRoot,
18088
- remote
18089
- );
18090
- return remoteDefaultBranch === null ? null : await resolveFetchedRemoteRef(
18369
+ const remoteBase = await dependencies.resolveRemoteBase(
18091
18370
  repoRoot,
18092
18371
  remote,
18093
- remoteDefaultBranch,
18094
- runGit2
18372
+ configuredDefaultBranch ?? void 0
18373
+ );
18374
+ return remoteBase === null ? null : resolveFetchedRemoteRef(
18375
+ { branch: remoteBase.branch, remote, repoRoot },
18376
+ dependencies.runGit
18095
18377
  );
18096
18378
  } catch {
18097
18379
  return null;
18098
18380
  }
18099
18381
  }
18100
- async function resolveFetchedRemoteRef(repoRoot, remote, branch, runGit2) {
18382
+ async function resolveFetchedRemoteRef(options, runGit2) {
18383
+ const { repoRoot, remote, branch } = options;
18101
18384
  try {
18102
18385
  await runGit2(repoRoot, ["fetch", "--prune", remote]);
18386
+ await runGit2(repoRoot, [
18387
+ "rev-parse",
18388
+ "--verify",
18389
+ "--quiet",
18390
+ `${remote}/${branch}`
18391
+ ]);
18103
18392
  return `${remote}/${branch}`;
18104
18393
  } catch {
18105
18394
  return null;
@@ -18108,33 +18397,39 @@ async function resolveFetchedRemoteRef(repoRoot, remote, branch, runGit2) {
18108
18397
  function resolveConfiguredString(value) {
18109
18398
  return typeof value === "string" && value.length > 0 ? value : null;
18110
18399
  }
18111
- async function isStaleCleanupCandidate(repoRoot, worktree, baseBranch, readWorktreeHealth2, isBranchMergedInto2) {
18400
+ async function isStaleCleanupCandidate(options, dependencies) {
18401
+ const { repoRoot, worktree, baseBranch } = options;
18112
18402
  if (baseBranch === null) {
18113
18403
  return false;
18114
18404
  }
18115
18405
  if (worktree.branch === null) {
18116
18406
  return false;
18117
18407
  }
18118
- const health = await readWorktreeHealth2(worktree.path);
18408
+ const health = await dependencies.readWorktreeHealth(worktree.path);
18119
18409
  if (health.status !== "clean" || !health.upstreamGone) {
18120
18410
  return false;
18121
18411
  }
18122
- return isBranchMergedInto2(repoRoot, worktree.branch, baseBranch);
18412
+ if (await dependencies.isBranchMergedInto(repoRoot, worktree.branch, baseBranch)) {
18413
+ return true;
18414
+ }
18415
+ return isBranchPatchEquivalentInto(
18416
+ { baseBranch, repoRoot, worktreeBranch: worktree.branch },
18417
+ dependencies.runGit
18418
+ );
18123
18419
  }
18124
- function resolveSelectedWorktrees(worktrees, selections) {
18125
- const selectedWorktrees = [];
18126
- const seenPaths = /* @__PURE__ */ new Set();
18127
- for (const selection of selections) {
18128
- const worktree = worktrees.find(
18129
- (entry) => entry.path === selection || entry.branch === selection
18130
- );
18131
- if (!worktree || seenPaths.has(worktree.path)) {
18132
- continue;
18133
- }
18134
- selectedWorktrees.push(worktree);
18135
- seenPaths.add(worktree.path);
18420
+ async function isBranchPatchEquivalentInto(options, runGit2) {
18421
+ const { repoRoot, worktreeBranch, baseBranch } = options;
18422
+ try {
18423
+ const output = await runGit2(repoRoot, [
18424
+ "cherry",
18425
+ baseBranch,
18426
+ worktreeBranch
18427
+ ]);
18428
+ const lines = output.split("\n").filter(Boolean);
18429
+ return lines.length > 0 && lines.every((line) => line.startsWith("-"));
18430
+ } catch {
18431
+ return false;
18136
18432
  }
18137
- return selectedWorktrees;
18138
18433
  }
18139
18434
  function reportCleanFailures(failures, stderr) {
18140
18435
  const noun = failures.length === 1 ? "worktree" : "worktrees";
@@ -18146,6 +18441,37 @@ function reportCleanFailures(failures, stderr) {
18146
18441
  `);
18147
18442
  }
18148
18443
  }
18444
+ function emitUndoHints(records, currentRepositoryRoot, stderr) {
18445
+ if (records.length === 0) return;
18446
+ if (!needsExplicitUndoIds(records, currentRepositoryRoot)) {
18447
+ stderr("undo: gji undo\n");
18448
+ return;
18449
+ }
18450
+ const message = records.length === 1 ? "undo: restore with:" : "undo: restore each repository with:";
18451
+ stderr(`${message}
18452
+ `);
18453
+ for (const record of records) {
18454
+ stderr(` gji undo --id ${record.id} (${record.repoRoot})
18455
+ `);
18456
+ }
18457
+ }
18458
+ function needsExplicitUndoIds(records, currentRepositoryRoot) {
18459
+ return records.length > 1 || records.some((record) => record.repoRoot !== currentRepositoryRoot);
18460
+ }
18461
+ function reportSkippedRepositories(repositories, stderr) {
18462
+ const noun = repositories.length === 1 ? "repository" : "repositories";
18463
+ stderr(
18464
+ `Skipped ${repositories.length} registered ${noun} because worktrees could not be listed:
18465
+ `
18466
+ );
18467
+ for (const repository of repositories) {
18468
+ stderr(`- ${repository.name} (${repository.path})
18469
+ `);
18470
+ }
18471
+ }
18472
+ function throwIfAborted3(signal) {
18473
+ if (signal?.aborted) throw new Error("Operation cancelled");
18474
+ }
18149
18475
  function formatCleanInfo(info, formatLastCommit3, formatUpstreamState3) {
18150
18476
  const branch = info.branch === null ? "detached" : `branch: ${info.branch}`;
18151
18477
  const status = `status: ${info.status}`;
@@ -18174,8 +18500,11 @@ function emitNoStaleCandidates(options) {
18174
18500
  function toMessage2(error) {
18175
18501
  return error instanceof Error ? error.message : String(error);
18176
18502
  }
18177
- async function defaultPromptForWorktrees(worktrees) {
18178
- return promptForMultipleWorktrees("Choose worktrees to clean", worktrees);
18503
+ async function defaultPromptForWorktrees(worktrees, scope, catalog) {
18504
+ return promptForMultipleWorktrees("Choose worktrees to clean", worktrees, {
18505
+ catalog,
18506
+ scope
18507
+ });
18179
18508
  }
18180
18509
  async function defaultConfirmRemoval(worktrees) {
18181
18510
  const branchCount = worktrees.filter(
@@ -19984,71 +20313,6 @@ function parsePrInput(input) {
19984
20313
  return urlMatch?.[1] ?? null;
19985
20314
  }
19986
20315
 
19987
- // src/application/worktree/sources.ts
19988
- var MAX_REPOSITORY_DISCOVERY_CONCURRENCY = 4;
19989
- async function listRegisteredWorktreeSources(cwd, repositoryPort, onSkipped) {
19990
- const registry = await repositoryPort.loadRegistry();
19991
- const currentRoot = await repositoryPort.detectRepository(cwd).then((repository) => repository.currentRoot).catch(() => null);
19992
- const results = await mapWithConcurrency(
19993
- registry,
19994
- MAX_REPOSITORY_DISCOVERY_CONCURRENCY,
19995
- async (entry) => {
19996
- try {
19997
- const worktrees = await repositoryPort.listWorktrees(entry.path);
19998
- return { entry, worktrees };
19999
- } catch {
20000
- onSkipped?.(entry);
20001
- return null;
20002
- }
20003
- }
20004
- );
20005
- const allItems = [];
20006
- for (const result of results) {
20007
- if (result === null) continue;
20008
- const { entry, worktrees } = result;
20009
- for (const worktree of worktrees) {
20010
- allItems.push({
20011
- repoRoot: entry.path,
20012
- repoName: entry.name,
20013
- worktree: {
20014
- ...worktree,
20015
- isCurrent: currentRoot !== null && worktree.path === currentRoot
20016
- }
20017
- });
20018
- }
20019
- }
20020
- return allItems;
20021
- }
20022
- async function listDiscoverableWorktreeSources(cwd, repositoryPort, onSkipped) {
20023
- const currentRepository = await repositoryPort.detectRepository(cwd).catch(() => null);
20024
- const registeredSources = await listRegisteredWorktreeSources(
20025
- cwd,
20026
- repositoryPort,
20027
- onSkipped
20028
- );
20029
- if (currentRepository === null) return dedupeSources(registeredSources);
20030
- let currentSources = [];
20031
- try {
20032
- currentSources = (await repositoryPort.listWorktrees(cwd)).map(
20033
- (worktree) => ({
20034
- repoName: currentRepository.repoName,
20035
- repoRoot: currentRepository.repoRoot,
20036
- worktree
20037
- })
20038
- );
20039
- } catch {
20040
- }
20041
- return dedupeSources([...currentSources, ...registeredSources]);
20042
- }
20043
- function dedupeSources(sources) {
20044
- const seen = /* @__PURE__ */ new Set();
20045
- return sources.filter((source) => {
20046
- if (seen.has(source.worktree.path)) return false;
20047
- seen.add(source.worktree.path);
20048
- return true;
20049
- });
20050
- }
20051
-
20052
20316
  // src/application/worktree/go-resolution.ts
20053
20317
  async function resolveGoBranch(options) {
20054
20318
  const {
@@ -20115,14 +20379,12 @@ async function resolveGoBranch(options) {
20115
20379
  return finish({ kind: "existing", source: pullRequestMatches[0] }, false);
20116
20380
  }
20117
20381
  }
20118
- let skippedRegisteredRepos = 0;
20119
- const registeredSources = await listRegisteredWorktreeSources(
20382
+ const registered = await listRegisteredWorktreeSources({
20120
20383
  cwd,
20121
- sourceDependencies,
20122
- () => {
20123
- skippedRegisteredRepos++;
20124
- }
20125
- );
20384
+ repositoryPort: sourceDependencies
20385
+ });
20386
+ const registeredSources = registered.sources;
20387
+ const skippedRegisteredRepos = registered.skipped.length;
20126
20388
  const crossRepoSources = registeredSources.filter(
20127
20389
  (source) => source.repoRoot !== repository?.repoRoot
20128
20390
  );
@@ -21705,17 +21967,16 @@ function createGoCommand(dependencies = {}) {
21705
21967
  let promptSources = currentSources;
21706
21968
  const loadAllSources = async () => {
21707
21969
  if (registeredSources === null) {
21708
- registeredSources = await listRegisteredWorktreeSources(
21709
- options.cwd,
21710
- {
21970
+ const registered = await listRegisteredWorktreeSources({
21971
+ cwd: options.cwd,
21972
+ repositoryPort: {
21711
21973
  ...runtime.repositoryContext,
21712
21974
  ...runtime.repositoryRegistry,
21713
21975
  ...runtime.worktrees
21714
- },
21715
- () => {
21716
- skippedRegisteredRepos++;
21717
21976
  }
21718
- );
21977
+ });
21978
+ registeredSources = registered.sources;
21979
+ skippedRegisteredRepos = registered.skipped.length;
21719
21980
  }
21720
21981
  return deduplicateSources([...currentSources, ...registeredSources]);
21721
21982
  };
@@ -21737,13 +21998,8 @@ function createGoCommand(dependencies = {}) {
21737
21998
  currentRepositoryScope = nextCurrentRepositoryScope;
21738
21999
  promptSources = nextSources;
21739
22000
  return {
21740
- entries: await buildWorktreePromptEntries(promptSources, {
21741
- metadata: currentRepositoryScope ? "full" : "fast",
21742
- catalog: withPullRequestQueries(
21743
- runtime,
21744
- dependencies.queryPullRequests
21745
- )
21746
- }),
22001
+ sources: promptSources,
22002
+ metadata: currentRepositoryScope ? "full" : "fast",
21747
22003
  label: currentRepositoryScope ? "current repository" : "all repositories",
21748
22004
  toggleLabel: currentRepositoryScope ? "all repositories" : "current repository"
21749
22005
  };
@@ -21757,7 +22013,11 @@ function createGoCommand(dependencies = {}) {
21757
22013
  dependencies.queryPullRequests
21758
22014
  )
21759
22015
  });
21760
- const selectedPath = await prompt(promptEntries, scope);
22016
+ const selectedPath = await prompt(
22017
+ promptEntries,
22018
+ scope,
22019
+ withPullRequestQueries(runtime, dependencies.queryPullRequests)
22020
+ );
21761
22021
  if (!selectedPath) {
21762
22022
  options.stderr("Aborted\n");
21763
22023
  return 1;
@@ -22055,8 +22315,11 @@ async function defaultConfirmBranchCreation(branch) {
22055
22315
  });
22056
22316
  return !pD(choice) && choice;
22057
22317
  }
22058
- async function promptForWorktree(worktrees, scope) {
22059
- return promptForSingleWorktree("Choose a worktree", worktrees, { scope });
22318
+ async function promptForWorktree(worktrees, scope, catalog) {
22319
+ return promptForSingleWorktree("Choose a worktree", worktrees, {
22320
+ catalog,
22321
+ scope
22322
+ });
22060
22323
  }
22061
22324
 
22062
22325
  // src/cli/commands/history-command.ts
@@ -22089,10 +22352,10 @@ async function buildHubData(cwd, dependencies = {}, runtime = defaultCliDependen
22089
22352
  };
22090
22353
  const queryRepositoryPullRequests = dependencies.queryRepositoryPullRequests ?? runtime.pullRequests.listOpenPullRequestsForRepository;
22091
22354
  const currentRepository = await detectRepository2(cwd).catch(() => null);
22092
- const sources = await listDiscoverableWorktreeSources(
22355
+ const { sources } = await listDiscoverableWorktreeSources({
22093
22356
  cwd,
22094
- sourceDependencies
22095
- );
22357
+ repositoryPort: sourceDependencies
22358
+ });
22096
22359
  const groups = /* @__PURE__ */ new Map();
22097
22360
  for (const source of sources) {
22098
22361
  if (!source.repoRoot) continue;
@@ -24446,10 +24709,14 @@ async function resolveWarpTarget(options) {
24446
24709
  );
24447
24710
  return null;
24448
24711
  }
24449
- let skippedRegisteredRepos = 0;
24450
- const allItems = (await listRegisteredWorktreeSources(options.cwd, sourceDependencies, () => {
24451
- skippedRegisteredRepos++;
24452
- })).filter((item) => item.repoRoot !== options.excludeRepoRoot);
24712
+ const registered = await listRegisteredWorktreeSources({
24713
+ cwd: options.cwd,
24714
+ repositoryPort: sourceDependencies
24715
+ });
24716
+ const skippedRegisteredRepos = registered.skipped.length;
24717
+ const allItems = registered.sources.filter(
24718
+ (item) => item.repoRoot !== options.excludeRepoRoot
24719
+ );
24453
24720
  if (allItems.length === 0) {
24454
24721
  emitError5(
24455
24722
  "no accessible worktrees found in any registered repo.",