@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
@@ -0,0 +1,109 @@
1
+ import { base64, fromBase64 } from '../../utils/base64.js';
2
+ import type { GraphReachabilityTable } from '../models/graph.js';
3
+ import type { GitCommitReachability } from '../providers/commits.js';
4
+
5
+ type ReachableRef = GitCommitReachability['refs'][number];
6
+
7
+ /**
8
+ * Canonical dictionary key for a reachable ref. The `r`/`l` discriminator keeps a remote branch from
9
+ * colliding with a local branch of the same name; the `refType` prefix separates tags from branches.
10
+ * Single-sourced here so the encoder ({@link createReachabilityTableBuilder}) and any future consumer
11
+ * can't drift on the convention.
12
+ */
13
+ export function reachableRefKey(ref: ReachableRef): string {
14
+ return `${ref.refType}:${ref.refType === 'branch' && ref.remote ? 'r' : 'l'}:${ref.name}`;
15
+ }
16
+
17
+ // Monotonic generation counter — each builder (i.e. each fresh graph walk) gets a distinct id, while a
18
+ // builder reused across `more()` pagination keeps its id. Only ever incremented host-side (the encoder);
19
+ // the webview treats the id as opaque. Module-scoped so ids are unique across a session's builders.
20
+ let nextReachabilityTableId = 0;
21
+
22
+ /**
23
+ * Builds a {@link GraphReachabilityTable} incrementally: interns refs into a shared dictionary and
24
+ * distinct membership bitmaps into a deduplicated set list. Kept next to {@link decodeReachabilitySet}
25
+ * (its exact inverse) so the wire format — first-seen dictionary indexing, LSB-first bit packing,
26
+ * base64 — has a single owner and a round-trippable test, rather than an encoder and decoder that can
27
+ * silently diverge across packages.
28
+ *
29
+ * Append-only by construction: an index, once assigned, is never reused — so a builder kept alive
30
+ * across paginated loads accumulates a table whose existing indices stay valid as it grows.
31
+ */
32
+ export function createReachabilityTableBuilder(): {
33
+ intern: (refs: Iterable<ReachableRef> | undefined) => number | undefined;
34
+ build: () => GraphReachabilityTable | undefined;
35
+ } {
36
+ const id = ++nextReachabilityTableId;
37
+ const dictionary: GitCommitReachability['refs'] = [];
38
+ const refKeyToIndex = new Map<string, number>();
39
+ const sets: string[] = [];
40
+ const setKeyToIndex = new Map<string, number>();
41
+
42
+ return {
43
+ /** Interns a ref set, returning its index into `sets`, or undefined when the set is empty. */
44
+ intern: (refs: Iterable<ReachableRef> | undefined): number | undefined => {
45
+ if (refs == null) return undefined;
46
+
47
+ let maxIndex = -1;
48
+ const indices: number[] = [];
49
+ for (const ref of refs) {
50
+ const key = reachableRefKey(ref);
51
+ let index = refKeyToIndex.get(key);
52
+ if (index == null) {
53
+ index = dictionary.length;
54
+ refKeyToIndex.set(key, index);
55
+ dictionary.push(ref);
56
+ }
57
+ indices.push(index);
58
+ if (index > maxIndex) {
59
+ maxIndex = index;
60
+ }
61
+ }
62
+ if (indices.length === 0) return undefined;
63
+
64
+ // Pack into a bitset sized to this set's high-water dictionary index, then intern by base64.
65
+ const bytes = new Uint8Array((maxIndex >> 3) + 1);
66
+ for (const index of indices) {
67
+ bytes[index >> 3] |= 1 << (index & 7);
68
+ }
69
+ const encoded = base64(bytes);
70
+ let setIndex = setKeyToIndex.get(encoded);
71
+ if (setIndex == null) {
72
+ setIndex = sets.length;
73
+ setKeyToIndex.set(encoded, setIndex);
74
+ sets.push(encoded);
75
+ }
76
+ return setIndex;
77
+ },
78
+
79
+ /** The accumulated table, or undefined when nothing was interned. */
80
+ build: (): GraphReachabilityTable | undefined =>
81
+ dictionary.length ? { id: id, dictionary: dictionary, sets: sets } : undefined,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Decodes the membership bitmap at `index` in a {@link GraphReachabilityTable} back into its list of
87
+ * reachable refs. Refs come back in the table's first-seen dictionary order — callers that need the
88
+ * canonical display order (current-first / local-before-remote / tags newest-first) sort the result
89
+ * with `compareReachableRefs`. Returns an empty array for an out-of-range/missing set index.
90
+ *
91
+ * The bitmap is at most `ceil(dictionary.length / 8)` bytes; earlier (shorter) bitmaps packed against
92
+ * a smaller dictionary stay valid — only bits actually present can be set, so the loop stops at the
93
+ * shorter of the dictionary length and the bitmap's bit count rather than scanning a guaranteed-zero tail.
94
+ */
95
+ export function decodeReachabilitySet(table: GraphReachabilityTable, index: number): GitCommitReachability['refs'] {
96
+ const { dictionary, sets } = table;
97
+ const encoded = sets[index];
98
+ if (encoded == null) return [];
99
+
100
+ const bytes = fromBase64(encoded);
101
+ const refs: GitCommitReachability['refs'] = [];
102
+ const count = Math.min(dictionary.length, bytes.length * 8);
103
+ for (let i = 0; i < count; i++) {
104
+ if ((bytes[i >> 3] ?? 0) & (1 << (i & 7))) {
105
+ refs.push(dictionary[i]);
106
+ }
107
+ }
108
+ return refs;
109
+ }
@@ -43,7 +43,7 @@ interface SessionRecord {
43
43
  * implementations stored just the first caller's hooks (`WatchHooks | undefined`) and
44
44
  * silently dropped subsequent callers' hooks — that broke the case where a low-level
45
45
  * subscriber (e.g. the Graph's secondary-worktree WIP watcher) opened the session
46
- * first without hooks, and a later `Repository.setupWatching` for the same path lost
46
+ * first without hooks, and a later `Repository.watch()` for the same path lost
47
47
  * its `markFetched` / `onIgnoresChanged` wiring. Each `watch()` call adds its hooks
48
48
  * to this set and removes them on dispose; fire sites iterate the set.
49
49
  */
@@ -78,6 +78,8 @@ export class RepositoryWatchService implements UnifiedDisposable {
78
78
  private readonly watchGroups = new Map<string, WatchGroup>();
79
79
  private readonly wtWatchers = new Map<string, { watcher: FileWatcher; filter?: GitIgnoreFilter }>();
80
80
  private _disposed = false;
81
+ /** Global suspend state (driven by window focus); inherited by sessions created via {@link watch}. */
82
+ private _suspended = false;
81
83
 
82
84
  constructor(options: WatchServiceOptions) {
83
85
  this.provider = options.watchingProvider;
@@ -135,6 +137,7 @@ export class RepositoryWatchService implements UnifiedDisposable {
135
137
  repoPath: repoPath,
136
138
  defaultRepoDelayMs: this.defaultRepoDelayMs,
137
139
  defaultWorkingTreeDelayMs: this.defaultWorkingTreeDelayMs,
140
+ suspended: this._suspended,
138
141
  lifecycle: {
139
142
  onFirstRepoSubscriber: (): void => {
140
143
  const rec = this.sessionMap.get(repoPath);
@@ -207,15 +210,17 @@ export class RepositoryWatchService implements UnifiedDisposable {
207
210
  return this.sessionMap.get(repoPath)?.session;
208
211
  }
209
212
 
210
- /** Suspend all sessions */
213
+ /** Suspend all sessions and mark the service suspended so late-joining sessions start suspended */
211
214
  suspendAll(): void {
215
+ this._suspended = true;
212
216
  for (const record of this.sessionMap.values()) {
213
217
  record.session.suspend();
214
218
  }
215
219
  }
216
220
 
217
- /** Resume all sessions, with optional per-session delay */
221
+ /** Resume all sessions (clearing the global suspend state), with optional per-session delay */
218
222
  resumeAll(getDelay?: (session: RepositoryWatchSession) => number): void {
223
+ this._suspended = false;
219
224
  for (const record of this.sessionMap.values()) {
220
225
  const delayMs = getDelay?.(record.session);
221
226
  record.session.resume(delayMs);
@@ -252,7 +257,7 @@ export class RepositoryWatchService implements UnifiedDisposable {
252
257
  },
253
258
  onFetchHeadChanged: function (repoPath: string): void {
254
259
  // Iterate at fire time so callers that joined after the group was wired up
255
- // still receive events (e.g. a `Repository.setupWatching` whose hooks landed
260
+ // still receive events (e.g. a `Repository.watch()` whose hooks landed
256
261
  // after a hookless `GitRepositoryService.watch` registered first).
257
262
  for (const h of watchHooks) {
258
263
  h.onFetchHeadChanged?.(repoPath);
@@ -37,6 +37,8 @@ export interface WatchSessionOptions {
37
37
  /** Default debounce for working tree changes. Default: 2500ms */
38
38
  readonly defaultWorkingTreeDelayMs?: number;
39
39
  readonly lifecycle?: WatchSessionLifecycle;
40
+ /** Initial suspended state — new sessions inherit the service-global suspend state. Default: false */
41
+ readonly suspended?: boolean;
40
42
  /**
41
43
  * Called whenever a debounced repo change event is dispatched to subscribers.
42
44
  * Does NOT count as a subscriber — used by WatchService for
@@ -66,16 +68,16 @@ export class RepositoryWatchSession implements UnifiedDisposable {
66
68
  private effectiveRepoMs: number;
67
69
  private readonly defaultRepoMs: number;
68
70
 
69
- private readonly wtEmitter = new Emitter<WorkingTreeChangeEvent>();
70
- private readonly wtDelays = new Map<number, number>();
71
- private wtNextId = 0;
72
- private pendingWTPaths = new Set<string>();
73
- private wtTimer: ReturnType<typeof setTimeout> | undefined;
74
- private effectiveWTMs: number;
75
- private readonly defaultWTMs: number;
71
+ private readonly workingTreeEmitter = new Emitter<WorkingTreeChangeEvent>();
72
+ private readonly workingTreeDelays = new Map<number, number>();
73
+ private workingTreeNextId = 0;
74
+ private pendingWorkingTreePaths = new Set<string>();
75
+ private workingTreeTimer: ReturnType<typeof setTimeout> | undefined;
76
+ private effectiveWorkingTreeMs: number;
77
+ private readonly defaultWorkingTreeMs: number;
76
78
 
77
79
  private _etagWorkingTree = 0;
78
- private _suspended = false;
80
+ private _suspended: boolean;
79
81
  private _disposed = false;
80
82
 
81
83
  private readonly lifecycle?: WatchSessionLifecycle;
@@ -84,11 +86,12 @@ export class RepositoryWatchSession implements UnifiedDisposable {
84
86
  constructor(options: WatchSessionOptions) {
85
87
  this.repoPath = options.repoPath;
86
88
  this.defaultRepoMs = options.defaultRepoDelayMs ?? defaultRepoDelay;
87
- this.defaultWTMs = options.defaultWorkingTreeDelayMs ?? defaultWorkingTreeDelay;
89
+ this.defaultWorkingTreeMs = options.defaultWorkingTreeDelayMs ?? defaultWorkingTreeDelay;
88
90
  this.effectiveRepoMs = this.defaultRepoMs;
89
- this.effectiveWTMs = this.defaultWTMs;
91
+ this.effectiveWorkingTreeMs = this.defaultWorkingTreeMs;
90
92
  this.lifecycle = options.lifecycle;
91
93
  this.onDidFireRepoChangeCallback = options.onDidFireRepoChange;
94
+ this._suspended = options.suspended ?? false;
92
95
  }
93
96
 
94
97
  [Symbol.dispose](): void {
@@ -101,15 +104,15 @@ export class RepositoryWatchSession implements UnifiedDisposable {
101
104
  this._disposed = true;
102
105
 
103
106
  this.cancelRepoTimer();
104
- this.cancelWTTimer();
107
+ this.cancelWorkingTreeTimer();
105
108
 
106
109
  this.pendingRepoEvent = undefined;
107
- this.pendingWTPaths.clear();
110
+ this.pendingWorkingTreePaths.clear();
108
111
  this.repoDelays.clear();
109
- this.wtDelays.clear();
112
+ this.workingTreeDelays.clear();
110
113
 
111
114
  this.repoEmitter.dispose();
112
- this.wtEmitter.dispose();
115
+ this.workingTreeEmitter.dispose();
113
116
  }
114
117
 
115
118
  get suspended(): boolean {
@@ -117,7 +120,7 @@ export class RepositoryWatchSession implements UnifiedDisposable {
117
120
  }
118
121
 
119
122
  get hasPendingChanges(): boolean {
120
- return this.pendingRepoEvent != null || this.pendingWTPaths.size > 0;
123
+ return this.pendingRepoEvent != null || this.pendingWorkingTreePaths.size > 0;
121
124
  }
122
125
 
123
126
  get repoSubscriberCount(): number {
@@ -125,7 +128,7 @@ export class RepositoryWatchSession implements UnifiedDisposable {
125
128
  }
126
129
 
127
130
  get workingTreeSubscriberCount(): number {
128
- return this.wtDelays.size;
131
+ return this.workingTreeDelays.size;
129
132
  }
130
133
 
131
134
  /**
@@ -197,26 +200,26 @@ export class RepositoryWatchSession implements UnifiedDisposable {
197
200
  return Object.assign(
198
201
  createDisposable(() => {}),
199
202
  {
200
- onDidChangeWorkingTree: this.wtEmitter.event,
203
+ onDidChangeWorkingTree: this.workingTreeEmitter.event,
201
204
  },
202
205
  );
203
206
  }
204
207
 
205
- const id = this.wtNextId++;
206
- const delay = opts?.delayMs ?? this.defaultWTMs;
207
- const wasEmpty = this.wtDelays.size === 0;
208
- const prevDelay = this.effectiveWTMs;
208
+ const id = this.workingTreeNextId++;
209
+ const delay = opts?.delayMs ?? this.defaultWorkingTreeMs;
210
+ const wasEmpty = this.workingTreeDelays.size === 0;
211
+ const prevDelay = this.effectiveWorkingTreeMs;
209
212
 
210
- this.wtDelays.set(id, delay);
211
- this.recalcWTDelay();
213
+ this.workingTreeDelays.set(id, delay);
214
+ this.recalcWorkingTreeDelay();
212
215
 
213
216
  if (wasEmpty) {
214
217
  this.lifecycle?.onFirstWorkingTreeSubscriber?.();
215
218
  }
216
219
 
217
220
  // If the effective delay got shorter while a timer is running, reschedule sooner
218
- if (this.effectiveWTMs < prevDelay && this.wtTimer != null) {
219
- this.scheduleWTFlush();
221
+ if (this.effectiveWorkingTreeMs < prevDelay && this.workingTreeTimer != null) {
222
+ this.scheduleWorkingTreeFlush();
220
223
  }
221
224
 
222
225
  let subDisposed = false;
@@ -226,16 +229,16 @@ export class RepositoryWatchSession implements UnifiedDisposable {
226
229
 
227
230
  subDisposed = true;
228
231
 
229
- this.wtDelays.delete(id);
230
- this.recalcWTDelay();
232
+ this.workingTreeDelays.delete(id);
233
+ this.recalcWorkingTreeDelay();
231
234
 
232
- if (this.wtDelays.size === 0) {
233
- this.cancelWTTimer();
234
- this.pendingWTPaths.clear();
235
+ if (this.workingTreeDelays.size === 0) {
236
+ this.cancelWorkingTreeTimer();
237
+ this.pendingWorkingTreePaths.clear();
235
238
  this.lifecycle?.onLastWorkingTreeSubscriber?.();
236
239
  }
237
240
  }),
238
- { onDidChangeWorkingTree: this.wtEmitter.event },
241
+ { onDidChangeWorkingTree: this.workingTreeEmitter.event },
239
242
  );
240
243
  }
241
244
 
@@ -243,7 +246,7 @@ export class RepositoryWatchSession implements UnifiedDisposable {
243
246
  this._suspended = true;
244
247
  // Cancel active timers — events continue to coalesce but don't fire
245
248
  this.cancelRepoTimer();
246
- this.cancelWTTimer();
249
+ this.cancelWorkingTreeTimer();
247
250
  }
248
251
 
249
252
  resume(delayMs?: number): void {
@@ -255,8 +258,8 @@ export class RepositoryWatchSession implements UnifiedDisposable {
255
258
  if (this.pendingRepoEvent != null) {
256
259
  this.scheduleRepoFlush(delayMs);
257
260
  }
258
- if (this.pendingWTPaths.size > 0) {
259
- this.scheduleWTFlush(delayMs);
261
+ if (this.pendingWorkingTreePaths.size > 0) {
262
+ this.scheduleWorkingTreeFlush(delayMs);
260
263
  }
261
264
  }
262
265
 
@@ -286,7 +289,7 @@ export class RepositoryWatchSession implements UnifiedDisposable {
286
289
  if (this._disposed) return;
287
290
 
288
291
  this._etagWorkingTree++;
289
- this.coalesceWT(paths);
292
+ this.coalesceWorkingTrees(paths);
290
293
  }
291
294
 
292
295
  private static minDelay(delays: Map<number, number>, fallback: number): number {
@@ -305,8 +308,11 @@ export class RepositoryWatchSession implements UnifiedDisposable {
305
308
  this.effectiveRepoMs = RepositoryWatchSession.minDelay(this.repoDelays, this.defaultRepoMs);
306
309
  }
307
310
 
308
- private recalcWTDelay(): void {
309
- this.effectiveWTMs = RepositoryWatchSession.minDelay(this.wtDelays, this.defaultWTMs);
311
+ private recalcWorkingTreeDelay(): void {
312
+ this.effectiveWorkingTreeMs = RepositoryWatchSession.minDelay(
313
+ this.workingTreeDelays,
314
+ this.defaultWorkingTreeMs,
315
+ );
310
316
  }
311
317
 
312
318
  private flushRepo(): void {
@@ -339,29 +345,29 @@ export class RepositoryWatchSession implements UnifiedDisposable {
339
345
  this.scheduleRepoFlush();
340
346
  }
341
347
 
342
- private flushWT(): void {
343
- this.wtTimer = undefined;
344
- if (this.pendingWTPaths.size === 0) return;
348
+ private flushWorkingTree(): void {
349
+ this.workingTreeTimer = undefined;
350
+ if (this.pendingWorkingTreePaths.size === 0) return;
345
351
 
346
- const paths = this.pendingWTPaths;
347
- this.pendingWTPaths = new Set();
348
- this.wtEmitter.fire({ repoPath: this.repoPath, paths: paths });
352
+ const paths = this.pendingWorkingTreePaths;
353
+ this.pendingWorkingTreePaths = new Set();
354
+ this.workingTreeEmitter.fire({ repoPath: this.repoPath, paths: paths });
349
355
  }
350
356
 
351
- private scheduleWTFlush(delayOverride?: number): void {
352
- if (this._suspended || this.pendingWTPaths.size === 0) return;
357
+ private scheduleWorkingTreeFlush(delayOverride?: number): void {
358
+ if (this._suspended || this.pendingWorkingTreePaths.size === 0) return;
353
359
 
354
- if (this.wtTimer != null) {
355
- clearTimeout(this.wtTimer);
360
+ if (this.workingTreeTimer != null) {
361
+ clearTimeout(this.workingTreeTimer);
356
362
  }
357
- this.wtTimer = setTimeout(() => this.flushWT(), delayOverride ?? this.effectiveWTMs);
363
+ this.workingTreeTimer = setTimeout(() => this.flushWorkingTree(), delayOverride ?? this.effectiveWorkingTreeMs);
358
364
  }
359
365
 
360
- private coalesceWT(paths: Iterable<string>): void {
366
+ private coalesceWorkingTrees(paths: Iterable<string>): void {
361
367
  for (const p of paths) {
362
- this.pendingWTPaths.add(p);
368
+ this.pendingWorkingTreePaths.add(p);
363
369
  }
364
- this.scheduleWTFlush();
370
+ this.scheduleWorkingTreeFlush();
365
371
  }
366
372
 
367
373
  private cancelRepoTimer(): void {
@@ -371,10 +377,10 @@ export class RepositoryWatchSession implements UnifiedDisposable {
371
377
  }
372
378
  }
373
379
 
374
- private cancelWTTimer(): void {
375
- if (this.wtTimer != null) {
376
- clearTimeout(this.wtTimer);
377
- this.wtTimer = undefined;
380
+ private cancelWorkingTreeTimer(): void {
381
+ if (this.workingTreeTimer != null) {
382
+ clearTimeout(this.workingTreeTimer);
383
+ this.workingTreeTimer = undefined;
378
384
  }
379
385
  }
380
386
  }
@@ -232,6 +232,7 @@ export class BranchesGitSubProvider implements GitBranchesSubProvider {
232
232
  // Pre-populate current branch cache for worktrees based on worktreePath info
233
233
  // This eliminates the need for rev-parse calls during branch reconstruction
234
234
  for (const branch of branches) {
235
+ // oxlint-disable-next-line typescript/prefer-optional-chain
235
236
  if (branch.worktree && branch.worktree.path) {
236
237
  const { name } = parseRefName(branch.refName);
237
238
  const reference = createReference(name, branch.worktree.path, {
@@ -129,6 +129,30 @@ export class CommitsGitSubProvider implements GitCommitsSubProvider {
129
129
  return isNaN(count) ? undefined : count;
130
130
  }
131
131
 
132
+ @debug({ exit: true })
133
+ async hasUnpublishedCommits(
134
+ repoPath: string,
135
+ rev: string,
136
+ cancellation?: AbortSignal,
137
+ ): Promise<boolean | undefined> {
138
+ // `<rev> --not --remotes` = commits reachable from `rev` but not from any remote-tracking ref.
139
+ // `--max-count=1` early-exits at the first such commit, so this is a presence probe, not a count.
140
+ const result = await this.git.run(
141
+ { cwd: repoPath, cancellation: cancellation, errors: 'ignore' },
142
+ 'rev-list',
143
+ '--max-count=1',
144
+ rev,
145
+ '--not',
146
+ '--remotes',
147
+ '--',
148
+ );
149
+ if (result.cancelled || cancellation?.aborted) {
150
+ throw new CancellationError();
151
+ }
152
+
153
+ return result.stdout.trim().length > 0;
154
+ }
155
+
132
156
  @debug({ exit: true })
133
157
  async getCommitDates(
134
158
  repoPath: string,
@@ -27,17 +27,24 @@ import type { Git } from '../exec/git.js';
27
27
  const mappedAuthorRegex = /(.+)\s<(.+)>/;
28
28
  const emptyArray: readonly never[] = Object.freeze([]);
29
29
 
30
+ /** Catch-all pattern matching every key in `.git/gk/config` for the one-shot bulk read. */
31
+ const gkConfigAllPattern = '.';
32
+
30
33
  /**
31
- * Namespaces whose individual reads should be served from one --get-regex fetch instead
32
- * of one `git config --get` per key. The regex cache backing getGkConfigRegex dedupes
33
- * concurrent callers so a burst of per-branch reads produces a single subprocess.
34
+ * Canonicalizes a git config key for lookup against `--get-regexp` output, which git emits with the
35
+ * section and variable-name lowercased and only the subsection case-preserved. Without this a
36
+ * subsection-less key like `gk.defaultRemote` would miss the lowercased `gk.defaultremote` in the map.
34
37
  */
35
- const gkConfigCacheableSets: readonly { match: RegExp; pattern: string }[] = [
36
- { match: /^branch\..+\.gk-associated-issues$/, pattern: '^branch\\..+\\.gk-associated-issues$' },
37
- { match: /^branch\..+\.gk-merge-base$/, pattern: '^branch\\..+\\.gk-merge-base$' },
38
- { match: /^branch\..+\.gk-merge-target$/, pattern: '^branch\\..+\\.gk-merge-target$' },
39
- { match: /^branch\..+\.gk-merge-target-user$/, pattern: '^branch\\..+\\.gk-merge-target-user$' },
40
- ];
38
+ function canonicalizeGitConfigKey(key: string): string {
39
+ const first = key.indexOf('.');
40
+ if (first === -1) return key.toLowerCase();
41
+
42
+ const last = key.lastIndexOf('.');
43
+ // `section.name` (no subsection) → fully lowercased; `section.subsection.name` → lowercase
44
+ // section + name, preserve the case-sensitive subsection in the middle.
45
+ if (first === last) return key.toLowerCase();
46
+ return `${key.slice(0, first).toLowerCase()}${key.slice(first, last)}${key.slice(last).toLowerCase()}`;
47
+ }
41
48
 
42
49
  /**
43
50
  * Parses git config --get-regex output into a Map.
@@ -309,45 +316,49 @@ export class ConfigGitSubProvider implements GitConfigSubProvider {
309
316
  }
310
317
 
311
318
  @debug()
312
- async getGkConfig(
313
- repoPath: string,
314
- key: GkConfigKeys | DeprecatedGkConfigKeys,
315
- options?: { type?: GitConfigType },
316
- ): Promise<string | undefined> {
317
- await this.migrateGkConfigFromGitConfig(this.cache.getCommonPath(repoPath));
319
+ async getGkConfig(repoPath: string, key: GkConfigKeys | DeprecatedGkConfigKeys): Promise<string | undefined> {
320
+ // Served in-memory from a single bulk read of the whole `.git/gk/config` (no gk read needs `--type`).
321
+ // Canonicalize the key the way git does for `--get-regexp` output so subsection-less keys
322
+ // (e.g. `gk.defaultRemote`) match, and coerce an empty value to undefined to match the old
323
+ // `git config --get` (`.trim() || undefined`) contract.
324
+ return (await this.getGkConfigMap(repoPath)).get(canonicalizeGitConfigKey(key)) || undefined;
325
+ }
318
326
 
319
- // Per-branch keys are commonly read in fan-out loops (e.g. Home overview enrichment).
320
- // Satisfy the read from a single --get-regex over the namespace instead of spawning
321
- // one `git config --get` per branch.
322
- const set = gkConfigCacheableSets.find(s => s.match.test(key));
323
- if (set != null) {
324
- return this.cache.getGkConfig(repoPath, key, async () => {
325
- const entries = await this.getGkConfigRegex(repoPath, set.pattern);
326
- return entries.get(key);
327
- });
328
- }
327
+ @debug()
328
+ async getGkConfigRegex(repoPath: string, pattern: string): Promise<Map<string, string>> {
329
+ const all = await this.getGkConfigMap(repoPath);
329
330
 
330
- return this.cache.getGkConfig(repoPath, key, () => this.getGkConfigCore(repoPath, key, options));
331
- }
331
+ // Filter the bulk map in-memory. The old path passed `pattern` to `git config --get-regex`
332
+ // (errors ignored → empty result on a bad pattern); preserve that no-throw contract here
333
+ // rather than letting an invalid-JS pattern reject the read.
334
+ let re: RegExp;
335
+ try {
336
+ re = new RegExp(pattern);
337
+ } catch {
338
+ return new Map();
339
+ }
332
340
 
333
- private async getGkConfigCore(
334
- repoPath: string,
335
- key: string,
336
- options?: { type?: GitConfigType },
337
- ): Promise<string | undefined> {
338
- const gkConfigPath = await this.getGkConfigPath(repoPath);
339
- if (!gkConfigPath) return undefined;
340
- return this.getConfigCore(repoPath, key, {
341
- ...options,
342
- runGitLocally: true,
343
- file: gkConfigPath,
344
- });
341
+ const result = new Map<string, string>();
342
+ for (const [k, v] of all) {
343
+ if (re.test(k)) {
344
+ result.set(k, v);
345
+ }
346
+ }
347
+ return result;
345
348
  }
346
349
 
347
- @debug()
348
- async getGkConfigRegex(repoPath: string, pattern: string): Promise<Map<string, string>> {
350
+ /**
351
+ * Reads the entire `.git/gk/config` in one `git config --get-regexp` and caches the parsed map
352
+ * per commonPath. Per-key (getGkConfig) and per-namespace (getGkConfigRegex) lookups are served
353
+ * from this single cached read — so a branch-overview render that fans out across several gk
354
+ * namespaces costs one `git config` call instead of one per namespace.
355
+ */
356
+ private async getGkConfigMap(repoPath: string): Promise<Map<string, string>> {
357
+ // Migrate BEFORE populating the cache, not inside the factory: a first-time migration calls
358
+ // clearCaches('gkConfig'), which would otherwise evict the in-flight gkConfigMap entry the
359
+ // cache wrapper synchronously stored and force a redundant bulk re-read.
349
360
  await this.migrateGkConfigFromGitConfig(this.cache.getCommonPath(repoPath));
350
- return this.cache.getGkConfigRegex(repoPath, pattern, () => this.getGkConfigRegexCore(repoPath, pattern));
361
+ return this.cache.getGkConfigMap(repoPath, () => this.getGkConfigRegexCore(repoPath, gkConfigAllPattern));
351
362
  }
352
363
 
353
364
  private async getGkConfigRegexCore(repoPath: string, pattern: string): Promise<Map<string, string>> {
@@ -377,7 +388,9 @@ export class ConfigGitSubProvider implements GitConfigSubProvider {
377
388
 
378
389
  const promise = this.setGkConfigCore(repoPath, key, value, options);
379
390
  this._pendingGkConfigWrites.set(dedupKey, promise);
380
- void promise.finally(() => this._pendingGkConfigWrites.delete(dedupKey));
391
+ // Swallow the cleanup chain's rejection so a failed write doesn't surface as an unhandled
392
+ // rejection separate from the caller's own handling of the returned promise.
393
+ void promise.finally(() => this._pendingGkConfigWrites.delete(dedupKey)).catch(() => {});
381
394
  return promise;
382
395
  }
383
396
 
@@ -398,7 +411,9 @@ export class ConfigGitSubProvider implements GitConfigSubProvider {
398
411
 
399
412
  await this.setConfigCore(repoPath, key, value, { file: gkConfigPath });
400
413
 
401
- // Invalidate the cached value for this key and clear all regex patterns for this scope
414
+ // Invalidate the bulk map (so the next read re-fetches) and the derived caches for this key's
415
+ // ref. The `.git/gk/config` file-watcher also fires `'gkConfig'` shortly after, which clears
416
+ // the same caches coarsely — both paths are idempotent.
402
417
  this.cache.deleteGkConfig(repoPath, key, options);
403
418
  }
404
419
 
@@ -418,11 +433,10 @@ export class ConfigGitSubProvider implements GitConfigSubProvider {
418
433
  const sshProgram = configMap.get('gpg.ssh.program');
419
434
  const allowedSignersFile = configMap.get('gpg.ssh.allowedsignersfile');
420
435
 
421
- // Check if git config has commit signing enabled
422
- const isEnabled = parseGitBoolean(enabledRaw);
423
-
424
- // Note: To override commit signing, pass it via the host/caller.
425
- // The library no longer reads a config for this.
436
+ // Check if git config has commit signing enabled, falling back to the host-level override
437
+ // (e.g. VS Code's `git.enableCommitSigning`) when `commit.gpgsign` is unset/false. The host
438
+ // override can only enable signing, never force it off.
439
+ const isEnabled = parseGitBoolean(enabledRaw) || this.context.config?.signing?.enabled === true;
426
440
 
427
441
  return {
428
442
  enabled: isEnabled,
@@ -218,7 +218,8 @@ export class DiffGitSubProvider implements GitDiffSubProvider {
218
218
  },
219
219
  ): Promise<GitFile[] | undefined> {
220
220
  try {
221
- const similarityThreshold = options?.similarityThreshold;
221
+ const similarityThreshold =
222
+ options?.similarityThreshold ?? this.context.config?.commits.similarityThreshold;
222
223
  const configs =
223
224
  options?.renameLimit != null
224
225
  ? [...gitConfigsDiff, '-c', `diff.renameLimit=${options.renameLimit}`]