@git.zone/cli 3.2.0 → 4.0.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.
Files changed (126) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/classes.gitzoneconfig.js +1 -1
  3. package/dist_ts/classes.project.js +1 -1
  4. package/dist_ts/gitzone.cli.js +1 -1
  5. package/dist_ts/gitzone.logging.js +1 -1
  6. package/dist_ts/helpers.changelog.js +1 -1
  7. package/dist_ts/helpers.climode.js +1 -1
  8. package/dist_ts/helpers.smartconfig.js +61 -11
  9. package/dist_ts/helpers.smartconfigmigrations.js +1 -1
  10. package/dist_ts/helpers.tsdocker.js +1 -1
  11. package/dist_ts/helpers.workflow.js +1 -1
  12. package/dist_ts/index.js +1 -1
  13. package/dist_ts/mod_audit/index.js +1 -1
  14. package/dist_ts/mod_commit/index.js +1 -1
  15. package/dist_ts/mod_commit/mod.helpers.js +1 -1
  16. package/dist_ts/mod_commit/mod.plugins.js +1 -1
  17. package/dist_ts/mod_commit/mod.ui.js +1 -1
  18. package/dist_ts/mod_config/classes.commitconfig.js +1 -1
  19. package/dist_ts/mod_config/classes.releaseconfig.js +1 -1
  20. package/dist_ts/mod_config/index.js +2 -2
  21. package/dist_ts/mod_config/mod.plugins.js +1 -1
  22. package/dist_ts/mod_deprecate/index.js +1 -1
  23. package/dist_ts/mod_deprecate/mod.plugins.js +1 -1
  24. package/dist_ts/mod_docker/classes.dockerpruner.js +7 -1
  25. package/dist_ts/mod_docker/index.js +1 -1
  26. package/dist_ts/mod_docker/mod.plugins.js +1 -1
  27. package/dist_ts/mod_format/classes.baseformatter.js +1 -1
  28. package/dist_ts/mod_format/classes.diffreporter.js +1 -1
  29. package/dist_ts/mod_format/classes.formatcontext.js +1 -1
  30. package/dist_ts/mod_format/classes.formatplanner.js +1 -1
  31. package/dist_ts/mod_format/classes.formatstats.js +1 -1
  32. package/dist_ts/mod_format/formatters/assets.formatter.js +1 -1
  33. package/dist_ts/mod_format/formatters/cleanup.formatter.js +1 -1
  34. package/dist_ts/mod_format/formatters/copy.formatter.js +1 -1
  35. package/dist_ts/mod_format/formatters/gitignore.formatter.js +1 -1
  36. package/dist_ts/mod_format/formatters/license.formatter.js +1 -1
  37. package/dist_ts/mod_format/formatters/packagejson.formatter.js +1 -1
  38. package/dist_ts/mod_format/formatters/prettier.formatter.js +1 -1
  39. package/dist_ts/mod_format/formatters/readme.formatter.js +1 -1
  40. package/dist_ts/mod_format/formatters/smartconfig.formatter.js +1 -1
  41. package/dist_ts/mod_format/formatters/templates.formatter.js +1 -1
  42. package/dist_ts/mod_format/formatters/tsconfig.formatter.js +1 -1
  43. package/dist_ts/mod_format/index.js +1 -1
  44. package/dist_ts/mod_format/interfaces.format.js +1 -1
  45. package/dist_ts/mod_format/mod.plugins.js +1 -1
  46. package/dist_ts/mod_helpers/index.js +1 -1
  47. package/dist_ts/mod_helpers/mod.plugins.js +1 -1
  48. package/dist_ts/mod_meta/index.js +1 -1
  49. package/dist_ts/mod_meta/meta.classes.meta.js +1 -1
  50. package/dist_ts/mod_meta/meta.interfaces.js +1 -1
  51. package/dist_ts/mod_meta/meta.plugins.js +1 -1
  52. package/dist_ts/mod_open/index.js +1 -1
  53. package/dist_ts/mod_open/mod.plugins.js +1 -1
  54. package/dist_ts/mod_release/index.js +4 -4
  55. package/dist_ts/mod_release/mod.plugins.js +1 -1
  56. package/dist_ts/mod_services/classes.dockercontainer.d.ts +50 -3
  57. package/dist_ts/mod_services/classes.dockercontainer.js +646 -84
  58. package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -24
  59. package/dist_ts/mod_services/classes.globalregistry.js +210 -62
  60. package/dist_ts/mod_services/classes.globalregistrystore.d.ts +48 -0
  61. package/dist_ts/mod_services/classes.globalregistrystore.js +156 -0
  62. package/dist_ts/mod_services/classes.interprocesslock.d.ts +21 -0
  63. package/dist_ts/mod_services/classes.interprocesslock.js +141 -0
  64. package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +15 -12
  65. package/dist_ts/mod_services/classes.serviceconfiguration.js +188 -91
  66. package/dist_ts/mod_services/classes.servicedatamarker.d.ts +32 -7
  67. package/dist_ts/mod_services/classes.servicedatamarker.js +145 -49
  68. package/dist_ts/mod_services/classes.servicemanager.d.ts +70 -28
  69. package/dist_ts/mod_services/classes.servicemanager.js +995 -421
  70. package/dist_ts/mod_services/classes.servicenames.d.ts +8 -0
  71. package/dist_ts/mod_services/classes.servicenames.js +54 -0
  72. package/dist_ts/mod_services/classes.serviceoptions.js +1 -1
  73. package/dist_ts/mod_services/classes.servicepruner.d.ts +3 -0
  74. package/dist_ts/mod_services/classes.servicepruner.js +208 -46
  75. package/dist_ts/mod_services/helpers.js +1 -1
  76. package/dist_ts/mod_services/index.d.ts +2 -0
  77. package/dist_ts/mod_services/index.js +81 -58
  78. package/dist_ts/mod_services/mod.plugins.js +1 -1
  79. package/dist_ts/mod_standard/index.js +2 -2
  80. package/dist_ts/mod_standard/mod.plugins.js +1 -1
  81. package/dist_ts/mod_start/index.js +1 -1
  82. package/dist_ts/mod_start/mod.plugins.js +1 -1
  83. package/dist_ts/mod_template/index.js +1 -1
  84. package/dist_ts/mod_template/mod.plugins.js +1 -1
  85. package/dist_ts/mod_tools/classes.packagemanager.js +1 -1
  86. package/dist_ts/mod_tools/index.js +1 -1
  87. package/dist_ts/mod_tools/mod.plugins.js +1 -1
  88. package/dist_ts/paths.js +1 -1
  89. package/dist_ts/plugins.d.ts +3 -1
  90. package/dist_ts/plugins.js +4 -2
  91. package/dist_ts_migration/001.service-selection.d.ts +9 -0
  92. package/dist_ts_migration/001.service-selection.js +51 -0
  93. package/dist_ts_migration/002.runtime-config.d.ts +19 -0
  94. package/dist_ts_migration/002.runtime-config.js +170 -0
  95. package/dist_ts_migration/003.service-data-marker.d.ts +9 -0
  96. package/dist_ts_migration/003.service-data-marker.js +156 -0
  97. package/dist_ts_migration/004.global-registry.d.ts +13 -0
  98. package/dist_ts_migration/004.global-registry.js +165 -0
  99. package/dist_ts_migration/index.d.ts +11 -0
  100. package/dist_ts_migration/index.js +59 -0
  101. package/package.json +6 -3
  102. package/readme.hints.md +102 -25
  103. package/readme.md +123 -28
  104. package/readme.plan.md +29 -16
  105. package/ts/00_commitinfo_data.ts +1 -1
  106. package/ts/helpers.smartconfig.ts +63 -10
  107. package/ts/mod_config/index.ts +1 -1
  108. package/ts/mod_docker/classes.dockerpruner.ts +6 -0
  109. package/ts/mod_release/index.ts +3 -3
  110. package/ts/mod_services/classes.dockercontainer.ts +912 -96
  111. package/ts/mod_services/classes.globalregistry.ts +290 -89
  112. package/ts/mod_services/classes.globalregistrystore.ts +279 -0
  113. package/ts/mod_services/classes.interprocesslock.ts +189 -0
  114. package/ts/mod_services/classes.serviceconfiguration.ts +215 -108
  115. package/ts/mod_services/classes.servicedatamarker.ts +210 -52
  116. package/ts/mod_services/classes.servicemanager.ts +1332 -512
  117. package/ts/mod_services/classes.servicenames.ts +76 -0
  118. package/ts/mod_services/classes.servicepruner.ts +310 -52
  119. package/ts/mod_services/index.ts +89 -62
  120. package/ts/mod_standard/index.ts +1 -1
  121. package/ts/plugins.ts +4 -0
  122. package/ts_migration/001.service-selection.ts +77 -0
  123. package/ts_migration/002.runtime-config.ts +218 -0
  124. package/ts_migration/003.service-data-marker.ts +216 -0
  125. package/ts_migration/004.global-registry.ts +272 -0
  126. package/ts_migration/index.ts +83 -0
@@ -1,39 +1,40 @@
1
1
  import * as plugins from '../plugins.js';
2
- import { DockerContainer } from './classes.dockercontainer.js';
2
+ import { DockerContainer, type IContainerInspectInfo } from './classes.dockercontainer.js';
3
3
  import { logger } from '../gitzone.logging.js';
4
+ import {
5
+ getServiceContainerLabels,
6
+ getServiceDataDirectory,
7
+ hasExactServiceContainerLabels,
8
+ serviceNames,
9
+ type TServiceName,
10
+ } from './classes.servicedatamarker.js';
11
+ import { withServiceDataOperationLock } from './classes.interprocesslock.js';
12
+ import {
13
+ GlobalRegistryStore,
14
+ assertCanonicalGlobalRegistryData,
15
+ type IGlobalRegistryData,
16
+ type IRegisteredProject,
17
+ } from './classes.globalregistrystore.js';
4
18
 
5
- export interface IRegisteredProject {
6
- projectPath: string;
7
- projectName: string;
8
- containers: {
9
- mongo?: string;
10
- minio?: string;
11
- elasticsearch?: string;
12
- };
13
- ports: {
14
- mongo?: number;
15
- s3?: number;
16
- s3Console?: number;
17
- elasticsearch?: number;
18
- };
19
- enabledServices: string[];
20
- lastActive: number;
21
- }
19
+ export { assertCanonicalGlobalRegistryData } from './classes.globalregistrystore.js';
20
+ export type {
21
+ IGlobalRegistryData,
22
+ IRegisteredProject,
23
+ } from './classes.globalregistrystore.js';
22
24
 
23
- export interface IGlobalRegistryData {
24
- projects: { [projectPath: string]: IRegisteredProject };
25
- }
25
+ const registryServiceNames: Record<string, TServiceName> = {
26
+ mongo: 'mongodb',
27
+ objectstorage: 'objectstorage',
28
+ elasticsearch: 'elasticsearch',
29
+ };
26
30
 
27
31
  export class GlobalRegistry {
28
32
  private static instance: GlobalRegistry | null = null;
29
- private kvStore: plugins.smartconfig.KeyValueStore<IGlobalRegistryData>;
33
+ private store: GlobalRegistryStore;
30
34
  private docker: DockerContainer;
31
35
 
32
- private constructor() {
33
- this.kvStore = new plugins.smartconfig.KeyValueStore({
34
- typeArg: 'userHomeDir',
35
- identityArg: 'gitzone-services',
36
- });
36
+ private constructor(storeArg: GlobalRegistryStore = new GlobalRegistryStore()) {
37
+ this.store = storeArg;
37
38
  this.docker = new DockerContainer();
38
39
  }
39
40
 
@@ -51,49 +52,84 @@ export class GlobalRegistry {
51
52
  * Register or update a project in the global registry
52
53
  */
53
54
  public async registerProject(data: Omit<IRegisteredProject, 'lastActive'>): Promise<void> {
54
- const allData = await this.kvStore.readAll();
55
- const projects = allData.projects || {};
56
-
57
- projects[data.projectPath] = {
58
- ...data,
59
- lastActive: Date.now(),
60
- };
61
-
62
- await this.kvStore.writeKey('projects', projects);
55
+ const projectPath = plugins.path.resolve(data.projectPath);
56
+ await this.store.transact((rawData) => {
57
+ const allData = assertCanonicalGlobalRegistryData(rawData);
58
+ const projects = { ...allData.projects };
59
+ const existingLegacy = projects[projectPath]?.legacy;
60
+ projects[projectPath] = {
61
+ ...data,
62
+ projectPath,
63
+ ...(existingLegacy ? { legacy: existingLegacy } : {}),
64
+ lastActive: Date.now(),
65
+ };
66
+ return {
67
+ result: undefined,
68
+ writeData: { schemaVersion: 2, projects },
69
+ };
70
+ });
63
71
  }
64
72
 
65
73
  /**
66
74
  * Remove a project from the registry
67
75
  */
68
- public async unregisterProject(projectPath: string): Promise<void> {
69
- const allData = await this.kvStore.readAll();
70
- const projects = allData.projects || {};
71
-
72
- if (projects[projectPath]) {
76
+ public async unregisterProject(
77
+ projectPathArg: string,
78
+ expectedProjectArg: IRegisteredProject,
79
+ ): Promise<void> {
80
+ const projectPath = plugins.path.resolve(projectPathArg);
81
+ await this.store.transact((rawData) => {
82
+ const allData = assertCanonicalGlobalRegistryData(rawData);
83
+ const projects = { ...allData.projects };
84
+ const currentProject = projects[projectPath];
85
+ if (!currentProject) {
86
+ return { result: undefined };
87
+ }
88
+ if (JSON.stringify(currentProject) !== JSON.stringify(expectedProjectArg)) {
89
+ throw new Error(
90
+ `Refusing to unregister changed global registry entry: ${projectPath}`,
91
+ );
92
+ }
73
93
  delete projects[projectPath];
74
- await this.kvStore.writeKey('projects', projects);
75
- }
94
+ return {
95
+ result: undefined,
96
+ writeData: { schemaVersion: 2, projects },
97
+ };
98
+ });
76
99
  }
77
100
 
78
101
  /**
79
102
  * Update the lastActive timestamp for a project
80
103
  */
81
104
  public async touchProject(projectPath: string): Promise<void> {
82
- const allData = await this.kvStore.readAll();
83
- const projects = allData.projects || {};
84
-
85
- if (projects[projectPath]) {
86
- projects[projectPath].lastActive = Date.now();
87
- await this.kvStore.writeKey('projects', projects);
88
- }
105
+ const resolvedProjectPath = plugins.path.resolve(projectPath);
106
+ await this.store.transact((rawData) => {
107
+ const allData = assertCanonicalGlobalRegistryData(rawData);
108
+ const projects = { ...allData.projects };
109
+ const existing = projects[resolvedProjectPath];
110
+ if (!existing) {
111
+ return { result: undefined };
112
+ }
113
+ projects[resolvedProjectPath] = {
114
+ ...existing,
115
+ lastActive: Date.now(),
116
+ };
117
+ return {
118
+ result: undefined,
119
+ writeData: { schemaVersion: 2, projects },
120
+ };
121
+ });
89
122
  }
90
123
 
91
124
  /**
92
125
  * Get all registered projects
93
126
  */
94
127
  public async getAllProjects(): Promise<{ [path: string]: IRegisteredProject }> {
95
- const allData = await this.kvStore.readAll();
96
- return allData.projects || {};
128
+ return (await this.readCanonicalData()).projects;
129
+ }
130
+
131
+ private async readCanonicalData(): Promise<IGlobalRegistryData> {
132
+ return this.store.readCanonical();
97
133
  }
98
134
 
99
135
  /**
@@ -101,7 +137,7 @@ export class GlobalRegistry {
101
137
  */
102
138
  public async isRegistered(projectPath: string): Promise<boolean> {
103
139
  const projects = await this.getAllProjects();
104
- return !!projects[projectPath];
140
+ return !!projects[plugins.path.resolve(projectPath)];
105
141
  }
106
142
 
107
143
  /**
@@ -111,7 +147,11 @@ export class GlobalRegistry {
111
147
  Array<{
112
148
  projectPath: string;
113
149
  projectName: string;
114
- containers: Array<{ name: string; status: string }>;
150
+ containers: Array<{
151
+ name: string;
152
+ status: string;
153
+ ownership?: 'owned' | 'conflict';
154
+ }>;
115
155
  lastActive: number;
116
156
  }>
117
157
  > {
@@ -119,15 +159,48 @@ export class GlobalRegistry {
119
159
  const result: Array<{
120
160
  projectPath: string;
121
161
  projectName: string;
122
- containers: Array<{ name: string; status: string }>;
162
+ containers: Array<{
163
+ name: string;
164
+ status: string;
165
+ ownership?: 'owned' | 'conflict';
166
+ }>;
123
167
  lastActive: number;
124
168
  }> = [];
125
169
 
126
170
  for (const [path, project] of Object.entries(projects)) {
127
- const containerStatuses: Array<{ name: string; status: string }> = [];
171
+ const containerStatuses: Array<{
172
+ name: string;
173
+ status: string;
174
+ ownership?: 'owned' | 'conflict';
175
+ }> = [];
128
176
 
129
- for (const containerName of Object.values(project.containers)) {
177
+ for (const [serviceKey, containerName] of Object.entries(project.containers)) {
130
178
  if (containerName) {
179
+ if (serviceKey === 'objectstorage') {
180
+ const objectStorage = await this.inspectRegisteredContainer(
181
+ project,
182
+ containerName,
183
+ );
184
+ if (!objectStorage) {
185
+ containerStatuses.push({ name: containerName, status: 'not_exists' });
186
+ continue;
187
+ }
188
+ const expectedLabels = getServiceContainerLabels({
189
+ projectPath: project.projectPath,
190
+ service: 'objectstorage',
191
+ dataPath: getServiceDataDirectory(project.projectPath, 'objectstorage'),
192
+ });
193
+ const owned = hasExactServiceContainerLabels(
194
+ objectStorage.labels,
195
+ expectedLabels,
196
+ );
197
+ containerStatuses.push({
198
+ name: containerName,
199
+ status: objectStorage.state,
200
+ ownership: owned ? 'owned' : 'conflict',
201
+ });
202
+ continue;
203
+ }
131
204
  const status = await this.docker.getStatus(containerName);
132
205
  containerStatuses.push({ name: containerName, status });
133
206
  }
@@ -153,13 +226,75 @@ export class GlobalRegistry {
153
226
  const failed: string[] = [];
154
227
 
155
228
  for (const project of Object.values(projects)) {
156
- for (const containerName of Object.values(project.containers)) {
229
+ for (const [serviceKey, containerName] of Object.entries(project.containers)) {
157
230
  if (containerName) {
158
- const status = await this.docker.getStatus(containerName);
159
- if (status === 'running') {
160
- if (await this.docker.stop(containerName)) {
161
- stopped.push(containerName);
162
- } else {
231
+ const service = registryServiceNames[serviceKey];
232
+ if (!service) {
233
+ failed.push(containerName);
234
+ continue;
235
+ }
236
+ try {
237
+ await withServiceDataOperationLock(project.projectPath, service, async () => {
238
+ const currentProjects = await this.getAllProjects();
239
+ const claims: Array<{ projectPath: string; service: TServiceName }> = [];
240
+ for (const [registeredPathArg, registeredProjectArg] of Object.entries(
241
+ currentProjects,
242
+ )) {
243
+ for (const [registeredServiceKeyArg, registeredNameArg] of Object.entries(
244
+ registeredProjectArg.containers,
245
+ )) {
246
+ const registeredService = registryServiceNames[registeredServiceKeyArg];
247
+ if (registeredService && registeredNameArg === containerName) {
248
+ claims.push({
249
+ projectPath: plugins.path.resolve(registeredPathArg),
250
+ service: registeredService,
251
+ });
252
+ }
253
+ }
254
+ }
255
+ const projectPath = plugins.path.resolve(project.projectPath);
256
+ const currentProject = Object.entries(currentProjects).find(
257
+ ([registeredPathArg]) =>
258
+ plugins.path.resolve(registeredPathArg) === projectPath,
259
+ )?.[1];
260
+ if (!currentProject || currentProject.containers[serviceKey] !== containerName) {
261
+ throw new Error('Registry container claim changed before stop');
262
+ }
263
+ const inspection = await this.inspectRegisteredContainer(
264
+ currentProject,
265
+ containerName,
266
+ );
267
+ if (!inspection) {
268
+ return;
269
+ }
270
+ const expectedLabels = getServiceContainerLabels({
271
+ projectPath,
272
+ service,
273
+ dataPath: getServiceDataDirectory(projectPath, service),
274
+ });
275
+ const labelOwned = hasExactServiceContainerLabels(
276
+ inspection.labels,
277
+ expectedLabels,
278
+ );
279
+ const hasGitZoneLabels = Object.keys(inspection.labels).some((keyArg) =>
280
+ keyArg.startsWith('git.zone.'),
281
+ );
282
+ const registryOwned =
283
+ service !== 'objectstorage' &&
284
+ !hasGitZoneLabels &&
285
+ claims.length === 1 &&
286
+ claims[0].projectPath === projectPath &&
287
+ claims[0].service === service;
288
+ if (!labelOwned && !registryOwned) {
289
+ throw new Error('Container ownership conflict');
290
+ }
291
+ if (inspection.running) {
292
+ await this.docker.execArgv(['stop', inspection.id], { timeoutMs: 60_000 });
293
+ stopped.push(containerName);
294
+ }
295
+ });
296
+ } catch {
297
+ if (!failed.includes(containerName)) {
163
298
  failed.push(containerName);
164
299
  }
165
300
  }
@@ -170,6 +305,41 @@ export class GlobalRegistry {
170
305
  return { stopped, failed };
171
306
  }
172
307
 
308
+ private async inspectRegisteredContainer(
309
+ projectArg: IRegisteredProject,
310
+ containerNameArg: string,
311
+ ): Promise<IContainerInspectInfo | undefined> {
312
+ const ids = await this.docker.listIds([`name=^/${containerNameArg}$`]);
313
+ if (ids.length === 0) {
314
+ return undefined;
315
+ }
316
+ const inspections = (await this.docker.inspectMany(ids)).filter(
317
+ (inspection) => inspection.name === containerNameArg,
318
+ );
319
+ if (inspections.length !== 1) {
320
+ throw new Error(
321
+ `Docker returned ambiguous ObjectStorage identity for ${projectArg.projectPath}.`,
322
+ );
323
+ }
324
+ return inspections[0];
325
+ }
326
+
327
+ private async exactContainerExists(containerNameArg: string): Promise<boolean> {
328
+ const ids = await this.docker.listIds([`name=^/${containerNameArg}$`]);
329
+ if (ids.length === 0) {
330
+ return false;
331
+ }
332
+ const inspections = (await this.docker.inspectMany(ids)).filter(
333
+ (inspection) => inspection.name === containerNameArg,
334
+ );
335
+ if (inspections.length !== 1) {
336
+ throw new Error(
337
+ `Docker returned ambiguous container identity for ${containerNameArg}.`,
338
+ );
339
+ }
340
+ return true;
341
+ }
342
+
173
343
  /**
174
344
  * Cheap staleness summary for a non-blocking hint after ordinary commands.
175
345
  *
@@ -205,38 +375,69 @@ export class GlobalRegistry {
205
375
  const removed: string[] = [];
206
376
  const kept: Array<{ projectPath: string; reason: string }> = [];
207
377
 
208
- for (const [projectPath, project] of Object.entries(projects)) {
209
- if (await plugins.smartfs.directory(projectPath).exists()) {
210
- continue;
211
- }
212
-
213
- // Same rule ServicePruner enforces: for containers created before labels
214
- // existed, this registry entry is the only thing that can still identify
215
- // them. Dropping it while one exists would hide the container from prune,
216
- // `stop -g` and `cleanup -g` alike, leaving it running forever under
217
- // `restart: unless-stopped`.
218
- const survivingContainers: string[] = [];
219
- for (const containerName of Object.values(project.containers)) {
220
- if (!containerName) {
221
- continue;
378
+ for (const projectPath of Object.keys(projects)) {
379
+ await this.withProjectServiceLocks(projectPath, async () => {
380
+ if (await plugins.smartfs.directory(projectPath).exists()) {
381
+ return;
222
382
  }
223
- if ((await this.docker.getStatus(containerName)) !== 'not_exists') {
224
- survivingContainers.push(containerName);
383
+ const currentProject = Object.entries(await this.getAllProjects()).find(
384
+ ([registeredPathArg]) =>
385
+ plugins.path.resolve(registeredPathArg) === plugins.path.resolve(projectPath),
386
+ )?.[1];
387
+ if (!currentProject) {
388
+ return;
389
+ }
390
+ if (currentProject.legacy?.minioContainer) {
391
+ kept.push({
392
+ projectPath,
393
+ reason: `preserved legacy MinIO container reference: ${currentProject.legacy.minioContainer}`,
394
+ });
395
+ return;
225
396
  }
226
- }
227
397
 
228
- if (survivingContainers.length > 0) {
229
- kept.push({
230
- projectPath,
231
- reason: `container(s) still exist: ${survivingContainers.join(', ')}`,
232
- });
233
- continue;
234
- }
398
+ // Same rule ServicePruner enforces: for containers created before labels
399
+ // existed, this registry entry is the only thing that can still identify
400
+ // them. Re-read under all service locks so a concurrent start cannot
401
+ // appear between the Docker check and compare-delete.
402
+ const survivingContainers: string[] = [];
403
+ for (const containerName of Object.values(currentProject.containers)) {
404
+ if (containerName && (await this.exactContainerExists(containerName))) {
405
+ survivingContainers.push(containerName);
406
+ }
407
+ }
408
+ const labeledIds = await this.docker.listIds([
409
+ `label=git.zone.project-path=${plugins.path.resolve(projectPath)}`,
410
+ ]);
411
+ if (labeledIds.length > 0) {
412
+ survivingContainers.push('project-labeled container');
413
+ }
414
+ if (survivingContainers.length > 0) {
415
+ kept.push({
416
+ projectPath,
417
+ reason: `container(s) still exist: ${survivingContainers.join(', ')}`,
418
+ });
419
+ return;
420
+ }
235
421
 
236
- await this.unregisterProject(projectPath);
237
- removed.push(projectPath);
422
+ await this.unregisterProject(projectPath, currentProject);
423
+ removed.push(projectPath);
424
+ });
238
425
  }
239
426
 
240
427
  return { removed, kept };
241
428
  }
429
+
430
+ private async withProjectServiceLocks<T>(
431
+ projectPathArg: string,
432
+ actionArg: () => Promise<T>,
433
+ serviceIndexArg = 0,
434
+ ): Promise<T> {
435
+ const service = serviceNames[serviceIndexArg];
436
+ if (!service) {
437
+ return actionArg();
438
+ }
439
+ return withServiceDataOperationLock(projectPathArg, service, () =>
440
+ this.withProjectServiceLocks(projectPathArg, actionArg, serviceIndexArg + 1),
441
+ );
442
+ }
242
443
  }