@git-stunts/git-warp 10.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- package/src/visualization/utils/unicode.js +52 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Git-backed persistence adapter for WARP graph storage.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the concrete implementation of {@link GraphPersistencePort}
|
|
5
|
+
* that translates high-level graph operations into Git plumbing commands. It serves
|
|
6
|
+
* as the primary adapter in the hexagonal architecture, bridging the domain layer
|
|
7
|
+
* to the underlying Git storage substrate.
|
|
8
|
+
*
|
|
9
|
+
* ## Architecture Role
|
|
10
|
+
*
|
|
11
|
+
* In WARP's hexagonal architecture, GitGraphAdapter sits at the infrastructure layer:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* Domain (WarpGraph, JoinReducer)
|
|
15
|
+
* ↓
|
|
16
|
+
* Ports (GraphPersistencePort - abstract interface)
|
|
17
|
+
* ↓
|
|
18
|
+
* Adapters (GitGraphAdapter - this module)
|
|
19
|
+
* ↓
|
|
20
|
+
* External (@git-stunts/plumbing → Git)
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* All graph data is stored as Git commits pointing to the well-known empty tree
|
|
24
|
+
* (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`). This design means no files appear
|
|
25
|
+
* in the working directory, yet all data inherits Git's content-addressing,
|
|
26
|
+
* cryptographic integrity, and distributed replication capabilities.
|
|
27
|
+
*
|
|
28
|
+
* ## Multi-Writer Concurrency
|
|
29
|
+
*
|
|
30
|
+
* WARP supports multiple concurrent writers without coordination. Each writer
|
|
31
|
+
* maintains an independent patch chain under `refs/warp/<graph>/writers/<writerId>`.
|
|
32
|
+
* This adapter handles the inevitable lock contention via automatic retry with
|
|
33
|
+
* exponential backoff for transient Git errors (ref locks, I/O timeouts).
|
|
34
|
+
*
|
|
35
|
+
* ## Security
|
|
36
|
+
*
|
|
37
|
+
* All user-supplied inputs (refs, OIDs, config keys) are validated before being
|
|
38
|
+
* passed to Git commands to prevent command injection attacks. See the private
|
|
39
|
+
* `_validate*` methods for validation rules.
|
|
40
|
+
*
|
|
41
|
+
* @module infrastructure/adapters/GitGraphAdapter
|
|
42
|
+
* @see {@link GraphPersistencePort} for the abstract interface contract
|
|
43
|
+
* @see {@link https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain} for Git plumbing concepts
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { retry } from '@git-stunts/alfred';
|
|
47
|
+
import GraphPersistencePort from '../../ports/GraphPersistencePort.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Transient Git errors that are safe to retry automatically.
|
|
51
|
+
*
|
|
52
|
+
* These patterns represent temporary conditions that resolve on their own:
|
|
53
|
+
*
|
|
54
|
+
* - **"cannot lock ref"**: Another process holds the ref lock (common in multi-writer
|
|
55
|
+
* scenarios where multiple writers attempt concurrent commits). Git uses file-based
|
|
56
|
+
* locking (`<ref>.lock` files), so concurrent writes naturally contend.
|
|
57
|
+
*
|
|
58
|
+
* - **"resource temporarily unavailable"**: OS-level I/O contention, typically from
|
|
59
|
+
* file descriptor limits or NFS lock issues on network filesystems.
|
|
60
|
+
*
|
|
61
|
+
* - **"connection timed out"**: Network issues when the Git repository is accessed
|
|
62
|
+
* over a network protocol (SSH, HTTPS) or when using NFS-mounted storage.
|
|
63
|
+
*
|
|
64
|
+
* Non-transient errors (e.g., "repository not found", "permission denied") are NOT
|
|
65
|
+
* retried and propagate immediately to the caller.
|
|
66
|
+
*
|
|
67
|
+
* @type {string[]}
|
|
68
|
+
* @private
|
|
69
|
+
*/
|
|
70
|
+
const TRANSIENT_ERROR_PATTERNS = [
|
|
71
|
+
'cannot lock ref',
|
|
72
|
+
'resource temporarily unavailable',
|
|
73
|
+
'connection timed out',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Determines if an error is transient and safe to retry.
|
|
78
|
+
* @param {Error} error - The error to check
|
|
79
|
+
* @returns {boolean} True if the error is transient
|
|
80
|
+
*/
|
|
81
|
+
function isTransientError(error) {
|
|
82
|
+
const message = (error.message || '').toLowerCase();
|
|
83
|
+
const stderr = (error.details?.stderr || '').toLowerCase();
|
|
84
|
+
const searchText = `${message} ${stderr}`;
|
|
85
|
+
return TRANSIENT_ERROR_PATTERNS.some(pattern => searchText.includes(pattern));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Default retry options for git operations.
|
|
90
|
+
* Uses exponential backoff with decorrelated jitter.
|
|
91
|
+
* @type {import('@git-stunts/alfred').RetryOptions}
|
|
92
|
+
*/
|
|
93
|
+
const DEFAULT_RETRY_OPTIONS = {
|
|
94
|
+
retries: 3,
|
|
95
|
+
delay: 100,
|
|
96
|
+
maxDelay: 2000,
|
|
97
|
+
backoff: 'exponential',
|
|
98
|
+
jitter: 'decorrelated',
|
|
99
|
+
shouldRetry: isTransientError,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extracts the exit code from a Git command error.
|
|
104
|
+
* Checks multiple possible locations where the exit code may be stored.
|
|
105
|
+
* @param {Error} err - The error object
|
|
106
|
+
* @returns {number|undefined} The exit code if found
|
|
107
|
+
*/
|
|
108
|
+
function getExitCode(err) {
|
|
109
|
+
return err?.details?.code ?? err?.exitCode ?? err?.code;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Checks whether a Git ref exists without resolving it.
|
|
114
|
+
* @param {function(Object): Promise<string>} execute - The git command executor function
|
|
115
|
+
* @param {string} ref - The ref to check (e.g., 'refs/warp/events/writers/alice')
|
|
116
|
+
* @returns {Promise<boolean>} True if the ref exists, false otherwise
|
|
117
|
+
* @throws {Error} If the git command fails for reasons other than a missing ref
|
|
118
|
+
*/
|
|
119
|
+
async function refExists(execute, ref) {
|
|
120
|
+
try {
|
|
121
|
+
await execute({ args: ['show-ref', '--verify', '--quiet', ref] });
|
|
122
|
+
return true;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (getExitCode(err) === 1) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Concrete implementation of {@link GraphPersistencePort} using Git plumbing commands.
|
|
133
|
+
*
|
|
134
|
+
* This adapter translates abstract graph persistence operations into Git plumbing
|
|
135
|
+
* commands (`commit-tree`, `hash-object`, `update-ref`, `cat-file`, etc.). It serves
|
|
136
|
+
* as the bridge between WARP's domain logic and Git's content-addressed storage.
|
|
137
|
+
*
|
|
138
|
+
* Implements all five focused ports via the composite GraphPersistencePort:
|
|
139
|
+
* - {@link CommitPort} — commit creation, reading, logging, counting, ping
|
|
140
|
+
* - {@link BlobPort} — blob read/write
|
|
141
|
+
* - {@link TreePort} — tree read/write, emptyTree getter
|
|
142
|
+
* - {@link RefPort} — ref update/read/delete
|
|
143
|
+
* - {@link ConfigPort} — git config get/set
|
|
144
|
+
*
|
|
145
|
+
* ## Retry Strategy
|
|
146
|
+
*
|
|
147
|
+
* All write operations use automatic retry with exponential backoff to handle
|
|
148
|
+
* transient Git errors. This is essential for multi-writer scenarios where
|
|
149
|
+
* concurrent writers may contend for ref locks:
|
|
150
|
+
*
|
|
151
|
+
* - **Retries**: 3 attempts by default
|
|
152
|
+
* - **Initial delay**: 100ms
|
|
153
|
+
* - **Max delay**: 2000ms (2 seconds)
|
|
154
|
+
* - **Backoff**: Exponential with decorrelated jitter to prevent thundering herd
|
|
155
|
+
* - **Retry condition**: Only transient errors (see {@link TRANSIENT_ERROR_PATTERNS})
|
|
156
|
+
*
|
|
157
|
+
* Custom retry options can be provided via the constructor to tune behavior
|
|
158
|
+
* for specific deployment environments (e.g., longer delays for NFS storage).
|
|
159
|
+
*
|
|
160
|
+
* ## Thread Safety
|
|
161
|
+
*
|
|
162
|
+
* This adapter is safe for concurrent use from multiple async contexts within
|
|
163
|
+
* the same Node.js process. Git's file-based locking provides external
|
|
164
|
+
* synchronization, and the retry logic handles lock contention gracefully.
|
|
165
|
+
*
|
|
166
|
+
* @extends GraphPersistencePort
|
|
167
|
+
* @implements {CommitPort}
|
|
168
|
+
* @implements {BlobPort}
|
|
169
|
+
* @implements {TreePort}
|
|
170
|
+
* @implements {RefPort}
|
|
171
|
+
* @implements {ConfigPort}
|
|
172
|
+
* @see {@link GraphPersistencePort} for the abstract interface contract
|
|
173
|
+
* @see {@link DEFAULT_RETRY_OPTIONS} for retry configuration details
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* // Basic usage with default retry options
|
|
177
|
+
* import Plumbing from '@git-stunts/plumbing';
|
|
178
|
+
* import GitGraphAdapter from './GitGraphAdapter.js';
|
|
179
|
+
*
|
|
180
|
+
* const plumbing = new Plumbing({ cwd: '/path/to/repo' });
|
|
181
|
+
* const adapter = new GitGraphAdapter({ plumbing });
|
|
182
|
+
*
|
|
183
|
+
* // Create a commit pointing to the empty tree
|
|
184
|
+
* const sha = await adapter.commitNode({ message: 'patch data...' });
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* // Custom retry options for high-latency storage
|
|
188
|
+
* const adapter = new GitGraphAdapter({
|
|
189
|
+
* plumbing,
|
|
190
|
+
* retryOptions: {
|
|
191
|
+
* retries: 5,
|
|
192
|
+
* delay: 200,
|
|
193
|
+
* maxDelay: 5000,
|
|
194
|
+
* }
|
|
195
|
+
* });
|
|
196
|
+
*/
|
|
197
|
+
export default class GitGraphAdapter extends GraphPersistencePort {
|
|
198
|
+
/**
|
|
199
|
+
* Creates a new GitGraphAdapter instance.
|
|
200
|
+
*
|
|
201
|
+
* @param {Object} options - Configuration options
|
|
202
|
+
* @param {import('@git-stunts/plumbing').default} options.plumbing - The Git plumbing
|
|
203
|
+
* instance to use for executing Git commands. Must be initialized with a valid
|
|
204
|
+
* repository path.
|
|
205
|
+
* @param {import('@git-stunts/alfred').RetryOptions} [options.retryOptions={}] - Custom
|
|
206
|
+
* retry options to override the defaults. Useful for tuning retry behavior based
|
|
207
|
+
* on deployment environment:
|
|
208
|
+
* - `retries` (number): Maximum retry attempts (default: 3)
|
|
209
|
+
* - `delay` (number): Initial delay in ms (default: 100)
|
|
210
|
+
* - `maxDelay` (number): Maximum delay cap in ms (default: 2000)
|
|
211
|
+
* - `backoff` ('exponential'|'linear'|'constant'): Backoff strategy
|
|
212
|
+
* - `jitter` ('full'|'decorrelated'|'none'): Jitter strategy
|
|
213
|
+
* - `shouldRetry` (function): Custom predicate for retryable errors
|
|
214
|
+
*
|
|
215
|
+
* @throws {Error} If plumbing is not provided
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* const adapter = new GitGraphAdapter({
|
|
219
|
+
* plumbing: new Plumbing({ cwd: '/repo' }),
|
|
220
|
+
* retryOptions: { retries: 5, delay: 200 }
|
|
221
|
+
* });
|
|
222
|
+
*/
|
|
223
|
+
constructor({ plumbing, retryOptions = {} }) {
|
|
224
|
+
super();
|
|
225
|
+
if (!plumbing) {
|
|
226
|
+
throw new Error('plumbing is required');
|
|
227
|
+
}
|
|
228
|
+
this.plumbing = plumbing;
|
|
229
|
+
this._retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...retryOptions };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Executes a git command with retry logic.
|
|
234
|
+
* @param {Object} options - Options to pass to plumbing.execute
|
|
235
|
+
* @returns {Promise<string>} Command output
|
|
236
|
+
* @private
|
|
237
|
+
*/
|
|
238
|
+
async _executeWithRetry(options) {
|
|
239
|
+
return await retry(() => this.plumbing.execute(options), this._retryOptions);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The well-known SHA for Git's empty tree object.
|
|
244
|
+
* @type {string}
|
|
245
|
+
* @readonly
|
|
246
|
+
*/
|
|
247
|
+
get emptyTree() {
|
|
248
|
+
return this.plumbing.emptyTree;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Creates a commit pointing to the empty tree.
|
|
253
|
+
* @param {Object} options
|
|
254
|
+
* @param {string} options.message - The commit message (typically CBOR-encoded patch data)
|
|
255
|
+
* @param {string[]} [options.parents=[]] - Parent commit SHAs
|
|
256
|
+
* @param {boolean} [options.sign=false] - Whether to GPG-sign the commit
|
|
257
|
+
* @returns {Promise<string>} The SHA of the created commit
|
|
258
|
+
* @throws {Error} If any parent OID is invalid
|
|
259
|
+
*/
|
|
260
|
+
async commitNode({ message, parents = [], sign = false }) {
|
|
261
|
+
for (const p of parents) {
|
|
262
|
+
this._validateOid(p);
|
|
263
|
+
}
|
|
264
|
+
const parentArgs = parents.flatMap(p => ['-p', p]);
|
|
265
|
+
const signArgs = sign ? ['-S'] : [];
|
|
266
|
+
const args = ['commit-tree', this.emptyTree, ...parentArgs, ...signArgs, '-m', message];
|
|
267
|
+
|
|
268
|
+
const oid = await this._executeWithRetry({ args });
|
|
269
|
+
return oid.trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Creates a commit pointing to a custom tree (not the empty tree).
|
|
274
|
+
* Used for WARP patch commits that have attachment trees.
|
|
275
|
+
* @param {Object} options
|
|
276
|
+
* @param {string} options.treeOid - The tree OID to point to
|
|
277
|
+
* @param {string[]} [options.parents=[]] - Parent commit SHAs
|
|
278
|
+
* @param {string} options.message - Commit message
|
|
279
|
+
* @param {boolean} [options.sign=false] - Whether to GPG sign
|
|
280
|
+
* @returns {Promise<string>} The created commit SHA
|
|
281
|
+
*/
|
|
282
|
+
async commitNodeWithTree({ treeOid, parents = [], message, sign = false }) {
|
|
283
|
+
this._validateOid(treeOid);
|
|
284
|
+
for (const p of parents) {
|
|
285
|
+
this._validateOid(p);
|
|
286
|
+
}
|
|
287
|
+
const parentArgs = parents.flatMap(p => ['-p', p]);
|
|
288
|
+
const signArgs = sign ? ['-S'] : [];
|
|
289
|
+
const args = ['commit-tree', treeOid, ...parentArgs, ...signArgs, '-m', message];
|
|
290
|
+
|
|
291
|
+
const oid = await this._executeWithRetry({ args });
|
|
292
|
+
return oid.trim();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Retrieves the raw commit message for a given SHA.
|
|
297
|
+
* @param {string} sha - The commit SHA to read
|
|
298
|
+
* @returns {Promise<string>} The raw commit message content
|
|
299
|
+
* @throws {Error} If the SHA is invalid
|
|
300
|
+
*/
|
|
301
|
+
async showNode(sha) {
|
|
302
|
+
this._validateOid(sha);
|
|
303
|
+
return await this._executeWithRetry({ args: ['show', '-s', '--format=%B', sha] });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Gets full commit metadata for a node.
|
|
308
|
+
* @param {string} sha - The commit SHA to retrieve
|
|
309
|
+
* @returns {Promise<{sha: string, message: string, author: string, date: string, parents: string[]}>}
|
|
310
|
+
* Full commit metadata including SHA, message, author, date, and parent SHAs
|
|
311
|
+
* @throws {Error} If the SHA is invalid or the commit format is malformed
|
|
312
|
+
*/
|
|
313
|
+
async getNodeInfo(sha) {
|
|
314
|
+
this._validateOid(sha);
|
|
315
|
+
// Format: SHA, author, date, parents (space-separated), then message
|
|
316
|
+
// Using %x00 to separate fields for reliable parsing
|
|
317
|
+
const format = '%H%x00%an <%ae>%x00%aI%x00%P%x00%B';
|
|
318
|
+
const output = await this._executeWithRetry({
|
|
319
|
+
args: ['show', '-s', `--format=${format}`, sha]
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const parts = output.split('\x00');
|
|
323
|
+
if (parts.length < 5) {
|
|
324
|
+
throw new Error(`Invalid commit format for SHA ${sha}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const [commitSha, author, date, parentsStr, ...messageParts] = parts;
|
|
328
|
+
const message = messageParts.join('\x00'); // In case message contained NUL (shouldn't happen)
|
|
329
|
+
const parents = parentsStr ? parentsStr.split(' ').filter(p => p) : [];
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
sha: commitSha.trim(),
|
|
333
|
+
message,
|
|
334
|
+
author: author.trim(),
|
|
335
|
+
date: date.trim(),
|
|
336
|
+
parents,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Returns raw git log output for a ref.
|
|
342
|
+
* @param {Object} options
|
|
343
|
+
* @param {string} options.ref - The Git ref to log from
|
|
344
|
+
* @param {number} [options.limit=50] - Maximum number of commits to return
|
|
345
|
+
* @param {string} [options.format] - Custom format string for git log
|
|
346
|
+
* @returns {Promise<string>} The raw log output
|
|
347
|
+
* @throws {Error} If the ref is invalid or the limit is out of range
|
|
348
|
+
*/
|
|
349
|
+
async logNodes({ ref, limit = 50, format }) {
|
|
350
|
+
this._validateRef(ref);
|
|
351
|
+
this._validateLimit(limit);
|
|
352
|
+
const args = ['log', `-${limit}`];
|
|
353
|
+
if (format) {
|
|
354
|
+
args.push(`--format=${format}`);
|
|
355
|
+
}
|
|
356
|
+
args.push(ref);
|
|
357
|
+
return await this._executeWithRetry({ args });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Streams git log output for the given ref.
|
|
362
|
+
* Uses the -z flag to produce NUL-terminated output, which:
|
|
363
|
+
* - Ensures reliable parsing of commits with special characters in messages
|
|
364
|
+
* - Ignores the i18n.logOutputEncoding config setting for consistent output
|
|
365
|
+
* @param {Object} options
|
|
366
|
+
* @param {string} options.ref - The ref to log from
|
|
367
|
+
* @param {number} [options.limit=1000000] - Maximum number of commits to return
|
|
368
|
+
* @param {string} [options.format] - Custom format string for git log
|
|
369
|
+
* @returns {Promise<import('node:stream').Readable>} A readable stream of git log output (NUL-terminated records)
|
|
370
|
+
* @throws {Error} If the ref is invalid or the limit is out of range
|
|
371
|
+
*/
|
|
372
|
+
async logNodesStream({ ref, limit = 1000000, format }) {
|
|
373
|
+
this._validateRef(ref);
|
|
374
|
+
this._validateLimit(limit);
|
|
375
|
+
// -z flag ensures NUL-terminated output and ignores i18n.logOutputEncoding config
|
|
376
|
+
const args = ['log', '-z', `-${limit}`];
|
|
377
|
+
if (format) {
|
|
378
|
+
// Strip NUL bytes from format - git -z flag handles NUL termination automatically
|
|
379
|
+
// Node.js child_process rejects args containing null bytes
|
|
380
|
+
// eslint-disable-next-line no-control-regex
|
|
381
|
+
const cleanFormat = format.replace(/\x00/g, '');
|
|
382
|
+
args.push(`--format=${cleanFormat}`);
|
|
383
|
+
}
|
|
384
|
+
args.push(ref);
|
|
385
|
+
return await this.plumbing.executeStream({ args });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Validates that a ref is safe to use in git commands.
|
|
390
|
+
* Prevents command injection via malicious ref names.
|
|
391
|
+
* @param {string} ref - The ref to validate
|
|
392
|
+
* @throws {Error} If ref contains invalid characters, is too long, or starts with -/--
|
|
393
|
+
* @private
|
|
394
|
+
*/
|
|
395
|
+
_validateRef(ref) {
|
|
396
|
+
if (!ref || typeof ref !== 'string') {
|
|
397
|
+
throw new Error('Ref must be a non-empty string');
|
|
398
|
+
}
|
|
399
|
+
// Prevent buffer overflow attacks with extremely long refs
|
|
400
|
+
if (ref.length > 1024) {
|
|
401
|
+
throw new Error(`Ref too long: ${ref.length} chars. Maximum is 1024`);
|
|
402
|
+
}
|
|
403
|
+
// Prevent git option injection (must check before pattern matching)
|
|
404
|
+
if (ref.startsWith('-') || ref.startsWith('--')) {
|
|
405
|
+
throw new Error(`Invalid ref: ${ref}. Refs cannot start with - or --. See https://github.com/git-stunts/git-warp#security`);
|
|
406
|
+
}
|
|
407
|
+
// Allow alphanumeric, ., /, -, _ in names
|
|
408
|
+
// Allow ancestry operators: ^ or ~ optionally followed by digits
|
|
409
|
+
// Allow range operators: .. between names
|
|
410
|
+
const validRefPattern = /^[a-zA-Z0-9._/-]+((~\d*|\^\d*|\.\.[a-zA-Z0-9._/-]+)*)$/;
|
|
411
|
+
if (!validRefPattern.test(ref)) {
|
|
412
|
+
throw new Error(`Invalid ref format: ${ref}. Only alphanumeric characters, ., /, -, _, ^, ~, and range operators are allowed. See https://github.com/git-stunts/git-warp#ref-validation`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Writes content as a Git blob and returns its OID.
|
|
418
|
+
* @param {Buffer|string} content - The blob content to write
|
|
419
|
+
* @returns {Promise<string>} The Git OID of the created blob
|
|
420
|
+
*/
|
|
421
|
+
async writeBlob(content) {
|
|
422
|
+
const oid = await this._executeWithRetry({
|
|
423
|
+
args: ['hash-object', '-w', '--stdin'],
|
|
424
|
+
input: content,
|
|
425
|
+
});
|
|
426
|
+
return oid.trim();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Creates a Git tree from mktree-formatted entries.
|
|
431
|
+
* @param {string[]} entries - Lines in git mktree format (e.g., "100644 blob <oid>\t<path>")
|
|
432
|
+
* @returns {Promise<string>} The Git OID of the created tree
|
|
433
|
+
*/
|
|
434
|
+
async writeTree(entries) {
|
|
435
|
+
const oid = await this._executeWithRetry({
|
|
436
|
+
args: ['mktree'],
|
|
437
|
+
input: `${entries.join('\n')}\n`,
|
|
438
|
+
});
|
|
439
|
+
return oid.trim();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Reads a tree and returns a map of path to content.
|
|
444
|
+
* Processes blobs sequentially to avoid spawning too many concurrent reads.
|
|
445
|
+
* @param {string} treeOid - The tree OID to read
|
|
446
|
+
* @returns {Promise<Record<string, Buffer>>} Map of file path to blob content
|
|
447
|
+
*/
|
|
448
|
+
async readTree(treeOid) {
|
|
449
|
+
const oids = await this.readTreeOids(treeOid);
|
|
450
|
+
const files = {};
|
|
451
|
+
// Process sequentially to avoid spawning thousands of concurrent readBlob calls
|
|
452
|
+
for (const [path, oid] of Object.entries(oids)) {
|
|
453
|
+
files[path] = await this.readBlob(oid);
|
|
454
|
+
}
|
|
455
|
+
return files;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Reads a tree and returns a map of path to blob OID.
|
|
460
|
+
* Useful for lazy-loading shards without reading all blob contents.
|
|
461
|
+
* @param {string} treeOid - The tree OID to read
|
|
462
|
+
* @returns {Promise<Record<string, string>>} Map of file path to blob OID
|
|
463
|
+
* @throws {Error} If the tree OID is invalid
|
|
464
|
+
*/
|
|
465
|
+
async readTreeOids(treeOid) {
|
|
466
|
+
this._validateOid(treeOid);
|
|
467
|
+
const output = await this._executeWithRetry({
|
|
468
|
+
args: ['ls-tree', '-r', '-z', treeOid]
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const oids = {};
|
|
472
|
+
// NUL-separated records: "mode type oid\tpath\0"
|
|
473
|
+
const records = output.split('\0');
|
|
474
|
+
for (const record of records) {
|
|
475
|
+
if (!record) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
// Format: "mode type oid\tpath"
|
|
479
|
+
const tabIndex = record.indexOf('\t');
|
|
480
|
+
if (tabIndex === -1) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const meta = record.slice(0, tabIndex);
|
|
484
|
+
const path = record.slice(tabIndex + 1);
|
|
485
|
+
const [, , oid] = meta.split(' ');
|
|
486
|
+
oids[path] = oid;
|
|
487
|
+
}
|
|
488
|
+
return oids;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Reads the content of a Git blob.
|
|
493
|
+
* @param {string} oid - The blob OID to read
|
|
494
|
+
* @returns {Promise<Buffer>} The blob content
|
|
495
|
+
* @throws {Error} If the OID is invalid
|
|
496
|
+
*/
|
|
497
|
+
async readBlob(oid) {
|
|
498
|
+
this._validateOid(oid);
|
|
499
|
+
const stream = await this.plumbing.executeStream({
|
|
500
|
+
args: ['cat-file', 'blob', oid]
|
|
501
|
+
});
|
|
502
|
+
return await stream.collect({ asString: false });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Updates a ref to point to an OID.
|
|
507
|
+
* @param {string} ref - The ref name (e.g., 'refs/warp/events/writers/alice')
|
|
508
|
+
* @param {string} oid - The OID to point to
|
|
509
|
+
* @returns {Promise<void>}
|
|
510
|
+
* @throws {Error} If the ref or OID is invalid
|
|
511
|
+
*/
|
|
512
|
+
async updateRef(ref, oid) {
|
|
513
|
+
this._validateRef(ref);
|
|
514
|
+
this._validateOid(oid);
|
|
515
|
+
await this._executeWithRetry({
|
|
516
|
+
args: ['update-ref', ref, oid]
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Reads the OID a ref points to.
|
|
522
|
+
* @param {string} ref - The ref name
|
|
523
|
+
* @returns {Promise<string|null>} The OID, or null if the ref does not exist
|
|
524
|
+
* @throws {Error} If the ref format is invalid
|
|
525
|
+
*/
|
|
526
|
+
async readRef(ref) {
|
|
527
|
+
this._validateRef(ref);
|
|
528
|
+
const exists = await refExists(this._executeWithRetry.bind(this), ref);
|
|
529
|
+
if (!exists) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const oid = await this._executeWithRetry({
|
|
534
|
+
args: ['rev-parse', ref]
|
|
535
|
+
});
|
|
536
|
+
return oid.trim();
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (getExitCode(err) === 1) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
throw err;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Deletes a ref.
|
|
547
|
+
* @param {string} ref - The ref name to delete
|
|
548
|
+
* @returns {Promise<void>}
|
|
549
|
+
* @throws {Error} If the ref format is invalid
|
|
550
|
+
*/
|
|
551
|
+
async deleteRef(ref) {
|
|
552
|
+
this._validateRef(ref);
|
|
553
|
+
await this._executeWithRetry({
|
|
554
|
+
args: ['update-ref', '-d', ref]
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Validates that an OID is safe to use in git commands.
|
|
560
|
+
* @param {string} oid - The OID to validate
|
|
561
|
+
* @throws {Error} If OID is invalid
|
|
562
|
+
* @private
|
|
563
|
+
*/
|
|
564
|
+
_validateOid(oid) {
|
|
565
|
+
if (!oid || typeof oid !== 'string') {
|
|
566
|
+
throw new Error('OID must be a non-empty string');
|
|
567
|
+
}
|
|
568
|
+
if (oid.length > 64) {
|
|
569
|
+
throw new Error(`OID too long: ${oid.length} chars. Maximum is 64`);
|
|
570
|
+
}
|
|
571
|
+
const validOidPattern = /^[0-9a-fA-F]{4,64}$/;
|
|
572
|
+
if (!validOidPattern.test(oid)) {
|
|
573
|
+
throw new Error(`Invalid OID format: ${oid}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Validates that a limit is a safe positive integer.
|
|
579
|
+
* @param {number} limit - The limit to validate
|
|
580
|
+
* @throws {Error} If limit is invalid
|
|
581
|
+
* @private
|
|
582
|
+
*/
|
|
583
|
+
_validateLimit(limit) {
|
|
584
|
+
if (typeof limit !== 'number' || !Number.isFinite(limit)) {
|
|
585
|
+
throw new Error('Limit must be a finite number');
|
|
586
|
+
}
|
|
587
|
+
if (!Number.isInteger(limit)) {
|
|
588
|
+
throw new Error('Limit must be an integer');
|
|
589
|
+
}
|
|
590
|
+
if (limit <= 0) {
|
|
591
|
+
throw new Error('Limit must be a positive integer');
|
|
592
|
+
}
|
|
593
|
+
if (limit > 10_000_000) {
|
|
594
|
+
throw new Error(`Limit too large: ${limit}. Maximum is 10,000,000`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Checks if a node (commit) exists in the repository.
|
|
600
|
+
* Uses `git cat-file -e` for efficient existence checking without loading content.
|
|
601
|
+
* @param {string} sha - The commit SHA to check
|
|
602
|
+
* @returns {Promise<boolean>} True if the node exists, false otherwise
|
|
603
|
+
* @throws {Error} If the SHA format is invalid
|
|
604
|
+
*/
|
|
605
|
+
async nodeExists(sha) {
|
|
606
|
+
this._validateOid(sha);
|
|
607
|
+
try {
|
|
608
|
+
await this._executeWithRetry({ args: ['cat-file', '-e', sha] });
|
|
609
|
+
return true;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
if (getExitCode(err) === 1) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Lists refs matching a prefix.
|
|
620
|
+
* @param {string} prefix - The ref prefix to match (e.g., 'refs/warp/events/writers/')
|
|
621
|
+
* @returns {Promise<string[]>} Array of matching ref paths
|
|
622
|
+
* @throws {Error} If the prefix is invalid
|
|
623
|
+
*/
|
|
624
|
+
async listRefs(prefix) {
|
|
625
|
+
this._validateRef(prefix);
|
|
626
|
+
const output = await this._executeWithRetry({
|
|
627
|
+
args: ['for-each-ref', '--format=%(refname)', prefix]
|
|
628
|
+
});
|
|
629
|
+
// Parse output - one ref per line, filter empty lines
|
|
630
|
+
return output.split('\n').filter(line => line.trim());
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Pings the repository to verify accessibility.
|
|
635
|
+
* Uses `git rev-parse --is-inside-work-tree` as a lightweight check.
|
|
636
|
+
*
|
|
637
|
+
* Note: latencyMs includes retry overhead if retries occur, so it may not
|
|
638
|
+
* reflect single-trip repository latency in degraded conditions.
|
|
639
|
+
*
|
|
640
|
+
* @returns {Promise<{ok: boolean, latencyMs: number}>} Health check result with latency
|
|
641
|
+
*/
|
|
642
|
+
async ping() {
|
|
643
|
+
const start = Date.now();
|
|
644
|
+
try {
|
|
645
|
+
await this._executeWithRetry({ args: ['rev-parse', '--is-inside-work-tree'] });
|
|
646
|
+
const latencyMs = Date.now() - start;
|
|
647
|
+
return { ok: true, latencyMs };
|
|
648
|
+
} catch {
|
|
649
|
+
const latencyMs = Date.now() - start;
|
|
650
|
+
return { ok: false, latencyMs };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Counts nodes reachable from a ref without loading them into memory.
|
|
656
|
+
* Uses `git rev-list --count` for O(1) memory efficiency.
|
|
657
|
+
* @param {string} ref - Git ref to count from (e.g., 'HEAD', 'main', SHA)
|
|
658
|
+
* @returns {Promise<number>} The count of reachable nodes
|
|
659
|
+
* @throws {Error} If the ref is invalid
|
|
660
|
+
*/
|
|
661
|
+
async countNodes(ref) {
|
|
662
|
+
this._validateRef(ref);
|
|
663
|
+
const output = await this._executeWithRetry({
|
|
664
|
+
args: ['rev-list', '--count', ref]
|
|
665
|
+
});
|
|
666
|
+
return parseInt(output.trim(), 10);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Checks if one commit is an ancestor of another.
|
|
671
|
+
* Uses `git merge-base --is-ancestor` for efficient ancestry testing.
|
|
672
|
+
*
|
|
673
|
+
* @param {string} potentialAncestor - The commit that might be an ancestor
|
|
674
|
+
* @param {string} descendant - The commit that might be a descendant
|
|
675
|
+
* @returns {Promise<boolean>} True if potentialAncestor is an ancestor of descendant
|
|
676
|
+
* @throws {Error} If either OID is invalid
|
|
677
|
+
*/
|
|
678
|
+
async isAncestor(potentialAncestor, descendant) {
|
|
679
|
+
this._validateOid(potentialAncestor);
|
|
680
|
+
this._validateOid(descendant);
|
|
681
|
+
try {
|
|
682
|
+
await this._executeWithRetry({
|
|
683
|
+
args: ['merge-base', '--is-ancestor', potentialAncestor, descendant]
|
|
684
|
+
});
|
|
685
|
+
return true; // Exit code 0 means it IS an ancestor
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if (this._getExitCode(err) === 1) {
|
|
688
|
+
return false; // Exit code 1 means it is NOT an ancestor
|
|
689
|
+
}
|
|
690
|
+
throw err; // Re-throw unexpected errors
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Reads a git config value.
|
|
696
|
+
* @param {string} key - The config key to read (e.g., 'warp.writerId.events')
|
|
697
|
+
* @returns {Promise<string|null>} The config value or null if not set
|
|
698
|
+
* @throws {Error} If the key format is invalid
|
|
699
|
+
*/
|
|
700
|
+
async configGet(key) {
|
|
701
|
+
this._validateConfigKey(key);
|
|
702
|
+
try {
|
|
703
|
+
const value = await this._executeWithRetry({
|
|
704
|
+
args: ['config', '--get', key]
|
|
705
|
+
});
|
|
706
|
+
// Preserve empty-string values; only drop trailing newline
|
|
707
|
+
return value.replace(/\n$/, '');
|
|
708
|
+
} catch (err) {
|
|
709
|
+
if (this._isConfigKeyNotFound(err)) {
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
throw err;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Sets a git config value.
|
|
718
|
+
* @param {string} key - The config key to set (e.g., 'warp.writerId.events')
|
|
719
|
+
* @param {string} value - The value to set
|
|
720
|
+
* @returns {Promise<void>}
|
|
721
|
+
* @throws {Error} If the key format is invalid or value is not a string
|
|
722
|
+
*/
|
|
723
|
+
async configSet(key, value) {
|
|
724
|
+
this._validateConfigKey(key);
|
|
725
|
+
if (typeof value !== 'string') {
|
|
726
|
+
throw new Error('Config value must be a string');
|
|
727
|
+
}
|
|
728
|
+
await this._executeWithRetry({
|
|
729
|
+
args: ['config', key, value]
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Validates that a config key is safe to use in git commands.
|
|
735
|
+
* @param {string} key - The config key to validate
|
|
736
|
+
* @throws {Error} If key is invalid
|
|
737
|
+
* @private
|
|
738
|
+
*/
|
|
739
|
+
_validateConfigKey(key) {
|
|
740
|
+
if (!key || typeof key !== 'string') {
|
|
741
|
+
throw new Error('Config key must be a non-empty string');
|
|
742
|
+
}
|
|
743
|
+
if (key.length > 256) {
|
|
744
|
+
throw new Error(`Config key too long: ${key.length} chars. Maximum is 256`);
|
|
745
|
+
}
|
|
746
|
+
// Prevent git option injection
|
|
747
|
+
if (key.startsWith('-')) {
|
|
748
|
+
throw new Error(`Invalid config key: ${key}. Keys cannot start with -`);
|
|
749
|
+
}
|
|
750
|
+
// Allow section.subsection.key format
|
|
751
|
+
const validKeyPattern = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
|
|
752
|
+
if (!validKeyPattern.test(key)) {
|
|
753
|
+
throw new Error(`Invalid config key format: ${key}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Extracts the exit code from a Git command error.
|
|
759
|
+
* Delegates to the standalone getExitCode helper.
|
|
760
|
+
* @param {Error} err - The error object
|
|
761
|
+
* @returns {number|undefined} The exit code if found
|
|
762
|
+
* @private
|
|
763
|
+
*/
|
|
764
|
+
_getExitCode(err) {
|
|
765
|
+
return getExitCode(err);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Checks if an error indicates a config key was not found.
|
|
770
|
+
* Exit code 1 from `git config --get` means the key doesn't exist.
|
|
771
|
+
* @param {Error} err - The error object
|
|
772
|
+
* @returns {boolean} True if the error indicates key not found
|
|
773
|
+
* @private
|
|
774
|
+
*/
|
|
775
|
+
_isConfigKeyNotFound(err) {
|
|
776
|
+
// Primary check: exit code 1 means key not found for git config --get
|
|
777
|
+
if (this._getExitCode(err) === 1) {
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
// Fallback for wrapped errors where exit code is embedded in message.
|
|
781
|
+
// This is intentionally conservative - only matches the exact pattern
|
|
782
|
+
// from git config failures to avoid false positives from unrelated errors.
|
|
783
|
+
const msg = (err.message || '').toLowerCase();
|
|
784
|
+
const stderr = (err.details?.stderr || '').toLowerCase();
|
|
785
|
+
return msg.includes('exit code 1') || stderr.includes('exit code 1');
|
|
786
|
+
}
|
|
787
|
+
}
|