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