@foss.global/gitmanager 2.2.0 → 2.4.0

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.
@@ -1,6 +1,8 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type {
3
3
  IGitManagerAncestryRequest,
4
+ IGitManagerAtomicPushRequest,
5
+ IGitManagerAtomicPushResult,
4
6
  IGitManagerArchiveInspection,
5
7
  IGitManagerArchiveInspectionOptions,
6
8
  IGitManagerArchiveInspectionRequest,
@@ -18,6 +20,8 @@ import type {
18
20
  IGitManagerMergeResult,
19
21
  IGitManagerReadFileRequest,
20
22
  IGitManagerRefRequest,
23
+ IGitManagerRefreshMirrorRequest,
24
+ IGitManagerRemoteTransferOptions,
21
25
  IGitManagerProtectedRefsApplyResult,
22
26
  IGitManagerSmartHttpRepositoryProtectedRefs,
23
27
  IGitManagerRepositoryBundle,
@@ -28,7 +32,9 @@ import type {
28
32
  IGitManagerRepositoryServiceRequest,
29
33
  IGitManagerRepositoryInfo,
30
34
  IGitManagerRepositorySelector,
35
+ IGitManagerRepositorySize,
31
36
  IGitManagerRestoreBundleRequest,
37
+ IGitManagerSetDefaultBranchRequest,
32
38
  IGitManagerSmartHttpRepositoryAccess,
33
39
  IGitManagerSmartHttpRepositoryVisibility,
34
40
  IGitManagerStatus,
@@ -36,6 +42,7 @@ import type {
36
42
  IGitManagerTagRequest,
37
43
  IGitManagerTreeEntry,
38
44
  IGitManagerTreeRequest,
45
+ IGitManagerTransferControlOptions,
39
46
  IGitManagerOptions,
40
47
  IGitManagerReadinessOptions,
41
48
  IRustGitManagerConfig,
@@ -49,6 +56,66 @@ import { runArchiveInspection } from './classes.archiveinspection.js';
49
56
 
50
57
  const readinessDefaultTimeoutMs = 2_000;
51
58
  const readinessMaximumTimeoutMs = 30_000;
59
+ const remoteTransferMaximumTimeoutMs = 21_600_000;
60
+
61
+ /**
62
+ * Checks transfer options before they reach the engine, which answers a
63
+ * malformed credential-bearing request with fixed text only. Messages never
64
+ * quote a credential value.
65
+ */
66
+ const assertRemoteTransferOptions = (optionsArg: IGitManagerRemoteTransferOptions): void => {
67
+ const { credential, caCertificates, timeoutMs } = optionsArg;
68
+ if (credential !== undefined) {
69
+ if (
70
+ credential === null
71
+ || typeof credential !== 'object'
72
+ || credential.kind !== 'basic'
73
+ || typeof credential.username !== 'string'
74
+ || typeof credential.password !== 'string'
75
+ || Object.keys(credential).some((key) => !['kind', 'username', 'password'].includes(key))
76
+ ) {
77
+ throw new TypeError('credential must be { kind: "basic", username: string, password: string }');
78
+ }
79
+ }
80
+ if (
81
+ caCertificates !== undefined
82
+ && (!Array.isArray(caCertificates) || caCertificates.some((certificate) => typeof certificate !== 'string'))
83
+ ) {
84
+ throw new TypeError('caCertificates must be an array of PEM strings');
85
+ }
86
+ if (
87
+ timeoutMs !== undefined
88
+ && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > remoteTransferMaximumTimeoutMs)
89
+ ) {
90
+ throw new RangeError(`timeoutMs must be an integer between 1 and ${remoteTransferMaximumTimeoutMs}`);
91
+ }
92
+ };
93
+
94
+ /**
95
+ * Checks the shape of push updates before they reach the engine, which
96
+ * answers a malformed request with fixed text only. Ref names and object IDs
97
+ * are validated by the engine.
98
+ */
99
+ const assertAtomicPushUpdates = (updatesArg: unknown): void => {
100
+ const isObjectIdOrNull = (valueArg: unknown): boolean => valueArg === null || typeof valueArg === 'string';
101
+ if (
102
+ !Array.isArray(updatesArg)
103
+ || updatesArg.some((update: unknown) => {
104
+ if (update === null || typeof update !== 'object') {
105
+ return true;
106
+ }
107
+ const record = update as Record<string, unknown>;
108
+ return typeof record.ref !== 'string'
109
+ || !isObjectIdOrNull(record.expectedOldObjectId)
110
+ || !isObjectIdOrNull(record.newObjectId)
111
+ || Object.keys(record).some((key) => !['ref', 'expectedOldObjectId', 'newObjectId'].includes(key));
112
+ })
113
+ ) {
114
+ throw new TypeError(
115
+ 'updates must be an array of { ref: string, expectedOldObjectId: string | null, newObjectId: string | null }',
116
+ );
117
+ }
118
+ };
52
119
 
53
120
  type TGitManagerReadinessAbortSource = 'caller' | 'deadline';
54
121
  type TGitManagerReadinessProbeOutcome = 'ready' | 'aborted' | 'unavailable';
@@ -339,14 +406,95 @@ export class GitManager extends plugins.EventEmitter {
339
406
  return this.bridge.initBareRepository(requestArg);
340
407
  }
341
408
 
342
- public async importMirror(requestArg: IGitManagerImportMirrorRequest): Promise<IGitManagerRepositoryInfo> {
409
+ /**
410
+ * Clones every ref of `remoteUrl` into a new mirror repository whose HEAD
411
+ * points at the source's default branch. Pass a credential as
412
+ * `credential`, never inside the URL: the engine hands it to Git only
413
+ * through the process environment and removes it from errors.
414
+ *
415
+ * The import runs alongside other commands and holds `org/repo` until it
416
+ * ends: another mirror transfer of it or a write to it (such as
417
+ * `initBareRepository` or `restoreRepositoryBundle`) fails with
418
+ * `repositoryBusy`. Aborting `optionsArg.signal`, or `stop()`, terminates
419
+ * the clone and removes the partial repository; the call then rejects with
420
+ * `transferAborted`, and no repository is left behind. An import whose
421
+ * clone already completed when the abort arrived returns its result.
422
+ */
423
+ public async importMirror(
424
+ requestArg: IGitManagerImportMirrorRequest,
425
+ optionsArg: IGitManagerTransferControlOptions = {},
426
+ ): Promise<IGitManagerRepositoryInfo> {
427
+ this.assertRunning();
428
+ assertRemoteTransferOptions(requestArg);
429
+ return this.bridge.importMirror(requestArg, optionsArg);
430
+ }
431
+
432
+ /**
433
+ * Fetches every ref of an imported mirror's remote again, with the same
434
+ * transfer options as `importMirror`, and points HEAD at the source's
435
+ * current default branch when the source advertises one.
436
+ *
437
+ * The refresh holds `org/repo` like `importMirror`: writes to it, including
438
+ * Smart HTTP pushes, are refused with `repositoryBusy` while it runs, and it
439
+ * is refused while such a write is in progress; reads proceed. Aborting
440
+ * `optionsArg.signal`, or `stop()`, terminates the fetch and rejects with
441
+ * `transferAborted`. The mirror's refs then stay as they were or, when the
442
+ * abort came after `git fetch --atomic` committed its ref transaction, are
443
+ * fully refreshed; HEAD moves only when the refresh completes.
444
+ */
445
+ public async refreshMirror(
446
+ requestArg: IGitManagerRefreshMirrorRequest,
447
+ optionsArg: IGitManagerTransferControlOptions = {},
448
+ ): Promise<IGitManagerRepositoryInfo> {
449
+ this.assertRunning();
450
+ assertRemoteTransferOptions(requestArg);
451
+ return this.bridge.refreshMirror(requestArg, optionsArg);
452
+ }
453
+
454
+ /**
455
+ * Pushes leased ref updates to `remoteUrl` as one atomic transaction: the
456
+ * remote applies all of them or none. The remote must advertise the
457
+ * `atomic` receive-pack capability; otherwise a `GitManagerError` with code
458
+ * `atomicPushUnsupported` is thrown and nothing is pushed. `remoteUrl` must
459
+ * be `https`, or `http` to a loopback IP address (`127.0.0.0/8`, `[::1]`;
460
+ * never a host name such as `localhost`), without user information; the
461
+ * credential travels as for `importMirror`. A ref that already holds its
462
+ * `newObjectId` is reported `ok` without its lease being checked; it is not
463
+ * changed, so `ok` never means the lease matched. Any refusal the remote
464
+ * reports per ref returns status `rejected`, with the refused refs `stale`
465
+ * or `rejected` and the up-to-date ones `ok`; the call throws only when the
466
+ * remote's answer does not say what it did.
467
+ *
468
+ * The push only reads the managed repository, so it runs alongside other
469
+ * commands and other transfers. Aborting `optionsArg.signal`, or `stop()`,
470
+ * rejects with `transferAborted`. An abort before `git push` starts (a
471
+ * signal already aborted, or an abort during the local object check)
472
+ * changes nothing on the remote. An abort while it runs terminates it, and
473
+ * the remote then holds all of the updates or none of them, unknown which:
474
+ * read the remote refs again before retrying.
475
+ */
476
+ public async pushRefs(
477
+ requestArg: IGitManagerAtomicPushRequest,
478
+ optionsArg: IGitManagerTransferControlOptions = {},
479
+ ): Promise<IGitManagerAtomicPushResult> {
343
480
  this.assertRunning();
344
- return this.bridge.importMirror(requestArg);
481
+ assertRemoteTransferOptions(requestArg);
482
+ assertAtomicPushUpdates(requestArg.updates);
483
+ return this.bridge.pushRefs(requestArg, optionsArg);
345
484
  }
346
485
 
347
- public async refreshMirror(requestArg: IGitManagerRepositorySelector): Promise<IGitManagerRepositoryInfo> {
486
+ /** Points the repository's HEAD at an existing branch; a missing branch is refused and HEAD stays unchanged. */
487
+ public async setDefaultBranch(
488
+ requestArg: IGitManagerSetDefaultBranchRequest,
489
+ ): Promise<IGitManagerRepositoryInfo> {
348
490
  this.assertRunning();
349
- return this.bridge.refreshMirror(requestArg);
491
+ return this.bridge.setDefaultBranch(requestArg);
492
+ }
493
+
494
+ /** Counts the repository's objects and their exact Git bytes. */
495
+ public async getRepositorySize(requestArg: IGitManagerRepositorySelector): Promise<IGitManagerRepositorySize> {
496
+ this.assertRunning();
497
+ return this.bridge.getRepositorySize(requestArg);
350
498
  }
351
499
 
352
500
  public async verifyRepository(requestArg: IGitManagerRepositorySelector): Promise<IGitManagerRepositoryInfo> {
@@ -369,29 +517,64 @@ export class GitManager extends plugins.EventEmitter {
369
517
  return this.bridge.createRepositoryArchive(requestArg, optionsArg);
370
518
  }
371
519
 
520
+ /**
521
+ * Starts a bounded `git upload-pack` or protected `git receive-pack` for an
522
+ * authenticated ingress such as SSH.
523
+ *
524
+ * A receive-pack holds its repository against mirror transfers: while its
525
+ * `guardTicketId` is held, `importMirror` and `refreshMirror` of the
526
+ * repository fail with `repositoryBusy`, and a receive-pack is refused with
527
+ * `repositoryBusy` while a mirror transfer runs. Release the ticket with
528
+ * `releaseRepositoryService()` in a `finally` once the process has exited
529
+ * and the push is reconciled; `stop()` releases every ticket. Upload-pack
530
+ * takes no ticket.
531
+ *
532
+ * The ticket ID is chosen here, before the engine is asked. When opening a
533
+ * receive-pack fails, including a bridge timeout after the engine already
534
+ * issued the ticket, the ticket is released before the error is thrown. If
535
+ * that release fails as well, the original error is still thrown: the
536
+ * release error becomes its `cause` when it has none, and otherwise both
537
+ * are thrown together as an `AggregateError` (`errors`: the original error,
538
+ * then the release error; `cause`: the original error).
539
+ */
372
540
  public async openRepositoryService(
373
541
  requestArg: IGitManagerRepositoryServiceRequest,
374
542
  ): Promise<GitManagerRepositoryService> {
375
543
  this.assertRunning();
376
544
  const serviceEpoch = this.lifecycleEpoch;
377
- const descriptor = await this.bridge.prepareRepositoryService({
378
- org: requestArg.org,
379
- repo: requestArg.repo,
380
- service: requestArg.service,
381
- ...(requestArg.protocol ? { protocol: requestArg.protocol } : {}),
382
- });
383
- if (
384
- this.lifecycleEpoch !== serviceEpoch
385
- || this.stopRequested
386
- || !this.running
387
- ) {
388
- throw new Error('GitManager stopped while preparing repository service');
545
+ const guardTicketId = requestArg.service === 'receive-pack'
546
+ ? plugins.crypto.randomUUID()
547
+ : undefined;
548
+ let service: GitManagerRepositoryService;
549
+ try {
550
+ const descriptor = await this.bridge.prepareRepositoryService({
551
+ org: requestArg.org,
552
+ repo: requestArg.repo,
553
+ service: requestArg.service,
554
+ ...(requestArg.protocol ? { protocol: requestArg.protocol } : {}),
555
+ ...(guardTicketId === undefined ? {} : { guardTicketId }),
556
+ });
557
+ if (
558
+ this.lifecycleEpoch !== serviceEpoch
559
+ || this.stopRequested
560
+ || !this.running
561
+ ) {
562
+ throw new Error('GitManager stopped while preparing repository service');
563
+ }
564
+ if (descriptor.guardTicketId !== guardTicketId) {
565
+ throw new Error('GitManager returned an invalid repository service descriptor');
566
+ }
567
+ service = new GitManagerRepositoryService(
568
+ descriptor,
569
+ requestArg,
570
+ this.config.dataDir,
571
+ );
572
+ } catch (error) {
573
+ if (guardTicketId === undefined) {
574
+ throw error;
575
+ }
576
+ throw await this.releaseUnusedTicket(guardTicketId, error);
389
577
  }
390
- const service = new GitManagerRepositoryService(
391
- descriptor,
392
- requestArg,
393
- this.config.dataDir,
394
- );
395
578
  this.repositoryServices.add(service);
396
579
  service.completed.finally(() => {
397
580
  this.repositoryServices.delete(service);
@@ -399,6 +582,46 @@ export class GitManager extends plugins.EventEmitter {
399
582
  return service;
400
583
  }
401
584
 
585
+ /**
586
+ * Releases the guard ticket of a receive-pack that failed to open and
587
+ * returns the error to throw: `errorArg`, carrying a failed release as its
588
+ * `cause` when it has none, or else an `AggregateError` of both.
589
+ */
590
+ private async releaseUnusedTicket(ticketIdArg: string, errorArg: unknown): Promise<unknown> {
591
+ try {
592
+ await this.releaseRepositoryService(ticketIdArg);
593
+ return errorArg;
594
+ } catch (releaseError) {
595
+ if (errorArg instanceof Error && errorArg.cause === undefined) {
596
+ errorArg.cause = releaseError;
597
+ return errorArg;
598
+ }
599
+ return new AggregateError(
600
+ [errorArg, releaseError],
601
+ 'Opening the repository service failed, and releasing its guard ticket failed',
602
+ { cause: errorArg },
603
+ );
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Releases the guard ticket of a receive-pack opened with
609
+ * `openRepositoryService()` (`service.guardTicketId`), so mirror transfers
610
+ * of its repository are admitted again. Call it in a `finally` after the
611
+ * Git process has exited. Releasing is idempotent: an unknown or already
612
+ * released ticket, or a stopped engine, whose tickets ended with it, is a
613
+ * no-op.
614
+ */
615
+ public async releaseRepositoryService(ticketIdArg: string): Promise<void> {
616
+ if (typeof ticketIdArg !== 'string') {
617
+ throw new TypeError('ticketId must be a string');
618
+ }
619
+ if (!this.bridge.running) {
620
+ return;
621
+ }
622
+ await this.bridge.releaseRepositoryService(ticketIdArg);
623
+ }
624
+
402
625
  public async restoreRepositoryBundle(
403
626
  requestArg: IGitManagerRestoreBundleRequest,
404
627
  ): Promise<IGitManagerRepositoryInfo> {
@@ -7,6 +7,9 @@ const gitManagerErrorCodes: ReadonlySet<string> = new Set<TGitManagerErrorCode>(
7
7
  'ancestryBudgetExceeded',
8
8
  'archiveLimitExceeded',
9
9
  'archiveRejected',
10
+ 'atomicPushUnsupported',
11
+ 'transferAborted',
12
+ 'repositoryBusy',
10
13
  ]);
11
14
 
12
15
  export const isGitManagerErrorCode = (valueArg: unknown): valueArg is TGitManagerErrorCode =>
@@ -64,12 +64,15 @@ const validateDescriptor = (
64
64
  || !plugins.path.isAbsolute(descriptorArg.hookPath)
65
65
  || !descriptorArg.protectedRefsPolicy
66
66
  || !/^[A-Za-z0-9_-]{1,65536}$/.test(descriptorArg.protectedRefsPolicy)
67
+ || typeof descriptorArg.guardTicketId !== 'string'
68
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(descriptorArg.guardTicketId)
67
69
  ) {
68
70
  throw new Error('GitManager returned an invalid receive-pack descriptor');
69
71
  }
70
72
  } else if (
71
73
  descriptorArg.hookPath !== undefined
72
74
  || descriptorArg.protectedRefsPolicy !== undefined
75
+ || descriptorArg.guardTicketId !== undefined
73
76
  ) {
74
77
  throw new Error('GitManager returned unexpected upload-pack hook state');
75
78
  }
@@ -80,6 +83,15 @@ export class GitManagerRepositoryService {
80
83
  public readonly stdout: plugins.stream.Readable;
81
84
  public readonly stderr: plugins.stream.Readable;
82
85
  public readonly completed: Promise<IGitManagerRepositoryServiceResult>;
86
+ /**
87
+ * Receive-pack only: the guard ticket that holds the repository against
88
+ * mirror transfers (`importMirror`, `refreshMirror`, which fail with
89
+ * `repositoryBusy`) until `GitManager.releaseRepositoryService()` releases
90
+ * it or the engine stops. Release it in a `finally` once the process has
91
+ * exited and the push is reconciled. A UUID that `openRepositoryService()`
92
+ * chose. Null for upload-pack.
93
+ */
94
+ public readonly guardTicketId: string | null;
83
95
  private readonly child: plugins.childProcess.ChildProcessWithoutNullStreams;
84
96
  private completionSettled = false;
85
97
  private failure: TGitManagerRepositoryServiceFailure | null = null;
@@ -91,6 +103,7 @@ export class GitManagerRepositoryService {
91
103
  homeDirectoryArg: string,
92
104
  ) {
93
105
  validateDescriptor(descriptorArg, requestArg);
106
+ this.guardTicketId = descriptorArg.guardTicketId ?? null;
94
107
  const args = descriptorArg.service === 'upload-pack'
95
108
  ? [
96
109
  'upload-pack',
package/ts/models.ts CHANGED
@@ -79,6 +79,15 @@ export interface IGitManagerRepositoryArchiveOptions {
79
79
  signal?: AbortSignal;
80
80
  }
81
81
 
82
+ /**
83
+ * Call options of the remote transfers `importMirror`, `refreshMirror` and
84
+ * `pushRefs`. Aborting `signal` stops the transfer; the call then rejects
85
+ * with a `GitManagerError` whose code is `transferAborted`.
86
+ */
87
+ export interface IGitManagerTransferControlOptions {
88
+ signal?: AbortSignal;
89
+ }
90
+
82
91
  export interface IGitManagerRepositoryArchive
83
92
  extends IGitManagerRepositorySelector {
84
93
  objectId: string;
@@ -145,6 +154,13 @@ export interface IGitManagerArchiveInspection {
145
154
  * answer is unknown, never `false`.
146
155
  * - `archiveLimitExceeded`: the archive exceeds a requested limit.
147
156
  * - `archiveRejected`: the archive breaks a format, path, or link rule.
157
+ * - `atomicPushUnsupported`: the push remote does not advertise the `atomic`
158
+ * receive-pack capability; nothing was pushed.
159
+ * - `transferAborted`: a remote transfer was aborted through its `signal` or
160
+ * by `stop()`. An aborted push may or may not have been applied by the
161
+ * remote; read its refs again.
162
+ * - `repositoryBusy`: a mirror transfer holds the repository, or a write to
163
+ * it is in progress when a mirror transfer starts; nothing was changed.
148
164
  */
149
165
  export type TGitManagerErrorCode =
150
166
  | 'invalidObjectId'
@@ -152,7 +168,10 @@ export type TGitManagerErrorCode =
152
168
  | 'objectNotCommit'
153
169
  | 'ancestryBudgetExceeded'
154
170
  | 'archiveLimitExceeded'
155
- | 'archiveRejected';
171
+ | 'archiveRejected'
172
+ | 'atomicPushUnsupported'
173
+ | 'transferAborted'
174
+ | 'repositoryBusy';
156
175
 
157
176
  export interface IGitManagerRepositoryLfsPointer {
158
177
  oid: string;
@@ -169,6 +188,17 @@ export interface IGitManagerRepositoryServiceRequest
169
188
  protocol?: 'version=0' | 'version=1' | 'version=2';
170
189
  }
171
190
 
191
+ /**
192
+ * The engine request that prepares a repository service. A receive-pack
193
+ * carries the guard ticket ID the facade chose, so the facade can release the
194
+ * ticket even when it never receives the engine's answer.
195
+ */
196
+ export interface IRustGitManagerRepositoryServicePrepareRequest
197
+ extends IGitManagerRepositoryServiceRequest {
198
+ /** Receive-pack only, and required there: a lowercase hyphenated UUID. */
199
+ guardTicketId?: string;
200
+ }
201
+
172
202
  export interface IRustGitManagerRepositoryServiceDescriptor
173
203
  extends IGitManagerRepositoryServiceRequest {
174
204
  repositoryPath: string;
@@ -178,6 +208,8 @@ export interface IRustGitManagerRepositoryServiceDescriptor
178
208
  maxResponseBytes: number;
179
209
  processTimeoutMs: number;
180
210
  idleTimeoutSeconds: number;
211
+ /** Receive-pack only: the requested guard ticket ID, which `releaseRepositoryService` releases. */
212
+ guardTicketId?: string;
181
213
  }
182
214
 
183
215
  /**
@@ -295,6 +327,10 @@ export type TGitManagerReadinessResult =
295
327
  };
296
328
 
297
329
  export type {
330
+ IGitManagerAtomicPushRefResult,
331
+ IGitManagerAtomicPushRefUpdate,
332
+ IGitManagerAtomicPushRequest,
333
+ IGitManagerAtomicPushResult,
298
334
  IGitManagerCommitDetail,
299
335
  IGitManagerCommitRequest,
300
336
  IGitManagerCommitSummary,
@@ -317,12 +353,18 @@ export type {
317
353
  IGitManagerRefInventoryPage,
318
354
  IGitManagerRefInventoryStale,
319
355
  IGitManagerRefRequest,
356
+ IGitManagerRefreshMirrorRequest,
357
+ IGitManagerRemoteBasicCredential,
358
+ IGitManagerRemoteTransferOptions,
320
359
  IGitManagerRepositoryInfo,
321
360
  IGitManagerRepositorySelector,
361
+ IGitManagerRepositorySize,
362
+ IGitManagerSetDefaultBranchRequest,
322
363
  IGitManagerTag,
323
364
  IGitManagerTagRequest,
324
365
  IGitManagerTreeEntry,
325
366
  IGitManagerTreeRequest,
367
+ TGitManagerAtomicPushRefStatus,
326
368
  TGitManagerDiffFileStatus,
327
369
  TGitManagerDiffLineKind,
328
370
  TGitManagerMergeMethod,
@@ -330,5 +372,6 @@ export type {
330
372
  TGitManagerObjectType,
331
373
  TGitManagerRefInventoryPagination,
332
374
  TGitManagerRefInventoryResult,
375
+ TGitManagerRemoteCredential,
333
376
  TGitManagerTreeEntryType,
334
377
  } from '@foss.global/interfaces';