@foss.global/gitmanager 2.3.0 → 2.5.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,
@@ -32,6 +34,7 @@ import type {
32
34
  IGitManagerRepositorySelector,
33
35
  IGitManagerRepositorySize,
34
36
  IGitManagerRestoreBundleRequest,
37
+ IGitManagerSetDefaultBranchRequest,
35
38
  IGitManagerSmartHttpRepositoryAccess,
36
39
  IGitManagerSmartHttpRepositoryVisibility,
37
40
  IGitManagerStatus,
@@ -39,6 +42,7 @@ import type {
39
42
  IGitManagerTagRequest,
40
43
  IGitManagerTreeEntry,
41
44
  IGitManagerTreeRequest,
45
+ IGitManagerTransferControlOptions,
42
46
  IGitManagerOptions,
43
47
  IGitManagerReadinessOptions,
44
48
  IRustGitManagerConfig,
@@ -87,6 +91,32 @@ const assertRemoteTransferOptions = (optionsArg: IGitManagerRemoteTransferOption
87
91
  }
88
92
  };
89
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
+ };
119
+
90
120
  type TGitManagerReadinessAbortSource = 'caller' | 'deadline';
91
121
  type TGitManagerReadinessProbeOutcome = 'ready' | 'aborted' | 'unavailable';
92
122
 
@@ -377,21 +407,90 @@ export class GitManager extends plugins.EventEmitter {
377
407
  }
378
408
 
379
409
  /**
380
- * Clones every ref of `remoteUrl` into a new mirror repository. Pass a
381
- * credential as `credential`, never inside the URL: the engine hands it to
382
- * Git only through the process environment and removes it from errors.
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.
383
444
  */
384
- public async importMirror(requestArg: IGitManagerImportMirrorRequest): Promise<IGitManagerRepositoryInfo> {
445
+ public async refreshMirror(
446
+ requestArg: IGitManagerRefreshMirrorRequest,
447
+ optionsArg: IGitManagerTransferControlOptions = {},
448
+ ): Promise<IGitManagerRepositoryInfo> {
385
449
  this.assertRunning();
386
450
  assertRemoteTransferOptions(requestArg);
387
- return this.bridge.importMirror(requestArg);
451
+ return this.bridge.refreshMirror(requestArg, optionsArg);
388
452
  }
389
453
 
390
- /** Fetches every ref of an imported mirror's remote again, with the same transfer options as `importMirror`. */
391
- public async refreshMirror(requestArg: IGitManagerRefreshMirrorRequest): Promise<IGitManagerRepositoryInfo> {
454
+ /**
455
+ * Pushes leased ref updates to `remoteUrl` as one atomic transaction: the
456
+ * remote applies all of them or none, however many there are. The remote
457
+ * must advertise the `atomic` receive-pack capability; otherwise a
458
+ * `GitManagerError` with code `atomicPushUnsupported` is thrown and nothing
459
+ * is pushed. A remote that refuses the push request as too large (HTTP 413)
460
+ * throws `remoteLimitExceeded`, and nothing is pushed. `remoteUrl` must
461
+ * be `https`, or `http` to a loopback IP address (`127.0.0.0/8`, `[::1]`;
462
+ * never a host name such as `localhost`), without user information; the
463
+ * credential travels as for `importMirror`. A ref that already holds its
464
+ * `newObjectId` is reported `ok` without its lease being checked; it is not
465
+ * changed, so `ok` never means the lease matched. Any refusal the remote
466
+ * reports per ref returns status `rejected`, with the refused refs `stale`
467
+ * or `rejected` and the up-to-date ones `ok`; the call throws only when the
468
+ * remote's answer does not say what it did.
469
+ *
470
+ * The push only reads the managed repository, so it runs alongside other
471
+ * commands and other transfers. Aborting `optionsArg.signal`, or `stop()`,
472
+ * rejects with `transferAborted`. An abort before the updates are sent (a
473
+ * signal already aborted, or an abort during the local object check or the
474
+ * remote ref listing) changes nothing on the remote. An abort after that
475
+ * terminates the push, and the remote then holds all of the updates or
476
+ * none of them, unknown which: read the remote refs again before retrying.
477
+ */
478
+ public async pushRefs(
479
+ requestArg: IGitManagerAtomicPushRequest,
480
+ optionsArg: IGitManagerTransferControlOptions = {},
481
+ ): Promise<IGitManagerAtomicPushResult> {
392
482
  this.assertRunning();
393
483
  assertRemoteTransferOptions(requestArg);
394
- return this.bridge.refreshMirror(requestArg);
484
+ assertAtomicPushUpdates(requestArg.updates);
485
+ return this.bridge.pushRefs(requestArg, optionsArg);
486
+ }
487
+
488
+ /** Points the repository's HEAD at an existing branch; a missing branch is refused and HEAD stays unchanged. */
489
+ public async setDefaultBranch(
490
+ requestArg: IGitManagerSetDefaultBranchRequest,
491
+ ): Promise<IGitManagerRepositoryInfo> {
492
+ this.assertRunning();
493
+ return this.bridge.setDefaultBranch(requestArg);
395
494
  }
396
495
 
397
496
  /** Counts the repository's objects and their exact Git bytes. */
@@ -420,29 +519,64 @@ export class GitManager extends plugins.EventEmitter {
420
519
  return this.bridge.createRepositoryArchive(requestArg, optionsArg);
421
520
  }
422
521
 
522
+ /**
523
+ * Starts a bounded `git upload-pack` or protected `git receive-pack` for an
524
+ * authenticated ingress such as SSH.
525
+ *
526
+ * A receive-pack holds its repository against mirror transfers: while its
527
+ * `guardTicketId` is held, `importMirror` and `refreshMirror` of the
528
+ * repository fail with `repositoryBusy`, and a receive-pack is refused with
529
+ * `repositoryBusy` while a mirror transfer runs. Release the ticket with
530
+ * `releaseRepositoryService()` in a `finally` once the process has exited
531
+ * and the push is reconciled; `stop()` releases every ticket. Upload-pack
532
+ * takes no ticket.
533
+ *
534
+ * The ticket ID is chosen here, before the engine is asked. When opening a
535
+ * receive-pack fails, including a bridge timeout after the engine already
536
+ * issued the ticket, the ticket is released before the error is thrown. If
537
+ * that release fails as well, the original error is still thrown: the
538
+ * release error becomes its `cause` when it has none, and otherwise both
539
+ * are thrown together as an `AggregateError` (`errors`: the original error,
540
+ * then the release error; `cause`: the original error).
541
+ */
423
542
  public async openRepositoryService(
424
543
  requestArg: IGitManagerRepositoryServiceRequest,
425
544
  ): Promise<GitManagerRepositoryService> {
426
545
  this.assertRunning();
427
546
  const serviceEpoch = this.lifecycleEpoch;
428
- const descriptor = await this.bridge.prepareRepositoryService({
429
- org: requestArg.org,
430
- repo: requestArg.repo,
431
- service: requestArg.service,
432
- ...(requestArg.protocol ? { protocol: requestArg.protocol } : {}),
433
- });
434
- if (
435
- this.lifecycleEpoch !== serviceEpoch
436
- || this.stopRequested
437
- || !this.running
438
- ) {
439
- throw new Error('GitManager stopped while preparing repository service');
547
+ const guardTicketId = requestArg.service === 'receive-pack'
548
+ ? plugins.crypto.randomUUID()
549
+ : undefined;
550
+ let service: GitManagerRepositoryService;
551
+ try {
552
+ const descriptor = await this.bridge.prepareRepositoryService({
553
+ org: requestArg.org,
554
+ repo: requestArg.repo,
555
+ service: requestArg.service,
556
+ ...(requestArg.protocol ? { protocol: requestArg.protocol } : {}),
557
+ ...(guardTicketId === undefined ? {} : { guardTicketId }),
558
+ });
559
+ if (
560
+ this.lifecycleEpoch !== serviceEpoch
561
+ || this.stopRequested
562
+ || !this.running
563
+ ) {
564
+ throw new Error('GitManager stopped while preparing repository service');
565
+ }
566
+ if (descriptor.guardTicketId !== guardTicketId) {
567
+ throw new Error('GitManager returned an invalid repository service descriptor');
568
+ }
569
+ service = new GitManagerRepositoryService(
570
+ descriptor,
571
+ requestArg,
572
+ this.config.dataDir,
573
+ );
574
+ } catch (error) {
575
+ if (guardTicketId === undefined) {
576
+ throw error;
577
+ }
578
+ throw await this.releaseUnusedTicket(guardTicketId, error);
440
579
  }
441
- const service = new GitManagerRepositoryService(
442
- descriptor,
443
- requestArg,
444
- this.config.dataDir,
445
- );
446
580
  this.repositoryServices.add(service);
447
581
  service.completed.finally(() => {
448
582
  this.repositoryServices.delete(service);
@@ -450,6 +584,46 @@ export class GitManager extends plugins.EventEmitter {
450
584
  return service;
451
585
  }
452
586
 
587
+ /**
588
+ * Releases the guard ticket of a receive-pack that failed to open and
589
+ * returns the error to throw: `errorArg`, carrying a failed release as its
590
+ * `cause` when it has none, or else an `AggregateError` of both.
591
+ */
592
+ private async releaseUnusedTicket(ticketIdArg: string, errorArg: unknown): Promise<unknown> {
593
+ try {
594
+ await this.releaseRepositoryService(ticketIdArg);
595
+ return errorArg;
596
+ } catch (releaseError) {
597
+ if (errorArg instanceof Error && errorArg.cause === undefined) {
598
+ errorArg.cause = releaseError;
599
+ return errorArg;
600
+ }
601
+ return new AggregateError(
602
+ [errorArg, releaseError],
603
+ 'Opening the repository service failed, and releasing its guard ticket failed',
604
+ { cause: errorArg },
605
+ );
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Releases the guard ticket of a receive-pack opened with
611
+ * `openRepositoryService()` (`service.guardTicketId`), so mirror transfers
612
+ * of its repository are admitted again. Call it in a `finally` after the
613
+ * Git process has exited. Releasing is idempotent: an unknown or already
614
+ * released ticket, or a stopped engine, whose tickets ended with it, is a
615
+ * no-op.
616
+ */
617
+ public async releaseRepositoryService(ticketIdArg: string): Promise<void> {
618
+ if (typeof ticketIdArg !== 'string') {
619
+ throw new TypeError('ticketId must be a string');
620
+ }
621
+ if (!this.bridge.running) {
622
+ return;
623
+ }
624
+ await this.bridge.releaseRepositoryService(ticketIdArg);
625
+ }
626
+
453
627
  public async restoreRepositoryBundle(
454
628
  requestArg: IGitManagerRestoreBundleRequest,
455
629
  ): Promise<IGitManagerRepositoryInfo> {
@@ -7,6 +7,10 @@ const gitManagerErrorCodes: ReadonlySet<string> = new Set<TGitManagerErrorCode>(
7
7
  'ancestryBudgetExceeded',
8
8
  'archiveLimitExceeded',
9
9
  'archiveRejected',
10
+ 'atomicPushUnsupported',
11
+ 'remoteLimitExceeded',
12
+ 'transferAborted',
13
+ 'repositoryBusy',
10
14
  ]);
11
15
 
12
16
  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,15 @@ 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
+ * - `remoteLimitExceeded`: the push remote refused the push request as too
160
+ * large (HTTP 413); nothing was pushed.
161
+ * - `transferAborted`: a remote transfer was aborted through its `signal` or
162
+ * by `stop()`. An aborted push may or may not have been applied by the
163
+ * remote; read its refs again.
164
+ * - `repositoryBusy`: a mirror transfer holds the repository, or a write to
165
+ * it is in progress when a mirror transfer starts; nothing was changed.
148
166
  */
149
167
  export type TGitManagerErrorCode =
150
168
  | 'invalidObjectId'
@@ -152,7 +170,11 @@ export type TGitManagerErrorCode =
152
170
  | 'objectNotCommit'
153
171
  | 'ancestryBudgetExceeded'
154
172
  | 'archiveLimitExceeded'
155
- | 'archiveRejected';
173
+ | 'archiveRejected'
174
+ | 'atomicPushUnsupported'
175
+ | 'remoteLimitExceeded'
176
+ | 'transferAborted'
177
+ | 'repositoryBusy';
156
178
 
157
179
  export interface IGitManagerRepositoryLfsPointer {
158
180
  oid: string;
@@ -169,6 +191,17 @@ export interface IGitManagerRepositoryServiceRequest
169
191
  protocol?: 'version=0' | 'version=1' | 'version=2';
170
192
  }
171
193
 
194
+ /**
195
+ * The engine request that prepares a repository service. A receive-pack
196
+ * carries the guard ticket ID the facade chose, so the facade can release the
197
+ * ticket even when it never receives the engine's answer.
198
+ */
199
+ export interface IRustGitManagerRepositoryServicePrepareRequest
200
+ extends IGitManagerRepositoryServiceRequest {
201
+ /** Receive-pack only, and required there: a lowercase hyphenated UUID. */
202
+ guardTicketId?: string;
203
+ }
204
+
172
205
  export interface IRustGitManagerRepositoryServiceDescriptor
173
206
  extends IGitManagerRepositoryServiceRequest {
174
207
  repositoryPath: string;
@@ -178,6 +211,8 @@ export interface IRustGitManagerRepositoryServiceDescriptor
178
211
  maxResponseBytes: number;
179
212
  processTimeoutMs: number;
180
213
  idleTimeoutSeconds: number;
214
+ /** Receive-pack only: the requested guard ticket ID, which `releaseRepositoryService` releases. */
215
+ guardTicketId?: string;
181
216
  }
182
217
 
183
218
  /**
@@ -295,6 +330,10 @@ export type TGitManagerReadinessResult =
295
330
  };
296
331
 
297
332
  export type {
333
+ IGitManagerAtomicPushRefResult,
334
+ IGitManagerAtomicPushRefUpdate,
335
+ IGitManagerAtomicPushRequest,
336
+ IGitManagerAtomicPushResult,
298
337
  IGitManagerCommitDetail,
299
338
  IGitManagerCommitRequest,
300
339
  IGitManagerCommitSummary,
@@ -323,10 +362,12 @@ export type {
323
362
  IGitManagerRepositoryInfo,
324
363
  IGitManagerRepositorySelector,
325
364
  IGitManagerRepositorySize,
365
+ IGitManagerSetDefaultBranchRequest,
326
366
  IGitManagerTag,
327
367
  IGitManagerTagRequest,
328
368
  IGitManagerTreeEntry,
329
369
  IGitManagerTreeRequest,
370
+ TGitManagerAtomicPushRefStatus,
330
371
  TGitManagerDiffFileStatus,
331
372
  TGitManagerDiffLineKind,
332
373
  TGitManagerMergeMethod,
@@ -2,6 +2,8 @@ import * as plugins from './plugins.js';
2
2
  import { GitManagerError, isGitManagerErrorCode } from './classes.gitmanagererror.js';
3
3
  import type {
4
4
  IGitManagerAncestryRequest,
5
+ IGitManagerAtomicPushRequest,
6
+ IGitManagerAtomicPushResult,
5
7
  IGitManagerCommitDetail,
6
8
  IGitManagerCommitRequest,
7
9
  IGitManagerCommitSummary,
@@ -24,11 +26,11 @@ import type {
24
26
  IGitManagerRepositoryArchiveOptions,
25
27
  IGitManagerRepositoryArchiveRequest,
26
28
  IGitManagerRepositoryLfsPointer,
27
- IGitManagerRepositoryServiceRequest,
28
29
  IGitManagerRepositoryInfo,
29
30
  IGitManagerRepositorySelector,
30
31
  IGitManagerRepositorySize,
31
32
  IGitManagerRestoreBundleRequest,
33
+ IGitManagerSetDefaultBranchRequest,
32
34
  IGitManagerSmartHttpRepositoryAccess,
33
35
  IGitManagerSmartHttpRepositoryVisibility,
34
36
  IGitManagerStatus,
@@ -36,9 +38,11 @@ import type {
36
38
  IGitManagerTagRequest,
37
39
  IGitManagerTreeEntry,
38
40
  IGitManagerTreeRequest,
41
+ IGitManagerTransferControlOptions,
39
42
  IRustGitManagerConfig,
40
43
  TGitManagerRefInventoryResult,
41
44
  IRustGitManagerRepositoryServiceDescriptor,
45
+ IRustGitManagerRepositoryServicePrepareRequest,
42
46
  } from './models.js';
43
47
 
44
48
  type TRepositoryArchiveCancelStatus =
@@ -56,6 +60,14 @@ interface IRepositoryArchiveCancelRequest {
56
60
  operationId: string;
57
61
  }
58
62
 
63
+ /** A remote transfer under the operation ID that `cancelTransfer` names. */
64
+ interface ITransferOperationRequest<TRequest> {
65
+ operationId: string;
66
+ request: TRequest;
67
+ }
68
+
69
+ type TTransferCancelStatus = 'cancelling' | 'not_found';
70
+
59
71
  type TGitManagerCommands = {
60
72
  start: { params: { config: IRustGitManagerConfig }; result: Record<string, never> };
61
73
  stop: { params: Record<string, never>; result: Record<string, never> };
@@ -64,14 +76,27 @@ type TGitManagerCommands = {
64
76
  setSmartHttpRepositoryVisibility: { params: IGitManagerSmartHttpRepositoryVisibility; result: Record<string, never> };
65
77
  setSmartHttpRepositoryProtectedRefs: { params: IGitManagerSmartHttpRepositoryProtectedRefs; result: IGitManagerProtectedRefsApplyResult };
66
78
  initBareRepository: { params: IGitManagerInitBareRepositoryRequest; result: IGitManagerRepositoryInfo };
67
- importMirror: { params: IGitManagerImportMirrorRequest; result: IGitManagerRepositoryInfo };
68
- refreshMirror: { params: IGitManagerRefreshMirrorRequest; result: IGitManagerRepositoryInfo };
79
+ importMirror: {
80
+ params: ITransferOperationRequest<IGitManagerImportMirrorRequest>;
81
+ result: IGitManagerRepositoryInfo;
82
+ };
83
+ refreshMirror: {
84
+ params: ITransferOperationRequest<IGitManagerRefreshMirrorRequest>;
85
+ result: IGitManagerRepositoryInfo;
86
+ };
87
+ pushRefs: {
88
+ params: ITransferOperationRequest<IGitManagerAtomicPushRequest>;
89
+ result: IGitManagerAtomicPushResult;
90
+ };
91
+ cancelTransfer: { params: { operationId: string }; result: { status: TTransferCancelStatus } };
92
+ setDefaultBranch: { params: IGitManagerSetDefaultBranchRequest; result: IGitManagerRepositoryInfo };
69
93
  verifyRepository: { params: IGitManagerRepositorySelector; result: IGitManagerRepositoryInfo };
70
94
  getRepositorySize: { params: IGitManagerRepositorySelector; result: IGitManagerRepositorySize };
71
95
  createRepositoryBundle: { params: IGitManagerRepositorySelector; result: IGitManagerRepositoryBundle };
72
96
  createRepositoryArchive: { params: IRepositoryArchiveOperationRequest; result: IGitManagerRepositoryArchive };
73
97
  cancelRepositoryArchive: { params: IRepositoryArchiveCancelRequest; result: { status: TRepositoryArchiveCancelStatus } };
74
- prepareRepositoryService: { params: IGitManagerRepositoryServiceRequest; result: IRustGitManagerRepositoryServiceDescriptor };
98
+ prepareRepositoryService: { params: IRustGitManagerRepositoryServicePrepareRequest; result: IRustGitManagerRepositoryServiceDescriptor };
99
+ releaseRepositoryService: { params: { ticketId: string }; result: { released: boolean } };
75
100
  restoreRepositoryBundle: { params: IGitManagerRestoreBundleRequest; result: IGitManagerRepositoryInfo };
76
101
  listRepositories: { params: Record<string, never>; result: IGitManagerRepositoryInfo[] };
77
102
  listRefs: { params: IGitManagerRefRequest; result: TGitManagerRefInventoryResult };
@@ -97,6 +122,9 @@ const defaultRemoteTransferTimeoutMs = 120_000;
97
122
  const remoteTransferResponseTimeoutMs = (timeoutMsArg: number | undefined): number =>
98
123
  (timeoutMsArg ?? defaultRemoteTransferTimeoutMs) + 30_000;
99
124
 
125
+ /** The cancel command is answered at once; the transfer reports its own end. */
126
+ const transferCancelTimeoutMs = 30_000;
127
+
100
128
  function getPackageRoot(): string {
101
129
  const thisDir = plugins.path.dirname(plugins.url.fileURLToPath(import.meta.url));
102
130
  return plugins.path.resolve(thisDir, '..');
@@ -227,19 +255,72 @@ export class RustGitManagerBridge extends plugins.EventEmitter {
227
255
  }
228
256
 
229
257
  public async initBareRepository(requestArg: IGitManagerInitBareRepositoryRequest): Promise<IGitManagerRepositoryInfo> {
230
- return this.bridge.sendCommand('initBareRepository', requestArg);
258
+ return this.bridge.sendCommand('initBareRepository', requestArg).catch(rethrowCodedError);
231
259
  }
232
260
 
233
- public async importMirror(requestArg: IGitManagerImportMirrorRequest): Promise<IGitManagerRepositoryInfo> {
234
- return this.bridge.sendCommand('importMirror', requestArg, {
235
- timeoutMs: remoteTransferResponseTimeoutMs(requestArg.timeoutMs),
236
- });
261
+ public async importMirror(
262
+ requestArg: IGitManagerImportMirrorRequest,
263
+ optionsArg: IGitManagerTransferControlOptions = {},
264
+ ): Promise<IGitManagerRepositoryInfo> {
265
+ return this.runTransfer('importMirror', requestArg, optionsArg);
237
266
  }
238
267
 
239
- public async refreshMirror(requestArg: IGitManagerRefreshMirrorRequest): Promise<IGitManagerRepositoryInfo> {
240
- return this.bridge.sendCommand('refreshMirror', requestArg, {
268
+ public async refreshMirror(
269
+ requestArg: IGitManagerRefreshMirrorRequest,
270
+ optionsArg: IGitManagerTransferControlOptions = {},
271
+ ): Promise<IGitManagerRepositoryInfo> {
272
+ return this.runTransfer('refreshMirror', requestArg, optionsArg);
273
+ }
274
+
275
+ public async pushRefs(
276
+ requestArg: IGitManagerAtomicPushRequest,
277
+ optionsArg: IGitManagerTransferControlOptions = {},
278
+ ): Promise<IGitManagerAtomicPushResult> {
279
+ return this.runTransfer('pushRefs', requestArg, optionsArg);
280
+ }
281
+
282
+ /**
283
+ * Runs a remote transfer under a fresh operation ID. Aborting the signal
284
+ * sends `cancelTransfer`; the engine then terminates the transfer's Git
285
+ * process and answers the transfer itself with `transferAborted`. A signal
286
+ * that is already aborted rejects before anything is sent.
287
+ */
288
+ private async runTransfer<TMethod extends 'importMirror' | 'refreshMirror' | 'pushRefs'>(
289
+ methodArg: TMethod,
290
+ requestArg: TGitManagerCommands[TMethod]['params']['request'],
291
+ optionsArg: IGitManagerTransferControlOptions,
292
+ ): Promise<TGitManagerCommands[TMethod]['result']> {
293
+ const signal = optionsArg.signal;
294
+ if (signal?.aborted) {
295
+ throw new GitManagerError('transferAborted', 'The transfer was aborted before it started', {
296
+ cause: signal.reason,
297
+ });
298
+ }
299
+ const operationId = plugins.crypto.randomUUID();
300
+ const params = { operationId, request: requestArg } as TGitManagerCommands[TMethod]['params'];
301
+ const transfer = this.bridge.sendCommand(methodArg, params, {
241
302
  timeoutMs: remoteTransferResponseTimeoutMs(requestArg.timeoutMs),
242
303
  });
304
+ const cancelTransfer = (): void => {
305
+ // Only the transfer's own response settles the call.
306
+ void this.bridge.sendCommand('cancelTransfer', { operationId }, {
307
+ timeoutMs: transferCancelTimeoutMs,
308
+ }).catch(() => undefined);
309
+ };
310
+ signal?.addEventListener('abort', cancelTransfer, { once: true });
311
+ try {
312
+ return await transfer;
313
+ } catch (error) {
314
+ return rethrowCodedError(error);
315
+ } finally {
316
+ signal?.removeEventListener('abort', cancelTransfer);
317
+ }
318
+ }
319
+
320
+ public async setDefaultBranch(
321
+ requestArg: IGitManagerSetDefaultBranchRequest,
322
+ ): Promise<IGitManagerRepositoryInfo> {
323
+ return this.bridge.sendCommand('setDefaultBranch', requestArg).catch(rethrowCodedError);
243
324
  }
244
325
 
245
326
  public async getRepositorySize(requestArg: IGitManagerRepositorySelector): Promise<IGitManagerRepositorySize> {
@@ -320,15 +401,20 @@ export class RustGitManagerBridge extends plugins.EventEmitter {
320
401
  }
321
402
 
322
403
  public async prepareRepositoryService(
323
- requestArg: IGitManagerRepositoryServiceRequest,
404
+ requestArg: IRustGitManagerRepositoryServicePrepareRequest,
324
405
  ): Promise<IRustGitManagerRepositoryServiceDescriptor> {
325
- return this.bridge.sendCommand('prepareRepositoryService', requestArg);
406
+ return this.bridge.sendCommand('prepareRepositoryService', requestArg).catch(rethrowCodedError);
407
+ }
408
+
409
+ /** Releases a receive-pack guard ticket; unknown or released tickets are a no-op. */
410
+ public async releaseRepositoryService(ticketIdArg: string): Promise<void> {
411
+ await this.bridge.sendCommand('releaseRepositoryService', { ticketId: ticketIdArg });
326
412
  }
327
413
 
328
414
  public async restoreRepositoryBundle(
329
415
  requestArg: IGitManagerRestoreBundleRequest,
330
416
  ): Promise<IGitManagerRepositoryInfo> {
331
- return this.bridge.sendCommand('restoreRepositoryBundle', requestArg);
417
+ return this.bridge.sendCommand('restoreRepositoryBundle', requestArg).catch(rethrowCodedError);
332
418
  }
333
419
 
334
420
  public async listRepositories(): Promise<IGitManagerRepositoryInfo[]> {
@@ -350,7 +436,7 @@ export class RustGitManagerBridge extends plugins.EventEmitter {
350
436
  public async mergeRepository(
351
437
  requestArg: IGitManagerMergeRequest,
352
438
  ): Promise<IGitManagerMergeResult> {
353
- return this.bridge.sendCommand('mergeRepository', requestArg);
439
+ return this.bridge.sendCommand('mergeRepository', requestArg).catch(rethrowCodedError);
354
440
  }
355
441
 
356
442
  public async listTree(requestArg: IGitManagerTreeRequest): Promise<IGitManagerTreeEntry[]> {