@gitkraken/core-gitlens 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/dist/git/cache.d.ts +12 -6
  3. package/dist/git/cache.d.ts.map +1 -1
  4. package/dist/git/cache.js +40 -39
  5. package/dist/git/cache.js.map +1 -1
  6. package/dist/git/context.d.ts +19 -0
  7. package/dist/git/context.d.ts.map +1 -1
  8. package/dist/git/context.js.map +1 -1
  9. package/dist/git/features.d.ts +1 -1
  10. package/dist/git/features.d.ts.map +1 -1
  11. package/dist/git/features.js +1 -0
  12. package/dist/git/features.js.map +1 -1
  13. package/dist/git/models/graph.d.ts +96 -0
  14. package/dist/git/models/graph.d.ts.map +1 -1
  15. package/dist/git/models/graph.js +24 -1
  16. package/dist/git/models/graph.js.map +1 -1
  17. package/dist/git/models/remoteProvider.d.ts +1 -0
  18. package/dist/git/models/remoteProvider.d.ts.map +1 -1
  19. package/dist/git/models/remoteProvider.js.map +1 -1
  20. package/dist/git/models/repository.d.ts +21 -12
  21. package/dist/git/models/repository.d.ts.map +1 -1
  22. package/dist/git/models/repository.js +70 -84
  23. package/dist/git/models/repository.js.map +1 -1
  24. package/dist/git/models/statusFile.d.ts +9 -0
  25. package/dist/git/models/statusFile.d.ts.map +1 -1
  26. package/dist/git/models/statusFile.js +11 -0
  27. package/dist/git/models/statusFile.js.map +1 -1
  28. package/dist/git/providers/commits.d.ts +4 -0
  29. package/dist/git/providers/commits.d.ts.map +1 -1
  30. package/dist/git/providers/config.d.ts +1 -3
  31. package/dist/git/providers/config.d.ts.map +1 -1
  32. package/dist/git/providers/operations.d.ts +39 -13
  33. package/dist/git/providers/operations.d.ts.map +1 -1
  34. package/dist/git/remotes/custom.d.ts +2 -0
  35. package/dist/git/remotes/custom.d.ts.map +1 -1
  36. package/dist/git/remotes/custom.js +20 -0
  37. package/dist/git/remotes/custom.js.map +1 -1
  38. package/dist/git/utils/contributor.utils.d.ts +3 -0
  39. package/dist/git/utils/contributor.utils.d.ts.map +1 -1
  40. package/dist/git/utils/contributor.utils.js +22 -0
  41. package/dist/git/utils/contributor.utils.js.map +1 -1
  42. package/dist/git/utils/reachability.utils.d.ts +37 -0
  43. package/dist/git/utils/reachability.utils.d.ts.map +1 -0
  44. package/dist/git/utils/reachability.utils.js +96 -0
  45. package/dist/git/utils/reachability.utils.js.map +1 -0
  46. package/dist/git/watching/watchService.d.ts +4 -2
  47. package/dist/git/watching/watchService.d.ts.map +1 -1
  48. package/dist/git/watching/watchService.js +8 -3
  49. package/dist/git/watching/watchService.js.map +1 -1
  50. package/dist/git/watching/watchSession.d.ts +14 -12
  51. package/dist/git/watching/watchSession.d.ts.map +1 -1
  52. package/dist/git/watching/watchSession.js +56 -55
  53. package/dist/git/watching/watchSession.js.map +1 -1
  54. package/dist/git-cli/providers/branches.d.ts.map +1 -1
  55. package/dist/git-cli/providers/branches.js +1 -0
  56. package/dist/git-cli/providers/branches.js.map +1 -1
  57. package/dist/git-cli/providers/commits.d.ts +1 -0
  58. package/dist/git-cli/providers/commits.d.ts.map +1 -1
  59. package/dist/git-cli/providers/commits.js +12 -0
  60. package/dist/git-cli/providers/commits.js.map +1 -1
  61. package/dist/git-cli/providers/config.d.ts +8 -4
  62. package/dist/git-cli/providers/config.d.ts.map +1 -1
  63. package/dist/git-cli/providers/config.js +62 -39
  64. package/dist/git-cli/providers/config.js.map +1 -1
  65. package/dist/git-cli/providers/diff.d.ts.map +1 -1
  66. package/dist/git-cli/providers/diff.js +1 -1
  67. package/dist/git-cli/providers/diff.js.map +1 -1
  68. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  69. package/dist/git-cli/providers/graph.js +132 -14
  70. package/dist/git-cli/providers/graph.js.map +1 -1
  71. package/dist/git-cli/providers/operations.d.ts +6 -5
  72. package/dist/git-cli/providers/operations.d.ts.map +1 -1
  73. package/dist/git-cli/providers/operations.js +97 -95
  74. package/dist/git-cli/providers/operations.js.map +1 -1
  75. package/dist/git-cli/providers/stash.d.ts.map +1 -1
  76. package/dist/git-cli/providers/stash.js +20 -2
  77. package/dist/git-cli/providers/stash.js.map +1 -1
  78. package/dist/git-cli/providers/status.d.ts.map +1 -1
  79. package/dist/git-cli/providers/status.js +5 -2
  80. package/dist/git-cli/providers/status.js.map +1 -1
  81. package/dist/ipc/discovery.d.ts +22 -0
  82. package/dist/ipc/discovery.d.ts.map +1 -1
  83. package/dist/ipc/discovery.js +111 -2
  84. package/dist/ipc/discovery.js.map +1 -1
  85. package/dist/plus/agents/providers/claudeCodeProvider.d.ts +73 -20
  86. package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -1
  87. package/dist/plus/agents/providers/claudeCodeProvider.js +394 -88
  88. package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -1
  89. package/dist/plus/agents/types.d.ts +62 -11
  90. package/dist/plus/agents/types.d.ts.map +1 -1
  91. package/dist/plus/ai/models/model.d.ts +1 -1
  92. package/dist/plus/ai/models/model.d.ts.map +1 -1
  93. package/dist/plus/ai/models/model.js.map +1 -1
  94. package/dist/plus/ai/models/provider.d.ts +3 -0
  95. package/dist/plus/ai/models/provider.d.ts.map +1 -1
  96. package/dist/plus/ai/providers/gitkrakenProvider.d.ts +1 -1
  97. package/dist/plus/ai/providers/gitkrakenProvider.d.ts.map +1 -1
  98. package/dist/plus/ai/providers/gitkrakenProvider.js +4 -1
  99. package/dist/plus/ai/providers/gitkrakenProvider.js.map +1 -1
  100. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts +4 -3
  101. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts.map +1 -1
  102. package/dist/plus/ai/providers/openAICompatibleProviderBase.js +9 -6
  103. package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
  104. package/dist/plus/ai/utils/ai.utils.d.ts.map +1 -1
  105. package/dist/plus/ai/utils/ai.utils.js +2 -0
  106. package/dist/plus/ai/utils/ai.utils.js.map +1 -1
  107. package/dist/plus/git-github/api/github.d.ts +2 -0
  108. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  109. package/dist/plus/git-github/api/github.js +93 -6
  110. package/dist/plus/git-github/api/github.js.map +1 -1
  111. package/dist/plus/git-github/providers/github/refs.js +1 -1
  112. package/dist/utils/date.d.ts.map +1 -1
  113. package/dist/utils/date.js +6 -0
  114. package/dist/utils/date.js.map +1 -1
  115. package/dist/utils/debounce.js +1 -1
  116. package/dist/utils/iterable.d.ts +7 -1
  117. package/dist/utils/iterable.d.ts.map +1 -1
  118. package/dist/utils/iterable.js +12 -4
  119. package/dist/utils/iterable.js.map +1 -1
  120. package/dist/utils/object.d.ts +22 -1
  121. package/dist/utils/object.d.ts.map +1 -1
  122. package/dist/utils/object.js +56 -3
  123. package/dist/utils/object.js.map +1 -1
  124. package/dist/utils/paging.js +14 -15
  125. package/dist/utils/paging.js.map +1 -1
  126. package/dist/utils/promiseCache.d.ts.map +1 -1
  127. package/dist/utils/promiseCache.js +12 -4
  128. package/dist/utils/promiseCache.js.map +1 -1
  129. package/dist/utils/string.js +1 -1
  130. package/package.json +1 -1
  131. package/src/git/cache.ts +58 -70
  132. package/src/git/context.ts +21 -0
  133. package/src/git/features.ts +2 -0
  134. package/src/git/models/graph.ts +97 -0
  135. package/src/git/models/remoteProvider.ts +1 -0
  136. package/src/git/models/repository.ts +84 -93
  137. package/src/git/models/statusFile.ts +12 -0
  138. package/src/git/providers/commits.ts +4 -0
  139. package/src/git/providers/config.ts +1 -5
  140. package/src/git/providers/operations.ts +42 -22
  141. package/src/git/remotes/custom.ts +23 -0
  142. package/src/git/utils/contributor.utils.ts +24 -0
  143. package/src/git/utils/reachability.utils.ts +109 -0
  144. package/src/git/watching/watchService.ts +9 -4
  145. package/src/git/watching/watchSession.ts +61 -55
  146. package/src/git-cli/providers/branches.ts +1 -0
  147. package/src/git-cli/providers/commits.ts +24 -0
  148. package/src/git-cli/providers/config.ts +63 -49
  149. package/src/git-cli/providers/diff.ts +2 -1
  150. package/src/git-cli/providers/graph.ts +145 -14
  151. package/src/git-cli/providers/operations.ts +118 -130
  152. package/src/git-cli/providers/stash.ts +20 -2
  153. package/src/git-cli/providers/status.ts +11 -3
  154. package/src/ipc/discovery.ts +132 -2
  155. package/src/plus/agents/providers/claudeCodeProvider.ts +430 -90
  156. package/src/plus/agents/types.ts +59 -11
  157. package/src/plus/ai/models/model.ts +1 -0
  158. package/src/plus/ai/models/provider.ts +7 -1
  159. package/src/plus/ai/providers/gitkrakenProvider.ts +4 -0
  160. package/src/plus/ai/providers/openAICompatibleProviderBase.ts +21 -4
  161. package/src/plus/ai/utils/ai.utils.ts +2 -0
  162. package/src/plus/git-github/api/github.ts +120 -8
  163. package/src/plus/git-github/providers/github/refs.ts +1 -1
  164. package/src/utils/date.ts +5 -0
  165. package/src/utils/debounce.ts +1 -1
  166. package/src/utils/iterable.ts +14 -3
  167. package/src/utils/object.ts +55 -4
  168. package/src/utils/paging.ts +14 -14
  169. package/src/utils/promiseCache.ts +24 -16
  170. package/src/utils/string.ts +1 -1
@@ -32,9 +32,9 @@ import {
32
32
  getRemoteNameFromBranchName,
33
33
  } from '../../git/utils/branch.utils.js';
34
34
  import { getChangedFilesCount } from '../../git/utils/commit.utils.js';
35
+ import { createReachabilityTableBuilder } from '../../git/utils/reachability.utils.js';
35
36
  import { isUncommitted } from '../../git/utils/revision.utils.js';
36
37
  import { getSearchQueryComparisonKey, parseSearchQueryGitCommand } from '../../git/utils/search.utils.js';
37
- import { compareReachableRefs } from '../../git/utils/sorting.js';
38
38
  import { getTagId } from '../../git/utils/tag.utils.js';
39
39
  import { isUserMatch } from '../../git/utils/user.utils.js';
40
40
  import { getWorktreeId, groupWorktreesByBranch } from '../../git/utils/worktree.utils.js';
@@ -119,9 +119,14 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
119
119
  const [worktrees, worktreesByBranch] = getSettledValue(worktreesResult) ?? [[], new Map<string, GitWorktree>()];
120
120
 
121
121
  let branchIdOfMainWorktree: string | undefined;
122
+ let mainWorktree: GitWorktree | undefined;
122
123
  if (worktreesByBranch != null) {
123
- branchIdOfMainWorktree = find(worktreesByBranch, ([, wt]) => wt.isDefault)?.[0];
124
- if (branchIdOfMainWorktree != null) {
124
+ const defaultEntry = find(worktreesByBranch, ([, wt]) => wt.isDefault);
125
+ if (defaultEntry != null) {
126
+ [branchIdOfMainWorktree, mainWorktree] = defaultEntry;
127
+ // Remove the main/default worktree so the branch-row decoration shows `+checkedout`
128
+ // (not `+worktree`) for the main checkout. `mainWorktree` is retained below purely so
129
+ // Undo Commit can still reach the main worktree's HEAD from a secondary worktree's graph.
125
130
  worktreesByBranch.delete(branchIdOfMainWorktree);
126
131
  }
127
132
  }
@@ -142,9 +147,62 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
142
147
  const avatars = new Map<string, string>();
143
148
  const ids = new Set<string>();
144
149
  const reachableFromHEAD = new Set<string>();
150
+ // SHAs on the first-parent chain from HEAD up to (excluding) the first merge commit — the only
151
+ // commits a plain (non-`--rebase-merges`) interactive rebase can safely rewrite. `headSha` seeds
152
+ // the chain; `rewriteableNextSha` tracks the next sha expected on it. Outer scope so they persist
153
+ // across `more()` pagination, like `reachableFromHEAD`.
154
+ const rewriteableFromHEAD = new Set<string>();
155
+ let headSha: string | undefined;
156
+ let rewriteableNextSha: string | undefined;
157
+ // Undo Commit is offered only on a worktree HEAD that is a LEAF (nothing is built on it) —
158
+ // undoing a commit other work is stacked on is unsafe. The leaf check is only needed for the
159
+ // undo-eligible tips (the active HEAD + each worktree's HEAD), so track just those shas rather
160
+ // than every commit, keeping this O(#worktrees) instead of O(#commits).
161
+ const undoableTipShas = new Set<string>();
162
+ if (headBranch?.sha != null) {
163
+ undoableTipShas.add(headBranch.sha);
164
+ }
165
+ for (const wt of worktrees) {
166
+ if (wt.sha != null) {
167
+ undoableTipShas.add(wt.sha);
168
+ }
169
+ }
170
+ // The subset of `undoableTipShas` that turn out to have a child (are an ancestor of another
171
+ // commit) — i.e. NOT leaves. Built newest-first during the walk, so by the time a tip's row is
172
+ // processed every newer commit (its only possible children) has been seen. Undo is withheld for
173
+ // shas recorded here. Stash rows are excluded (a stash sitting on a tip must not block undoing it).
174
+ const tipShasWithChildren = new Set<string>();
175
+ // SHAs reachable from HEAD's tracking upstream tip. Combined with `reachableFromHEAD`,
176
+ // this lets us mark commits as unpushed (reachable from HEAD but not from HEAD's upstream).
177
+ const reachableFromHeadUpstream = new Set<string>();
145
178
 
146
179
  // Map<sha, Map<refKey, ref>> — inner map deduplicates refs during propagation
147
180
  const reachableRefs = new Map<string, Map<string, ReachableRef>>();
181
+
182
+ // Stable, append-only reachability table built as we walk — the PRIMARY representation shipped to
183
+ // consumers. Lives in the outer scope so it accumulates across `more()` pagination; indices
184
+ // already assigned never change, since git reachability only propagates to older (later-walked)
185
+ // commits. Per-row ref arrays are NOT retained — each row keeps only its set index
186
+ // (`contexts.reachabilityIndex`), and the working `reachableRefs` entry is dropped once emitted.
187
+ // The encoder lives next to its decoder in `reachability.utils` so the wire format can't drift.
188
+ const reachabilityBuilder = createReachabilityTableBuilder();
189
+
190
+ function finalizeRowReachability(
191
+ row: GitGraphRow,
192
+ sha: string,
193
+ refs: Map<string, ReachableRef> | undefined,
194
+ ): void {
195
+ const setIndex = reachabilityBuilder.intern(refs?.values());
196
+ if (setIndex != null) {
197
+ (row.contexts ??= {}).reachabilityIndex = setIndex;
198
+ }
199
+ // Drop the transient per-row arrays so emitted rows don't retain reachability, and release the
200
+ // working entry — once a commit is emitted its children are all walked and its parents already
201
+ // seeded, so nothing reads `reachableRefs.get(sha)` again (orderings keep parents after children).
202
+ row.reachability = undefined;
203
+ reachableRefs.delete(sha);
204
+ }
205
+
148
206
  const rowStats: GitGraphRowsStats = new Map<string, GitGraphRowStats>();
149
207
  let pendingRowsStatsCount = 0;
150
208
  let iterations = 0;
@@ -224,6 +282,12 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
224
282
  branchIdOfMainWorktree: branchIdOfMainWorktree,
225
283
  stashes: gitStash?.stashes,
226
284
  reachableFromHEAD: reachableFromHEAD,
285
+ rewriteableFromHEAD: rewriteableFromHEAD,
286
+ tipShasWithChildren: tipShasWithChildren,
287
+ // `undefined` when HEAD has no upstream, so the processor flags nothing as
288
+ // unpublished; otherwise the live set (mutated during the walk, read by ref).
289
+ reachableFromHeadUpstream:
290
+ headRefUpstreamName != null ? reachableFromHeadUpstream : undefined,
227
291
  avatars: avatars,
228
292
  }
229
293
  : undefined;
@@ -288,6 +352,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
288
352
  if (tip.startsWith('HEAD')) {
289
353
  head = true;
290
354
  reachableFromHEAD.add(shaOrRemapped);
355
+ headSha ??= shaOrRemapped;
291
356
 
292
357
  if (tip !== 'HEAD') {
293
358
  tip = tip.substring(8);
@@ -302,12 +367,16 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
302
367
  if (branchName === 'HEAD') continue;
303
368
 
304
369
  remoteBranchId = getBranchId(repoPath, true, tip);
370
+ const isHeadUpstream = tip === headRefUpstreamName;
371
+ if (isHeadUpstream) {
372
+ reachableFromHeadUpstream.add(shaOrRemapped);
373
+ }
305
374
  refRemoteHead = {
306
375
  id: remoteBranchId,
307
376
  name: branchName,
308
377
  owner: remote.name,
309
378
  url: remote.url,
310
- current: tip === headRefUpstreamName,
379
+ current: isHeadUpstream,
311
380
  hostingServiceType: remote.provider?.gkProviderId,
312
381
  };
313
382
  refRemoteHeads.push(refRemoteHead);
@@ -318,7 +387,16 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
318
387
 
319
388
  branch = branchMap.get(tip);
320
389
  branchId = branch?.id ?? getBranchId(repoPath, false, tip);
321
- const worktree = worktreesByBranch?.get(branchId);
390
+ // `worktreesByBranch` has the main/default worktree removed (above), so recover it
391
+ // here — Undo Commit needs to reach the main worktree's HEAD even when the active
392
+ // workspace is a secondary worktree.
393
+ const worktree =
394
+ worktreesByBranch?.get(branchId) ??
395
+ (branchId === branchIdOfMainWorktree ? mainWorktree : undefined);
396
+ const worktreeRef =
397
+ worktree != null
398
+ ? { id: getWorktreeId(repoPath, worktree.name), path: worktree.path }
399
+ : undefined;
322
400
  refHead = {
323
401
  id: branchId,
324
402
  name: tip,
@@ -330,7 +408,12 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
330
408
  id: getBranchId(repoPath, true, branch.upstream.name),
331
409
  }
332
410
  : undefined,
333
- worktreeId: worktree != null ? getWorktreeId(repoPath, worktree.name) : undefined,
411
+ worktree: worktreeRef,
412
+ // Mirror `worktree.id` for `@gitkraken/gitkraken-components` — its bundled renderer
413
+ // reads `head.worktreeId` to pick the WORKTREE vs HEAD ref-badge. Scope to NON-default
414
+ // worktrees so the main checkout keeps its HEAD badge even though `worktree` (above)
415
+ // is populated for it for Undo routing.
416
+ worktreeId: worktree?.isDefault === false ? worktreeRef?.id : undefined,
334
417
  };
335
418
  refHeads.push(refHead);
336
419
  if (branch?.upstream?.name != null) {
@@ -353,6 +436,27 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
353
436
  }
354
437
  }
355
438
 
439
+ // First-parent rewriteable chain from HEAD: include each commit only while HEAD and
440
+ // every commit down to it has exactly one parent. Stops at (excludes) the first merge
441
+ // and the root — mirrors GitKraken's getDistinctCommitsFromHeadToFirstMergeCommit.
442
+ // Off-chain commits (a merge's other-parent ancestry, or anything below the first merge)
443
+ // are reachable-from-HEAD but NOT safely history-rewriteable by a plain interactive
444
+ // rebase. Relies on the same "commit emitted before its parents" ordering as above.
445
+ if (shaOrRemapped === headSha || shaOrRemapped === rewriteableNextSha) {
446
+ if (parents.length === 1) {
447
+ rewriteableFromHEAD.add(shaOrRemapped);
448
+ rewriteableNextSha = parents[0];
449
+ } else {
450
+ // A merge (>1 parents) or the root (0 parents) terminates the chain.
451
+ rewriteableNextSha = undefined;
452
+ }
453
+ }
454
+ if (reachableFromHeadUpstream.has(shaOrRemapped)) {
455
+ for (parent of parents) {
456
+ reachableFromHeadUpstream.add(parent);
457
+ }
458
+ }
459
+
356
460
  // Seed reachability from all ref types on this commit
357
461
  if (refHeads.length > 0 || refRemoteHeads.length > 0 || refTags.length > 0) {
358
462
  let refs = reachableRefs.get(shaOrRemapped);
@@ -406,12 +510,13 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
406
510
  heads: refHeads,
407
511
  remotes: refRemoteHeads,
408
512
  tags: refTags,
409
- reachability: refs?.size
410
- ? { partial: true, refs: [...refs.values()].sort(compareReachableRefs) }
411
- : undefined,
513
+ // Transient: the row processor's `+unique` decision reads this; stripped just below.
514
+ // Unsorted (the consumer re-sorts after decoding) — order doesn't affect interning.
515
+ reachability: refs?.size ? { partial: true, refs: [...refs.values()] } : undefined,
412
516
  isCurrentUser: true,
413
517
  };
414
518
  rowProcessor?.processRow(row, graphCtx!);
519
+ finalizeRowReachability(row, shaOrRemapped, refs);
415
520
  rows.push(row);
416
521
 
417
522
  if (stash.stats != null) {
@@ -424,6 +529,17 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
424
529
  } else {
425
530
  isCurrentUser = isUserMatch(currentUser, commit.author, commit.authorEmail);
426
531
 
532
+ // Mark any undo-eligible tip that THIS (non-stash) commit builds on as non-leaf, so
533
+ // its Undo Commit is withheld. Uses the full parent set (incl. merge second-parents)
534
+ // but only records shas that are actually undo tips — newer commits are walked first,
535
+ // so a tip is recorded before its own row is processed. Stash rows are intentionally
536
+ // not handled here, so a stash based on a tip doesn't block undoing that tip.
537
+ for (parent of parents) {
538
+ if (undoableTipShas.has(parent)) {
539
+ tipShasWithChildren.add(parent);
540
+ }
541
+ }
542
+
427
543
  const refs = reachableRefs.get(shaOrRemapped);
428
544
  const row: GitGraphRow = {
429
545
  sha: shaOrRemapped,
@@ -437,12 +553,13 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
437
553
  heads: refHeads,
438
554
  remotes: refRemoteHeads,
439
555
  tags: refTags,
440
- reachability: refs?.size
441
- ? { partial: true, refs: [...refs.values()].sort(compareReachableRefs) }
442
- : undefined,
556
+ // Transient: the row processor's `+unique` decision reads this; stripped just below.
557
+ // Unsorted (the consumer re-sorts after decoding) — order doesn't affect interning.
558
+ reachability: refs?.size ? { partial: true, refs: [...refs.values()] } : undefined,
443
559
  isCurrentUser: isCurrentUser || undefined,
444
560
  };
445
561
  rowProcessor?.processRow(row, graphCtx!);
562
+ finalizeRowReachability(row, shaOrRemapped, refs);
446
563
  rows.push(row);
447
564
 
448
565
  if (commit.stats != null) {
@@ -460,7 +577,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
460
577
  if (deferStats) {
461
578
  pendingRowsStatsCount++;
462
579
 
463
- // eslint-disable-next-line no-async-promise-executor
580
+ // oxlint-disable-next-line no-async-promise-executor
464
581
  const promise = new Promise<void>(async resolve => {
465
582
  try {
466
583
  const args = [...statsParser.arguments];
@@ -514,6 +631,8 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
514
631
  worktrees: worktrees,
515
632
  worktreesByBranch: worktreesByBranch,
516
633
  reachableFromHEAD: reachableFromHEAD,
634
+ rewriteableFromHEAD: rewriteableFromHEAD,
635
+ reachability: reachabilityBuilder.build(),
517
636
  rows: rows,
518
637
  id: sha ?? rev,
519
638
  rowsStats: rowStats,
@@ -538,7 +657,19 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
538
657
  }
539
658
  }
540
659
 
541
- return getCommitsForGraphCore.call(this, defaultLimit, selectSha, undefined, cancellation);
660
+ let graph = await getCommitsForGraphCore.call(this, defaultLimit, selectSha, undefined, cancellation);
661
+
662
+ // Spurious-empty guard: the repo has branches (so `git log --all` should yield commits) yet the
663
+ // log returned no rows. That's an inconsistent result — almost always a transient ref-read race
664
+ // while a concurrent git operation rewrites refs/packed-refs — not a genuinely empty repo.
665
+ // Returning it would flash "No commits" in the graph. Retry once (transient glitches clear within
666
+ // ms). A truly empty repo has no branches and is left untouched.
667
+ if (graph.rows.length === 0 && branches?.length && !cancellation?.aborted) {
668
+ scope?.warn(`graph returned 0 rows but repo has ${branches.length} branches; retrying`);
669
+ graph = await getCommitsForGraphCore.call(this, defaultLimit, selectSha, undefined, cancellation);
670
+ }
671
+
672
+ return graph;
542
673
  }
543
674
 
544
675
  @debug({
@@ -25,6 +25,7 @@ import { getBranchNameAndRemote, getBranchTrackingWithoutRemote } from '../../gi
25
25
  import { isBranchReference } from '../../git/utils/reference.utils.js';
26
26
  import { debug } from '../../utils/decorators/log.js';
27
27
  import { sequentialize } from '../../utils/decorators/sequentialize.js';
28
+ import { chunkByStringLength } from '../../utils/iterable.js';
28
29
  import { Logger } from '../../utils/logger.js';
29
30
  import { getScopedLogger } from '../../utils/logger.scoped.js';
30
31
  import { normalizePath, splitPath } from '../../utils/path.js';
@@ -51,124 +52,22 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
51
52
  async checkout(
52
53
  repoPath: string,
53
54
  ref: string,
54
- options?: { createBranch?: string } | { path?: string },
55
+ options?: { createBranch?: string },
55
56
  runOptions?: GitOperationRunOptions,
56
57
  ): Promise<void> {
57
58
  const scope = getScopedLogger();
58
59
 
59
- try {
60
- await this.checkoutCore(repoPath, ref, options, runOptions);
61
- this.context.hooks?.cache?.onReset?.(repoPath, 'branches', 'status');
62
- this.context.hooks?.repository?.onChanged?.(repoPath, ['head', 'heads', 'index']);
63
- } catch (ex) {
64
- scope?.error(ex);
65
- throw ex;
66
- }
67
- }
68
-
69
- private async checkoutCore(
70
- repoPath: string,
71
- ref: string,
72
- { createBranch, path }: { createBranch?: string; path?: string } = {},
73
- runOptions?: GitOperationRunOptions,
74
- ): Promise<void> {
75
60
  const params = ['checkout'];
76
- if (createBranch) {
77
- params.push('-b', createBranch, ref, '--');
61
+ if (options?.createBranch) {
62
+ params.push('-b', options.createBranch, ref, '--');
78
63
  } else {
79
64
  params.push(ref, '--');
80
-
81
- if (path) {
82
- [path, repoPath] = splitPath(path, repoPath, true);
83
- params.push(path);
84
- }
85
65
  }
86
66
 
87
67
  try {
88
- await this.git.run({ cwd: repoPath, ...runOptions }, ...params);
89
- } catch (ex) {
90
- throw getGitCommandError(
91
- 'checkout',
92
- ex,
93
- reason =>
94
- new CheckoutError(
95
- { reason: reason ?? 'other', ref: ref, gitCommand: { repoPath: repoPath, args: params } },
96
- ex,
97
- ),
98
- );
99
- }
100
- }
101
-
102
- @debug()
103
- async checkoutConflictedPath(
104
- repoPath: string,
105
- path: string,
106
- side: 'ours' | 'theirs',
107
- runOptions?: GitOperationRunOptions,
108
- ): Promise<void> {
109
- const scope = getScopedLogger();
110
-
111
- [path, repoPath] = splitPath(path, repoPath, true);
112
- const params = ['checkout', `--${side}`, '--', path];
113
-
114
- try {
115
- await this.git.run({ cwd: repoPath, ...runOptions }, ...params);
116
- this.context.hooks?.cache?.onReset?.(repoPath, 'status');
117
- this.context.hooks?.repository?.onChanged?.(repoPath, ['index']);
118
- } catch (ex) {
119
- scope?.error(ex);
120
- throw getGitCommandError(
121
- 'checkout',
122
- ex,
123
- reason =>
124
- new CheckoutError(
125
- { reason: reason ?? 'other', ref: side, gitCommand: { repoPath: repoPath, args: params } },
126
- ex,
127
- ),
128
- );
129
- }
130
- }
131
-
132
- @debug()
133
- async checkoutConflictedPaths(
134
- repoPath: string,
135
- paths: string[],
136
- side: 'ours' | 'theirs',
137
- runOptions?: GitOperationRunOptions,
138
- ): Promise<void> {
139
- if (!paths.length) return;
140
-
141
- const scope = getScopedLogger();
142
- const normalized = paths.map(p => splitPath(p, repoPath, true)[0]);
143
-
144
- // Build batches by accumulating the length of each path + a small per-arg overhead (separator +
145
- // possible quoting), so a single long path — or just the overhead itself — can't push a batch
146
- // past the CLI length limit. Always keep at least one path per batch, even if a lone path is
147
- // longer than the limit (the OS may still accept it on some platforms).
148
- const perArgOverhead = 3;
149
- const batches: string[][] = [];
150
- let current: string[] = [];
151
- let currentLen = 0;
152
- for (const p of normalized) {
153
- const cost = p.length + perArgOverhead;
154
- if (current.length > 0 && currentLen + cost > maxGitCliLength) {
155
- batches.push(current);
156
- current = [];
157
- currentLen = 0;
158
- }
159
- current.push(p);
160
- currentLen += cost;
161
- }
162
- if (current.length) {
163
- batches.push(current);
164
- }
165
-
166
- try {
167
- for (const batch of batches) {
168
- await this.git.run({ cwd: repoPath, ...runOptions }, 'checkout', `--${side}`, '--', ...batch);
169
- }
170
- this.context.hooks?.cache?.onReset?.(repoPath, 'status');
171
- this.context.hooks?.repository?.onChanged?.(repoPath, ['index']);
68
+ await this.git.run({ cwd: repoPath, errors: 'throw', ...runOptions }, ...params);
69
+ this.context.hooks?.cache?.onReset?.(repoPath, 'branches', 'status');
70
+ this.context.hooks?.repository?.onChanged?.(repoPath, ['head', 'heads', 'index']);
172
71
  } catch (ex) {
173
72
  scope?.error(ex);
174
73
  throw getGitCommandError(
@@ -176,11 +75,7 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
176
75
  ex,
177
76
  reason =>
178
77
  new CheckoutError(
179
- {
180
- reason: reason ?? 'other',
181
- ref: side,
182
- gitCommand: { repoPath: repoPath, args: ['checkout', `--${side}`, '--', ...normalized] },
183
- },
78
+ { reason: reason ?? 'other', ref: ref, gitCommand: { repoPath: repoPath, args: params } },
184
79
  ex,
185
80
  ),
186
81
  );
@@ -383,7 +278,7 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
383
278
  }
384
279
 
385
280
  try {
386
- await this.git.run({ cwd: repoPath, ...runOptions }, ...params);
281
+ await this.git.run({ cwd: repoPath, errors: 'throw', ...runOptions }, ...params);
387
282
  } catch (ex) {
388
283
  throw getGitCommandError(
389
284
  'fetch',
@@ -530,7 +425,7 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
530
425
  }
531
426
 
532
427
  try {
533
- await this.git.run({ cwd: repoPath, configs: gitConfigsPull, ...runOptions }, ...params);
428
+ await this.git.run({ cwd: repoPath, configs: gitConfigsPull, errors: 'throw', ...runOptions }, ...params);
534
429
  } catch (ex) {
535
430
  await this.throwIfSigningError(repoPath, params, ex, options.source);
536
431
  throw getGitCommandError(
@@ -574,12 +469,17 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
574
469
  const branch = await this.provider.branches.getBranch(repoPath);
575
470
  if (branch == null) return;
576
471
 
577
- branchName =
578
- options?.reference != null
579
- ? `${options.reference.ref}:${
580
- options?.publish != null ? 'refs/heads/' : ''
581
- }${branch.nameWithoutRemote}`
582
- : branch.name;
472
+ if (options?.reference != null) {
473
+ branchName = `${options.reference.ref}:${
474
+ options?.publish != null ? 'refs/heads/' : ''
475
+ }${branch.nameWithoutRemote}`;
476
+ } else {
477
+ const tracking = options?.publish == null ? branch.trackingWithoutRemote : undefined;
478
+ branchName =
479
+ tracking != null && tracking !== branch.nameWithoutRemote
480
+ ? `${branch.name}:${tracking}`
481
+ : branch.name;
482
+ }
583
483
  remoteName = branch.remoteName ?? options?.publish?.remote;
584
484
  upstreamName = options?.reference == null && options?.publish != null ? branch.name : undefined;
585
485
 
@@ -599,10 +499,12 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
599
499
 
600
500
  let forceOpts: PushForceOptions | undefined;
601
501
  if (options?.force) {
602
- forceOpts = {
603
- withLease: true,
604
- ifIncludes: true,
605
- };
502
+ // Honor the host's force-push preferences (e.g. VS Code's `git.useForcePushWithLease` /
503
+ // `git.useForcePushIfIncludes`); fall back to the safer `--force-with-lease` behavior.
504
+ const useForceWithLease = this.context.config?.push?.useForceWithLease ?? true;
505
+ forceOpts = useForceWithLease
506
+ ? { withLease: true, ifIncludes: this.context.config?.push?.useForceIfIncludes ?? true }
507
+ : { withLease: false };
606
508
  }
607
509
 
608
510
  try {
@@ -688,7 +590,7 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
688
590
  }
689
591
 
690
592
  try {
691
- await this.git.run({ cwd: repoPath, ...runOptions }, ...params);
593
+ await this.git.run({ cwd: repoPath, errors: 'throw', ...runOptions }, ...params);
692
594
  } catch (ex) {
693
595
  const error = getGitCommandError(
694
596
  'push',
@@ -758,6 +660,8 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
758
660
  branch?: string;
759
661
  editor?: string;
760
662
  interactive?: boolean;
663
+ programmaticEditor?: boolean;
664
+ messageEditor?: string;
761
665
  onto?: string;
762
666
  updateRefs?: boolean;
763
667
  source?: unknown;
@@ -767,7 +671,7 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
767
671
  const scope = getScopedLogger();
768
672
 
769
673
  const args = ['rebase'];
770
- let configs: string[] | undefined;
674
+ const configs: string[] = [];
771
675
 
772
676
  if (options?.autoStash !== false) {
773
677
  args.push('--autostash');
@@ -777,8 +681,24 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
777
681
  args.push('--interactive');
778
682
 
779
683
  if (options.editor) {
780
- configs = ['-c', `sequence.editor=${options.editor}`];
684
+ configs.push('-c', `sequence.editor=${options.editor}`);
781
685
  }
686
+
687
+ // A script-based sequence editor rewrites the todo by command word + SHA, so git must emit a
688
+ // plain, natural-order todo regardless of the user's rebase config: `rebase.autosquash` would
689
+ // reorder commits and rewrite `pick`→`fixup`, and `rebase.abbreviateCommands` would emit `p`.
690
+ if (options.programmaticEditor) {
691
+ configs.push('-c', 'rebase.autosquash=false', '-c', 'rebase.abbreviateCommands=false');
692
+ }
693
+ }
694
+
695
+ // Drive per-commit message editing (the combined message a `squash` produces, or a `reword`).
696
+ // `GIT_EDITOR` (not `core.editor`) is what git's interactive-rebase backend honors for the `reword`
697
+ // amend step, so set it on the environment; also set `core.editor` config as a fallback.
698
+ let env = runOptions?.env;
699
+ if (options?.messageEditor) {
700
+ configs.push('-c', `core.editor=${options.messageEditor}`);
701
+ env = { ...env, GIT_EDITOR: options.messageEditor };
782
702
  }
783
703
 
784
704
  if (options?.updateRefs) {
@@ -798,7 +718,7 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
798
718
  try {
799
719
  await this.git.run(
800
720
  // Avoid a timeout since rebases can take a long time (set to 0 to disable)
801
- { cwd: repoPath, errors: 'throw', configs: configs, timeout: 0, ...runOptions },
721
+ { cwd: repoPath, errors: 'throw', configs: configs, timeout: 0, ...runOptions, env: env },
802
722
  ...args,
803
723
  );
804
724
  this.context.hooks?.cache?.onReset?.(repoPath, 'branches', 'status');
@@ -859,7 +779,7 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
859
779
  params.push(rev, '--');
860
780
 
861
781
  try {
862
- await this.git.run({ cwd: repoPath, ...runOptions }, ...params);
782
+ await this.git.run({ cwd: repoPath, errors: 'throw', ...runOptions }, ...params);
863
783
  } catch (ex) {
864
784
  throw getGitCommandError(
865
785
  'reset',
@@ -870,6 +790,74 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
870
790
  }
871
791
  }
872
792
 
793
+ @sequentialize({ getQueueKey: rp => rp })
794
+ @debug()
795
+ async restore(
796
+ repoPath: string,
797
+ path: string | string[],
798
+ options?: { ref?: string; side?: 'ours' | 'theirs' },
799
+ runOptions?: GitOperationRunOptions,
800
+ ): Promise<void> {
801
+ const paths = Array.isArray(path) ? path : [path];
802
+ if (!paths.length) return;
803
+
804
+ const scope = getScopedLogger();
805
+ const normalized = paths.map(p => splitPath(p, repoPath, true)[0]);
806
+
807
+ // `side` and `ref` are mutually exclusive in git (--ours/--theirs apply during conflict
808
+ // resolution and take no source ref); side takes precedence if both are somehow supplied.
809
+ // Use a truthy check on `ref` so an empty string falls through to index-restore rather than
810
+ // producing a malformed `git checkout '' -- …`. Implemented via `checkout` rather than
811
+ // `git restore` for compatibility with the minimum supported git (2.7.2); `git restore`
812
+ // only landed in 2.23.
813
+ const selector = options?.side != null ? `--${options.side}` : options?.ref || undefined;
814
+ const prefix = selector != null ? ['checkout', selector, '--'] : ['checkout', '--'];
815
+
816
+ try {
817
+ if (await this.git.supports('git:checkout:pathspec-from-file')) {
818
+ // Git ≥ 2.26: stream all pathspecs via stdin (NUL-separated) in a single invocation —
819
+ // no CLI-length limit (so no batching), and robust for paths with spaces/newlines.
820
+ // Pathspecs come from the file rather than argv, so `--` isn't used.
821
+ const args = ['checkout'];
822
+ if (selector != null) {
823
+ args.push(selector);
824
+ }
825
+ args.push('--pathspec-from-file=-', '--pathspec-file-nul');
826
+ await this.git.run(
827
+ { cwd: repoPath, errors: 'throw', ...runOptions, stdin: normalized.join('\0') },
828
+ ...args,
829
+ );
830
+ } else {
831
+ // Older git: pass pathspecs as args, one git invocation per chunk (collapsing N
832
+ // path-mode checkouts into ⌈N / chunk⌉ spawns). Reserve the command prefix length and
833
+ // a per-arg overhead (separator + possible quoting) so the assembled command line
834
+ // can't exceed the OS limit even with many short paths.
835
+ const perArgOverhead = 3;
836
+ const prefixLength = prefix.reduce((sum, arg) => sum + arg.length + perArgOverhead, 0);
837
+ for (const batch of chunkByStringLength(normalized, maxGitCliLength - prefixLength, perArgOverhead)) {
838
+ await this.git.run({ cwd: repoPath, errors: 'throw', ...runOptions }, ...prefix, ...batch);
839
+ }
840
+ }
841
+ this.context.hooks?.cache?.onReset?.(repoPath, 'status');
842
+ this.context.hooks?.repository?.onChanged?.(repoPath, ['index']);
843
+ } catch (ex) {
844
+ scope?.error(ex);
845
+ throw getGitCommandError(
846
+ 'checkout',
847
+ ex,
848
+ reason =>
849
+ new CheckoutError(
850
+ {
851
+ reason: reason ?? 'other',
852
+ ref: options?.side ?? options?.ref,
853
+ gitCommand: { repoPath: repoPath, args: [...prefix, ...normalized] },
854
+ },
855
+ ex,
856
+ ),
857
+ );
858
+ }
859
+ }
860
+
873
861
  @sequentialize({ getQueueKey: rp => rp })
874
862
  @debug()
875
863
  async revert(
@@ -390,7 +390,25 @@ export class StashGitSubProvider implements GitStashSubProvider {
390
390
  params.push('--include-untracked');
391
391
  }
392
392
 
393
- if (options?.keepIndex && !options?.includeUntracked) {
393
+ // `git stash push --keep-index --include-untracked -- <pathspec>` hits a bug in git when the
394
+ // pathspec names an untracked file: the restoring checkout can't match an untracked path in the
395
+ // index tree, so it fails and the staged changes are lost.
396
+ //
397
+ // $ mkdir stash-test && cd stash-test && git init
398
+ // $ echo a > a.txt
399
+ // $ git add a.txt
400
+ // $ git commit -m init
401
+ // $ echo b > b.txt
402
+ // $ git stash push --keep-index --include-untracked -- b.txt
403
+ // Saved working directory and index state WIP on main: 8a280fe init
404
+ // error: pathspec ':(prefix:0)b.txt' did not match any file(s) known to git
405
+ //
406
+ // Only suppress --keep-index for that case — when the selection actually includes untracked
407
+ // files (`includeUntracked`) AND we're stashing by pathspec. Keying off `includeUntracked` (the
408
+ // caller's intent) rather than the `--include-untracked` flag is deliberate: we add
409
+ // `--include-untracked` to every pathspec push above as a safety net, but a tracked-only
410
+ // selection can — and must — still keep the index intact.
411
+ if (options?.keepIndex && !(options?.includeUntracked && options?.pathspecs?.length)) {
394
412
  params.push('--keep-index');
395
413
  }
396
414
 
@@ -494,7 +512,7 @@ export function convertStashesToStdin(stashOrStashes: GitStash | ReadonlyMap<str
494
512
  }
495
513
 
496
514
  const stashSummaryRegex =
497
- // eslint-disable-next-line no-control-regex
515
+ // oxlint-disable-next-line no-control-regex
498
516
  /(?:(?:(?<wip>WIP) on|On) (?<onref>[^/](?!.*\/\.)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[^\x00-\x1F\x7F ~^:?*[\\]+[^./]):\s*)?(?<summary>.*)$/s;
499
517
 
500
518
  function createStash(