@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
@@ -19,6 +19,14 @@ export interface GitGraphRowHead {
19
19
  isCurrentHead: boolean;
20
20
  context?: string | object;
21
21
  upstream?: { name: string; id: string };
22
+ /** Set when this branch is checked out in a (non-default) worktree. Grouped so producers can't
23
+ * half-populate id-without-path or vice versa. GitLens consumers should read this field. */
24
+ worktree?: { id: string; path: string };
25
+ /** Upstream-component-compatibility mirror of `worktree?.id`. The bundled
26
+ * `@gitkraken/gitkraken-components` library still reads `worktreeId` to switch between
27
+ * WORKTREE and HEAD ref-badge styling — keeping it populated preserves the visual cue.
28
+ * Producers MUST set this whenever they set `worktree`; do not read it from GitLens code,
29
+ * read `worktree.id` instead. */
22
30
  worktreeId?: string;
23
31
  }
24
32
 
@@ -40,11 +48,43 @@ export interface GitGraphRowTag {
40
48
  context?: string | object;
41
49
  }
42
50
 
51
+ /**
52
+ * Compact, host-computed flags for a commit/stash row, shipped instead of the bulky serialized
53
+ * `contexts.row`/`contexts.avatar` blobs (which duplicated sha/message/repoPath already on the row).
54
+ * The webview reconstructs the full webview-item contexts from these flags + row fields + repoPath.
55
+ * Bit 0: reachable-from-HEAD (`+current`); Bit 1: unique-to-one-local-branch (`+unique`);
56
+ * Bit 2: has-children — set ONLY on undo-eligible tip rows (active HEAD + worktree HEADs) that have a
57
+ * child, to gate Undo Commit to leaf tips (undoing a commit other work is stacked on is unsafe). It is
58
+ * NOT a general has-children signal: non-tip rows never carry it (the host only computes it for tips);
59
+ * Bit 3: unpushed/ahead-of-upstream (`+unpublished`).
60
+ * Bit 4: history-rewriteable (`+rewriteable`) — on the first-parent chain from HEAD up to (excluding)
61
+ * the first merge commit, so a plain (non-`--rebase-merges`) interactive rebase can safely rewrite it;
62
+ * gates squash/drop/reword/modify. Strictly narrower than `+current` (reachable-from-HEAD includes a
63
+ * merge's other-parent ancestry, which is NOT safely rewriteable).
64
+ * (`+HEAD` derives from `row.heads[].isCurrentHead`; contributor `+current` from `row.isCurrentUser`.)
65
+ */
66
+ export const enum GitGraphRowContextFlags {
67
+ None = 0,
68
+ ReachableFromHead = 1 << 0,
69
+ UniqueToBranch = 1 << 1,
70
+ HasChildren = 1 << 2,
71
+ Unpublished = 1 << 3,
72
+ RewriteableFromHead = 1 << 4,
73
+ }
74
+
43
75
  /**
44
76
  * Context data attached to graph row regions for command/menu handling.
45
77
  * Structurally compatible with @gitkraken/gitkraken-components RowContexts.
46
78
  */
47
79
  export interface GitGraphRowContexts {
80
+ /** Compact replacement for the serialized commit `row`/`avatar` contexts; see {@link GitGraphRowContextFlags}. */
81
+ flags?: GitGraphRowContextFlags;
82
+ /**
83
+ * Index into the per-graph `GraphReachabilityTable.sets` for this row's reachable-refs set,
84
+ * shipped instead of the per-row `reachability` object (which dominated the graph payload). The
85
+ * webview rebuilds `row.reachability` from the shared table. Absent ⇒ no reachability for this row.
86
+ */
87
+ reachabilityIndex?: number;
48
88
  row?: string | object;
49
89
  ref?: string | object;
50
90
  refGroups?: Record<string, string | object>;
@@ -63,6 +103,28 @@ export interface GitGraphRowStats {
63
103
  deletions: number;
64
104
  }
65
105
 
106
+ /**
107
+ * Wire encoding for per-row reachability, shipped once per graph payload instead of inline on every
108
+ * row. Git reachability is monotone (adjacent commits share ref-sets), so the rows collapse to a
109
+ * relatively small number of distinct sets over the repo's ref universe — `dictionary` (the shared
110
+ * ref list) + `sets` (distinct membership bitmaps, base64; bit i ⇒ `dictionary[i]` reachable) + a
111
+ * per-row index (`GitGraphRowContexts.reachabilityIndex`) reconstructs every row's `reachability`
112
+ * client-side for a fraction of the bytes the inline objects cost. `current` is a per-ref property
113
+ * (the checked-out HEAD branch) and lives in the dictionary entry.
114
+ *
115
+ * The table is append-only within a graph generation, so the host ships only the entries appended
116
+ * since its last push (a delta) on same-generation pagination and the full table on a new generation;
117
+ * `id` lets the webview tell the two apart (same `id` ⇒ append the delta, new `id` ⇒ replace + reset
118
+ * its decode cache). A fresh `getGraph` walk mints a new `id`; `more()` keeps the same one.
119
+ */
120
+ export interface GraphReachabilityTable {
121
+ /** Generation id — stable across `more()` pagination, new per fresh graph walk. */
122
+ readonly id: number;
123
+ readonly dictionary: GitCommitReachability['refs'];
124
+ /** Distinct ref-membership bitmaps, base64-encoded `Uint8Array` of `ceil(dictionary.length/8)` bytes. */
125
+ readonly sets: string[];
126
+ }
127
+
66
128
  /** Library-owned graph row type. Structurally compatible with @gitkraken/gitkraken-components GraphRow. */
67
129
  export interface GitGraphRow {
68
130
  sha: string;
@@ -78,6 +140,12 @@ export interface GitGraphRow {
78
140
  tags?: GitGraphRowTag[];
79
141
  contexts?: GitGraphRowContexts;
80
142
  stats?: GitGraphRowStats;
143
+ /**
144
+ * Transient, set only for the duration of {@link GraphRowProcessor.processRow} (the row processor's
145
+ * `+unique` decision reads it). The provider strips it before pushing the row and records the
146
+ * reachable-ref set into the shared {@link GitGraph.reachability} table instead (see
147
+ * {@link GitGraphRowContexts.reachabilityIndex}), so emitted rows do NOT retain the per-row arrays.
148
+ */
81
149
  reachability?: GitCommitReachability;
82
150
  isCurrentUser?: boolean;
83
151
  }
@@ -99,6 +167,22 @@ export interface GitGraph {
99
167
  /** SHAs reachable from HEAD (for enrichment: marking commits on the current branch) */
100
168
  readonly reachableFromHEAD?: ReadonlySet<string>;
101
169
 
170
+ /**
171
+ * SHAs on the first-parent chain from HEAD up to (excluding) the first merge commit — i.e. the
172
+ * commits a plain interactive rebase can safely rewrite. Empty when HEAD itself is a merge. Used by
173
+ * the graph's history-rewriting commands (squash/drop/reword/modify) to validate selections.
174
+ */
175
+ readonly rewriteableFromHEAD?: ReadonlySet<string>;
176
+
177
+ /**
178
+ * Shared, append-only reachability table for the loaded rows (the primary representation — rows
179
+ * carry only a {@link GitGraphRowContexts.reachabilityIndex} into it, not per-row ref arrays).
180
+ * Grows monotonically across {@link more} pagination within a graph session: indices already
181
+ * assigned never change, so consumers can decode any row on demand and cache by index. Absent ⇒
182
+ * no row in the graph has reachability. See {@link GraphReachabilityTable}.
183
+ */
184
+ readonly reachability?: GraphReachabilityTable;
185
+
102
186
  /** The rows for the set of commits requested */
103
187
  readonly rows: GitGraphRow[];
104
188
  readonly id?: string;
@@ -178,5 +262,18 @@ export interface GraphContext {
178
262
  readonly branchIdOfMainWorktree: string | undefined;
179
263
  readonly stashes: ReadonlyMap<string, GitStashCommit> | undefined;
180
264
  readonly reachableFromHEAD: ReadonlySet<string>;
265
+ /** SHAs on the first-parent chain from HEAD up to (excluding) the first merge commit — the commits
266
+ * a plain interactive rebase can safely rewrite. Empty when HEAD is a merge. Sets the
267
+ * {@link GitGraphRowContextFlags.RewriteableFromHead} flag, gating squash/drop/reword/modify. */
268
+ readonly rewriteableFromHEAD: ReadonlySet<string>;
269
+ /** The subset of undo-eligible tip shas (active HEAD + worktree HEADs) that have at least one
270
+ * child — i.e. are NOT leaves. Scoped to tips (not all commits) for performance; do not treat as
271
+ * a general has-children signal. Sets the {@link GitGraphRowContextFlags.HasChildren} flag, which
272
+ * gates Undo Commit to leaf tips. */
273
+ readonly tipShasWithChildren: ReadonlySet<string>;
274
+ /** SHAs reachable from HEAD's tracking-upstream tip, or `undefined` when HEAD has no upstream.
275
+ * A commit reachable from HEAD but NOT from this set is unpushed (`+unpublished`); `undefined`
276
+ * means there's no upstream to be ahead of, so nothing is flagged. */
277
+ readonly reachableFromHeadUpstream: ReadonlySet<string> | undefined;
181
278
  readonly avatars: Map<string, string>;
182
279
  }
@@ -74,6 +74,7 @@ export interface RemotesUrlsConfig {
74
74
  readonly fileInCommit: string;
75
75
  readonly fileLine: string;
76
76
  readonly fileRange: string;
77
+ readonly avatar?: string;
77
78
  }
78
79
 
79
80
  export abstract class RemoteProvider<T extends ResourceDescriptor = ResourceDescriptor> implements ProviderReference {
@@ -7,7 +7,6 @@ import type { UnifiedDisposable } from '../../utils/disposable.js';
7
7
  import { createDisposable } from '../../utils/disposable.js';
8
8
  import type { Event } from '../../utils/event.js';
9
9
  import { Emitter } from '../../utils/event.js';
10
- import { getScopedLogger } from '../../utils/logger.scoped.js';
11
10
  import { basename, normalizePath } from '../../utils/path.js';
12
11
  import type { Uri } from '../../utils/uri.js';
13
12
  import { fileUri } from '../../utils/uri.js';
@@ -84,8 +83,6 @@ export interface RepositoryInit {
84
83
  readonly gitDir: GitDir | undefined;
85
84
  readonly index: number;
86
85
  readonly root: boolean;
87
- readonly closed?: boolean;
88
- readonly suspended?: boolean;
89
86
  readonly watchService: RepositoryWatchService;
90
87
  }
91
88
 
@@ -118,19 +115,21 @@ export class Repository {
118
115
  readonly root: boolean;
119
116
 
120
117
  protected readonly _gitDir: GitDir | undefined;
121
- protected _pendingResumeTimer: ReturnType<typeof setTimeout> | undefined;
122
118
  protected _repoSubscription: RepositorySubscription | undefined;
123
- protected _suspended: boolean;
124
119
  protected _watchHandle: WatchHandle | undefined;
125
120
  protected readonly _watchService: RepositoryWatchService;
126
121
 
127
122
  private _pendingWorkingTreeChange?: RepositoryWorkingTreeChangeEvent;
128
123
  private _pendingWorkingTreeFlush = false;
129
124
  private _repoChangeListener: UnifiedDisposable | undefined;
125
+ /** Outstanding leases on the shared {@link _watchHandle} across `watch()` + `watchWorkingTree()`. */
126
+ private _watchHandleRefs = 0;
127
+ /** Outstanding `watch()` (repo-change) leases; drives the single shared bridge subscription. */
128
+ private _watchRefs = 0;
129
+ private _disposed = false;
130
130
 
131
131
  constructor(init: RepositoryInit) {
132
132
  ({
133
- closed: this._closed = init.closed ?? false,
134
133
  id: this.id,
135
134
  index: this.index,
136
135
  gitDir: this._gitDir,
@@ -138,7 +137,6 @@ export class Repository {
138
137
  path: this.path,
139
138
  provider: this.provider,
140
139
  root: this.root,
141
- suspended: this._suspended = init.suspended ?? false,
142
140
  uri: this.uri,
143
141
  watchService: this._watchService,
144
142
  } = init);
@@ -156,45 +154,25 @@ export class Repository {
156
154
  this._name = `${prefix}${this._name}`;
157
155
  }
158
156
  }
159
-
160
- this.setupWatching();
161
157
  }
162
158
 
163
159
  dispose(): void {
164
- clearTimeout(this._pendingResumeTimer);
160
+ this._disposed = true;
165
161
  this._repoChangeListener?.dispose();
166
162
  this._repoChangeListener = undefined;
167
163
  this._repoSubscription?.dispose();
168
164
  this._repoSubscription = undefined;
169
165
  this._watchHandle?.dispose();
170
166
  this._watchHandle = undefined;
167
+ this._watchHandleRefs = 0;
168
+ this._watchRefs = 0;
171
169
  this._onDidChange.dispose();
172
170
  this._onDidChangeWorkingTree.dispose();
173
171
  }
174
172
 
175
- protected _closed: boolean;
176
- get closed(): boolean {
177
- return this._closed;
178
- }
179
- set closed(value: boolean) {
180
- const changed = this._closed !== value;
181
- this._closed = value;
182
- if (changed) {
183
- if (this._closed) {
184
- // When closing, fire immediately even if suspended
185
- this.fireChange('closed', true);
186
-
187
- this._repoChangeListener?.dispose();
188
- this._repoChangeListener = undefined;
189
- this._repoSubscription?.dispose();
190
- this._repoSubscription = undefined;
191
- } else {
192
- this.setupWatching();
193
- this.fireChange('opened');
194
- }
195
-
196
- this.onClosedChanged(value);
197
- }
173
+ /** Indicates whether this repository currently has an active `watch()` (repo-change) lease. */
174
+ get watching(): boolean {
175
+ return this._watchRefs > 0;
198
176
  }
199
177
 
200
178
  get etag(): number {
@@ -249,41 +227,6 @@ export class Repository {
249
227
  return this.provider.virtual;
250
228
  }
251
229
 
252
- @trace({ onlyExit: true })
253
- suspend(): void {
254
- this._suspended = true;
255
- this._watchHandle?.session.suspend();
256
-
257
- if (this._pendingResumeTimer != null) {
258
- clearTimeout(this._pendingResumeTimer);
259
- this._pendingResumeTimer = undefined;
260
- }
261
- }
262
-
263
- @trace({ onlyExit: true })
264
- resume(delayMs?: number): void {
265
- if (delayMs) {
266
- if (this._pendingResumeTimer != null) {
267
- clearTimeout(this._pendingResumeTimer);
268
- }
269
- this._pendingResumeTimer = setTimeout(() => {
270
- this._pendingResumeTimer = undefined;
271
- this.resume();
272
- }, delayMs);
273
- return;
274
- }
275
-
276
- const scope = getScopedLogger();
277
-
278
- if (!this._suspended) {
279
- scope?.addExitInfo('ignored; not suspended');
280
- return;
281
- }
282
-
283
- this._suspended = false;
284
- this._watchHandle?.session.resume();
285
- }
286
-
287
230
  waitForRepoChange(timeoutMs: number): Promise<boolean> {
288
231
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
289
232
  let listener: UnifiedDisposable | undefined;
@@ -313,17 +256,57 @@ export class Repository {
313
256
  ]);
314
257
  }
315
258
 
259
+ /**
260
+ * Starts watching this repository's `.git` directory for repo-change events (delivered via
261
+ * {@link onDidChange}) and returns a lease. Ref-counted: the underlying watch handle is acquired
262
+ * on the first lease (across `watch()` and {@link watchWorkingTree}) and released when the last
263
+ * is disposed. The model is inert until watched. No-ops (returns a disposable that does nothing)
264
+ * for repositories without a git directory (e.g. virtual repos).
265
+ */
266
+ @trace({ onlyExit: true })
267
+ watch(): UnifiedDisposable {
268
+ const acquired = this.acquireWatchHandle();
269
+ if (acquired == null) return createDisposable(() => {});
270
+
271
+ if (this._watchRefs++ === 0) {
272
+ const sub = acquired.handle.session.subscribe();
273
+ this._repoSubscription = sub;
274
+ this._repoChangeListener = sub.onDidChange(e => this.onSessionRepoChange(e));
275
+ }
276
+
277
+ return createDisposable(
278
+ () => {
279
+ // Repo already torn down — its handle/subscriptions are gone; skip to avoid a negative refcount
280
+ if (this._disposed) return;
281
+
282
+ if (--this._watchRefs === 0) {
283
+ this._repoChangeListener?.dispose();
284
+ this._repoChangeListener = undefined;
285
+ this._repoSubscription?.dispose();
286
+ this._repoSubscription = undefined;
287
+ }
288
+ acquired.lease.dispose();
289
+ },
290
+ { once: true },
291
+ );
292
+ }
293
+
316
294
  @trace({ onlyExit: true })
317
295
  watchWorkingTree(delay: number = 2500): UnifiedDisposable {
318
- const sub = this._watchHandle?.session.subscribeToWorkingTree({ delayMs: delay });
319
- if (sub == null) return createDisposable(() => {});
296
+ const acquired = this.acquireWatchHandle();
297
+ if (acquired == null) return createDisposable(() => {});
320
298
 
299
+ const sub = acquired.handle.session.subscribeToWorkingTree({ delayMs: delay });
321
300
  const listener = sub.onDidChangeWorkingTree(e => this.onWorkingTreeChanged(e.paths));
322
301
 
323
- return createDisposable(() => {
324
- listener.dispose();
325
- sub.dispose();
326
- });
302
+ return createDisposable(
303
+ () => {
304
+ listener.dispose();
305
+ sub.dispose();
306
+ acquired.lease.dispose();
307
+ },
308
+ { once: true },
309
+ );
327
310
  }
328
311
 
329
312
  /**
@@ -338,7 +321,7 @@ export class Repository {
338
321
  protected fireChange(change: RepositoryChange, force: boolean): void;
339
322
  @trace()
340
323
  protected fireChange(...args: RepositoryChange[] | [RepositoryChange, boolean]): void {
341
- const lastArg = args[args.length - 1];
324
+ const lastArg = args.at(-1);
342
325
  const force = typeof lastArg === 'boolean' ? lastArg : false;
343
326
  const changes = (force ? args.slice(0, -1) : args) as RepositoryChange[];
344
327
 
@@ -353,9 +336,6 @@ export class Repository {
353
336
  this._watchHandle?.session.fireChange(...changes);
354
337
  }
355
338
 
356
- /** Called when the closed state changes. Override to add behavior (e.g., branch access tracking). */
357
- protected onClosedChanged(_closed: boolean): void {}
358
-
359
339
  /** Called when the watch service notifies of FETCH_HEAD changes. */
360
340
  protected onFetchHeadChanged(): void {
361
341
  // Don't reset `_lastFetched` here: the FS watcher can fire even when the on-disk mtime
@@ -414,39 +394,50 @@ export class Repository {
414
394
  (this._pendingWorkingTreeChange.uris as Set<Uri>).add(fileUri(p));
415
395
  }
416
396
 
417
- if (this._suspended) return;
418
397
  if (this._pendingWorkingTreeFlush) return;
419
398
 
420
399
  this._pendingWorkingTreeFlush = true;
421
400
  queueMicrotask(() => {
422
401
  this._pendingWorkingTreeFlush = false;
423
402
  const e = this._pendingWorkingTreeChange;
424
- if (e == null || this._suspended) return;
403
+ if (e == null) return;
425
404
 
426
405
  this._pendingWorkingTreeChange = undefined;
427
406
  this._onDidChangeWorkingTree.fire(e);
428
407
  });
429
408
  }
430
409
 
431
- @trace({ onlyExit: true })
432
- private setupWatching(): void {
433
- if (this._gitDir == null || this._closed) return;
434
- if (this._repoSubscription != null) return;
435
-
436
- const scope = getScopedLogger();
410
+ /**
411
+ * Lazily acquires (and ref-counts) the shared {@link _watchHandle} for this repository, returning
412
+ * the handle plus a one-shot lease whose disposal decrements the ref count (releasing the handle
413
+ * when the last lease is gone). Returns `undefined` for repositories without a git directory.
414
+ */
415
+ private acquireWatchHandle(): { handle: WatchHandle; lease: UnifiedDisposable } | undefined {
416
+ if (this._gitDir == null || this._disposed) return undefined;
437
417
 
438
418
  this._watchHandle ??= this._watchService.watch(this.path, this._gitDir, {
439
419
  onFetchHeadChanged: () => this.onFetchHeadChanged(),
440
420
  onGitIgnoreChanged: () => this.onGitIgnoreChanged(),
441
421
  onIgnoresChanged: () => this.onIgnoresChanged(),
442
422
  } satisfies WatchHooks);
443
- if (this._watchHandle == null) return;
444
-
445
- const sub = this._watchHandle.session.subscribe();
446
- this._repoSubscription = sub;
447
- this._repoChangeListener = sub.onDidChange(e => this.onSessionRepoChange(e));
448
-
449
- scope?.trace(`subscribed to repo changes for '${this.uri.toString()}'`);
423
+ const handle = this._watchHandle;
424
+ if (handle == null) return undefined;
425
+
426
+ this._watchHandleRefs++;
427
+ const lease = createDisposable(
428
+ () => {
429
+ // Repo already torn down — the handle is gone; skip to avoid a negative refcount
430
+ if (this._disposed) return;
431
+
432
+ if (--this._watchHandleRefs === 0) {
433
+ this._watchHandle?.dispose();
434
+ this._watchHandle = undefined;
435
+ }
436
+ },
437
+ { once: true },
438
+ );
439
+
440
+ return { handle: handle, lease: lease };
450
441
  }
451
442
 
452
443
  static is(repository: unknown): repository is Repository {
@@ -94,6 +94,18 @@ export class GitStatusFile implements GitFile {
94
94
  return this.conflictStatus != null;
95
95
  }
96
96
 
97
+ /**
98
+ * True when the file has both staged (index) and unstaged (working-tree) changes and is not
99
+ * conflicted. Used by the host-side discard flows to apply partial-discard semantics — drop the
100
+ * working-tree delta while preserving the staged portion. Centralized here so those host paths
101
+ * agree on what "mixed" means. (The webview button predicate can't consume this getter — it
102
+ * runs against the wire `GitFileChangeShape` and infers the same state from the duplicate
103
+ * staged/unstaged rows the WIP feed emits per mixed path.)
104
+ */
105
+ get mixed(): boolean {
106
+ return this.indexStatus != null && this.workingTreeStatus != null && this.conflictStatus == null;
107
+ }
108
+
97
109
  get staged(): boolean {
98
110
  return this.indexStatus != null;
99
111
  }
@@ -78,6 +78,10 @@ export interface GitCommitReachability {
78
78
  export interface GitCommitsSubProvider {
79
79
  getCommit(repoPath: string, rev: string, cancellation?: AbortSignal): Promise<GitCommit | undefined>;
80
80
  getCommitCount(repoPath: string, rev: string, cancellation?: AbortSignal): Promise<number | undefined>;
81
+ /** Whether `rev` has any commits not reachable from any remote-tracking ref (i.e. unpushed/unpublished).
82
+ * Cheap early-exit probe (`rev-list --not --remotes <rev> -n 1`) — it does NOT count them. Returns
83
+ * `undefined` when it can't be determined. */
84
+ hasUnpublishedCommits?(repoPath: string, rev: string, cancellation?: AbortSignal): Promise<boolean | undefined>;
81
85
  /** Cheap author/committer date lookup for a single revision. Skips the full commit parse (no files, parents, or message) — use when only the dates are needed. Pass a full SHA so caching stays correct (commits are immutable; refs are not). */
82
86
  getCommitDates?(
83
87
  repoPath: string,
@@ -88,11 +88,7 @@ export interface GitConfigSubProvider {
88
88
  | []
89
89
  >;
90
90
 
91
- getGkConfig?(
92
- repoPath: string,
93
- key: GkConfigKeys | DeprecatedGkConfigKeys,
94
- options?: { type?: GitConfigType },
95
- ): Promise<string | undefined>;
91
+ getGkConfig?(repoPath: string, key: GkConfigKeys | DeprecatedGkConfigKeys): Promise<string | undefined>;
96
92
  getGkConfigRegex?(repoPath: string, pattern: string): Promise<Map<string, string>>;
97
93
  setGkConfig?(
98
94
  repoPath: string,
@@ -21,31 +21,14 @@ export interface GitOperationResult {
21
21
  export type GitOperationRunOptions = Pick<GitRunOptions, 'env' | 'cancellation' | 'timeout'>;
22
22
 
23
23
  export interface GitOperationsSubProvider {
24
- checkout(
25
- repoPath: string,
26
- ref: string,
27
- options?: { createBranch?: string | undefined } | { path?: string | undefined },
28
- runOptions?: GitOperationRunOptions,
29
- ): Promise<void>;
30
24
  /**
31
- * Checks out one side of a conflicted path during a paused merge/rebase/cherry-pick.
32
- * Executes `git checkout --ours|--theirs -- <path>`, leaving the file unstaged.
25
+ * Switches the repository to a branch or ref. Pure branch/ref switching — does not accept
26
+ * a path. For path-level operations use {@link restore}.
33
27
  */
34
- checkoutConflictedPath(
35
- repoPath: string,
36
- path: string,
37
- side: 'ours' | 'theirs',
38
- runOptions?: GitOperationRunOptions,
39
- ): Promise<void>;
40
- /**
41
- * Checks out one side for multiple conflicted paths during a paused merge/rebase/cherry-pick.
42
- * Batches paths into `git checkout --ours|--theirs -- <paths...>`, chunked to stay under the
43
- * CLI length limit. Leaves files unstaged.
44
- */
45
- checkoutConflictedPaths(
28
+ checkout(
46
29
  repoPath: string,
47
- paths: string[],
48
- side: 'ours' | 'theirs',
30
+ ref: string,
31
+ options?: { createBranch?: string | undefined },
49
32
  runOptions?: GitOperationRunOptions,
50
33
  ): Promise<void>;
51
34
  cherryPick(
@@ -110,8 +93,24 @@ export interface GitOperationsSubProvider {
110
93
  options?: {
111
94
  autoStash?: boolean;
112
95
  branch?: string;
96
+ /** Command set as `sequence.editor` — edits the interactive rebase todo list. */
113
97
  editor?: string;
114
98
  interactive?: boolean;
99
+ /**
100
+ * Set when the `editor` is a script that rewrites the todo by command word + SHA (e.g. the
101
+ * Commit Graph's headless squash/drop/reword) rather than a human. Forces git to emit a plain,
102
+ * natural-order todo by disabling `rebase.autosquash` (which would reorder commits and rewrite
103
+ * `pick`→`fixup` for `fixup!`/`squash!` commits) and `rebase.abbreviateCommands` (which would
104
+ * emit `p` instead of `pick`). Both honor the user's git config otherwise.
105
+ */
106
+ programmaticEditor?: boolean;
107
+ /**
108
+ * Command git uses to edit per-commit messages (the combined message a `squash` produces, or a
109
+ * `reword`). Applied as `GIT_EDITOR` — which git's interactive-rebase `reword`/`squash` step honors,
110
+ * unlike `core.editor` — with `core.editor` also set as a fallback. When omitted, git falls back to
111
+ * the user's configured editor.
112
+ */
113
+ messageEditor?: string;
115
114
  onto?: string;
116
115
  updateRefs?: boolean;
117
116
  source?: unknown;
@@ -124,6 +123,27 @@ export interface GitOperationsSubProvider {
124
123
  options?: { mode?: 'hard' | 'keep' | 'merge' | 'mixed' | 'soft' },
125
124
  runOptions?: GitOperationRunOptions,
126
125
  ): Promise<void>;
126
+ /**
127
+ * Restores the working-tree copy of one or more paths. Single path or array; arrays are
128
+ * chunked under the CLI length limit and dispatched as one git invocation per chunk
129
+ * (typically one for normal-sized batches).
130
+ *
131
+ * Source selection — `options` is mutually exclusive (`side` wins if both are supplied):
132
+ *
133
+ * - **No options** (`git checkout -- <paths>`): copies the index to the working tree, leaving
134
+ * the index untouched. Use to drop unstaged changes (mixed-file partial discard preserves
135
+ * the staged portion this way).
136
+ * - **`{ ref }`** (`git checkout <ref> -- <paths>`): resets BOTH the index and the working
137
+ * tree of the path(s) to `<ref>`. Use to fully revert files to HEAD, a specific commit, etc.
138
+ * - **`{ side }`** (`git checkout --ours|--theirs -- <paths>`): conflict-resolution
139
+ * shortcut, valid only during a paused merge/rebase/cherry-pick. Leaves files unstaged.
140
+ */
141
+ restore(
142
+ repoPath: string,
143
+ path: string | string[],
144
+ options?: { ref?: string; side?: 'ours' | 'theirs' },
145
+ runOptions?: GitOperationRunOptions,
146
+ ): Promise<void>;
127
147
  revert(
128
148
  repoPath: string,
129
149
  refs: string[],
@@ -35,6 +35,29 @@ export class CustomRemoteProvider extends RemoteProvider {
35
35
  return [];
36
36
  }
37
37
 
38
+ get avatarUrlTemplate(): string | undefined {
39
+ return this.urls.avatar;
40
+ }
41
+
42
+ getUrlForAvatar(email: string, size: number): string | undefined {
43
+ if (this.urls.avatar == null) return undefined;
44
+
45
+ // Split on the last `@` so local-parts that contain `@` are preserved (per RFC 5322)
46
+ const at = email.lastIndexOf('@');
47
+ const emailName = at === -1 ? email : email.slice(0, at);
48
+ const domain = at === -1 ? '' : email.slice(at + 1);
49
+
50
+ // Component-encode identity values — commit emails are attacker-controllable and
51
+ // must never be able to inject URL-structural characters (`/`, `?`, `#`, ...) into the
52
+ // resulting URL
53
+ return interpolate(this.urls.avatar, {
54
+ email: encodeURIComponent(email),
55
+ emailName: encodeURIComponent(emailName),
56
+ domain: encodeURIComponent(domain),
57
+ size: String(size),
58
+ });
59
+ }
60
+
38
61
  protected override getUrlForRepository(): string {
39
62
  return this.getUrl(this.urls.repository, this.getContext());
40
63
  }
@@ -79,3 +79,27 @@ export function calculateDistribution<T extends string>(
79
79
  export function matchContributor(c: GitContributor, user: GitUser): boolean {
80
80
  return c.name === user.name && c.email === user.email && c.username === user.username;
81
81
  }
82
+
83
+ /** Appends `Co-authored-by:` trailers for the given coauthors to a commit message, replacing
84
+ * any existing trailer block. Each coauthor is the `Name <email>` form (GitContributor.coauthor). */
85
+ export function appendCoauthorsToMessage(message: string, coauthors: Iterable<string>): string {
86
+ const index = message.indexOf('Co-authored-by: ');
87
+ if (index !== -1) {
88
+ message = message.substring(0, index - 1).trimEnd();
89
+ }
90
+
91
+ for (const coauthor of coauthors) {
92
+ let newlines;
93
+ if (message.includes('Co-authored-by: ')) {
94
+ newlines = '\n';
95
+ } else if (message.length !== 0 && message.endsWith('\n')) {
96
+ newlines = '\n\n';
97
+ } else {
98
+ newlines = '\n\n\n';
99
+ }
100
+
101
+ message += `${newlines}Co-authored-by: ${coauthor}`;
102
+ }
103
+
104
+ return message;
105
+ }