@git.zone/cli 2.25.2 → 3.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.
@@ -100,8 +100,7 @@ export class Meta {
100
100
  `cd ${this.cwd} && git pull origin master`,
101
101
  );
102
102
  if (gitCleanArg) {
103
- logger.log('info', `cleaning the repository from old directories`);
104
- await this.smartshellInstance.exec(`cd ${this.cwd} && git clean -fd`);
103
+ await this.cleanUntrackedWithConsent();
105
104
  }
106
105
  logger.log('info', `syncing to remote origin master`);
107
106
  await this.smartshellInstance.exec(
@@ -109,6 +108,54 @@ export class Meta {
109
108
  );
110
109
  }
111
110
 
111
+ /**
112
+ * Remove untracked files from the meta repository, but only after showing
113
+ * exactly what would go and getting a yes.
114
+ *
115
+ * This used to be an unconditional `git clean -fd` on a code path that every
116
+ * meta subcommand reaches, including `meta update`. It deletes untracked,
117
+ * non-ignored files — which is a user's uncommitted scratch work, not the
118
+ * "old directories" the log line claimed, since cloned project directories
119
+ * are normally gitignored and `-fd` does not touch ignored paths.
120
+ */
121
+ private async cleanUntrackedWithConsent(): Promise<void> {
122
+ const dryRun = await this.smartshellInstance.execSilent(
123
+ `cd ${this.cwd} && git clean -nd`,
124
+ );
125
+ if (dryRun.exitCode !== 0) {
126
+ logger.log('note', 'could not determine untracked files; skipping cleanup');
127
+ return;
128
+ }
129
+
130
+ const candidates = (dryRun.stdout || '')
131
+ .trim()
132
+ .split(/\r?\n/)
133
+ .map((line) => line.replace(/^Would remove\s+/, '').trim())
134
+ .filter(Boolean);
135
+
136
+ if (candidates.length === 0) {
137
+ return;
138
+ }
139
+
140
+ logger.log('note', `${candidates.length} untracked path(s) in the meta repository:`);
141
+ for (const candidate of candidates) {
142
+ logger.log('info', ` ${candidate}`);
143
+ }
144
+
145
+ const shouldClean =
146
+ await plugins.smartinteract.SmartInteract.getCliConfirmation(
147
+ 'Delete these untracked paths?',
148
+ false,
149
+ );
150
+ if (!shouldClean) {
151
+ logger.log('note', 'keeping untracked paths');
152
+ return;
153
+ }
154
+
155
+ logger.log('info', 'cleaning the repository from untracked paths');
156
+ await this.smartshellInstance.exec(`cd ${this.cwd} && git clean -fd`);
157
+ }
158
+
112
159
  /**
113
160
  * update the locally cloned repositories
114
161
  */
@@ -299,17 +346,45 @@ export class Meta {
299
346
  return;
300
347
  }
301
348
 
349
+ // The name comes from .meta.json, which is just a file on disk: a key of
350
+ // `../something` would otherwise resolve outside the meta repo and be
351
+ // deleted. Require the target to be a direct child of cwd.
352
+ const targetDirectory = plugins.path.resolve(paths.cwd, projectNameArg);
353
+ const expectedParent = plugins.path.resolve(paths.cwd);
354
+ if (
355
+ plugins.path.dirname(targetDirectory) !== expectedParent ||
356
+ targetDirectory === expectedParent
357
+ ) {
358
+ throw new Error(
359
+ `Refusing to remove "${projectNameArg}": it does not resolve to a direct child of ${expectedParent}`,
360
+ );
361
+ }
362
+
363
+ // Deleting a clone can destroy uncommitted or unpushed work, so it is
364
+ // confirmed rather than assumed.
365
+ const targetExists = await plugins.smartfs.directory(targetDirectory).exists();
366
+ if (targetExists) {
367
+ const shouldContinue =
368
+ await plugins.smartinteract.SmartInteract.getCliConfirmation(
369
+ `Delete directory ${targetDirectory} and everything in it?`,
370
+ false,
371
+ );
372
+ if (!shouldContinue) {
373
+ logger.log('note', 'Cancelled — nothing was removed');
374
+ return;
375
+ }
376
+ }
377
+
302
378
  delete this.metaRepoData.projects[projectNameArg];
303
379
 
304
380
  logger.log('info', 'removing project from .meta.json');
305
381
  await this.sortMetaRepoData();
306
382
  await this.writeToDisk();
307
383
 
308
- logger.log('info', 'removing directory from cwd');
309
- await plugins.smartfs
310
- .directory(plugins.path.join(paths.cwd, projectNameArg))
311
- .recursive()
312
- .delete();
384
+ if (targetExists) {
385
+ logger.log('info', 'removing directory from cwd');
386
+ await plugins.smartfs.directory(targetDirectory).recursive().delete();
387
+ }
313
388
  await this.updateLocalRepos();
314
389
  }
315
390
  }
@@ -200,18 +200,43 @@ export class GlobalRegistry {
200
200
  /**
201
201
  * Remove stale registry entries (projects that no longer exist on disk)
202
202
  */
203
- public async cleanup(): Promise<string[]> {
203
+ public async cleanup(): Promise<{ removed: string[]; kept: Array<{ projectPath: string; reason: string }> }> {
204
204
  const projects = await this.getAllProjects();
205
205
  const removed: string[] = [];
206
+ const kept: Array<{ projectPath: string; reason: string }> = [];
206
207
 
207
- for (const projectPath of Object.keys(projects)) {
208
- const exists = await plugins.smartfs.directory(projectPath).exists();
209
- if (!exists) {
210
- await this.unregisterProject(projectPath);
211
- removed.push(projectPath);
208
+ for (const [projectPath, project] of Object.entries(projects)) {
209
+ if (await plugins.smartfs.directory(projectPath).exists()) {
210
+ continue;
212
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;
222
+ }
223
+ if ((await this.docker.getStatus(containerName)) !== 'not_exists') {
224
+ survivingContainers.push(containerName);
225
+ }
226
+ }
227
+
228
+ if (survivingContainers.length > 0) {
229
+ kept.push({
230
+ projectPath,
231
+ reason: `container(s) still exist: ${survivingContainers.join(', ')}`,
232
+ });
233
+ continue;
234
+ }
235
+
236
+ await this.unregisterProject(projectPath);
237
+ removed.push(projectPath);
213
238
  }
214
239
 
215
- return removed;
240
+ return { removed, kept };
216
241
  }
217
242
  }
@@ -1162,10 +1162,17 @@ async function handleGlobalCleanup(globalRegistry: GlobalRegistry) {
1162
1162
  helpers.printHeader("Cleanup Registry (Global)");
1163
1163
 
1164
1164
  logger.log("note", "Checking for stale registry entries...");
1165
- const removed = await globalRegistry.cleanup();
1165
+ const { removed, kept } = await globalRegistry.cleanup();
1166
+
1167
+ for (const keptEntry of kept) {
1168
+ logger.log(
1169
+ "note",
1170
+ ` kept ${keptEntry.projectPath}: ${keptEntry.reason} — removing the entry would make them unidentifiable`,
1171
+ );
1172
+ }
1166
1173
 
1167
1174
  if (removed.length === 0) {
1168
- logger.log("ok", "No stale entries found");
1175
+ logger.log("ok", "No stale entries removed");
1169
1176
  return;
1170
1177
  }
1171
1178
 
@@ -26,7 +26,10 @@ const commandSummaries: ICommandHelpSummary[] = [
26
26
  { name: "tools", description: "Manage the global @git.zone toolchain" },
27
27
  { name: "template", description: "Create a project from a template" },
28
28
  { name: "open", description: "Open project assets and CI pages" },
29
- { name: "docker", description: "Run Docker-related maintenance tasks" },
29
+ {
30
+ name: "docker",
31
+ description: "Report and reclaim Docker resources created by git.zone tooling",
32
+ },
30
33
  {
31
34
  name: "deprecate",
32
35
  description: "Deprecate npm packages across registries",
@@ -245,6 +248,11 @@ async function showCommandHelp(
245
248
  modTools.showHelp(mode);
246
249
  return true;
247
250
  }
251
+ case "docker": {
252
+ const modDocker = await import("../mod_docker/index.js");
253
+ modDocker.showHelp(mode);
254
+ return true;
255
+ }
248
256
  default:
249
257
  return false;
250
258
  }
@@ -362,6 +362,26 @@ export class PackageManagerUtil {
362
362
  return results;
363
363
  }
364
364
 
365
+ // Only shims belonging to packages this tool manages may be removed.
366
+ // Pointing at a legacy root is not sufficient evidence of ownership: a
367
+ // legacy root retained precisely because it holds unmanaged packages also
368
+ // holds their shims, and deleting those silently breaks unrelated global
369
+ // CLIs that have nothing to do with @git.zone.
370
+ const managedLegacyBinNames = new Set<string>();
371
+ for (const legacyRoot of legacyRoots) {
372
+ for (const packageInfo of legacyRoot.packages) {
373
+ if (!packageInfo.packagePath) {
374
+ continue;
375
+ }
376
+ const packageJson = await readJson(
377
+ plugins.path.join(packageInfo.packagePath, "package.json"),
378
+ );
379
+ for (const binName of getPackageBinNames(packageInfo.name, packageJson)) {
380
+ managedLegacyBinNames.add(binName);
381
+ }
382
+ }
383
+ }
384
+
365
385
  for (const pnpmShimDir of pnpmShimDirs) {
366
386
  try {
367
387
  const entries = await plugins.fs.readdir(pnpmShimDir, {
@@ -372,6 +392,11 @@ export class PackageManagerUtil {
372
392
  continue;
373
393
  }
374
394
 
395
+ if (!managedLegacyBinNames.has(entry.name)) {
396
+ // Not a binary of a package this tool manages — leave it alone.
397
+ continue;
398
+ }
399
+
375
400
  const filePath = plugins.path.join(pnpmShimDir, entry.name);
376
401
  let content = "";
377
402
  try {