@gitkraken/core-gitlens 0.5.113 → 0.5.114

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 (241) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/git/cache.d.ts +4 -0
  3. package/dist/git/cache.d.ts.map +1 -1
  4. package/dist/git/cache.js +33 -0
  5. package/dist/git/cache.js.map +1 -1
  6. package/dist/git/context.d.ts +10 -0
  7. package/dist/git/context.d.ts.map +1 -1
  8. package/dist/git/context.js.map +1 -1
  9. package/dist/git/errors.d.ts +6 -3
  10. package/dist/git/errors.d.ts.map +1 -1
  11. package/dist/git/errors.js +9 -1
  12. package/dist/git/errors.js.map +1 -1
  13. package/dist/git/features.d.ts +1 -1
  14. package/dist/git/features.d.ts.map +1 -1
  15. package/dist/git/features.js +38 -0
  16. package/dist/git/features.js.map +1 -1
  17. package/dist/git/gitHealth.d.ts +153 -0
  18. package/dist/git/gitHealth.d.ts.map +1 -0
  19. package/dist/git/gitHealth.js +353 -0
  20. package/dist/git/gitHealth.js.map +1 -0
  21. package/dist/git/models/search.d.ts +6 -3
  22. package/dist/git/models/search.d.ts.map +1 -1
  23. package/dist/git/models/search.js +5 -1
  24. package/dist/git/models/search.js.map +1 -1
  25. package/dist/git/parsers/diffParser.d.ts +8 -2
  26. package/dist/git/parsers/diffParser.d.ts.map +1 -1
  27. package/dist/git/parsers/diffParser.js +27 -4
  28. package/dist/git/parsers/diffParser.js.map +1 -1
  29. package/dist/git/providers/config.d.ts +36 -3
  30. package/dist/git/providers/config.d.ts.map +1 -1
  31. package/dist/git/providers/graph.d.ts +9 -0
  32. package/dist/git/providers/graph.d.ts.map +1 -1
  33. package/dist/git/providers/maintenance.d.ts +222 -0
  34. package/dist/git/providers/maintenance.d.ts.map +1 -0
  35. package/dist/git/providers/maintenance.js +20 -0
  36. package/dist/git/providers/maintenance.js.map +1 -0
  37. package/dist/git/providers/operations.d.ts +10 -0
  38. package/dist/git/providers/operations.d.ts.map +1 -1
  39. package/dist/git/providers/pausedOperations.d.ts +3 -0
  40. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  41. package/dist/git/providers/provider.d.ts +2 -0
  42. package/dist/git/providers/provider.d.ts.map +1 -1
  43. package/dist/git/providers/tags.d.ts +3 -0
  44. package/dist/git/providers/tags.d.ts.map +1 -1
  45. package/dist/git/repositoryService.d.ts +1 -0
  46. package/dist/git/repositoryService.d.ts.map +1 -1
  47. package/dist/git/repositoryService.js.map +1 -1
  48. package/dist/git/run.types.d.ts +8 -0
  49. package/dist/git/run.types.d.ts.map +1 -1
  50. package/dist/git/service.js +3 -0
  51. package/dist/git/service.js.map +1 -1
  52. package/dist/git/utils/search.utils.d.ts.map +1 -1
  53. package/dist/git/utils/search.utils.js +112 -5
  54. package/dist/git/utils/search.utils.js.map +1 -1
  55. package/dist/git/watching/watchService.d.ts +3 -0
  56. package/dist/git/watching/watchService.d.ts.map +1 -1
  57. package/dist/git/watching/watchService.js +8 -0
  58. package/dist/git/watching/watchService.js.map +1 -1
  59. package/dist/git-cli/cliGitProvider.d.ts +11 -0
  60. package/dist/git-cli/cliGitProvider.d.ts.map +1 -1
  61. package/dist/git-cli/cliGitProvider.js +12 -0
  62. package/dist/git-cli/cliGitProvider.js.map +1 -1
  63. package/dist/git-cli/exec/git.d.ts +23 -1
  64. package/dist/git-cli/exec/git.d.ts.map +1 -1
  65. package/dist/git-cli/exec/git.js +83 -8
  66. package/dist/git-cli/exec/git.js.map +1 -1
  67. package/dist/git-cli/exec/gitQueue.d.ts +2 -0
  68. package/dist/git-cli/exec/gitQueue.d.ts.map +1 -1
  69. package/dist/git-cli/exec/gitQueue.js +19 -17
  70. package/dist/git-cli/exec/gitQueue.js.map +1 -1
  71. package/dist/git-cli/parsers/logParser.d.ts.map +1 -1
  72. package/dist/git-cli/parsers/logParser.js +4 -1
  73. package/dist/git-cli/parsers/logParser.js.map +1 -1
  74. package/dist/git-cli/providers/blame.d.ts.map +1 -1
  75. package/dist/git-cli/providers/blame.js +4 -7
  76. package/dist/git-cli/providers/blame.js.map +1 -1
  77. package/dist/git-cli/providers/branches.d.ts.map +1 -1
  78. package/dist/git-cli/providers/branches.js +13 -3
  79. package/dist/git-cli/providers/branches.js.map +1 -1
  80. package/dist/git-cli/providers/commits.d.ts.map +1 -1
  81. package/dist/git-cli/providers/commits.js +34 -3
  82. package/dist/git-cli/providers/commits.js.map +1 -1
  83. package/dist/git-cli/providers/config.d.ts +27 -0
  84. package/dist/git-cli/providers/config.d.ts.map +1 -1
  85. package/dist/git-cli/providers/config.js +31 -6
  86. package/dist/git-cli/providers/config.js.map +1 -1
  87. package/dist/git-cli/providers/graph.d.ts +8 -0
  88. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  89. package/dist/git-cli/providers/graph.js +85 -6
  90. package/dist/git-cli/providers/graph.js.map +1 -1
  91. package/dist/git-cli/providers/maintenance.d.ts +306 -0
  92. package/dist/git-cli/providers/maintenance.d.ts.map +1 -0
  93. package/dist/git-cli/providers/maintenance.js +2129 -0
  94. package/dist/git-cli/providers/maintenance.js.map +1 -0
  95. package/dist/git-cli/providers/operations.d.ts +1 -0
  96. package/dist/git-cli/providers/operations.d.ts.map +1 -1
  97. package/dist/git-cli/providers/operations.js +8 -0
  98. package/dist/git-cli/providers/operations.js.map +1 -1
  99. package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
  100. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  101. package/dist/git-cli/providers/pausedOperations.js +1 -1
  102. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  103. package/dist/git-cli/providers/tags.d.ts +3 -0
  104. package/dist/git-cli/providers/tags.d.ts.map +1 -1
  105. package/dist/git-cli/providers/tags.js +23 -1
  106. package/dist/git-cli/providers/tags.js.map +1 -1
  107. package/dist/plus/agents/agentCapabilities.d.ts +77 -0
  108. package/dist/plus/agents/agentCapabilities.d.ts.map +1 -0
  109. package/dist/plus/agents/agentCapabilities.js +197 -0
  110. package/dist/plus/agents/agentCapabilities.js.map +1 -0
  111. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts +77 -13
  112. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts.map +1 -1
  113. package/dist/plus/agents/providers/claudeCodeTranscript.js +263 -55
  114. package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -1
  115. package/dist/plus/agents/providers/{claudeCodeProvider.d.ts → gkAgentProvider.d.ts} +52 -19
  116. package/dist/plus/agents/providers/gkAgentProvider.d.ts.map +1 -0
  117. package/dist/plus/agents/providers/{claudeCodeProvider.js → gkAgentProvider.js} +935 -262
  118. package/dist/plus/agents/providers/gkAgentProvider.js.map +1 -0
  119. package/dist/plus/agents/types.d.ts +113 -35
  120. package/dist/plus/agents/types.d.ts.map +1 -1
  121. package/dist/plus/agents/types.js +7 -4
  122. package/dist/plus/agents/types.js.map +1 -1
  123. package/dist/plus/ai/models/promptTemplates.d.ts +1 -1
  124. package/dist/plus/ai/models/promptTemplates.d.ts.map +1 -1
  125. package/dist/plus/ai/prompts.d.ts +4 -0
  126. package/dist/plus/ai/prompts.d.ts.map +1 -1
  127. package/dist/plus/ai/prompts.js +59 -4
  128. package/dist/plus/ai/prompts.js.map +1 -1
  129. package/dist/plus/git-github/api/github.d.ts +44 -1
  130. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  131. package/dist/plus/git-github/api/github.js +318 -25
  132. package/dist/plus/git-github/api/github.js.map +1 -1
  133. package/dist/plus/git-github/api/issueSearchQuery.d.ts +13 -0
  134. package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -1
  135. package/dist/plus/git-github/api/issueSearchQuery.js +41 -0
  136. package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -1
  137. package/dist/plus/git-github/providers/github/blame.d.ts.map +1 -1
  138. package/dist/plus/git-github/providers/github/blame.js +2 -1
  139. package/dist/plus/git-github/providers/github/blame.js.map +1 -1
  140. package/dist/plus/integrations/integrationService.d.ts +11 -0
  141. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  142. package/dist/plus/integrations/integrationService.js +4 -0
  143. package/dist/plus/integrations/integrationService.js.map +1 -1
  144. package/dist/plus/integrations/manager.d.ts +26 -0
  145. package/dist/plus/integrations/manager.d.ts.map +1 -1
  146. package/dist/plus/integrations/models/gitHostIntegration.d.ts +31 -0
  147. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  148. package/dist/plus/integrations/models/gitHostIntegration.js +31 -0
  149. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  150. package/dist/plus/integrations/models/integration.d.ts +1 -1
  151. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  152. package/dist/plus/integrations/models/integration.js.map +1 -1
  153. package/dist/plus/integrations/providers/github.d.ts +9 -0
  154. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  155. package/dist/plus/integrations/providers/github.js +7 -0
  156. package/dist/plus/integrations/providers/github.js.map +1 -1
  157. package/dist/plus/integrations/reads/broaden.d.ts +47 -10
  158. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  159. package/dist/plus/integrations/reads/broaden.js +144 -33
  160. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  161. package/dist/plus/integrations/reads/filters.d.ts +18 -0
  162. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  163. package/dist/plus/integrations/reads/filters.js +37 -1
  164. package/dist/plus/integrations/reads/filters.js.map +1 -1
  165. package/dist/plus/integrations/reads/issueBatch.d.ts +51 -0
  166. package/dist/plus/integrations/reads/issueBatch.d.ts.map +1 -0
  167. package/dist/plus/integrations/reads/issueBatch.js +91 -0
  168. package/dist/plus/integrations/reads/issueBatch.js.map +1 -0
  169. package/dist/plus/integrations/reads/sweeps.d.ts.map +1 -1
  170. package/dist/plus/integrations/reads/sweeps.js +5 -3
  171. package/dist/plus/integrations/reads/sweeps.js.map +1 -1
  172. package/dist/utils/promiseCache.d.ts +19 -0
  173. package/dist/utils/promiseCache.d.ts.map +1 -1
  174. package/dist/utils/promiseCache.js +30 -0
  175. package/dist/utils/promiseCache.js.map +1 -1
  176. package/dist/utils/resourceUsage.d.ts +5 -0
  177. package/dist/utils/resourceUsage.d.ts.map +1 -0
  178. package/dist/utils/resourceUsage.js +2 -0
  179. package/dist/utils/resourceUsage.js.map +1 -0
  180. package/dist/utils/string.d.ts +12 -0
  181. package/dist/utils/string.d.ts.map +1 -1
  182. package/dist/utils/string.js +36 -1
  183. package/dist/utils/string.js.map +1 -1
  184. package/docs/integrations.md +29 -25
  185. package/docs/kepler-read-api-parity.md +12 -4
  186. package/package.json +11 -3
  187. package/src/git/cache.ts +36 -0
  188. package/src/git/context.ts +11 -0
  189. package/src/git/errors.ts +13 -1
  190. package/src/git/features.ts +51 -0
  191. package/src/git/gitHealth.ts +522 -0
  192. package/src/git/models/search.ts +19 -4
  193. package/src/git/parsers/diffParser.ts +27 -4
  194. package/src/git/providers/config.ts +42 -1
  195. package/src/git/providers/graph.ts +12 -0
  196. package/src/git/providers/maintenance.ts +224 -0
  197. package/src/git/providers/operations.ts +10 -0
  198. package/src/git/providers/pausedOperations.ts +3 -0
  199. package/src/git/providers/provider.ts +2 -0
  200. package/src/git/providers/tags.ts +1 -0
  201. package/src/git/repositoryService.ts +1 -0
  202. package/src/git/run.types.ts +8 -0
  203. package/src/git/service.ts +3 -0
  204. package/src/git/utils/search.utils.ts +129 -5
  205. package/src/git/watching/watchService.ts +10 -0
  206. package/src/git-cli/cliGitProvider.ts +25 -0
  207. package/src/git-cli/exec/git.ts +120 -6
  208. package/src/git-cli/exec/gitQueue.ts +19 -17
  209. package/src/git-cli/parsers/logParser.ts +4 -1
  210. package/src/git-cli/providers/blame.ts +3 -8
  211. package/src/git-cli/providers/branches.ts +28 -8
  212. package/src/git-cli/providers/commits.ts +34 -2
  213. package/src/git-cli/providers/config.ts +38 -6
  214. package/src/git-cli/providers/graph.ts +107 -7
  215. package/src/git-cli/providers/maintenance.ts +2599 -0
  216. package/src/git-cli/providers/operations.ts +11 -0
  217. package/src/git-cli/providers/pausedOperations.ts +7 -2
  218. package/src/git-cli/providers/tags.ts +36 -1
  219. package/src/plus/agents/agentCapabilities.ts +286 -0
  220. package/src/plus/agents/providers/claudeCodeTranscript.ts +313 -58
  221. package/src/plus/agents/providers/{claudeCodeProvider.ts → gkAgentProvider.ts} +1123 -277
  222. package/src/plus/agents/types.ts +137 -40
  223. package/src/plus/ai/models/promptTemplates.ts +1 -1
  224. package/src/plus/ai/prompts.ts +62 -4
  225. package/src/plus/git-github/api/github.ts +353 -24
  226. package/src/plus/git-github/api/issueSearchQuery.ts +49 -1
  227. package/src/plus/git-github/providers/github/blame.ts +2 -1
  228. package/src/plus/integrations/integrationService.ts +15 -0
  229. package/src/plus/integrations/manager.ts +26 -0
  230. package/src/plus/integrations/models/gitHostIntegration.ts +48 -0
  231. package/src/plus/integrations/models/integration.ts +1 -0
  232. package/src/plus/integrations/providers/github.ts +18 -0
  233. package/src/plus/integrations/reads/broaden.ts +282 -70
  234. package/src/plus/integrations/reads/filters.ts +39 -1
  235. package/src/plus/integrations/reads/issueBatch.ts +197 -0
  236. package/src/plus/integrations/reads/sweeps.ts +19 -12
  237. package/src/utils/promiseCache.ts +43 -0
  238. package/src/utils/resourceUsage.ts +5 -0
  239. package/src/utils/string.ts +38 -1
  240. package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +0 -1
  241. package/dist/plus/agents/providers/claudeCodeProvider.js.map +0 -1
@@ -0,0 +1,2129 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { randomUUID } from 'node:crypto';
8
+ import { mkdir, open, readdir, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
9
+ import { hostname } from 'node:os';
10
+ import * as process from 'node:process';
11
+ import { gitFeaturesByVersion } from '../../git/features.js';
12
+ import { looseRefsThreshold } from '../../git/gitHealth.js';
13
+ import { CancellationError } from '../../utils/cancellation.js';
14
+ import { debug } from '../../utils/decorators/log.js';
15
+ import { getScopedLogger } from '../../utils/logger.scoped.js';
16
+ import { arePathsEqual, joinPaths } from '../../utils/path.js';
17
+ import { getSettledValue } from '../../utils/promise.js';
18
+ import { escapeRegex } from '../../utils/string.js';
19
+ import { fsExists } from '../exec/exec.js';
20
+ import { canonicalizeGitConfigKey, isGitBooleanFalse, parseConfigRegexOutput, parseGitBoolean } from './config.js';
21
+ /** Deterministic sample of the 256 object fanout dirs (`00`,`10`,…,`f0`) used to extrapolate loose-object count. */
22
+ const looseObjectSampleDirs = Array.from({ length: 16 }, (_, i) => (i * 16).toString(16).padStart(2, '0'));
23
+ /** Stops the ref probe at four times the recommendation threshold: bounded, but still useful for telemetry. */
24
+ const looseRefProbeLimit = looseRefsThreshold * 4;
25
+ /** Worktree-local ownership journal for the sparse-index command lever. */
26
+ const sparseIndexAppliedMarker = 'sparse-index.applied';
27
+ const sparseIndexLockFile = 'sparse-index.lock';
28
+ const sparseIndexPendingMarker = 'sparse-index.pending';
29
+ /** How long the global `maintenance.repo` registration list is served from cache (mirrors the config caches). */
30
+ const registeredMaintenanceReposTtlMs = 30 * 1000;
31
+ /** How long a repo's worktree list is served from cache — worktrees can be added/removed mid-session. */
32
+ const worktreePathsTtlMs = 30 * 1000;
33
+ /** Demand cadence for the commit-graph write: refreshed at most this often per object database. */
34
+ const commitGraphRefreshIntervalMs = 5 * 60 * 1000;
35
+ /** Git's default `maintenance.incremental-repack.auto` threshold. */
36
+ const defaultIncrementalRepackAutoThreshold = 10;
37
+ /** Bounds defensive reads of repository-controlled MIDX metadata. */
38
+ const maxMultiPackIndexChunkBytes = 16 * 1024 * 1024;
39
+ const maxMultiPackIndexChainBytes = 1024 * 1024;
40
+ /** Sentinel recorded in a `gk.applied.*` marker when the lever's prior LOCAL value was absent (→ undo unsets it). */
41
+ const unsetConfigSentinel = 'unset';
42
+ /**
43
+ * Sentinel recorded when the prior LOCAL value was present but EMPTY (`key =`). Git reads an empty value as
44
+ * boolean false, and a local empty value shadows any global value — so undo must restore the empty value, not
45
+ * unset the key (unsetting would let a global `true` resurface). The marker can't store the empty string
46
+ * itself: `--get-regexp` prints a value-less line for it, which the bulk gk-config parser drops.
47
+ */
48
+ const emptyConfigSentinel = 'empty';
49
+ /**
50
+ * The ONLY tolerable non-zero exits from the config reads/writes below: `--get`/`--get-all`/`--get-regex`
51
+ * exit 1 when the key (or, for `--get-regex`, the pattern) is unset/unmatched, `--unset-all` exits 5 when
52
+ * it was already absent. Any other non-zero means git couldn't read or write the config at all (a held
53
+ * `config.lock`, a read-only `.git`, a malformed file) — which must never be mistaken for "unset"/"already
54
+ * gone", since that reads as success and drops the ownership marker.
55
+ *
56
+ * Note the writes use `--unset-all`, never `--unset`: `--unset` REFUSES a multi-valued key and exits 5 too —
57
+ * the same code as the benign case — leaving the values in place. `--unset-all` removes every local value
58
+ * (the correct restore for an `unset` prior) and reserves 5 for genuinely absent.
59
+ */
60
+ const gitConfigGetMissingExitCode = 1;
61
+ const gitConfigUnsetMissingExitCode = 5;
62
+ /** `update-index --test-untracked-cache` exits 1 when the filesystem can't support it. */
63
+ const untrackedCacheUnsupportedExitCode = 1;
64
+ /** Poll interval and ceiling for waiting on another window's ownership-marker lock. */
65
+ const markerLockRetryMs = 50;
66
+ const markerLockMaxAttempts = 20;
67
+ const emptyGkMarkers = {
68
+ fsmonitorNotApplicable: false,
69
+ untrackedCacheNotApplicable: false,
70
+ applied: {
71
+ untrackedCache: false,
72
+ fsmonitor: false,
73
+ manyFiles: false,
74
+ backgroundMaintenance: false,
75
+ sparseIndex: false,
76
+ },
77
+ commitGraphDisabled: false,
78
+ };
79
+ class MarkerLockContentionError extends Error {
80
+ }
81
+ const untrackedCacheChange = {
82
+ configKey: 'core.untrackedCache',
83
+ markerKey: 'gk.applied.untrackedCache',
84
+ pendingKey: 'gk.pending.untrackedCache',
85
+ value: 'true',
86
+ };
87
+ const fsmonitorChange = {
88
+ configKey: 'core.fsmonitor',
89
+ markerKey: 'gk.applied.fsmonitor',
90
+ pendingKey: 'gk.pending.fsmonitor',
91
+ value: 'true',
92
+ };
93
+ const manyFilesChange = {
94
+ configKey: 'feature.manyFiles',
95
+ markerKey: 'gk.applied.manyFiles',
96
+ pendingKey: 'gk.pending.manyFiles',
97
+ value: 'true',
98
+ };
99
+ const skipHashChange = {
100
+ configKey: 'index.skipHash',
101
+ markerKey: 'gk.applied.skipHash',
102
+ pendingKey: 'gk.pending.skipHash',
103
+ value: 'true',
104
+ };
105
+ const configLeverChanges = [
106
+ untrackedCacheChange,
107
+ fsmonitorChange,
108
+ manyFilesChange,
109
+ skipHashChange,
110
+ ];
111
+ function encodePendingConfigChange(change) {
112
+ return JSON.stringify(change);
113
+ }
114
+ function decodePendingConfigChange(value) {
115
+ try {
116
+ const parsed = JSON.parse(value);
117
+ return typeof parsed.prior === 'string' && typeof parsed.value === 'string'
118
+ ? { prior: parsed.prior, value: parsed.value }
119
+ : undefined;
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
125
+ /** `core.fsmonitor` is enabled when set to a truthy bool OR a hook path — unset or any false spelling is "off". */
126
+ function isFsmonitorEnabled(value) {
127
+ if (value == null)
128
+ return false;
129
+ return !isGitBooleanFalse(value);
130
+ }
131
+ /** Parses Git's integer syntax, including the case-insensitive binary `k`, `m`, and `g` suffixes. */
132
+ function parseGitInteger(value) {
133
+ const match = /^([+-]?\d+)([kmg])?$/i.exec(value?.trim() ?? '');
134
+ if (match == null)
135
+ return undefined;
136
+ const multiplier = match[2]?.toLowerCase() === 'k' ? 1024 : match[2]?.toLowerCase() === 'm' ? 1024 ** 2 : match[2] ? 1024 ** 3 : 1;
137
+ const parsed = Number.parseInt(match[1], 10) * multiplier;
138
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
139
+ }
140
+ /**
141
+ * Whether `fsmonitor--daemon` reported that this git build has no backend for the platform (as opposed to
142
+ * simply not running yet, which also exits non-zero). Git uses 128 for any fatal, so the message matters.
143
+ */
144
+ function isFsmonitorDaemonUnsupported(result) {
145
+ if (result.exitCode !== 128)
146
+ return false;
147
+ return `${result.stderr ?? ''}${result.stdout ?? ''}`.includes('not supported on this platform');
148
+ }
149
+ /** Platforms with a built-in FSMonitor daemon backend, and the git floor each one needs. */
150
+ function fsmonitorFeatureForPlatform() {
151
+ switch (process.platform) {
152
+ case 'win32':
153
+ case 'darwin':
154
+ return 'git:fsmonitor';
155
+ // Linux got an inotify backend only in 2.55.
156
+ case 'linux':
157
+ return 'git:fsmonitor:linux';
158
+ default:
159
+ return undefined;
160
+ }
161
+ }
162
+ /** Parses `git count-objects -v` (`key: number` per line) into the {@link GitHealthDetails} breakdown. */
163
+ function parseCountObjects(stdout) {
164
+ const values = new Map();
165
+ for (const line of stdout.split('\n')) {
166
+ const sep = line.indexOf(':');
167
+ if (sep === -1)
168
+ continue;
169
+ const key = line.slice(0, sep).trim();
170
+ const value = parseInt(line.slice(sep + 1).trim(), 10);
171
+ if (!Number.isNaN(value)) {
172
+ values.set(key, value);
173
+ }
174
+ }
175
+ // git reports the size fields in KiB — normalize to bytes here.
176
+ return {
177
+ count: values.get('count') ?? 0,
178
+ size: (values.get('size') ?? 0) * 1024,
179
+ inPack: values.get('in-pack') ?? 0,
180
+ packs: values.get('packs') ?? 0,
181
+ sizePack: (values.get('size-pack') ?? 0) * 1024,
182
+ prunePackable: values.get('prune-packable') ?? 0,
183
+ garbage: values.get('garbage') ?? 0,
184
+ sizeGarbage: (values.get('size-garbage') ?? 0) * 1024,
185
+ };
186
+ }
187
+ export class MaintenanceGitSubProvider {
188
+ context;
189
+ git;
190
+ cache;
191
+ provider;
192
+ constructor(context, git, cache, provider) {
193
+ this.context = context;
194
+ this.git = git;
195
+ this.cache = cache;
196
+ this.provider = provider;
197
+ }
198
+ // Last commit-graph refresh this session, keyed by BOTH worktree path (cheap synchronous re-entry
199
+ // throttle) and resolved common git dir (the authoritative throttle — worktrees share one object
200
+ // database, so N open worktrees must coalesce to one write). The namespaces can't collide: worktree
201
+ // keys are working-tree roots, common-dir keys are `.git` directory paths.
202
+ commitGraphWrittenAt = new Map();
203
+ // Common git dirs with a write currently running — single-flight so overlapping hints (or several
204
+ // worktrees racing the same interval boundary) never stack concurrent writes on one object database.
205
+ commitGraphWriteInflight = new Set();
206
+ request(repoPath, task) {
207
+ // Only the commit-graph has a demand cadence — its cache is most valuable immediately after a history
208
+ // walk, so graph-load and repo-open hint it and the throttle decides. Other maintenance tasks are owned
209
+ // by the daily pass (a demand write buys nothing there), so they no-op here.
210
+ switch (task) {
211
+ case 'commit-graph':
212
+ return this.ensureCommitGraph(repoPath);
213
+ case 'loose-objects':
214
+ case 'incremental-repack':
215
+ case 'pack-refs':
216
+ return undefined;
217
+ }
218
+ }
219
+ /**
220
+ * Opportunistically refreshes git's commit-graph (the demand cadence behind `request('commit-graph')`).
221
+ * Without one that COVERS the history, any ordered walk (`--date-order`/`--topo-order` — exactly the
222
+ * graph's initial load) must inflate every commit object to sort BEFORE the first row can be emitted,
223
+ * costing a full-history walk per open; the commit-graph also accelerates `git log`, merge-base, and
224
+ * ahead/behind across the whole extension. It's a standard acceleration cache (`git gc`/`git maintenance`
225
+ * write it routinely) and is always safe to delete. Fire-and-forget: refreshed at most every few minutes
226
+ * per object database (a current chain makes the write a near-no-op), at background queue priority,
227
+ * failures swallowed. Cross-WINDOW writes aren't coordinated here — git's own `commit-graph-chain.lock`
228
+ * serializes them; a loser errors and is swallowed. Resolves `true` only when the write completes; the
229
+ * health service uses that signal for one post-write freshness probe, while graph consumers ignore it.
230
+ */
231
+ ensureCommitGraph(repoPath) {
232
+ // Governed by the auto-tier master switch (`gitlens.gitOptimizations.enabled`), fed into both
233
+ // mapped config slots below.
234
+ if (this.context.config?.maintenance?.enabled === false)
235
+ return Promise.resolve(false);
236
+ if (this.context.config?.graph?.writeCommitGraph === false)
237
+ return Promise.resolve(false);
238
+ const lastWrittenAt = this.commitGraphWrittenAt.get(repoPath);
239
+ if (lastWrittenAt != null && Date.now() - lastWrittenAt < commitGraphRefreshIntervalMs) {
240
+ return Promise.resolve(false);
241
+ }
242
+ this.commitGraphWrittenAt.set(repoPath, Date.now());
243
+ return (async () => {
244
+ try {
245
+ if (!(await this.git.supports('git:commit-graph')))
246
+ return false;
247
+ // Re-throttle + single-flight on the COMMON git dir so this worktree's write coalesces with
248
+ // its siblings' (they all share the one object database the commit-graph lives in). Resolved
249
+ // via the config sub-provider's cached git-dir, so repeat hints don't re-spawn `rev-parse`.
250
+ const gitDir = await this.provider.config.getGitDir(repoPath).catch(() => undefined);
251
+ const commonKey = gitDir != null ? (gitDir.commonUri ?? gitDir.uri).fsPath : repoPath;
252
+ if (this.commitGraphWriteInflight.has(commonKey))
253
+ return false;
254
+ // Skip the common-dir re-throttle when resolution fell back to repoPath itself — the sync
255
+ // gate above JUST stamped that key, so re-checking it here would early-return forever and
256
+ // the write would never run for repos whose git-dir resolve fails.
257
+ if (commonKey !== repoPath) {
258
+ const lastCommonWriteAt = this.commitGraphWrittenAt.get(commonKey);
259
+ if (lastCommonWriteAt != null && Date.now() - lastCommonWriteAt < commitGraphRefreshIntervalMs) {
260
+ return false;
261
+ }
262
+ this.commitGraphWrittenAt.set(commonKey, Date.now());
263
+ }
264
+ this.commitGraphWriteInflight.add(commonKey);
265
+ try {
266
+ if (await this.isCommitGraphWriteDisabled(repoPath))
267
+ return false;
268
+ await this.writeCommitGraph(repoPath, true);
269
+ return true;
270
+ }
271
+ finally {
272
+ this.commitGraphWriteInflight.delete(commonKey);
273
+ }
274
+ }
275
+ catch {
276
+ // Best-effort acceleration only — never surface failures (shallow/partial clones,
277
+ // read-only repos, ancient gits behind the feature gate, etc.).
278
+ return false;
279
+ }
280
+ })();
281
+ }
282
+ /** Shared opt-out gate for automatic and explicit writes. */
283
+ async isCommitGraphWriteDisabled(repoPath, cancellation) {
284
+ // Respect an explicit read opt-out — writing a cache this git will never read is pure waste.
285
+ // `--type=bool` normalizes every falsy spelling Git accepts (`false`/`no`/`off`/`0`). Unset means
286
+ // Git reads the cache; a malformed value isn't false, matching Git's own read behavior.
287
+ const optOut = await this.runQuietly(repoPath, cancellation, 'config', '--type=bool', '--get', 'core.commitGraph');
288
+ this.throwIfDidNotComplete(optOut, cancellation);
289
+ if (optOut.stdout.trim() === 'false')
290
+ return true;
291
+ // The per-repository off switch (Git Health), independent of the settings-level auto-tier gates.
292
+ return (await this.provider.config.getGkConfig(repoPath, 'gk.commitGraphDisabled')) === 'true';
293
+ }
294
+ /**
295
+ * The commit-graph write itself. Always a `--split` write: mere existence isn't enough (a stale or thin
296
+ * chain — e.g. one auto-gc increment — accelerates nothing); `--split` appends an incremental layer
297
+ * covering only the uncovered commits (near-no-op when current) and git self-merges layers by its
298
+ * geometric policy. Background priority: a first write on a huge repo can run for a while and must never
299
+ * hold a queue slot ahead of interactive work. Throws on failure — callers decide whether to swallow.
300
+ */
301
+ async writeCommitGraph(repoPath, bounded, cancellation) {
302
+ const partialClone = await this.isPartialClone(repoPath, cancellation);
303
+ const args = ['commit-graph', 'write', '--reachable', bounded || partialClone ? '--split' : '--split=replace'];
304
+ // Changed-path Bloom filters make `git log -- <path>` fast, but computing them for the WHOLE history
305
+ // on a big repo is expensive, so automatic writes seed at most 512 filters. A bounded split write does
306
+ // NOT backfill older layers on later passes; only the explicit task uses an unbounded replace write to
307
+ // provide complete reachable-history coverage. Partial/promisor clones omit filters entirely because
308
+ // computing them needs missing trees and would otherwise lazy-fetch from the network.
309
+ if (!partialClone && (await this.git.supports('git:commit-graph:changed-paths'))) {
310
+ args.push('--changed-paths');
311
+ if (bounded) {
312
+ args.push('--max-new-filters=512');
313
+ }
314
+ }
315
+ await this.git.run({
316
+ cwd: repoPath,
317
+ priority: 'background',
318
+ cancellation: cancellation,
319
+ env: { GIT_NO_LAZY_FETCH: '1' },
320
+ selfMaintenance: true,
321
+ }, ...args);
322
+ }
323
+ /** Config-only partial/promisor detection; never examines an object and therefore cannot itself lazy-fetch. */
324
+ async isPartialClone(repoPath, cancellation) {
325
+ const result = await this.runQuietly(repoPath, cancellation, 'config', '--local', '--get-regex', '^(extensions\\.partialclone|remote\\..*\\.promisor)$');
326
+ this.throwIfDidNotComplete(result, cancellation);
327
+ if (result.exitCode !== 0 && result.exitCode !== gitConfigGetMissingExitCode) {
328
+ throw new Error(`Unable to detect a partial clone (git exited ${String(result.exitCode)})`);
329
+ }
330
+ if (result.exitCode !== 0)
331
+ return false;
332
+ const config = parseConfigRegexOutput(result.stdout, { includeValueless: true });
333
+ for (const [key, value] of config) {
334
+ if (key === 'extensions.partialclone' || (key.endsWith('.promisor') && parseGitBoolean(value))) {
335
+ return true;
336
+ }
337
+ }
338
+ return false;
339
+ }
340
+ /** Honors an explicit MIDX opt-out on both automatic and user-invoked incremental repacks. */
341
+ async isMultiPackIndexEnabled(repoPath, cancellation) {
342
+ const result = await this.runQuietly(repoPath, cancellation, 'config', '--bool', '--get', 'core.multiPackIndex');
343
+ this.throwIfDidNotComplete(result, cancellation);
344
+ if (result.exitCode === gitConfigGetMissingExitCode)
345
+ return true;
346
+ if (result.exitCode !== 0) {
347
+ throw new Error(`Unable to read core.multiPackIndex (git exited ${String(result.exitCode)})`);
348
+ }
349
+ return parseGitBoolean(result.stdout.trim());
350
+ }
351
+ async getHealthSnapshot(repoPath, cancellation) {
352
+ await this.reconcilePendingConfigChanges(repoPath, cancellation);
353
+ const gitDir = await this.provider.config.getGitDir(repoPath);
354
+ // Object store lives in the COMMON git dir (shared across worktrees); the index is per-worktree.
355
+ const commonGitDir = (gitDir.commonUri ?? gitDir.uri).fsPath;
356
+ const objectsDir = joinPaths(commonGitDir, 'objects');
357
+ const packDir = joinPaths(objectsDir, 'pack');
358
+ const indexPath = joinPaths(gitDir.uri.fsPath, 'index');
359
+ const [commitGraphStat, multiPackIndex, packs, looseObjects, indexBytes, indexEntryCount, sharedIndex, conflictOperation, config, maintenanceRegistered, gkMarkers, supportsMaintenanceRun, changedPaths, changedPathsFeatureSupported, shallowRepository, partialClone, looseRefs, supportsPackRefsMaintenance,] = await Promise.allSettled([
360
+ this.probeCommitGraph(objectsDir),
361
+ this.probeMultiPackIndex(packDir),
362
+ this.probePacks(objectsDir),
363
+ this.sampleLooseObjects(objectsDir),
364
+ this.fileBytes(indexPath),
365
+ this.probeIndexEntryCount(indexPath),
366
+ this.probeSharedIndex(gitDir.uri.fsPath),
367
+ this.probeConflictOperation(gitDir.uri.fsPath),
368
+ this.probeConfig(repoPath, cancellation),
369
+ this.isMaintenanceRegistered(repoPath, cancellation),
370
+ this.probeGkMarkers(repoPath),
371
+ this.git.supports('git:maintenance'),
372
+ this.probeChangedPathFilters(objectsDir),
373
+ this.git.supports('git:commit-graph:changed-paths'),
374
+ this.probePathPresence(joinPaths(commonGitDir, 'shallow')),
375
+ this.isPartialClone(repoPath, cancellation),
376
+ this.probeLooseRefs(joinPaths(commonGitDir, 'refs')),
377
+ this.git.supports('git:maintenance:pack-refs'),
378
+ ]);
379
+ const markers = getSettledValue(gkMarkers) ?? emptyGkMarkers;
380
+ const configValue = getSettledValue(config);
381
+ const sparseIndexState = await this.reconcileSparseIndexMarkers(repoPath, gitDir.uri.fsPath, configValue?.sparseIndex, cancellation).catch(() => ({ enabled: configValue?.sparseIndex, applied: false }));
382
+ const rawIndexBytes = getSettledValue(indexBytes) ?? 0;
383
+ const rawIndexEntryCount = getSettledValue(indexEntryCount);
384
+ const sharedIndexValue = getSettledValue(sharedIndex);
385
+ const conflictOperationValue = getSettledValue(conflictOperation);
386
+ const packsValue = getSettledValue(packs);
387
+ const multiPackIndexValue = getSettledValue(multiPackIndex);
388
+ const indexedPackNames = multiPackIndexValue?.packNames;
389
+ const packsOutsideMultiPackIndex = configValue?.multiPackIndexEnabled === true && packsValue != null && indexedPackNames != null
390
+ ? packsValue.names.filter(name => !indexedPackNames.has(name)).length
391
+ : undefined;
392
+ const splitIndex = configValue?.splitIndex === true || sharedIndexValue?.present === true
393
+ ? true
394
+ : configValue != null && sharedIndexValue != null
395
+ ? false
396
+ : undefined;
397
+ const indexEntryCountType = configValue == null ||
398
+ rawIndexEntryCount == null ||
399
+ sharedIndexValue == null ||
400
+ conflictOperationValue == null
401
+ ? 'unavailable'
402
+ : splitIndex
403
+ ? 'split'
404
+ : conflictOperationValue
405
+ ? 'conflicted'
406
+ : sparseIndexState.enabled
407
+ ? 'sparse'
408
+ : 'full';
409
+ return {
410
+ repository: {
411
+ shallow: getSettledValue(shallowRepository),
412
+ partial: getSettledValue(partialClone),
413
+ sparseCheckout: configValue?.sparseCheckout,
414
+ sparseCheckoutCone: configValue?.sparseCheckoutCone,
415
+ sparseIndex: sparseIndexState.enabled,
416
+ splitIndex: splitIndex,
417
+ refFormat: configValue?.refFormat ?? 'unknown',
418
+ },
419
+ looseRefs: getSettledValue(looseRefs) ?? { count: 0, exact: false },
420
+ commitGraph: {
421
+ ...(getSettledValue(commitGraphStat) ?? { present: false, mtime: undefined }),
422
+ changedPaths: getSettledValue(changedPaths) ?? false,
423
+ changedPathsSupported: getSettledValue(changedPathsFeatureSupported) === true && getSettledValue(partialClone) === false,
424
+ disabled: markers.commitGraphDisabled,
425
+ readDisabled: configValue?.commitGraphReadDisabled ?? false,
426
+ },
427
+ multiPackIndex: multiPackIndexValue?.present ?? false,
428
+ multiPackIndexEnabled: configValue?.multiPackIndexEnabled,
429
+ packCount: packsValue?.names.length ?? 0,
430
+ packsOutsideMultiPackIndex: packsOutsideMultiPackIndex,
431
+ incrementalRepackAutoThreshold: configValue?.incrementalRepackAutoThreshold,
432
+ packBytes: packsValue?.bytes ?? 0,
433
+ looseObjects: getSettledValue(looseObjects) ?? { objectsInSampledDirs: 0, dirsSampled: 0 },
434
+ // The main file is only the mutable layer of a split index. Include its largest shared base so the
435
+ // byte-size fallback remains a useful working-tree signal instead of classifying it from a tiny delta.
436
+ indexBytes: rawIndexBytes + (indexEntryCountType === 'split' ? (sharedIndexValue?.bytes ?? 0) : 0),
437
+ indexEntryCount: indexEntryCountType === 'full' || indexEntryCountType === 'sparse' ? rawIndexEntryCount : undefined,
438
+ indexEntryCountType: indexEntryCountType,
439
+ // `untrackedCacheConfigured: true` on failure — see `probeConfig`, the auto tier must not guess.
440
+ config: configValue ?? {
441
+ fsmonitor: false,
442
+ untrackedCache: false,
443
+ untrackedCacheConfigured: true,
444
+ manyFiles: false,
445
+ },
446
+ // A rejection (the registration list was unreadable) must surface as `undefined`, never `false` —
447
+ // see the doc on `maintenanceRegistered` for why.
448
+ maintenanceRegistered: getSettledValue(maintenanceRegistered),
449
+ fsmonitorNotApplicable: markers.fsmonitorNotApplicable,
450
+ untrackedCacheNotApplicable: markers.untrackedCacheNotApplicable,
451
+ applied: { ...markers.applied, sparseIndex: sparseIndexState.applied },
452
+ supportsMaintenanceRun: getSettledValue(supportsMaintenanceRun) ?? false,
453
+ supportsPackRefsMaintenance: getSettledValue(supportsPackRefsMaintenance) ?? false,
454
+ };
455
+ }
456
+ /**
457
+ * Reads all Git Health gk markers (not-applicable flags + `gk.applied.*` ownership) from ONE bulk
458
+ * `.git/gk/config` read (git lowercases the section + variable in `--get-regexp` output, preserving the
459
+ * `applied` subsection). A read failure degrades to all-false — the report just re-suggests, never crashes.
460
+ */
461
+ async probeGkMarkers(repoPath) {
462
+ const map = await this.provider.config
463
+ .getGkConfigRegex(repoPath, '^gk\\.')
464
+ .catch(() => new Map());
465
+ return {
466
+ fsmonitorNotApplicable: map.get('gk.fsmonitornotapplicable') === 'true',
467
+ untrackedCacheNotApplicable: map.get('gk.untrackedcachenotapplicable') === 'true',
468
+ applied: {
469
+ untrackedCache: map.has('gk.applied.untrackedcache'),
470
+ fsmonitor: map.has('gk.applied.fsmonitor'),
471
+ manyFiles: map.has('gk.applied.manyfiles'),
472
+ backgroundMaintenance: map.has('gk.applied.backgroundmaintenance'),
473
+ sparseIndex: false,
474
+ },
475
+ commitGraphDisabled: map.get('gk.commitgraphdisabled') === 'true',
476
+ };
477
+ }
478
+ async getHealthDetails(repoPath, cancellation) {
479
+ const [commitCountResult, countObjectsResult] = await Promise.allSettled([
480
+ this.runQuietly(repoPath, cancellation, 'rev-list', '--count', '--all'),
481
+ this.runQuietly(repoPath, cancellation, 'count-objects', '-v'),
482
+ ]);
483
+ const commitCountRaw = getSettledValue(commitCountResult)?.stdout.trim();
484
+ const commitCount = commitCountRaw ? parseInt(commitCountRaw, 10) : undefined;
485
+ const countObjectsStdout = getSettledValue(countObjectsResult)?.stdout;
486
+ return {
487
+ commitCount: commitCount != null && !Number.isNaN(commitCount) ? commitCount : undefined,
488
+ countObjects: countObjectsStdout ? parseCountObjects(countObjectsStdout) : undefined,
489
+ };
490
+ }
491
+ // Capabilities depend only on git version + platform, both fixed for the session — computed once.
492
+ _capabilities;
493
+ getCapabilities(_repoPath) {
494
+ return (this._capabilities ??= this.getCapabilitiesCore());
495
+ }
496
+ async getCapabilitiesCore() {
497
+ const requiresGit = (feature, suffix) => `Requires Git ${gitFeaturesByVersion.get(feature)} or later${suffix ?? ''}`;
498
+ // The scheduler gate differs by platform: launchctl/schtasks (win/mac) vs systemd timer (Linux,
499
+ // since 2.31–2.33 is cron-only and modern distros often ship without cron).
500
+ const maintenanceStartFeature = this.backgroundMaintenanceFeature;
501
+ // The FSMonitor floor is per-platform (Linux's inotify backend is much newer), and a platform with no
502
+ // backend at all has no floor to check.
503
+ const fsmonitorFeature = fsmonitorFeatureForPlatform();
504
+ const [untrackedCacheResult, fsmonitorResult, backgroundMaintenanceResult, manyFilesResult, skipHashResult, sparseIndexResult,] = await Promise.allSettled([
505
+ this.git.supports('git:untrackedCache'),
506
+ fsmonitorFeature != null ? this.git.supports(fsmonitorFeature) : false,
507
+ this.git.supports(maintenanceStartFeature),
508
+ this.git.supports('git:manyFiles'),
509
+ this.git.supports('git:index:skipHash'),
510
+ this.git.supports('git:sparse-index'),
511
+ ]);
512
+ const untrackedCache = getSettledValue(untrackedCacheResult) ?? false;
513
+ const fsmonitor = getSettledValue(fsmonitorResult) ?? false;
514
+ const backgroundMaintenance = getSettledValue(backgroundMaintenanceResult) ?? false;
515
+ const manyFiles = getSettledValue(manyFilesResult) ?? false;
516
+ const skipHash = getSettledValue(skipHashResult) ?? false;
517
+ const sparseIndex = getSettledValue(sparseIndexResult) ?? false;
518
+ return [
519
+ {
520
+ id: 'untrackedCache',
521
+ supported: untrackedCache,
522
+ reason: untrackedCache ? undefined : requiresGit('git:untrackedCache'),
523
+ },
524
+ {
525
+ id: 'fsmonitor',
526
+ supported: fsmonitor,
527
+ reason: fsmonitorFeature == null
528
+ ? 'Only available on Windows, macOS, and Linux'
529
+ : !fsmonitor
530
+ ? requiresGit(fsmonitorFeature)
531
+ : undefined,
532
+ },
533
+ {
534
+ id: 'backgroundMaintenance',
535
+ supported: backgroundMaintenance,
536
+ reason: backgroundMaintenance
537
+ ? undefined
538
+ : requiresGit(maintenanceStartFeature, maintenanceStartFeature === 'git:maintenance:start:systemd'
539
+ ? ' (for systemd timer scheduling)'
540
+ : undefined),
541
+ },
542
+ {
543
+ id: 'manyFiles',
544
+ supported: manyFiles,
545
+ reason: manyFiles ? undefined : requiresGit('git:manyFiles'),
546
+ // On Git 2.40+, feature.manyFiles also enables index.skipHash. Its zeroed trailing hash may not
547
+ // be understood by older Git versions or common third-party index readers.
548
+ note: manyFiles && skipHash
549
+ ? 'Also enables index.skipHash — Git before 2.40 reports the zeroed hash as corrupt, and some libgit2- and JGit-based tools may reject or misdiagnose the index'
550
+ : undefined,
551
+ },
552
+ {
553
+ id: 'sparseIndex',
554
+ supported: sparseIndex,
555
+ reason: sparseIndex ? undefined : requiresGit('git:sparse-index'),
556
+ note: sparseIndex
557
+ ? 'Older Git versions and some tools that read the index directly do not understand sparse-directory entries'
558
+ : undefined,
559
+ },
560
+ ];
561
+ }
562
+ async runMaintenanceTask(repoPath, task, options) {
563
+ const cancellation = options?.cancellation;
564
+ // Not-applicable (the installed git can't run this task) short-circuits to `false`; a genuine command
565
+ // failure throws (the git error propagates so the ask-tier "Run Maintenance Now" UI can surface it).
566
+ if (task === 'commit-graph') {
567
+ // Direct write, gated at 2.24 — routing through `maintenance run --task=commit-graph` (2.30) would
568
+ // drop 2.24–2.29 users on the highest-value lever. The explicit path skips the demand throttle.
569
+ if (!(await this.git.supports('git:commit-graph')))
570
+ return false;
571
+ if (await this.isCommitGraphWriteDisabled(repoPath, cancellation))
572
+ return false;
573
+ await this.writeCommitGraph(repoPath, false, cancellation);
574
+ return true;
575
+ }
576
+ const maintenanceFeature = task === 'pack-refs' ? 'git:maintenance:pack-refs' : 'git:maintenance';
577
+ if (!(await this.git.supports(maintenanceFeature)))
578
+ return false;
579
+ if (task === 'incremental-repack' && !(await this.isMultiPackIndexEnabled(repoPath, cancellation))) {
580
+ return false;
581
+ }
582
+ await this.git.run({
583
+ cwd: repoPath,
584
+ errors: 'throw',
585
+ priority: 'background',
586
+ cancellation: cancellation,
587
+ selfMaintenance: true,
588
+ }, 'maintenance', 'run', ...(options?.auto ? ['--auto'] : []), `--task=${task}`);
589
+ return true;
590
+ }
591
+ async applyOptimization(repoPath, id, cancellation) {
592
+ await this.reconcilePendingConfigChanges(repoPath, cancellation);
593
+ switch (id) {
594
+ case 'untrackedCache':
595
+ return this.applyUntrackedCache(repoPath, cancellation);
596
+ case 'fsmonitor':
597
+ return this.applyFsmonitor(repoPath, cancellation);
598
+ case 'backgroundMaintenance':
599
+ return this.startBackgroundMaintenance(repoPath, cancellation);
600
+ case 'manyFiles':
601
+ return this.applyManyFiles(repoPath, cancellation);
602
+ case 'sparseIndex':
603
+ return this.applySparseIndex(repoPath, cancellation);
604
+ }
605
+ }
606
+ async revertOptimization(repoPath, id, cancellation) {
607
+ await this.reconcilePendingConfigChanges(repoPath, cancellation);
608
+ switch (id) {
609
+ case 'untrackedCache':
610
+ await this.revertConfigLever(repoPath, 'core.untrackedCache', 'gk.applied.untrackedCache', cancellation);
611
+ break;
612
+ case 'fsmonitor':
613
+ if (await this.revertConfigLever(repoPath, 'core.fsmonitor', 'gk.applied.fsmonitor', cancellation)) {
614
+ // Stop the daemon so it doesn't linger after the config is gone (best-effort) — only when
615
+ // WE applied fsmonitor; a user-enabled daemon (no marker) is left running.
616
+ await this.runQuietly(repoPath, cancellation, 'fsmonitor--daemon', 'stop');
617
+ }
618
+ break;
619
+ case 'backgroundMaintenance':
620
+ await this.revertBackgroundMaintenance(repoPath, cancellation);
621
+ break;
622
+ case 'manyFiles':
623
+ // Ownership is checked without clearing anything, then the SUB-lever is restored first.
624
+ // `revertConfigLever` clears the marker it acts on, so doing the parent first would strand a
625
+ // failed or cancelled `index.skipHash` restore: the retry would find no parent marker and
626
+ // skip straight past the sub-lever, leaving it set forever.
627
+ if ((await this.provider.config.getGkConfig(repoPath, 'gk.applied.manyFiles')) != null) {
628
+ // `index.skipHash` is only ever set on 2.40+, so its marker is absent otherwise and this
629
+ // no-ops.
630
+ await this.revertConfigLever(repoPath, 'index.skipHash', 'gk.applied.skipHash', cancellation);
631
+ await this.revertConfigLever(repoPath, 'feature.manyFiles', 'gk.applied.manyFiles', cancellation);
632
+ }
633
+ break;
634
+ case 'sparseIndex':
635
+ await this.revertSparseIndex(repoPath, cancellation);
636
+ break;
637
+ }
638
+ }
639
+ /**
640
+ * The Git Health per-repository commit-graph off switch (`gk.commitGraphDisabled`). Unlike the
641
+ * ownership markers above, this isn't a "record prior, then restore" lever — it's a plain flag with no
642
+ * underlying config value to protect — so it needs no marker lock; two windows toggling it concurrently
643
+ * just resolve to whichever write lands last.
644
+ */
645
+ async setCommitGraphDisabled(repoPath, disabled, cancellation) {
646
+ if (disabled) {
647
+ await this.provider.config.setGkConfig(repoPath, 'gk.commitGraphDisabled', 'true');
648
+ return;
649
+ }
650
+ // Tolerate an already-absent marker (re-enabling twice, or a repo GitLens never disabled):
651
+ // `setGkConfig`'s underlying `--unset` throws on a genuinely missing key, so check first.
652
+ if ((await this.readGkMarkerUncached(repoPath, 'gk.commitGraphDisabled', cancellation)) == null)
653
+ return;
654
+ await this.provider.config.setGkConfig(repoPath, 'gk.commitGraphDisabled', undefined);
655
+ }
656
+ async applyUntrackedCache(repoPath, cancellation) {
657
+ // Bail before the probe when the lever is already configured (bareword included) — nothing to apply
658
+ // either way, and the probe below isn't safe to run unconditionally: `core.untrackedCache` is one of
659
+ // git's few TRISTATE config keys (`true`/`false`/`keep`), and unlike a plain boolean its tristate
660
+ // reader has no NULL case — `update-index --test-untracked-cache` (and `git status`) FATALS on a
661
+ // bareword `core.untrackedCache` ("missing value for 'core.untrackedcache'") rather than reading it
662
+ // as true. This is a fast-path only; the authoritative check re-runs inside the lock below.
663
+ if ((await this.probeConfig(repoPath, cancellation)).untrackedCacheConfigured)
664
+ return false;
665
+ // Untracked cache is only CORRECT when directory mtimes are reliable; on network/virtual filesystems
666
+ // it produces wrong `git status`. Gate on git's own filesystem probe (it can take a few seconds, but
667
+ // this only runs at apply time — already background/daily). A failure records not-applicable (like
668
+ // fsmonitor) so it's never auto-retried.
669
+ if (!(await this.testUntrackedCacheSupport(repoPath, cancellation))) {
670
+ await this.markGkConfigSafe(repoPath, 'gk.untrackedCacheNotApplicable', 'true');
671
+ return false;
672
+ }
673
+ // Decide and mutate inside ONE lock, as late as possible. The filesystem probe above can take several
674
+ // seconds on a large repo — ample time for the user to set this key themselves — and re-checking
675
+ // outside the lock would still leave a gap between the check and the write.
676
+ //
677
+ // This cannot be made airtight against an external writer: a plain `git config` from a terminal
678
+ // takes no lock we can observe, so a write landing between our final read and ours still loses. The
679
+ // window is now two git invocations rather than the length of the probe.
680
+ return this.withMarkerLock(repoPath, async () => {
681
+ if ((await this.probeConfig(repoPath, cancellation)).untrackedCacheConfigured)
682
+ return false;
683
+ return this.applyConfigChangesUnlocked(repoPath, [untrackedCacheChange], cancellation);
684
+ });
685
+ }
686
+ /** Whether git's filesystem probe says the untracked cache can be used here (exit 0). */
687
+ async testUntrackedCacheSupport(repoPath, cancellation) {
688
+ const result = await this.runQuietly(repoPath, cancellation, 'update-index', '--test-untracked-cache');
689
+ // A cancelled/timed-out probe resolves `exitCode: 0` — never read that as "supported". This probe
690
+ // exists to keep the untracked cache OFF filesystems with unreliable directory mtimes, which are
691
+ // exactly the slow/network filesystems where it is most likely to hit the timeout.
692
+ this.throwIfDidNotComplete(result, cancellation);
693
+ // 0 = this filesystem supports it, 1 = it doesn't. Any OTHER exit isn't a verdict about the
694
+ // filesystem at all — a spawn-level failure (a non-numeric error code parses to NaN) or a repo-level
695
+ // error — and the caller turns `false` into a PERMANENT not-applicable marker. Throw instead, so a
696
+ // one-off hiccup during a background pass just retries on the next pass.
697
+ if (result.exitCode !== 0 && result.exitCode !== untrackedCacheUnsupportedExitCode) {
698
+ throw new Error(`Untracked-cache probe did not complete (git exited ${String(result.exitCode)})`);
699
+ }
700
+ return result.exitCode === 0;
701
+ }
702
+ async applyFsmonitor(repoPath, cancellation) {
703
+ const scope = getScopedLogger();
704
+ if ((await this.probeConfig(repoPath, cancellation)).fsmonitor)
705
+ return false;
706
+ const fsmonitorFeature = fsmonitorFeatureForPlatform();
707
+ if (fsmonitorFeature == null)
708
+ return false;
709
+ if (!(await this.git.supports(fsmonitorFeature)))
710
+ return false;
711
+ // A new-enough git is not proof the daemon can actually run here — the backend is compiled per
712
+ // platform. Ask it directly BEFORE touching config, because the warm-up below cannot tell us: with an
713
+ // unsupported daemon `git status` exits 0 in silence, so trusting it would leave `core.fsmonitor=true`
714
+ // set on a repo that gains nothing from it.
715
+ const probe = await this.runQuietly(repoPath, cancellation, 'fsmonitor--daemon', 'status');
716
+ this.throwIfDidNotComplete(probe, cancellation);
717
+ if (isFsmonitorDaemonUnsupported(probe)) {
718
+ scope?.warn(`FSMonitor daemon unsupported by this git build for '${repoPath}'; marking not-applicable`);
719
+ await this.markGkConfigSafe(repoPath, 'gk.fsmonitorNotApplicable', 'true');
720
+ return false;
721
+ }
722
+ const applied = await this.withMarkerLock(repoPath, async () => {
723
+ await this.reconcilePendingConfigChangesUnlocked(repoPath, cancellation);
724
+ if ((await this.probeConfig(repoPath, cancellation)).fsmonitor)
725
+ return false;
726
+ return this.applyConfigChangesUnlocked(repoPath, [fsmonitorChange], cancellation);
727
+ });
728
+ if (!applied)
729
+ return false;
730
+ // A `status` with `core.fsmonitor=true` spins up the built-in daemon; if it can't start, the command
731
+ // errors. Run it quietly so the EXIT CODE stays visible — the distinction between "git ran and
732
+ // rejected this repo" and "the command never completed" decides whether the repo earns a permanent
733
+ // not-applicable marker.
734
+ const result = await this.runQuietly(repoPath, cancellation, 'status', '--porcelain');
735
+ // Check "did it complete" BEFORE the exit code: a cancelled or never-run command also resolves 0.
736
+ if (result.completion.status === 'exited' && result.exitCode === 0 && cancellation?.aborted !== true) {
737
+ return true;
738
+ }
739
+ // Roll back on a signal that can't itself be aborted: this path exists to leave the repo exactly as we
740
+ // found it, and a rollback cancelled halfway leaves `core.fsmonitor` ON with its ownership marker
741
+ // cleared — the lever stuck on, and GitLens no longer offering to undo it.
742
+ await this.restorePriorAndClear(repoPath, 'core.fsmonitor', 'gk.applied.fsmonitor');
743
+ await this.runQuietly(repoPath, undefined, 'fsmonitor--daemon', 'stop');
744
+ // A cancelled or timed-out warm-up says nothing about whether the daemon can start — and a first cold
745
+ // `status` on the very large repos fsmonitor targets is exactly what hits the timeout.
746
+ this.throwIfDidNotComplete(result, cancellation);
747
+ // Neither does a failure to run git at all, which surfaces as a non-numeric error code (→ NaN). Only a
748
+ // real git exit is evidence about THIS repo, so only that earns the permanent marker.
749
+ if (!Number.isInteger(result.exitCode)) {
750
+ throw new Error(`FSMonitor warm-up did not complete (git exited ${String(result.exitCode)})`);
751
+ }
752
+ // Before blaming FSMonitor, re-run the same command now that the rollback has restored the prior
753
+ // config — i.e. the baseline. If THAT fails too, the repository is broken for an unrelated reason (a
754
+ // corrupt index, permissions) and recording not-applicable would permanently suppress a lever that
755
+ // was never the cause.
756
+ const baseline = await this.runQuietly(repoPath, cancellation, 'status', '--porcelain');
757
+ this.throwIfDidNotComplete(baseline, cancellation);
758
+ if (baseline.exitCode !== 0) {
759
+ throw new Error(`Repository status fails independently of FSMonitor (git exited ${String(baseline.exitCode)})`);
760
+ }
761
+ scope?.warn(`FSMonitor failed to start for '${repoPath}'; reverting and marking not-applicable`);
762
+ await this.markGkConfigSafe(repoPath, 'gk.fsmonitorNotApplicable', 'true');
763
+ return false;
764
+ }
765
+ async applyManyFiles(repoPath, cancellation) {
766
+ // Self-gate like the other invasive levers: older git accepts the unknown key silently, so without
767
+ // this a direct (or stale) caller gets `true` back for a setting that does nothing. Report-level
768
+ // capability filtering doesn't protect callers that reach `applyOptimization` some other way.
769
+ if (!(await this.git.supports('git:manyFiles')))
770
+ return false;
771
+ // `feature.manyFiles` implies index v4 + untracked cache; add `index.skipHash` explicitly where
772
+ // the installed git supports it (2.40+) for the extra index-write speedup. Both writes and both
773
+ // ownership markers share one transaction so any failed write/marker restores the whole new state.
774
+ const changes = [manyFilesChange];
775
+ if (await this.git.supports('git:index:skipHash')) {
776
+ changes.push(skipHashChange);
777
+ }
778
+ // feature.manyFiles only DEFAULTS the untracked cache on. Respect an explicit true/false/keep (and
779
+ // avoid probing a bareword, which makes update-index fatal); only an unset key needs the same
780
+ // filesystem-correctness probe as the standalone lever. Re-check under the marker lock so a config
781
+ // change during the potentially slow probe cannot make this decision stale.
782
+ const initialConfig = await this.probeConfig(repoPath, cancellation);
783
+ // A repo already marked not-applicable must not re-run the multi-second filesystem probe on every
784
+ // attempt — an explicit `core.untrackedCache` still overrides the marker, same as below.
785
+ if (!initialConfig.untrackedCacheConfigured &&
786
+ (await this.provider.config.getGkConfig(repoPath, 'gk.untrackedCacheNotApplicable')) === 'true') {
787
+ return false;
788
+ }
789
+ const initialSupport = initialConfig.untrackedCacheConfigured
790
+ ? undefined
791
+ : await this.testUntrackedCacheSupport(repoPath, cancellation);
792
+ return this.withMarkerLock(repoPath, async () => {
793
+ await this.reconcilePendingConfigChangesUnlocked(repoPath, cancellation);
794
+ const localManyFiles = await this.getLocalConfig(repoPath, manyFilesChange.configKey, cancellation);
795
+ const manyFilesMarker = await this.readGkMarkerUncached(repoPath, manyFilesChange.markerKey, cancellation);
796
+ if (parseGitBoolean(localManyFiles) && manyFilesMarker == null)
797
+ return false;
798
+ const currentConfig = await this.probeConfig(repoPath, cancellation);
799
+ if (!currentConfig.untrackedCacheConfigured) {
800
+ const supported = initialSupport ?? (await this.testUntrackedCacheSupport(repoPath, cancellation));
801
+ if (!supported) {
802
+ await this.markGkConfigSafe(repoPath, 'gk.untrackedCacheNotApplicable', 'true');
803
+ return false;
804
+ }
805
+ }
806
+ return this.applyConfigChangesUnlocked(repoPath, changes, cancellation);
807
+ });
808
+ }
809
+ /** Enables sparse-directory index entries for an existing cone-mode sparse checkout. */
810
+ async applySparseIndex(repoPath, cancellation) {
811
+ if (!(await this.git.supports('git:sparse-index')))
812
+ return false;
813
+ const gitDir = await this.provider.config.getGitDir(repoPath);
814
+ const markerPaths = this.getSparseIndexMarkerPaths(gitDir.uri.fsPath);
815
+ return this.withMarkerLock(repoPath, async () => {
816
+ const config = await this.probeConfig(repoPath, cancellation);
817
+ const current = await this.reconcileSparseIndexMarkersUnlocked(markerPaths, config.sparseIndex);
818
+ if (current.enabled)
819
+ return current.applied;
820
+ if (!config.sparseCheckout || !config.sparseCheckoutCone)
821
+ return false;
822
+ const [sharedIndex, conflictOperation] = await Promise.all([
823
+ this.probeSharedIndex(gitDir.uri.fsPath),
824
+ this.probeConflictOperation(gitDir.uri.fsPath),
825
+ ]);
826
+ if (sharedIndex?.present === true || conflictOperation)
827
+ return false;
828
+ await mkdir(markerPaths.dir, { recursive: true });
829
+ const pending = await open(markerPaths.pending, 'wx');
830
+ await pending.close();
831
+ try {
832
+ await this.runSparseCheckoutReapply(repoPath, true, cancellation);
833
+ const completed = await this.reconcileSparseIndexMarkersUnlocked(markerPaths, true);
834
+ return completed.enabled && completed.applied;
835
+ }
836
+ catch (ex) {
837
+ try {
838
+ // The command can fail after replacing its config/index locks. Repair ownership from the
839
+ // resulting state without the cancelled signal so a successful mutation never loses Undo.
840
+ const repaired = await this.reconcileSparseIndexMarkersUnlocked(markerPaths, await this.probeSparseIndexEnabled(repoPath));
841
+ if (repaired.enabled && repaired.applied)
842
+ return true;
843
+ }
844
+ catch (repairEx) {
845
+ const error = new Error('Unable to enable the sparse index or reconcile its ownership record', {
846
+ cause: ex,
847
+ });
848
+ error.errors = [repairEx];
849
+ throw error;
850
+ }
851
+ throw ex;
852
+ }
853
+ }, undefined, markerPaths);
854
+ }
855
+ /** Expands a sparse index only when this worktree's marker proves GitLens enabled it. */
856
+ async revertSparseIndex(repoPath, cancellation) {
857
+ const gitDir = await this.provider.config.getGitDir(repoPath);
858
+ const markerPaths = this.getSparseIndexMarkerPaths(gitDir.uri.fsPath);
859
+ await this.withMarkerLock(repoPath, async () => {
860
+ const current = await this.reconcileSparseIndexMarkersUnlocked(markerPaths, await this.probeSparseIndexEnabled(repoPath, cancellation));
861
+ if (!current.applied)
862
+ return;
863
+ try {
864
+ await this.runSparseCheckoutReapply(repoPath, false, cancellation);
865
+ await rm(markerPaths.applied, { force: true });
866
+ }
867
+ catch (ex) {
868
+ try {
869
+ const repaired = await this.reconcileSparseIndexMarkersUnlocked(markerPaths, await this.probeSparseIndexEnabled(repoPath));
870
+ if (!repaired.enabled && !repaired.applied)
871
+ return;
872
+ }
873
+ catch (repairEx) {
874
+ const error = new Error('Unable to disable the sparse index or reconcile its ownership record', {
875
+ cause: ex,
876
+ });
877
+ error.errors = [repairEx];
878
+ throw error;
879
+ }
880
+ throw ex;
881
+ }
882
+ }, undefined, markerPaths);
883
+ }
884
+ async runSparseCheckoutReapply(repoPath, enabled, cancellation) {
885
+ await this.git.run({
886
+ cwd: repoPath,
887
+ errors: 'throw',
888
+ priority: 'background',
889
+ cancellation: cancellation,
890
+ selfMaintenance: true,
891
+ }, 'sparse-checkout', 'reapply', enabled ? '--sparse-index' : '--no-sparse-index');
892
+ }
893
+ /**
894
+ * The `git maintenance start` version gate for the current platform: launchctl/schtasks scheduling
895
+ * (win/mac) needs 2.31; Linux relies on a systemd timer (2.34), since 2.31–2.33 is cron-only and
896
+ * modern distros often ship without cron.
897
+ */
898
+ get backgroundMaintenanceFeature() {
899
+ return process.platform === 'win32' || process.platform === 'darwin'
900
+ ? 'git:maintenance:start'
901
+ : 'git:maintenance:start:systemd';
902
+ }
903
+ async startBackgroundMaintenance(repoPath, cancellation) {
904
+ const scope = getScopedLogger();
905
+ if (!(await this.git.supports(this.backgroundMaintenanceFeature)))
906
+ return false;
907
+ // Resolve the path we're about to register BEFORE recording anything, so the marker below records what
908
+ // was actually used rather than something undo has to re-derive later.
909
+ const registeredPath = await this.resolveMaintenanceRepoPath(repoPath, cancellation);
910
+ // Re-check registration with a FRESH read (bypass the registered-repos TTL cache): the report that
911
+ // drove this apply can be stale, and without this a registration made between the probe and the
912
+ // click gets claimed as ours — a later Undo would then unregister someone else's entry.
913
+ this._registeredMaintenanceRepos = undefined;
914
+ if (await this.isPathRegistered(repoPath, registeredPath, cancellation))
915
+ return false;
916
+ // Record priors BEFORE starting. `git maintenance register` sets local `maintenance.auto=false` AND
917
+ // `maintenance.strategy=incremental`, and `unregister` restores NEITHER — so both must be captured or
918
+ // undo silently leaves a key behind. `start` can also fail after writing config while installing the
919
+ // scheduler, so recording first is what makes even a partial start recoverable.
920
+ //
921
+ // Both go through `recordPriorAndMark`, which is idempotent: a second apply must not re-record the
922
+ // prior, or it captures the value git itself just wrote and undo "restores" that instead.
923
+ await this.recordPriorAndMark(repoPath, 'maintenance.auto', 'gk.applied.maintenanceAuto', cancellation);
924
+ await this.recordPriorAndMark(repoPath, 'maintenance.strategy', 'gk.applied.maintenanceStrategy', cancellation);
925
+ // The marker's VALUE is the registered path, not just presence. `git maintenance register` records a
926
+ // per-worktree path, so undo must unregister EXACTLY what was registered: re-deriving it wedges undo
927
+ // whenever the derivation differs at revert time (a `worktree list` that failed here and fell back to
928
+ // this worktree, or a sibling the user registered since).
929
+ await this.provider.config.setGkConfig(repoPath, 'gk.applied.backgroundMaintenance', registeredPath);
930
+ // `start` registers the repo AND installs the system scheduler (launchctl/schtasks/systemd/cron). A
931
+ // genuine failure throws (the ask-tier UI surfaces it); unsupported already returned false above.
932
+ try {
933
+ await this.git.run({
934
+ cwd: registeredPath,
935
+ errors: 'throw',
936
+ priority: 'background',
937
+ cancellation: cancellation,
938
+ selfMaintenance: true,
939
+ }, 'maintenance', 'start');
940
+ }
941
+ catch (ex) {
942
+ // `start` can fail AFTER registering, while installing the scheduler (e.g. Git ≥ 2.34 on a Linux
943
+ // box with no systemd actually running — the version gate can't detect that), leaving the repo
944
+ // registered with `maintenance.auto=false` and nothing scheduled: strictly worse than before. The
945
+ // markers written above make that recoverable, but nothing would ever PROMPT the undo — so undo it
946
+ // here, unsignalled, then rethrow so the ask-tier UI still surfaces the original failure.
947
+ this._registeredMaintenanceRepos = undefined;
948
+ await this.revertBackgroundMaintenance(repoPath).catch((revertEx) => scope?.error(revertEx, `Unable to roll back a partial 'maintenance start' for '${repoPath}'`));
949
+ throw ex;
950
+ }
951
+ this._registeredMaintenanceRepos = undefined;
952
+ return true;
953
+ }
954
+ async revertBackgroundMaintenance(repoPath, cancellation) {
955
+ const scope = getScopedLogger();
956
+ // No ownership marker → GitLens didn't register it → never unregister the user's own registration.
957
+ const marker = await this.provider.config.getGkConfig(repoPath, 'gk.applied.backgroundMaintenance');
958
+ if (marker == null)
959
+ return;
960
+ // The marker records the path we registered (older markers stored `true` — fall back to the same
961
+ // derivation those were written with).
962
+ const registeredPath = marker === 'true' ? await this.resolveMaintenanceRepoPath(repoPath, cancellation) : marker;
963
+ // `maintenance unregister` exits non-zero when the repo isn't registered; `--force` (which suppresses
964
+ // that) only exists on Git ≥ 2.39, so tolerate the benign non-zero exit via `errors: 'ignore'`.
965
+ // `unregister` also targets the CWD's repository, so it needs that worktree to still exist.
966
+ let unregistered = false;
967
+ if ((await this.git.supports('git:maintenance')) && (await fsExists(registeredPath))) {
968
+ const result = await this.runQuietly(registeredPath, cancellation, 'maintenance', 'unregister');
969
+ // A cancelled unregister resolves `exitCode: 0` just like the benign "wasn't registered" exit —
970
+ // don't clear the ownership markers below on the strength of a command that never ran.
971
+ this.throwIfDidNotComplete(result, cancellation);
972
+ this._registeredMaintenanceRepos = undefined;
973
+ // `unregister` also exits non-zero for a REAL failure, and pre-2.39 git has no `--force` to tell
974
+ // that apart from the benign "wasn't registered" — so confirm rather than trust the exit code.
975
+ // Check OUR path only, never the whole worktree family: a sibling the user registered themselves
976
+ // is theirs to remove, and waiting for it to disappear would block undo forever.
977
+ unregistered = !(await this.isPathRegistered(repoPath, registeredPath, cancellation));
978
+ }
979
+ if (!unregistered) {
980
+ // Neither precondition above is guaranteed at undo time — the worktree may be gone, or git may
981
+ // have been downgraded below `maintenance`. Delete the exact global entry ourselves instead of
982
+ // giving up: dropping ownership here while the registration survives would leave the repo
983
+ // scheduled forever with nothing left to identify it as GitLens's. `--fixed-value` (2.30+, the
984
+ // same floor as `git maintenance`) stops the path being read as a regex; both the recorded and
985
+ // the symlink-resolved spelling are tried, since `register` stores the resolved one.
986
+ for (const candidate of await this.resolveRegistrationCandidates([registeredPath])) {
987
+ await this.runQuietly(repoPath, cancellation, 'config', '--global', '--unset', '--fixed-value', 'maintenance.repo', candidate);
988
+ }
989
+ this._registeredMaintenanceRepos = undefined;
990
+ if (await this.isPathRegistered(repoPath, registeredPath, cancellation)) {
991
+ throw new Error('Unable to unregister background maintenance');
992
+ }
993
+ scope?.warn(`Removed the global maintenance.repo entry for '${registeredPath}' directly`);
994
+ }
995
+ // `unregister` restores NEITHER key `register` wrote — verified against git: both `maintenance.auto`
996
+ // and `maintenance.strategy` survive it. Restore each recorded prior ourselves, else undo leaves the
997
+ // repo with no scheduled maintenance, no auto-gc, and a stray strategy key: strictly worse than
998
+ // before it was applied. Restore BEFORE clearing markers, so a failure here is retryable.
999
+ const priorAuto = await this.provider.config.getGkConfig(repoPath, 'gk.applied.maintenanceAuto');
1000
+ if (priorAuto != null) {
1001
+ await this.restoreLocalConfig(repoPath, 'maintenance.auto', priorAuto, cancellation);
1002
+ }
1003
+ // Guarded on both sides: a marker written by a build that predates strategy tracking won't have this
1004
+ // key, and clearing an absent gk key errors rather than no-oping.
1005
+ const priorStrategy = await this.provider.config.getGkConfig(repoPath, 'gk.applied.maintenanceStrategy');
1006
+ if (priorStrategy != null) {
1007
+ await this.restoreLocalConfig(repoPath, 'maintenance.strategy', priorStrategy, cancellation);
1008
+ await this.provider.config.setGkConfig(repoPath, 'gk.applied.maintenanceStrategy', undefined);
1009
+ }
1010
+ // The OS scheduler `start` installed is deliberately LEFT IN PLACE. It's global rather than per-repo,
1011
+ // and `git maintenance register` (which a user may well have run by hand) does not install one — so
1012
+ // tearing it down here could silently stop maintenance for repos GitLens never touched. A scheduler
1013
+ // with nothing registered simply finds no work, whereas removing one someone else relies on is not
1014
+ // recoverable by them. Leaving it inert is the safer asymmetry.
1015
+ if (priorAuto != null) {
1016
+ await this.provider.config.setGkConfig(repoPath, 'gk.applied.maintenanceAuto', undefined);
1017
+ }
1018
+ await this.provider.config.setGkConfig(repoPath, 'gk.applied.backgroundMaintenance', undefined);
1019
+ }
1020
+ /**
1021
+ * Records a config lever's prior LOCAL value (or the `unset` sentinel) under its ownership marker BEFORE
1022
+ * mutating, so undo restores the exact prior and never a hardcoded inverse; the marker's presence also
1023
+ * means "applied by GitLens" (so undo is never offered for a user-enabled lever).
1024
+ */
1025
+ async recordPriorAndMark(repoPath, configKey, markerKey, cancellation) {
1026
+ // Idempotent by design, and atomic across processes. An existing marker means WE already applied this
1027
+ // lever, so the value it holds IS the user's prior — re-recording would capture the value GitLens
1028
+ // itself set (a second apply from a stale view action, or a sibling window racing this one) and undo
1029
+ // would then "restore" the lever to on, permanently. The first record is the only truthful one.
1030
+ //
1031
+ // The check and the write are two separate git invocations, so an in-memory guard would only protect
1032
+ // one provider; the lock is what makes the pair safe between VS Code windows.
1033
+ await this.withMarkerLock(repoPath, () => this.recordPriorUnlocked(repoPath, configKey, markerKey, cancellation));
1034
+ }
1035
+ /** The body of {@link recordPriorAndMark}, for callers already holding the marker lock. */
1036
+ async recordPriorUnlocked(repoPath, configKey, markerKey, cancellation) {
1037
+ // Read UNCACHED: the provider's bulk gk map can be stale for exactly the window the lock exists to
1038
+ // close — another process may have written the marker since it was populated.
1039
+ if ((await this.readGkMarkerUncached(repoPath, markerKey, cancellation)) != null)
1040
+ return;
1041
+ // `getLocalConfig` reads via `--get-regex`, not `--get` — see its doc comment for why: `--get` can't
1042
+ // tell a bareword-true prior apart from an explicit-empty (false) one. A bareword prior comes back as
1043
+ // the string `'true'` here, which falls into the plain `prior` branch below and is recorded verbatim —
1044
+ // `key true` is the value git's CLI writes for the same boolean, since it has no way to write a bareword.
1045
+ const prior = await this.getLocalConfig(repoPath, configKey, cancellation);
1046
+ await this.provider.config.setGkConfig(repoPath, markerKey, prior == null ? unsetConfigSentinel : prior === '' ? emptyConfigSentinel : prior);
1047
+ }
1048
+ /**
1049
+ * Reconciles write-ahead records left by an interrupted direct config mutation. The cached namespace read
1050
+ * keeps the normal probe free of extra subprocesses; an actual pending record is re-read under the marker
1051
+ * lock before it is finalized or cleared.
1052
+ */
1053
+ async reconcilePendingConfigChanges(repoPath, cancellation) {
1054
+ const markers = await this.provider.config.getGkConfigRegex(repoPath, '^gk\\.pending\\.');
1055
+ if (!configLeverChanges.some(change => markers.has(canonicalizeGitConfigKey(change.pendingKey))))
1056
+ return;
1057
+ await this.withMarkerLock(repoPath, () => this.reconcilePendingConfigChangesUnlocked(repoPath, cancellation));
1058
+ }
1059
+ /** Body of {@link reconcilePendingConfigChanges} for a caller already holding the marker lock. */
1060
+ async reconcilePendingConfigChangesUnlocked(repoPath, cancellation) {
1061
+ for (const change of configLeverChanges) {
1062
+ const raw = await this.readGkMarkerUncached(repoPath, change.pendingKey, cancellation);
1063
+ if (raw == null)
1064
+ continue;
1065
+ const pending = decodePendingConfigChange(raw);
1066
+ if (pending == null || pending.value !== change.value) {
1067
+ // A malformed record can't be reconciled into an ownership marker — drop it rather than throw,
1068
+ // which would otherwise wedge every future health snapshot/apply/revert on this repo. Failing
1069
+ // toward "not GitLens's" is the safe direction: the user keeps whatever config exists, GitLens
1070
+ // just never offers Undo for it.
1071
+ await this.provider.config.setGkConfig(repoPath, change.pendingKey, undefined);
1072
+ continue;
1073
+ }
1074
+ const applied = await this.readGkMarkerUncached(repoPath, change.markerKey, cancellation);
1075
+ if (applied == null) {
1076
+ const current = await this.getLocalConfig(repoPath, change.configKey, cancellation);
1077
+ if (current === pending.value) {
1078
+ await this.provider.config.setGkConfig(repoPath, change.markerKey, pending.prior);
1079
+ }
1080
+ }
1081
+ // If the intended value never landed (or a user changed it afterward), leave the config untouched and
1082
+ // drop only the uncommitted journal record. If it did land, ownership was finalized above first.
1083
+ await this.provider.config.setGkConfig(repoPath, change.pendingKey, undefined);
1084
+ }
1085
+ }
1086
+ /**
1087
+ * Applies direct config levers for a caller holding the ownership-marker lock. A write-ahead record closes
1088
+ * the crash gap between the local config and ownership files; an ordinary failure restores every config
1089
+ * value and marker without cancellation.
1090
+ */
1091
+ async applyConfigChangesUnlocked(repoPath, changes, cancellation) {
1092
+ await this.reconcilePendingConfigChangesUnlocked(repoPath, cancellation);
1093
+ const states = [];
1094
+ let alreadyApplied = false;
1095
+ for (const change of changes) {
1096
+ const marker = await this.readGkMarkerUncached(repoPath, change.markerKey, cancellation);
1097
+ const current = await this.getLocalConfig(repoPath, change.configKey, cancellation);
1098
+ if (current === change.value) {
1099
+ alreadyApplied ||= marker != null;
1100
+ continue;
1101
+ }
1102
+ const rollbackPrior = current == null ? unsetConfigSentinel : current === '' ? emptyConfigSentinel : current;
1103
+ if (marker != null) {
1104
+ states.push({
1105
+ change: change,
1106
+ prior: marker,
1107
+ rollbackPrior: rollbackPrior,
1108
+ alreadyOwned: true,
1109
+ });
1110
+ continue;
1111
+ }
1112
+ states.push({
1113
+ change: change,
1114
+ prior: rollbackPrior,
1115
+ rollbackPrior: rollbackPrior,
1116
+ alreadyOwned: false,
1117
+ });
1118
+ }
1119
+ if (states.length === 0)
1120
+ return alreadyApplied;
1121
+ const written = [];
1122
+ const marked = [];
1123
+ const pending = new Set();
1124
+ try {
1125
+ for (const state of states) {
1126
+ if (state.alreadyOwned)
1127
+ continue;
1128
+ await this.provider.config.setGkConfig(repoPath, state.change.pendingKey, encodePendingConfigChange({ prior: state.prior, value: state.change.value }));
1129
+ pending.add(state);
1130
+ }
1131
+ for (const state of states) {
1132
+ await this.setLocalConfig(repoPath, state.change.configKey, state.change.value, cancellation);
1133
+ written.push(state);
1134
+ }
1135
+ for (const state of states) {
1136
+ if (state.alreadyOwned)
1137
+ continue;
1138
+ await this.provider.config.setGkConfig(repoPath, state.change.markerKey, state.prior);
1139
+ marked.push(state);
1140
+ }
1141
+ for (const state of pending) {
1142
+ await this.provider.config.setGkConfig(repoPath, state.change.pendingKey, undefined);
1143
+ pending.delete(state);
1144
+ }
1145
+ return true;
1146
+ }
1147
+ catch (ex) {
1148
+ const rollbackErrors = [];
1149
+ for (const state of written.toReversed()) {
1150
+ try {
1151
+ await this.restoreLocalConfig(repoPath, state.change.configKey, state.rollbackPrior);
1152
+ }
1153
+ catch (rollbackEx) {
1154
+ rollbackErrors.push(rollbackEx);
1155
+ }
1156
+ }
1157
+ for (const state of marked.toReversed()) {
1158
+ try {
1159
+ await this.provider.config.setGkConfig(repoPath, state.change.markerKey, undefined);
1160
+ }
1161
+ catch (rollbackEx) {
1162
+ rollbackErrors.push(rollbackEx);
1163
+ }
1164
+ }
1165
+ for (const state of pending) {
1166
+ try {
1167
+ await this.provider.config.setGkConfig(repoPath, state.change.pendingKey, undefined);
1168
+ }
1169
+ catch (rollbackEx) {
1170
+ rollbackErrors.push(rollbackEx);
1171
+ }
1172
+ }
1173
+ if (rollbackErrors.length) {
1174
+ const error = new Error('Unable to apply Git maintenance settings and completely restore their prior values', { cause: ex });
1175
+ error.errors = rollbackErrors;
1176
+ throw error;
1177
+ }
1178
+ throw ex;
1179
+ }
1180
+ }
1181
+ async claimMaintenancePass(repoPath, intervalMs) {
1182
+ // The stamp is the CROSS-WINDOW throttle, so reading it and writing it must be one transaction —
1183
+ // otherwise two windows both observe an expired value and both run a pass. Uses the same lock and
1184
+ // the same uncached read as the ownership markers.
1185
+ return this.withMarkerLock(repoPath, async () => {
1186
+ const raw = await this.readGkMarkerUncached(repoPath, 'gk.maintenanceLastRun');
1187
+ const last = raw != null ? Date.parse(raw) : Number.NaN;
1188
+ if (!Number.isNaN(last) && Date.now() - last < intervalMs)
1189
+ return false;
1190
+ // Claim BEFORE the work, not after: a pass can run for minutes, and a window probing in that gap
1191
+ // would otherwise see no stamp and start a second concurrent pass over the same object database.
1192
+ await this.provider.config.setGkConfig(repoPath, 'gk.maintenanceLastRun', new Date().toISOString(), {
1193
+ skipInvalidation: ['branchOverviews', 'baseBranchName'],
1194
+ });
1195
+ return true;
1196
+ });
1197
+ }
1198
+ /** The `.git/gk/` directory (common dir, so worktrees of a repo share it). */
1199
+ async getGkDir(repoPath) {
1200
+ const gitDir = await this.provider.config.getGitDir(repoPath);
1201
+ return joinPaths((gitDir.commonUri ?? gitDir.uri).fsPath, 'gk');
1202
+ }
1203
+ getSparseIndexMarkerPaths(gitDirPath) {
1204
+ const dir = joinPaths(gitDirPath, 'gk');
1205
+ return {
1206
+ dir: dir,
1207
+ applied: joinPaths(dir, sparseIndexAppliedMarker),
1208
+ contention: 'sparseIndex',
1209
+ lock: joinPaths(dir, sparseIndexLockFile),
1210
+ pending: joinPaths(dir, sparseIndexPendingMarker),
1211
+ };
1212
+ }
1213
+ /**
1214
+ * Reconciles the sparse-index command's worktree-local write-ahead marker. The fast path is filesystem
1215
+ * only; a pending or stale-applied marker takes the worktree's sparse-index operation lock and re-reads
1216
+ * Git config before mutating anything, so it cannot act on a snapshot that raced another window's
1217
+ * apply/undo. A live operation keeps that lock across the index rewrite, but snapshots never wait for it:
1218
+ * they return the last committed marker state until the operation publishes its result.
1219
+ */
1220
+ async reconcileSparseIndexMarkers(repoPath, gitDirPath, knownEnabled, cancellation) {
1221
+ const markerPaths = this.getSparseIndexMarkerPaths(gitDirPath);
1222
+ const [pending, applied] = await Promise.all([fsExists(markerPaths.pending), fsExists(markerPaths.applied)]);
1223
+ if (knownEnabled == null)
1224
+ return { enabled: undefined, applied: false };
1225
+ if (!pending && (!applied || knownEnabled)) {
1226
+ return { enabled: knownEnabled, applied: applied && knownEnabled };
1227
+ }
1228
+ try {
1229
+ return await this.withMarkerLock(repoPath, async () => this.reconcileSparseIndexMarkersUnlocked(markerPaths, await this.probeSparseIndexEnabled(repoPath, cancellation)), { retryMs: 0, maxAttempts: 0 }, markerPaths);
1230
+ }
1231
+ catch (ex) {
1232
+ if (!(ex instanceof MarkerLockContentionError))
1233
+ throw ex;
1234
+ // The config probe is the truthful state even if the lock holder crashed after changing it. Marker
1235
+ // presence only proves ownership while that config is still enabled; reconciliation can resume after
1236
+ // the live operation finishes or a positively dead owner's lock is removed manually.
1237
+ return { enabled: knownEnabled, applied: applied && knownEnabled };
1238
+ }
1239
+ }
1240
+ /** Body of {@link reconcileSparseIndexMarkers} for a caller already holding the ownership lock. */
1241
+ async reconcileSparseIndexMarkersUnlocked(markerPaths, enabled) {
1242
+ const pending = await fsExists(markerPaths.pending);
1243
+ let applied = await fsExists(markerPaths.applied);
1244
+ if (pending) {
1245
+ if (enabled) {
1246
+ await mkdir(markerPaths.dir, { recursive: true });
1247
+ if (applied) {
1248
+ await rm(markerPaths.pending, { force: true });
1249
+ }
1250
+ else {
1251
+ await rename(markerPaths.pending, markerPaths.applied);
1252
+ applied = true;
1253
+ }
1254
+ }
1255
+ else {
1256
+ await rm(markerPaths.pending, { force: true });
1257
+ }
1258
+ }
1259
+ if (applied && !enabled) {
1260
+ await rm(markerPaths.applied, { force: true });
1261
+ applied = false;
1262
+ }
1263
+ return { enabled: enabled, applied: applied };
1264
+ }
1265
+ /** Reads the effective worktree-scoped sparse-index switch without relying on a cached config map. */
1266
+ async probeSparseIndexEnabled(repoPath, cancellation) {
1267
+ const result = await this.runQuietly(repoPath, cancellation, 'config', '--bool', '--get', 'index.sparse');
1268
+ this.throwIfDidNotComplete(result, cancellation);
1269
+ if (result.exitCode === gitConfigGetMissingExitCode)
1270
+ return false;
1271
+ if (result.exitCode !== 0) {
1272
+ throw new Error(`Unable to read 'index.sparse' (git exited ${String(result.exitCode)})`);
1273
+ }
1274
+ return parseGitBoolean(result.stdout);
1275
+ }
1276
+ /** Reads an ownership marker straight from `.git/gk/config`, bypassing every cache. */
1277
+ async readGkMarkerUncached(repoPath, markerKey, cancellation) {
1278
+ const result = await this.runQuietly(repoPath, cancellation, 'config', '--file', joinPaths(await this.getGkDir(repoPath), 'config'), '--get', markerKey);
1279
+ this.throwIfDidNotComplete(result, cancellation);
1280
+ if (result.exitCode !== 0 && result.exitCode !== gitConfigGetMissingExitCode) {
1281
+ throw new Error(`Unable to read the '${markerKey}' marker (git exited ${String(result.exitCode)})`);
1282
+ }
1283
+ return result.stdout.trim() || undefined;
1284
+ }
1285
+ /**
1286
+ * Runs `fn` holding an exclusive on-disk lock over one ownership-marker scope. By default that is the
1287
+ * repository family's config-marker scope; callers can provide a worktree-local lock location for an
1288
+ * operation whose state is isolated per worktree. Two layers make this safe across processes:
1289
+ *
1290
+ * - **Exclusive-create ownership, with an identity record.** `open(lockPath, 'wx')` — the same `O_EXCL`
1291
+ * primitive git uses for its own `*.lock` files — is the only atomic step; a contender that loses the
1292
+ * race retries for up to `maxAttempts`, then fails loudly rather than proceeding unlocked (see below).
1293
+ * The winner immediately writes its host, pid, and a fresh random `ownerId` into the lock file — an
1294
+ * identity record, not a liveness lease, since nothing here ever acts on it while another holder is
1295
+ * still running.
1296
+ * - **Ownership-verified release.** On the way out, the lock file is removed only if its `ownerId` still
1297
+ * matches the one this call wrote — a fresh random value per acquisition can't collide with a
1298
+ * re-created lock the way an inode COULD in principle on some filesystems, so it's the primary check;
1299
+ * the inode is also compared as a cheap secondary one.
1300
+ *
1301
+ * A lock left behind by a crashed window is deliberately NOT recovered automatically. Two designs were
1302
+ * tried and rejected. Time-based leasing can't tell a dead owner from a slow one — an age alone doesn't
1303
+ * distinguish them (git timeouts are user-configurable, and can be disabled entirely), and making it tell
1304
+ * them apart takes a full lease-plus-fencing-token protocol this lock has no need for otherwise.
1305
+ * Automatic stealing (rename the lock aside once a PID probe says its owner is provably dead) was tried
1306
+ * next, and is unsound for a different reason: automatic recovery of an abandoned fixed-path lock cannot
1307
+ * be made race-free using only the portable stock-Node primitives available here (exclusive-create and
1308
+ * rename) — every recovery attempt separates observation from mutation, so a delayed stealer can rename
1309
+ * away a NEW, live lock a fresh contender created in the gap between the probe and the rename, evicting a
1310
+ * holder that was never dead. Kernel-managed advisory locking (`flock`) would close that gap, but Node
1311
+ * exposes no portable interface to it and a native, platform-sensitive dependency is disproportionate
1312
+ * here. Therefore a contender NEVER mutates an existing lock — only the creator removes one, and no stale
1313
+ * observation can authorize mutation. Recovery is a guided MANUAL step instead: a give-up runs the same PID
1314
+ * probe ONCE, purely to choose the error's wording. For the sparse-operation lock, a confirmed-live owner gets
1315
+ * wait-only guidance, a confirmed-dead owner gets crash-recovery guidance, and an unverifiable owner gets the
1316
+ * lock path plus guarded manual-recovery guidance. The shared marker lock retains its generic non-dead error.
1317
+ * No verdict authorizes an automatic steal.
1318
+ *
1319
+ * Because a live holder can't lose its lock, `fn` needs no cancellation coupling to the lock itself — the
1320
+ * critical section only ever ends on its own terms.
1321
+ *
1322
+ * Acquisition beyond the initial `open` is not atomic: the ownership record is written in a separate step
1323
+ * just after. A crash landing exactly there — not caught by anything, since the process is gone — leaves
1324
+ * a permanently-empty orphan lock; the give-up error's manual-deletion instructions are the only recovery
1325
+ * for that case, same as for a confirmed-dead owner. A failure our OWN code can observe in that window
1326
+ * (the post-`mkdir` setup, or the ownership write itself) is different: it fails closed, removing the
1327
+ * lock it just created — ownership is certain there, since we hold the only handle — before rethrowing,
1328
+ * so a transient error (e.g. `ENOSPC`) never strands a lock nothing can ever prove dead.
1329
+ *
1330
+ * Deliberately NOT named `config.lock` — that's the name git takes when writing `.git/gk/config` itself.
1331
+ */
1332
+ async withMarkerLock(repoPath, fn,
1333
+ // `retryMs`/`maxAttempts` define the acquisition policy; health snapshots deliberately use zero retries
1334
+ // for the sparse operation lock. The remaining callbacks are test seams: `probeOwner` replaces the real
1335
+ // PID probe, `writeRecord` forces post-open setup failures, and `openLock` forces acquisition failures.
1336
+ timings, lockLocation) {
1337
+ const retryMs = timings?.retryMs ?? markerLockRetryMs;
1338
+ const maxAttempts = timings?.maxAttempts ?? markerLockMaxAttempts;
1339
+ const dir = lockLocation?.dir ?? (await this.getGkDir(repoPath));
1340
+ const lockPath = lockLocation?.lock ?? joinPaths(dir, 'applied.lock');
1341
+ try {
1342
+ await mkdir(dir, { recursive: true });
1343
+ }
1344
+ catch (ex) {
1345
+ // Never proceed unlocked: a lock that can't even be set up must not let the guarded transaction
1346
+ // run without one. Recoverable by the same manual fix as any other acquisition failure.
1347
+ throw new Error(`Could not acquire the Git maintenance settings lock at '${lockPath}'. If this persists and no ` +
1348
+ `other VS Code window is using this repository, delete that file and try again.`, { cause: ex });
1349
+ }
1350
+ let handle;
1351
+ for (let attempt = 0; handle == null; attempt++) {
1352
+ try {
1353
+ handle = await (timings?.openLock ? timings.openLock(lockPath) : open(lockPath, 'wx'));
1354
+ }
1355
+ catch (ex) {
1356
+ if (ex.code !== 'EEXIST') {
1357
+ // Never proceed unlocked: the whole point is that a concurrent window must not read a
1358
+ // stale "marker absent" and record the wrong prior. Failing loudly is recoverable; racing
1359
+ // is not.
1360
+ throw new Error(`Could not acquire the Git maintenance settings lock at '${lockPath}'. If this persists ` +
1361
+ `and no other VS Code window is using this repository, delete that file and try again.`, { cause: ex });
1362
+ }
1363
+ // Bounded so a lock this window can never take (see the doc comment above) doesn't spin the
1364
+ // loop forever.
1365
+ if (attempt >= maxAttempts) {
1366
+ // One diagnostic probe, purely to choose the error's wording — see the doc comment above
1367
+ // for why a 'dead' verdict still doesn't get stolen from automatically.
1368
+ const verdict = await this.probeLockOwner(lockPath, timings?.probeOwner).catch(() => 'unverifiable');
1369
+ if (verdict === 'dead') {
1370
+ throw new MarkerLockContentionError(lockLocation?.contention === 'sparseIndex'
1371
+ ? `A previous VS Code window appears to have crashed while updating the sparse index. ` +
1372
+ `Delete '${lockPath}' to recover.`
1373
+ : `A previous VS Code window appears to have crashed while updating Git maintenance ` +
1374
+ `settings. Delete '${lockPath}' to recover.`, { cause: ex });
1375
+ }
1376
+ if (lockLocation?.contention === 'sparseIndex' && verdict === 'alive') {
1377
+ throw new MarkerLockContentionError('A sparse-index update is still in progress. Wait for it to finish and try again.', { cause: ex });
1378
+ }
1379
+ throw new MarkerLockContentionError(lockLocation?.contention === 'sparseIndex'
1380
+ ? `Could not verify whether another window is still updating the sparse index. If no ` +
1381
+ `update is in progress, delete '${lockPath}' and try again.`
1382
+ : `Another window is currently updating Git maintenance settings. If no other VS Code ` +
1383
+ `window is using this repository, delete '${lockPath}' and try again.`, { cause: ex });
1384
+ }
1385
+ await new Promise(resolve => setTimeout(resolve, retryMs));
1386
+ }
1387
+ }
1388
+ // Narrowed into a const so the rest of this method sees the non-undefined type.
1389
+ const lockHandle = handle;
1390
+ // A fresh random value per acquisition — the primary identity the release check below verifies.
1391
+ const ownerId = randomUUID();
1392
+ let ino;
1393
+ try {
1394
+ // Recorded as early as possible, so a contender that arrives at any point during our hold finds
1395
+ // an owner to probe rather than an empty file it must treat as unverifiable.
1396
+ const writeRecord = timings?.writeRecord ??
1397
+ (async (h) => {
1398
+ await h.writeFile(JSON.stringify({ host: hostname(), pid: process.pid, ownerId: ownerId }));
1399
+ });
1400
+ await writeRecord(lockHandle);
1401
+ // Secondary check for release below — see there for why ownerId is the primary one.
1402
+ ino = (await lockHandle.stat()).ino;
1403
+ }
1404
+ catch (ex) {
1405
+ // A failure here (e.g. ENOSPC) is usually ours to clean up — but not always: the same
1406
+ // only-the-creator-removes invariant the release path enforces above applies here too. If the lock
1407
+ // was manually deleted and RECREATED by a new owner in this exact window, removing it unconditionally
1408
+ // would delete THEIR file. Verify identity first: `handle.stat()` reads through our own open
1409
+ // descriptor (valid even after the path is unlinked or replaced) against a fresh stat of the path
1410
+ // itself; ENOENT or a mismatch means nothing of ours remains there, so skip the rm.
1411
+ const ourStat = await lockHandle.stat().catch(() => undefined);
1412
+ await lockHandle.close().catch(() => { });
1413
+ const pathStat = await stat(lockPath).catch(() => undefined);
1414
+ if (ourStat != null && ourStat.ino === pathStat?.ino) {
1415
+ await rm(lockPath, { force: true }).catch(() => { });
1416
+ }
1417
+ throw new Error(`Could not acquire the Git maintenance settings lock at '${lockPath}'. If this persists and no ` +
1418
+ `other VS Code window is using this repository, delete that file and try again.`, { cause: ex });
1419
+ }
1420
+ try {
1421
+ return await fn();
1422
+ }
1423
+ finally {
1424
+ await lockHandle.close().catch(() => { });
1425
+ // Only remove the file if it's still the one we created — `ownerId` is the authoritative check,
1426
+ // the inode compared too as a cheap secondary one; neither alone is proof on every filesystem, but
1427
+ // both matching is.
1428
+ const currentOwner = await readFile(lockPath, 'utf8')
1429
+ .then(raw => (raw ? JSON.parse(raw) : undefined))
1430
+ .catch(() => undefined);
1431
+ const current = await stat(lockPath).catch(() => undefined);
1432
+ if (currentOwner?.ownerId === ownerId && current?.ino === ino) {
1433
+ await rm(lockPath, { force: true }).catch(() => { });
1434
+ }
1435
+ }
1436
+ }
1437
+ /**
1438
+ * Diagnoses the process recorded in `lockPath` for the give-up error's wording ONLY — see
1439
+ * `withMarkerLock`'s doc comment for why a verdict never causes an automatic steal. Returns 'dead' only when
1440
+ * the OS kill-probe returns `ESRCH`, 'alive' when it succeeds, and 'unverifiable' for unreadable/malformed
1441
+ * content, a lock recorded on another host, or a probe that fails with anything but `ESRCH`.
1442
+ */
1443
+ async probeLockOwner(lockPath, probeOwner) {
1444
+ const owner = await readFile(lockPath, 'utf8')
1445
+ .then(raw => (raw ? JSON.parse(raw) : undefined))
1446
+ .catch(() => undefined);
1447
+ // Empty or unparseable: either the owner is mid-open/mid-write (a race with our own read) or a crash
1448
+ // landed exactly between its `open` and its ownership write, orphaning a permanently-empty lock —
1449
+ // neither is evidence the owner is dead.
1450
+ if (owner == null || typeof owner.host !== 'string' || typeof owner.pid !== 'number')
1451
+ return 'unverifiable';
1452
+ if (probeOwner != null)
1453
+ return probeOwner({ host: owner.host, pid: owner.pid });
1454
+ // A lock recorded on another host (shared filesystem) can't be probed locally — a PID probe here would
1455
+ // be asking about an unrelated process on THIS machine.
1456
+ if (owner.host !== hostname())
1457
+ return 'unverifiable';
1458
+ try {
1459
+ // Signal 0: sends nothing, only asks the OS whether the process exists and is ours to signal.
1460
+ process.kill(owner.pid, 0);
1461
+ return 'alive'; // No throw — alive, or at least not provably dead.
1462
+ }
1463
+ catch (ex) {
1464
+ // ESRCH = no such process → provably dead. EPERM (belongs to someone else) and anything else
1465
+ // can't be told apart from "alive". PID recycling also lands here as "alive" (we can't tell),
1466
+ // which is the correct conservative answer.
1467
+ return ex.code === 'ESRCH' ? 'dead' : 'unverifiable';
1468
+ }
1469
+ }
1470
+ /**
1471
+ * Reverts a config lever GitLens applied: restores its recorded prior LOCAL value (set it back, or unset
1472
+ * when the prior was absent) and clears the marker. Returns `false` (a no-op) when no marker exists —
1473
+ * a lever the user enabled themselves is never touched.
1474
+ */
1475
+ async revertConfigLever(repoPath, configKey, markerKey, cancellation) {
1476
+ // Same transaction boundary as apply, and the same uncached read. Without both, a window holding a
1477
+ // stale "marker absent" concludes the lever isn't GitLens's and Undo silently does nothing while the
1478
+ // config stays enabled.
1479
+ return this.withMarkerLock(repoPath, async () => {
1480
+ const prior = await this.readGkMarkerUncached(repoPath, markerKey, cancellation);
1481
+ if (prior == null)
1482
+ return false;
1483
+ await this.restoreLocalConfig(repoPath, configKey, prior, cancellation);
1484
+ await this.provider.config.setGkConfig(repoPath, markerKey, undefined);
1485
+ return true;
1486
+ });
1487
+ }
1488
+ /** Restores a lever's config to its recorded prior after a failed apply, then drops the ownership marker. */
1489
+ async restorePriorAndClear(repoPath, configKey, markerKey, cancellation) {
1490
+ const prior = await this.provider.config.getGkConfig(repoPath, markerKey);
1491
+ if (prior != null) {
1492
+ await this.restoreLocalConfig(repoPath, configKey, prior, cancellation);
1493
+ }
1494
+ await this.provider.config.setGkConfig(repoPath, markerKey, undefined);
1495
+ }
1496
+ /** Sets a local config value back to a recorded prior, decoding the `unset`/`empty` sentinels. */
1497
+ async restoreLocalConfig(repoPath, key, prior, cancellation) {
1498
+ if (prior === unsetConfigSentinel) {
1499
+ await this.unsetLocalConfig(repoPath, key, cancellation);
1500
+ }
1501
+ else if (prior === emptyConfigSentinel) {
1502
+ await this.setLocalConfig(repoPath, key, '', cancellation);
1503
+ }
1504
+ else {
1505
+ await this.setLocalConfig(repoPath, key, prior, cancellation);
1506
+ }
1507
+ }
1508
+ /** Writes a gk marker, swallowing+logging any failure — a marker write must never fail the apply itself. */
1509
+ async markGkConfigSafe(repoPath, key, value) {
1510
+ const scope = getScopedLogger();
1511
+ try {
1512
+ await this.provider.config.setGkConfig(repoPath, key, value);
1513
+ }
1514
+ catch (ex) {
1515
+ scope?.error(ex, `Failed to record '${key}' marker`);
1516
+ }
1517
+ }
1518
+ /**
1519
+ * Throws when a run that swallowed its errors (`errors: 'ignore'`) was actually cancelled or never ran.
1520
+ * Such a run resolves with no meaningful exit code, so `completion` — not `exitCode` — is the only thing
1521
+ * that proves a command ran. Without this a timeout reads as a clean success, and a safety probe or an
1522
+ * undo would act on a command that never happened.
1523
+ */
1524
+ throwIfDidNotComplete(result, cancellation) {
1525
+ if (result.completion.status === 'cancelled' || cancellation?.aborted === true) {
1526
+ throw new CancellationError();
1527
+ }
1528
+ // `failed` covers both a queue rejection/spawn failure (`unstarted`) and a signal kill, neither of
1529
+ // which produced a trustworthy result — for a maintenance probe both must read as "did not run".
1530
+ if (result.completion.status === 'failed')
1531
+ throw new Error('Git command did not run');
1532
+ }
1533
+ /**
1534
+ * Reads a config key's LOCAL-scope value (independent of global/system config). `undefined` when unset.
1535
+ *
1536
+ * Uses `--get-regex` (anchored to the exact key), not `--get`: `--get` exits 0 with EMPTY stdout for
1537
+ * BOTH a bareword entry (`[section]\n\tkey`, valid syntax, git-boolean TRUE) and an explicit empty value
1538
+ * (`key =`, git-boolean FALSE) — indistinguishable, which would record a bareword-true prior as the
1539
+ * `unset` sentinel and let undo flip the user's true to false. `--get-regex` prints the two differently
1540
+ * (a bareword line has no space; an empty value keeps a trailing one), which `parseConfigRegexOutput`'s
1541
+ * `includeValueless` tells apart; for a multi-valued key it also prints every value in file order, so the
1542
+ * LAST line in the map is still the `--get`-equivalent value.
1543
+ */
1544
+ async getLocalConfig(repoPath, key, cancellation) {
1545
+ const result = await this.git.run({
1546
+ cwd: repoPath,
1547
+ errors: 'ignore',
1548
+ runLocally: true,
1549
+ cancellation: cancellation,
1550
+ selfMaintenance: true,
1551
+ }, 'config', '--local', '--get-regex', `^${escapeRegex(key)}$`);
1552
+ this.throwIfDidNotComplete(result, cancellation);
1553
+ // An unreadable config must not read back as "unset": that value gets recorded as the lever's prior,
1554
+ // so a later undo would DELETE a setting the user had all along instead of restoring it. Exit 1 is
1555
+ // git's "no match" — genuinely unset, the same discipline as `--get`.
1556
+ if (result.exitCode !== 0 && result.exitCode !== gitConfigGetMissingExitCode) {
1557
+ throw new Error(`Unable to read '${key}' (git exited ${result.exitCode})`);
1558
+ }
1559
+ if (result.exitCode !== 0)
1560
+ return undefined;
1561
+ // A bareword line maps to `'true'`, an explicit-empty line to `''` — collapsing either into the
1562
+ // other misrecords the prior undo later restores. Deliberately NOT `.trim()`-ed: an explicit-empty
1563
+ // line's meaning IS its trailing space, and `.trim()` on the whole (possibly multi-line) stdout
1564
+ // strips it whenever that line lands last — `parseConfigRegexOutput` already skips the blank line a
1565
+ // trailing newline produces, so no trim is needed. Canonicalize for the lookup: `--get-regex` prints
1566
+ // the section + variable name lowercased.
1567
+ return parseConfigRegexOutput(result.stdout, { includeValueless: true }).get(canonicalizeGitConfigKey(key));
1568
+ }
1569
+ /** Sets a LOCAL config value. Throws the git error on a genuine write failure (config refused). */
1570
+ async setLocalConfig(repoPath, key, value, cancellation) {
1571
+ await this.git.run({
1572
+ cwd: repoPath,
1573
+ errors: 'throw',
1574
+ runLocally: true,
1575
+ cancellation: cancellation,
1576
+ selfMaintenance: true,
1577
+ }, 'config', '--local', key, value);
1578
+ this.cache.deleteConfig(repoPath, key);
1579
+ }
1580
+ async unsetLocalConfig(repoPath, key, cancellation) {
1581
+ // `--unset-all` of an absent key exits non-zero; `errors: 'ignore'` swallows that without throwing.
1582
+ // It is deliberately not `--unset`, which refuses a multi-valued key with the SAME exit 5 while
1583
+ // leaving the values set — indistinguishable from the benign case, so undo would clear its ownership
1584
+ // marker with the lever still on. See the exit-code constants above.
1585
+ const result = await this.git.run({
1586
+ cwd: repoPath,
1587
+ errors: 'ignore',
1588
+ runLocally: true,
1589
+ cancellation: cancellation,
1590
+ selfMaintenance: true,
1591
+ }, 'config', '--local', '--unset-all', key);
1592
+ // ...but so does a cancelled/timed-out run, and so does a genuine write failure. Surface both, so an
1593
+ // undo can't clear its ownership marker — handing the lever back to the user as "theirs", with no
1594
+ // Undo ever offered again — while the config is in fact still set.
1595
+ this.throwIfDidNotComplete(result, cancellation);
1596
+ if (result.exitCode !== 0 && result.exitCode !== gitConfigUnsetMissingExitCode) {
1597
+ throw new Error(`Unable to unset '${key}' (git exited ${result.exitCode})`);
1598
+ }
1599
+ this.cache.deleteConfig(repoPath, key);
1600
+ }
1601
+ /** Best-effort run; returns the result so callers that must distinguish "didn't run" can check it. */
1602
+ async runQuietly(repoPath, cancellation, ...args) {
1603
+ return this.git.run({
1604
+ cwd: repoPath,
1605
+ errors: 'ignore',
1606
+ priority: 'background',
1607
+ cancellation: cancellation,
1608
+ selfMaintenance: true,
1609
+ }, ...args);
1610
+ }
1611
+ async probeConfig(repoPath, cancellation) {
1612
+ // One `git config --get-regex` for the levers, commit-graph read switch, and index shape, reading
1613
+ // MERGED config — so a key's presence means the user set it somewhere (local, worktree, global, or
1614
+ // system). Git lowercases the section + variable in the output.
1615
+ //
1616
+ // Deliberately NOT routed through the config sub-provider's cached helper: that one swallows git
1617
+ // errors and resolves an EMPTY map, which is indistinguishable from "none of these keys are set" —
1618
+ // and the silent auto tier must never mistake an unreadable config for an unset one and rewrite it.
1619
+ // Reading directly keeps the exit code, which tells the two apart.
1620
+ const result = await this.runQuietly(repoPath, cancellation, 'config', '--get-regex', '^(core\\.fsmonitor|core\\.untrackedcache|core\\.commitgraph|core\\.multipackindex|core\\.sparsecheckout|core\\.sparsecheckoutcone|core\\.splitindex|extensions\\.refstorage|feature\\.manyfiles|index\\.sparse|maintenance\\.incremental-repack\\.auto)$');
1621
+ this.throwIfDidNotComplete(result, cancellation);
1622
+ // Exit 1 is git's "no matches" — genuinely unset. Anything else means we couldn't read the
1623
+ // config; `getHealthSnapshot` turns the throw into a fail-closed snapshot.
1624
+ if (result.exitCode !== 0 && result.exitCode !== gitConfigGetMissingExitCode) {
1625
+ throw new Error(`Unable to read git config (git exited ${String(result.exitCode)})`);
1626
+ }
1627
+ // `includeValueless`: a BAREWORD entry (`core.untrackedCache` with no `=value`) is git's own
1628
+ // boolean-true shorthand — a naive parse drops it (no space in the line), misreporting a lever
1629
+ // the user has genuinely enabled as both "off" and "unconfigured" (eligible to be re-suggested).
1630
+ // An explicit EMPTY value (`core.untrackedCache=`, a deliberate false) is a DIFFERENT, byte-distinct
1631
+ // line (it keeps a trailing space) and was already parsed correctly without this option.
1632
+ //
1633
+ // Deliberately NOT `.trim()`-ed: when the empty-value line lands last, `.trim()` on the whole
1634
+ // (possibly multi-line) stdout strips that meaningful trailing space and it misreads as a bareword.
1635
+ // `parseConfigRegexOutput` already skips the blank line a trailing newline produces.
1636
+ const map = parseConfigRegexOutput(result.stdout, { includeValueless: true });
1637
+ const incrementalRepackAuto = map.get('maintenance.incremental-repack.auto');
1638
+ const incrementalRepackAutoThreshold = incrementalRepackAuto == null
1639
+ ? defaultIncrementalRepackAutoThreshold
1640
+ : parseGitInteger(incrementalRepackAuto);
1641
+ const multiPackIndex = map.get('core.multipackindex');
1642
+ const multiPackIndexEnabled = multiPackIndex == null
1643
+ ? true
1644
+ : isGitBooleanFalse(multiPackIndex)
1645
+ ? false
1646
+ : parseGitBoolean(multiPackIndex)
1647
+ ? true
1648
+ : undefined;
1649
+ return {
1650
+ fsmonitor: isFsmonitorEnabled(map.get('core.fsmonitor')),
1651
+ untrackedCache: parseGitBoolean(map.get('core.untrackedcache')),
1652
+ untrackedCacheConfigured: map.has('core.untrackedcache'),
1653
+ manyFiles: parseGitBoolean(map.get('feature.manyfiles')),
1654
+ // Explicitly-false only — an unset key means git reads the cache (the default). Surfaced so the
1655
+ // Health view can't claim the commit-graph is "maintained by GitLens" when `ensureCommitGraph`
1656
+ // honors this same setting as a write opt-out.
1657
+ commitGraphReadDisabled: map.has('core.commitgraph') && isGitBooleanFalse(map.get('core.commitgraph') ?? ''),
1658
+ multiPackIndexEnabled: multiPackIndexEnabled,
1659
+ incrementalRepackAutoThreshold: incrementalRepackAutoThreshold,
1660
+ refFormat: map.get('extensions.refstorage') == null
1661
+ ? 'files'
1662
+ : map.get('extensions.refstorage') === 'reftable'
1663
+ ? 'reftable'
1664
+ : 'unknown',
1665
+ sparseCheckout: parseGitBoolean(map.get('core.sparsecheckout')),
1666
+ sparseCheckoutCone: parseGitBoolean(map.get('core.sparsecheckoutcone')),
1667
+ sparseIndex: parseGitBoolean(map.get('index.sparse')),
1668
+ splitIndex: parseGitBoolean(map.get('core.splitindex')),
1669
+ };
1670
+ }
1671
+ // The registered-repo list is process-global git state — cache it briefly (invalidated on
1672
+ // register/unregister) so N repos probed together read it with one subprocess, not N.
1673
+ _registeredMaintenanceRepos;
1674
+ /** The global `maintenance.repo` list. Throws rather than reporting an unreadable list as "none". */
1675
+ async readRegisteredMaintenancePaths(repoPath, cancellation) {
1676
+ const cached = this._registeredMaintenanceRepos;
1677
+ if (cached != null && Date.now() - cached.at <= registeredMaintenanceReposTtlMs)
1678
+ return cached.paths;
1679
+ const result = await this.runQuietly(repoPath, cancellation, 'config', '--global', '--get-all', 'maintenance.repo');
1680
+ // An aborted or unreadable list resolves EMPTY under `errors: 'ignore'`. Reading that as "nothing is
1681
+ // registered" would both cache a lie and let a revert clear ownership for a still-registered repo.
1682
+ // Exit 1 is git's "key not set" — a genuinely empty list.
1683
+ this.throwIfDidNotComplete(result, cancellation);
1684
+ if (result.exitCode !== 0 && result.exitCode !== gitConfigGetMissingExitCode) {
1685
+ throw new Error(`Unable to read registered maintenance repos (git exited ${String(result.exitCode)})`);
1686
+ }
1687
+ const paths = result.stdout
1688
+ .split('\n')
1689
+ .map(line => line.trim())
1690
+ .filter(line => line !== '');
1691
+ this._registeredMaintenanceRepos = { at: Date.now(), paths: paths };
1692
+ return paths;
1693
+ }
1694
+ /**
1695
+ * `git maintenance register` stores the symlink-RESOLVED worktree path, while GitLens deliberately keeps
1696
+ * the symlinked path as the repo path — so match against both spellings.
1697
+ */
1698
+ async matchesRegistered(registered, paths) {
1699
+ if (!registered.length)
1700
+ return false;
1701
+ const candidates = await this.resolveRegistrationCandidates(paths);
1702
+ for (const line of registered) {
1703
+ for (const candidate of candidates) {
1704
+ if (arePathsEqual(line, candidate))
1705
+ return true;
1706
+ }
1707
+ }
1708
+ return false;
1709
+ }
1710
+ /** Each path plus its symlink-resolved spelling — `maintenance register` stores the resolved one. */
1711
+ async resolveRegistrationCandidates(paths) {
1712
+ const candidates = new Set(paths);
1713
+ for (const path of paths) {
1714
+ const real = await realpath(path).catch(() => undefined);
1715
+ if (real != null) {
1716
+ candidates.add(real);
1717
+ }
1718
+ }
1719
+ return [...candidates];
1720
+ }
1721
+ /**
1722
+ * Whether ANY worktree of this repo family is registered — the DETECTION question ("should we suggest
1723
+ * this?"). Family-wide, because a sibling the user registered already covers the shared object database,
1724
+ * and suggesting it again would leave two scheduled entries for one repo.
1725
+ */
1726
+ async isMaintenanceRegistered(repoPath, cancellation) {
1727
+ const registered = await this.readRegisteredMaintenancePaths(repoPath, cancellation);
1728
+ return this.matchesRegistered(registered, await this.resolveWorktreePaths(repoPath, cancellation));
1729
+ }
1730
+ /**
1731
+ * Whether ONE specific path is registered — the OWNERSHIP question ("did our unregister take?"). Never
1732
+ * ask this family-wide: a sibling the user registered is theirs, and it would block undo forever.
1733
+ */
1734
+ async isPathRegistered(repoPath, path, cancellation) {
1735
+ const registered = await this.readRegisteredMaintenancePaths(repoPath, cancellation);
1736
+ return this.matchesRegistered(registered, [path]);
1737
+ }
1738
+ // Cached briefly rather than for the session: a worktree added or removed mid-session must become visible
1739
+ // to family detection, else a sibling the user registers afterwards is never seen and the repo gets
1740
+ // re-suggested for maintenance it already has. The TTL keeps it off the per-probe hot path.
1741
+ _worktreePaths = new Map();
1742
+ /**
1743
+ * Every worktree path of this repo, MAIN FIRST — git's `worktree list` order, which holds regardless of
1744
+ * which worktree it runs from, and lists the repo itself for a bare one.
1745
+ *
1746
+ * `git maintenance register` records the CURRENT worktree's path, so a repo family can accumulate
1747
+ * several `maintenance.repo` entries — several scheduled runs over a single shared object database —
1748
+ * while the `gk.applied.*` ownership marker lives in the shared common git dir and can only describe
1749
+ * one of them. GitLens therefore REGISTERS the main worktree ({@link resolveMaintenanceRepoPath}) but
1750
+ * DETECTS across the whole family, so a registration the user made from a sibling isn't missed.
1751
+ */
1752
+ async resolveWorktreePaths(repoPath, cancellation) {
1753
+ // Keyed by the COMMON path: the worktree list is identical for every sibling of a repo family, so a
1754
+ // per-worktree key would spawn one `worktree list` per open sibling instead of one per family.
1755
+ const cacheKey = this.cache.getCommonPath(repoPath);
1756
+ const cached = this._worktreePaths.get(cacheKey);
1757
+ if (cached != null && Date.now() - cached.at <= worktreePathsTtlMs)
1758
+ return cached.paths;
1759
+ const result = await this.runQuietly(repoPath, cancellation, 'worktree', 'list', '--porcelain');
1760
+ // Fall back to the repo path (today's behavior) rather than caching a cancelled or failed read.
1761
+ if (result.completion.status !== 'exited' || result.exitCode !== 0 || cancellation?.aborted === true) {
1762
+ return [repoPath];
1763
+ }
1764
+ const paths = result.stdout
1765
+ .split('\n')
1766
+ .filter(l => l.startsWith('worktree '))
1767
+ .map(l => l.slice('worktree '.length).trim())
1768
+ .filter(l => l !== '');
1769
+ if (!paths.length)
1770
+ return [repoPath];
1771
+ this._worktreePaths.set(cacheKey, { at: Date.now(), paths: paths });
1772
+ return paths;
1773
+ }
1774
+ /** The path GitLens registers for background maintenance: the repo's MAIN worktree. */
1775
+ async resolveMaintenanceRepoPath(repoPath, cancellation) {
1776
+ return (await this.resolveWorktreePaths(repoPath, cancellation))[0];
1777
+ }
1778
+ async probeCommitGraph(objectsDir) {
1779
+ const infoDir = joinPaths(objectsDir, 'info');
1780
+ // A repo may have a single `commit-graph` file OR a split `commit-graphs/` chain dir — either counts.
1781
+ const [single, split] = await Promise.allSettled([
1782
+ this.statMtime(joinPaths(infoDir, 'commit-graph')),
1783
+ this.statMtime(joinPaths(infoDir, 'commit-graphs')),
1784
+ ]);
1785
+ const mtime = getSettledValue(single) ?? getSettledValue(split);
1786
+ return { present: mtime != null, mtime: mtime };
1787
+ }
1788
+ /**
1789
+ * Whether the NEWEST commit-graph layer carries changed-path Bloom filters (the `BIDX` chunk). Only the
1790
+ * newest layer matters here — `--changed-paths` computes filters for the just-written batch, so an older
1791
+ * base layer written without them says nothing about current coverage. Reads only the header + chunk
1792
+ * lookup table (a handful of bytes), never the graph body. Never throws — any failure (missing file, a
1793
+ * malformed chain, a truncated read) reads as "no filters", matching every other probe in this file.
1794
+ */
1795
+ async probeChangedPathFilters(objectsDir) {
1796
+ try {
1797
+ const infoDir = joinPaths(objectsDir, 'info');
1798
+ const graphPath = await this.resolveNewestCommitGraphLayer(infoDir);
1799
+ return await this.hasBloomFilterChunk(graphPath);
1800
+ }
1801
+ catch {
1802
+ return false;
1803
+ }
1804
+ }
1805
+ /** The newest graph layer's file path: the chain's last-listed layer when split, else the single file. */
1806
+ async resolveNewestCommitGraphLayer(infoDir) {
1807
+ const chainPath = joinPaths(infoDir, 'commit-graphs', 'commit-graph-chain');
1808
+ try {
1809
+ const chain = await readFile(chainPath, 'utf8');
1810
+ const hashes = chain
1811
+ .split('\n')
1812
+ .map(line => line.trim())
1813
+ .filter(line => line !== '');
1814
+ if (hashes.length) {
1815
+ return joinPaths(infoDir, 'commit-graphs', `graph-${hashes.at(-1)}.graph`);
1816
+ }
1817
+ }
1818
+ catch {
1819
+ // No chain (or unreadable) — fall through to the single-file layout.
1820
+ }
1821
+ return joinPaths(infoDir, 'commit-graph');
1822
+ }
1823
+ /**
1824
+ * Reads a commit-graph file's fixed 8-byte header (`CGPH` magic, version, hash version, chunk count,
1825
+ * base-graph count) plus the chunk lookup table (`(chunkCount + 1)` entries of 4-byte id + 8-byte
1826
+ * big-endian offset), and reports whether a `BIDX` (Bloom-filter-index) chunk id is present. Bounded to
1827
+ * the header + table only — never reads the graph body.
1828
+ */
1829
+ async hasBloomFilterChunk(graphPath) {
1830
+ let handle;
1831
+ try {
1832
+ handle = await open(graphPath, 'r');
1833
+ const header = Buffer.alloc(8);
1834
+ const { bytesRead: headerBytesRead } = await handle.read(header, 0, 8, 0);
1835
+ if (headerBytesRead < 8 || header.toString('ascii', 0, 4) !== 'CGPH')
1836
+ return false;
1837
+ const chunkCount = header.readUInt8(6);
1838
+ const tableLength = (chunkCount + 1) * 12;
1839
+ const table = Buffer.alloc(tableLength);
1840
+ const { bytesRead: tableBytesRead } = await handle.read(table, 0, tableLength, 8);
1841
+ if (tableBytesRead < tableLength)
1842
+ return false;
1843
+ for (let i = 0; i <= chunkCount; i++) {
1844
+ const offset = i * 12;
1845
+ if (table.toString('ascii', offset, offset + 4) === 'BIDX')
1846
+ return true;
1847
+ }
1848
+ return false;
1849
+ }
1850
+ finally {
1851
+ await handle?.close().catch(() => { });
1852
+ }
1853
+ }
1854
+ /**
1855
+ * Reads the pack names represented by the active classic or incremental MIDX. A classic MIDX wins when
1856
+ * both layouts exist, matching Git's own lookup order. Malformed metadata is reported as unknown instead
1857
+ * of treating every pack as uncovered and silently launching repair work.
1858
+ */
1859
+ async probeMultiPackIndex(packDir) {
1860
+ const classicPath = joinPaths(packDir, 'multi-pack-index');
1861
+ try {
1862
+ const packNames = await this.readMultiPackIndexPackNames(classicPath);
1863
+ return { present: true, packNames: packNames };
1864
+ }
1865
+ catch (ex) {
1866
+ if (ex.code !== 'ENOENT')
1867
+ return { present: true, packNames: undefined };
1868
+ }
1869
+ const chainDir = joinPaths(packDir, 'multi-pack-index.d');
1870
+ const chainPath = joinPaths(chainDir, 'multi-pack-index-chain');
1871
+ let chain;
1872
+ try {
1873
+ if ((await stat(chainPath)).size > maxMultiPackIndexChainBytes) {
1874
+ return { present: true, packNames: undefined };
1875
+ }
1876
+ chain = await readFile(chainPath, 'utf8');
1877
+ }
1878
+ catch (ex) {
1879
+ return ex.code === 'ENOENT'
1880
+ ? { present: false, packNames: new Set() }
1881
+ : { present: true, packNames: undefined };
1882
+ }
1883
+ const hashes = chain
1884
+ .split('\n')
1885
+ .map(line => line.trim())
1886
+ .filter(Boolean);
1887
+ if (hashes.length === 0 || hashes.some(hash => !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(hash))) {
1888
+ return { present: true, packNames: undefined };
1889
+ }
1890
+ const layers = await Promise.allSettled(hashes.map(hash => this.readMultiPackIndexPackNames(joinPaths(chainDir, `multi-pack-index-${hash}.midx`))));
1891
+ if (layers.some(layer => layer.status === 'rejected'))
1892
+ return { present: true, packNames: undefined };
1893
+ const packNames = new Set();
1894
+ for (const layer of layers) {
1895
+ if (layer.status !== 'fulfilled')
1896
+ continue;
1897
+ for (const name of layer.value) {
1898
+ packNames.add(name);
1899
+ }
1900
+ }
1901
+ return { present: true, packNames: packNames };
1902
+ }
1903
+ /** Reads and validates the `PNAM` chunk of one MIDX file. */
1904
+ async readMultiPackIndexPackNames(path) {
1905
+ let handle;
1906
+ try {
1907
+ handle = await open(path, 'r');
1908
+ const header = Buffer.alloc(12);
1909
+ const { bytesRead } = await handle.read(header, 0, header.length, 0);
1910
+ if (bytesRead < header.length || header.toString('ascii', 0, 4) !== 'MIDX') {
1911
+ throw new Error('Invalid multi-pack-index header');
1912
+ }
1913
+ const chunkCount = header.readUInt8(6);
1914
+ const expectedPackCount = header.readUInt32BE(8);
1915
+ const tableLength = (chunkCount + 1) * 12;
1916
+ const table = Buffer.alloc(tableLength);
1917
+ const { bytesRead: tableBytesRead } = await handle.read(table, 0, tableLength, header.length);
1918
+ if (tableBytesRead < tableLength)
1919
+ throw new Error('Invalid multi-pack-index chunk table');
1920
+ let namesStart;
1921
+ const chunkOffsets = [];
1922
+ for (let i = 0; i <= chunkCount; i++) {
1923
+ const offset = i * 12;
1924
+ const chunkOffset = Number(table.readBigUInt64BE(offset + 4));
1925
+ if (!Number.isSafeInteger(chunkOffset))
1926
+ throw new Error('Invalid multi-pack-index chunk offset');
1927
+ chunkOffsets.push(chunkOffset);
1928
+ if (table.toString('ascii', offset, offset + 4) === 'PNAM') {
1929
+ namesStart = chunkOffset;
1930
+ }
1931
+ }
1932
+ if (namesStart == null)
1933
+ throw new Error('Multi-pack-index is missing its pack-name chunk');
1934
+ const namesEnd = Math.min(...chunkOffsets.filter(offset => offset > namesStart));
1935
+ const namesLength = namesEnd - namesStart;
1936
+ if (!Number.isSafeInteger(namesEnd) || namesLength < 0 || namesLength > maxMultiPackIndexChunkBytes) {
1937
+ throw new Error('Invalid multi-pack-index pack-name chunk');
1938
+ }
1939
+ const namesChunk = Buffer.alloc(namesLength);
1940
+ const { bytesRead: namesBytesRead } = await handle.read(namesChunk, 0, namesLength, namesStart);
1941
+ if (namesBytesRead < namesLength)
1942
+ throw new Error('Short multi-pack-index pack-name chunk');
1943
+ const names = namesChunk
1944
+ .toString('utf8')
1945
+ .split('\0')
1946
+ .filter(name => /^pack-(?:[0-9a-f]{40}|[0-9a-f]{64})\.idx$/.test(name))
1947
+ .map(name => `${name.slice(0, -4)}.pack`);
1948
+ if (names.length !== expectedPackCount || new Set(names).size !== names.length) {
1949
+ throw new Error('Multi-pack-index pack-name count does not match its header');
1950
+ }
1951
+ return new Set(names);
1952
+ }
1953
+ finally {
1954
+ await handle?.close().catch(() => { });
1955
+ }
1956
+ }
1957
+ async probePacks(objectsDir) {
1958
+ const packDir = joinPaths(objectsDir, 'pack');
1959
+ let entries;
1960
+ try {
1961
+ entries = await readdir(packDir);
1962
+ }
1963
+ catch (ex) {
1964
+ if (ex.code === 'ENOENT')
1965
+ return { names: [], bytes: 0 };
1966
+ throw ex;
1967
+ }
1968
+ const packFiles = entries.filter(name => name.endsWith('.pack'));
1969
+ const sizes = await Promise.allSettled(packFiles.map(name => this.fileBytes(joinPaths(packDir, name))));
1970
+ const bytes = sizes.reduce((sum, r) => sum + (getSettledValue(r) ?? 0), 0);
1971
+ return { names: packFiles, bytes: bytes };
1972
+ }
1973
+ async sampleLooseObjects(objectsDir) {
1974
+ const counts = await Promise.allSettled(looseObjectSampleDirs.map(async (dir) => {
1975
+ try {
1976
+ return (await readdir(joinPaths(objectsDir, dir))).length;
1977
+ }
1978
+ catch {
1979
+ // A missing fanout dir means ~no objects with that prefix — count it as 0, not skipped.
1980
+ return 0;
1981
+ }
1982
+ }));
1983
+ const objectsInSampledDirs = counts.reduce((sum, r) => sum + (getSettledValue(r) ?? 0), 0);
1984
+ return { objectsInSampledDirs: objectsInSampledDirs, dirsSampled: looseObjectSampleDirs.length };
1985
+ }
1986
+ /**
1987
+ * Counts loose refs without traversing past the point where the recommendation decision is already made.
1988
+ * `readDirectory` is a test seam for deterministic ref-layout races between parent and child reads.
1989
+ */
1990
+ async probeLooseRefs(refsDir, readDirectory = dir => readdir(dir, { withFileTypes: true })) {
1991
+ const pending = [refsDir];
1992
+ let count = 0;
1993
+ while (pending.length !== 0) {
1994
+ const dir = pending.pop();
1995
+ let entries;
1996
+ try {
1997
+ entries = await readDirectory(dir);
1998
+ }
1999
+ catch (ex) {
2000
+ const code = ex.code;
2001
+ if (code === 'ENOENT' || (dir !== refsDir && code === 'ENOTDIR')) {
2002
+ if (dir === refsDir)
2003
+ return { count: 0, exact: true };
2004
+ // Ref updates can remove a child directory or replace it with a same-named ref after its
2005
+ // parent was listed. It contributes no child refs now, so continue the bounded walk.
2006
+ continue;
2007
+ }
2008
+ throw ex;
2009
+ }
2010
+ for (const entry of entries) {
2011
+ if (entry.isDirectory()) {
2012
+ pending.push(joinPaths(dir, entry.name));
2013
+ continue;
2014
+ }
2015
+ // Lock files are transient coordination state, not refs `pack-refs` can optimize.
2016
+ if (entry.name.endsWith('.lock'))
2017
+ continue;
2018
+ count++;
2019
+ if (count >= looseRefProbeLimit)
2020
+ return { count: count, exact: false };
2021
+ }
2022
+ }
2023
+ return { count: count, exact: true };
2024
+ }
2025
+ async statMtime(path) {
2026
+ try {
2027
+ return (await stat(path)).mtimeMs;
2028
+ }
2029
+ catch {
2030
+ return undefined;
2031
+ }
2032
+ }
2033
+ /** Presence probe that distinguishes a missing path from an unreadable one. */
2034
+ async probePathPresence(path) {
2035
+ try {
2036
+ await stat(path);
2037
+ return true;
2038
+ }
2039
+ catch (ex) {
2040
+ if (ex.code === 'ENOENT')
2041
+ return false;
2042
+ throw ex;
2043
+ }
2044
+ }
2045
+ async fileBytes(path) {
2046
+ try {
2047
+ return (await stat(path)).size;
2048
+ }
2049
+ catch {
2050
+ return 0;
2051
+ }
2052
+ }
2053
+ /**
2054
+ * Reads the index entry count straight from the index header — `DIRC` signature, version, entry count,
2055
+ * all big-endian, the first 12 bytes of `.git/index` — for free, no `git` invocation needed.
2056
+ * `undefined` on any failure (missing index, short read, wrong signature): never a guess, never a throw.
2057
+ */
2058
+ async probeIndexEntryCount(indexPath) {
2059
+ let handle;
2060
+ try {
2061
+ handle = await open(indexPath, 'r');
2062
+ const header = Buffer.alloc(12);
2063
+ const { bytesRead } = await handle.read(header, 0, 12, 0);
2064
+ if (bytesRead < 12 || header.toString('ascii', 0, 4) !== 'DIRC')
2065
+ return undefined;
2066
+ return header.readUInt32BE(8);
2067
+ }
2068
+ catch {
2069
+ return undefined;
2070
+ }
2071
+ finally {
2072
+ await handle?.close().catch(() => { });
2073
+ }
2074
+ }
2075
+ /**
2076
+ * Conservative split-index detection without spawning git or scanning index bodies. A shared-index file
2077
+ * proves this worktree uses split indexes; its largest base file is included in the byte-size proxy. Stale
2078
+ * bases can only make that explicitly-approximate signal larger.
2079
+ */
2080
+ async probeSharedIndex(gitDir) {
2081
+ try {
2082
+ const names = (await readdir(gitDir)).filter(name => name.startsWith('sharedindex.'));
2083
+ const sizes = await Promise.all(names.map(name => this.fileBytes(joinPaths(gitDir, name))));
2084
+ return { present: names.length > 0, bytes: Math.max(0, ...sizes) };
2085
+ }
2086
+ catch {
2087
+ return undefined;
2088
+ }
2089
+ }
2090
+ /**
2091
+ * Conflict stages duplicate paths in the header count. Operation-state files conservatively suppress the
2092
+ * exact claim without an unconditional `ls-files --unmerged` index scan on every staging event.
2093
+ */
2094
+ async probeConflictOperation(gitDir) {
2095
+ const states = await Promise.all([
2096
+ fsExists(joinPaths(gitDir, 'MERGE_HEAD')),
2097
+ fsExists(joinPaths(gitDir, 'CHERRY_PICK_HEAD')),
2098
+ fsExists(joinPaths(gitDir, 'REVERT_HEAD')),
2099
+ fsExists(joinPaths(gitDir, 'rebase-merge')),
2100
+ fsExists(joinPaths(gitDir, 'rebase-apply')),
2101
+ ]);
2102
+ return states.some(Boolean);
2103
+ }
2104
+ }
2105
+ __decorate([
2106
+ debug()
2107
+ ], MaintenanceGitSubProvider.prototype, "getHealthSnapshot", null);
2108
+ __decorate([
2109
+ debug()
2110
+ ], MaintenanceGitSubProvider.prototype, "getHealthDetails", null);
2111
+ __decorate([
2112
+ debug()
2113
+ ], MaintenanceGitSubProvider.prototype, "getCapabilities", null);
2114
+ __decorate([
2115
+ debug()
2116
+ ], MaintenanceGitSubProvider.prototype, "runMaintenanceTask", null);
2117
+ __decorate([
2118
+ debug()
2119
+ ], MaintenanceGitSubProvider.prototype, "applyOptimization", null);
2120
+ __decorate([
2121
+ debug()
2122
+ ], MaintenanceGitSubProvider.prototype, "revertOptimization", null);
2123
+ __decorate([
2124
+ debug()
2125
+ ], MaintenanceGitSubProvider.prototype, "setCommitGraphDisabled", null);
2126
+ __decorate([
2127
+ debug()
2128
+ ], MaintenanceGitSubProvider.prototype, "claimMaintenancePass", null);
2129
+ //# sourceMappingURL=maintenance.js.map