@savvy-web/silk-effects 5.8.1 → 5.9.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.
@@ -338,13 +338,10 @@ function applyEffect(root, dryRun, changelogModules, inspector, fs) {
338
338
  version: fresh
339
339
  } : p;
340
340
  });
341
- versionFileUpdates = yield* Effect.try({
342
- try: () => scopes.length > 0 ? VersionFiles.processResolvedVersionFiles(scopes, dryRun) : [],
343
- catch: (e) => new ReleasePlanError({
344
- phase: "apply",
345
- reason: errMsg(e)
346
- })
347
- });
341
+ versionFileUpdates = scopes.length > 0 ? yield* VersionFiles.processResolvedVersionFiles(scopes, dryRun).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.mapError((e) => new ReleasePlanError({
342
+ phase: "apply",
343
+ reason: errMsg(e)
344
+ }))) : [];
348
345
  }
349
346
  return {
350
347
  dryRun,
@@ -1,7 +1,7 @@
1
+ import { VersionFileError } from "../errors.js";
1
2
  import { LegacyVersionFilesSchema } from "../schemas/version-files.js";
2
3
  import { jsonPathGet, jsonPathResolve, parseJsonPath } from "./jsonpath.js";
3
- import { Effect, Path, Schema } from "effect";
4
- import { readFileSync, writeFileSync } from "node:fs";
4
+ import { Effect, FileSystem, Path, Schema } from "effect";
5
5
  import { join, relative, resolve } from "node:path";
6
6
  import { GlobPatternOptions } from "@effected/glob";
7
7
  import { compileAndExpand } from "@effected/walker";
@@ -102,34 +102,32 @@ var VersionFiles = class VersionFiles {
102
102
  * @returns Array of workspace packages with versions
103
103
  */
104
104
  static discoverVersions(cwd, packages) {
105
- const resolvedCwd = resolve(cwd);
106
- const results = [];
107
- const seen = /* @__PURE__ */ new Set();
108
- for (const pkg of packages) {
109
- if (seen.has(pkg.path) || !pkg.version) continue;
110
- seen.add(pkg.path);
111
- results.push({
112
- name: pkg.name,
113
- path: pkg.path,
114
- version: pkg.version
115
- });
116
- }
117
- if (!seen.has(resolvedCwd)) {
118
- const version = readPackageVersion(resolvedCwd);
119
- if (version) {
120
- let rootName = "root";
121
- try {
122
- const pkg = JSON.parse(readFileSync(join(resolvedCwd, "package.json"), "utf-8"));
123
- if (pkg.name) rootName = pkg.name;
124
- } catch {}
105
+ return Effect.gen(function* () {
106
+ const resolvedCwd = resolve(cwd);
107
+ const results = [];
108
+ const seen = /* @__PURE__ */ new Set();
109
+ for (const pkg of packages) {
110
+ if (seen.has(pkg.path) || !pkg.version) continue;
111
+ seen.add(pkg.path);
125
112
  results.push({
126
- name: rootName,
127
- path: resolvedCwd,
128
- version
113
+ name: pkg.name,
114
+ path: pkg.path,
115
+ version: pkg.version
129
116
  });
130
117
  }
131
- }
132
- return results;
118
+ if (!seen.has(resolvedCwd)) {
119
+ const version = yield* readPackageVersion(resolvedCwd);
120
+ if (version) {
121
+ const rootName = yield* readPackageName(resolvedCwd);
122
+ results.push({
123
+ name: rootName,
124
+ path: resolvedCwd,
125
+ version
126
+ });
127
+ }
128
+ }
129
+ return results;
130
+ });
133
131
  }
134
132
  /**
135
133
  * Determine which workspace version applies to a given file path
@@ -224,16 +222,19 @@ var VersionFiles = class VersionFiles {
224
222
  * @see {@link VersionFiles.applyVersionEdit} for the per-path edit
225
223
  */
226
224
  static updateFile(filePath, jsonPaths, version) {
227
- const original = readFileSync(filePath, "utf-8");
228
- const { content, previousValues, totalChanged } = VersionFiles.computeUpdate(original, jsonPaths, version);
229
- if (totalChanged === 0) return;
230
- writeFileSync(filePath, content, "utf-8");
231
- return {
232
- filePath,
233
- jsonPaths,
234
- version,
235
- previousValues
236
- };
225
+ return Effect.gen(function* () {
226
+ const fs = yield* FileSystem.FileSystem;
227
+ const original = yield* fs.readFileString(filePath);
228
+ const { content, previousValues, totalChanged } = VersionFiles.computeUpdate(original, jsonPaths, version);
229
+ if (totalChanged === 0) return;
230
+ yield* fs.writeFileString(filePath, content);
231
+ return {
232
+ filePath,
233
+ jsonPaths,
234
+ version,
235
+ previousValues
236
+ };
237
+ });
237
238
  }
238
239
  /**
239
240
  * Compute the full update for a document without touching the filesystem:
@@ -330,30 +331,15 @@ var VersionFiles = class VersionFiles {
330
331
  */
331
332
  static processVersionFiles(cwd, configs, dryRun = false, packages = []) {
332
333
  return Effect.gen(function* () {
333
- const workspaces = VersionFiles.discoverVersions(cwd, packages);
334
+ const workspaces = yield* VersionFiles.discoverVersions(cwd, packages);
334
335
  const rootVersion = workspaces.find((ws) => ws.path === resolve(cwd))?.version ?? "0.0.0";
335
336
  const resolved = yield* VersionFiles.resolveGlobs(configs, cwd);
336
337
  const updates = [];
337
338
  for (const [filePath, config] of resolved) {
338
339
  const jsonPaths = config.paths ?? ["$.version"];
339
340
  const version = config.package ? workspaces.find((ws) => ws.name === config.package)?.version ?? rootVersion : VersionFiles.resolveVersion(filePath, workspaces, rootVersion);
340
- try {
341
- if (dryRun) {
342
- const content = readFileSync(filePath, "utf-8");
343
- const { previousValues, totalChanged } = VersionFiles.computeUpdate(content, jsonPaths, version);
344
- if (totalChanged > 0) updates.push({
345
- filePath,
346
- jsonPaths,
347
- version,
348
- previousValues
349
- });
350
- } else {
351
- const result = VersionFiles.updateFile(filePath, jsonPaths, version);
352
- if (result) updates.push(result);
353
- }
354
- } catch (error) {
355
- throw new Error(`Failed to update ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
356
- }
341
+ const result = yield* VersionFiles.applyOne(filePath, jsonPaths, version, dryRun).pipe(Effect.orDie);
342
+ if (result) updates.push(result);
357
343
  }
358
344
  return updates;
359
345
  });
@@ -380,28 +366,61 @@ var VersionFiles = class VersionFiles {
380
366
  * @internal
381
367
  */
382
368
  static processResolvedVersionFiles(scopes, dryRun = false) {
383
- const updates = [];
384
- for (const scope of scopes) for (const vf of scope.versionFiles) {
385
- const jsonPaths = vf.paths.length > 0 ? vf.paths : ["$.version"];
386
- for (const filePath of vf.matchedFiles) try {
387
- if (dryRun) {
388
- const content = readFileSync(filePath, "utf-8");
389
- const { previousValues, totalChanged } = VersionFiles.computeUpdate(content, jsonPaths, scope.version);
390
- if (totalChanged > 0) updates.push({
391
- filePath,
392
- jsonPaths,
393
- version: scope.version,
394
- previousValues
395
- });
396
- } else {
397
- const result = VersionFiles.updateFile(filePath, jsonPaths, scope.version);
369
+ return Effect.gen(function* () {
370
+ const updates = [];
371
+ for (const scope of scopes) for (const vf of scope.versionFiles) {
372
+ const jsonPaths = vf.paths.length > 0 ? vf.paths : ["$.version"];
373
+ for (const filePath of vf.matchedFiles) {
374
+ const result = yield* VersionFiles.applyOne(filePath, jsonPaths, scope.version, dryRun);
398
375
  if (result) updates.push(result);
399
376
  }
400
- } catch (error) {
401
- throw new Error(`Failed to update ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
402
377
  }
378
+ return updates;
379
+ });
380
+ }
381
+ /**
382
+ * Apply one version file's update, shared by both process entry points.
383
+ *
384
+ * @remarks
385
+ * Fails TYPED with `VersionFileError`, carrying the offending `filePath`. The two callers then
386
+ * choose their own posture, which is not the same and must not be unified:
387
+ * {@link VersionFiles.processVersionFiles} (legacy) turns it into a DEFECT,
388
+ * matching the synchronous throw it had under `node:fs`, while
389
+ * {@link VersionFiles.processResolvedVersionFiles} leaves it typed because
390
+ * `ReleasePlanner.apply` converts it to a `ReleasePlanError` — a defect there
391
+ * would bypass the inspector's catch and crash `apply()`.
392
+ *
393
+ * @internal
394
+ */
395
+ static applyOne(filePath, jsonPaths, version, dryRun) {
396
+ return Effect.gen(function* () {
397
+ if (dryRun) {
398
+ const content = yield* (yield* FileSystem.FileSystem).readFileString(filePath);
399
+ const { previousValues, totalChanged } = VersionFiles.computeUpdate(content, jsonPaths, version);
400
+ return totalChanged > 0 ? {
401
+ filePath,
402
+ jsonPaths,
403
+ version,
404
+ previousValues
405
+ } : void 0;
406
+ }
407
+ return yield* VersionFiles.updateFile(filePath, jsonPaths, version);
408
+ }).pipe(Effect.mapError((error) => new VersionFileError({
409
+ filePath,
410
+ reason: VersionFiles.describeFailure(error)
411
+ })), Effect.catchDefect((defect) => Effect.fail(new VersionFileError({
412
+ filePath,
413
+ reason: VersionFiles.describeFailure(defect)
414
+ }))));
415
+ }
416
+ /** Render a `PlatformError` the way the old `node:fs` catch rendered an `Error`. */
417
+ static describeFailure(error) {
418
+ if (typeof error === "object" && error !== null) {
419
+ const e = error;
420
+ if (typeof e.message === "string") return e.message;
421
+ if (typeof e.reason === "string") return e.reason;
403
422
  }
404
- return updates;
423
+ return String(error);
405
424
  }
406
425
  };
407
426
  /**
@@ -450,12 +469,32 @@ function expandGlob(source, cwd) {
450
469
  *
451
470
  * @internal
452
471
  */
472
+ /**
473
+ * Read the `name` field from a `package.json` in the given directory, falling
474
+ * back to `"root"` when it is absent, unreadable, or malformed.
475
+ *
476
+ * @param dir - Absolute path to the directory containing `package.json`
477
+ * @returns The package name, or `"root"`
478
+ *
479
+ * @internal
480
+ */
481
+ function readPackageName(dir) {
482
+ return Effect.gen(function* () {
483
+ const raw = yield* (yield* FileSystem.FileSystem).readFileString(join(dir, "package.json"));
484
+ return yield* Effect.try({
485
+ try: () => JSON.parse(raw).name,
486
+ catch: (e) => e
487
+ });
488
+ }).pipe(Effect.map((name) => name ?? "root"), Effect.orElseSucceed(() => "root"));
489
+ }
453
490
  function readPackageVersion(dir) {
454
- try {
455
- return JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")).version;
456
- } catch {
457
- return;
458
- }
491
+ return Effect.gen(function* () {
492
+ const raw = yield* (yield* FileSystem.FileSystem).readFileString(join(dir, "package.json"));
493
+ return yield* Effect.try({
494
+ try: () => JSON.parse(raw).version,
495
+ catch: (e) => e
496
+ });
497
+ }).pipe(Effect.orElseSucceed(() => void 0));
459
498
  }
460
499
 
461
500
  //#endregion
package/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
4
4
  import { PackageManagerDetector, PublishConfig, PublishTarget, PublishabilityDetector, VersioningStrategy, WorkspaceDiscovery, WorkspaceDiscoveryFailure, WorkspacePackage, WorkspaceSnapshotAtFailure, WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, WorkspaceStateSnapshot, WorkspacesOptions } from "@effected/workspaces";
5
5
  import { Git } from "@effected/git";
6
6
  import { GlobExpansionError } from "@effected/walker";
7
+ import * as PlatformError from "effect/PlatformError";
7
8
  import { YamlFormattingOptions } from "@effected/yaml";
8
9
  import { Section, SectionId } from "@effected/templates";
9
10
  import { ToolDiscovery } from "@effected/commands";
@@ -4808,7 +4809,7 @@ declare class VersionFiles {
4808
4809
  name: string;
4809
4810
  version: string;
4810
4811
  path: string;
4811
- }>): WorkspaceVersion[];
4812
+ }>): Effect.Effect<WorkspaceVersion[], never, FileSystem.FileSystem>;
4812
4813
  /**
4813
4814
  * Determine which workspace version applies to a given file path
4814
4815
  * using longest-prefix matching.
@@ -4883,7 +4884,7 @@ declare class VersionFiles {
4883
4884
  * @see {@link jsonPathResolve} for concrete-path enumeration
4884
4885
  * @see {@link VersionFiles.applyVersionEdit} for the per-path edit
4885
4886
  */
4886
- static updateFile(filePath: string, jsonPaths: readonly string[], version: string): VersionFileUpdate | undefined;
4887
+ static updateFile(filePath: string, jsonPaths: readonly string[], version: string): Effect.Effect<VersionFileUpdate | undefined, PlatformError.PlatformError, FileSystem.FileSystem>;
4887
4888
  /**
4888
4889
  * Compute the full update for a document without touching the filesystem:
4889
4890
  * the edited content, the previous values at every matched path, and how
@@ -4965,7 +4966,24 @@ declare class VersionFiles {
4965
4966
  *
4966
4967
  * @internal
4967
4968
  */
4968
- static processResolvedVersionFiles(scopes: ReadonlyArray<ResolvedPackageScope>, dryRun?: boolean): VersionFileUpdate[];
4969
+ static processResolvedVersionFiles(scopes: ReadonlyArray<ResolvedPackageScope>, dryRun?: boolean): Effect.Effect<VersionFileUpdate[], VersionFileError, FileSystem.FileSystem>;
4970
+ /**
4971
+ * Apply one version file's update, shared by both process entry points.
4972
+ *
4973
+ * @remarks
4974
+ * Fails TYPED with `VersionFileError`, carrying the offending `filePath`. The two callers then
4975
+ * choose their own posture, which is not the same and must not be unified:
4976
+ * {@link VersionFiles.processVersionFiles} (legacy) turns it into a DEFECT,
4977
+ * matching the synchronous throw it had under `node:fs`, while
4978
+ * {@link VersionFiles.processResolvedVersionFiles} leaves it typed because
4979
+ * `ReleasePlanner.apply` converts it to a `ReleasePlanError` — a defect there
4980
+ * would bypass the inspector's catch and crash `apply()`.
4981
+ *
4982
+ * @internal
4983
+ */
4984
+ private static applyOne;
4985
+ /** Render a `PlatformError` the way the old `node:fs` catch rendered an `Error`. */
4986
+ private static describeFailure;
4969
4987
  }
4970
4988
  //#endregion
4971
4989
  //#region ../../node_modules/.pnpm/micromark-util-types@2.0.2/node_modules/micromark-util-types/index.d.ts
@@ -9188,6 +9206,25 @@ declare const ReposRestoreResult: Schema.Struct<{
9188
9206
  }>;
9189
9207
  /** @public */
9190
9208
  type ReposRestoreResult = typeof ReposRestoreResult.Type;
9209
+ /**
9210
+ * Result of clearing a stale submodule registration from the superproject's
9211
+ * LOCAL git config.
9212
+ *
9213
+ * @remarks
9214
+ * `section` is the registration name the section was cleared under (e.g.
9215
+ * `.repos/effect-old`), and `removedKeys` lists the full config keys the
9216
+ * removed section actually carried — the achieved-state report, read from the
9217
+ * config BEFORE the removal so a caller can see exactly what is gone. There
9218
+ * is no `commitMessage`: the local config is unversioned, so a deregister
9219
+ * stages nothing and leaves nothing to commit.
9220
+ * @public
9221
+ */
9222
+ declare const ReposDeregisterResult: Schema.Struct<{
9223
+ readonly section: Schema.String;
9224
+ readonly removedKeys: Schema.$Array<Schema.String>;
9225
+ }>;
9226
+ /** @public */
9227
+ type ReposDeregisterResult = typeof ReposDeregisterResult.Type;
9191
9228
  /**
9192
9229
  * Result of an agent-note mutation against a vendored repo.
9193
9230
  * @public
@@ -9233,6 +9270,24 @@ declare class ReposConfigStore extends ReposConfigStore_base {
9233
9270
  }
9234
9271
  //#endregion
9235
9272
  //#region src/repos/services/drift.d.ts
9273
+ /**
9274
+ * Extracts the submodule NAME from a `submodule.<name>.<property>` config key.
9275
+ *
9276
+ * @remarks
9277
+ * A submodule's name is routinely a PATH (`git submodule add` derives the
9278
+ * section name from the path), so the name itself contains dots — which means
9279
+ * the key cannot be split on `.` and read positionally. Strip the fixed
9280
+ * `submodule.` prefix and the final `.<property>` segment instead; everything
9281
+ * between is the name, dots and all. Returns `undefined` for any key that is
9282
+ * not a `submodule.*.*` key.
9283
+ *
9284
+ * Shared with `ReposManager.deregister`, which must attribute config keys to
9285
+ * a registration name the exact same way this check does — two parsers here
9286
+ * would let the report and the remedy disagree about what a section is named.
9287
+ *
9288
+ * @internal
9289
+ */
9290
+ declare const submoduleNameFromKey: (key: string) => string | undefined;
9236
9291
  /**
9237
9292
  * The {@link ReposDrift} service shape.
9238
9293
  * @public
@@ -9424,6 +9479,31 @@ interface ReposManagerShape {
9424
9479
  */
9425
9480
  readonly rename: (root: string, oldName: string, newName: string) => Effect.Effect<ReposRenameResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
9426
9481
  readonly restore: (root: string, names?: ReadonlyArray<string>) => Effect.Effect<ReposRestoreResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
9482
+ /**
9483
+ * Clears a STALE submodule registration — a `submodule.<section>.*`
9484
+ * section in the superproject's LOCAL git config left behind by a rename
9485
+ * or an unvendoring, which `git submodule status` then lists as a phantom
9486
+ * entry the manifest has never heard of (`ReposDrift` reports it as
9487
+ * `localRegistrationDivergence`).
9488
+ *
9489
+ * `section` is the registration name exactly as the drift report states it
9490
+ * (e.g. `.repos/effect-old`) — the `submodule.` prefix is implied, never
9491
+ * passed. Refuses (typed `ReposConfigError`): a section outside
9492
+ * `.repos/` (this machinery does not own it — it may be a real submodule
9493
+ * of the host repo), the canonical registration of a live manifest entry,
9494
+ * and a registration whose module gitdir still backs a live entry under a
9495
+ * DIVERGED name (the same gitdir attribution `ReposDrift` uses — that
9496
+ * state's remedy is a re-vendor, not a deregister) — UNLESS that entry's
9497
+ * canonical section is also registered, in which case the old-named twin
9498
+ * is a genuine orphan and removing it is a crashed-rename recovery's own
9499
+ * final step. Reconciling a LIVE
9500
+ * registration is `sync`'s job and unvendoring is `remove`'s, so
9501
+ * deregistration only ever touches state nothing else owns. Touches only
9502
+ * the superproject's local config — no vendored worktree — so unlike the
9503
+ * other mutating ops it needs no lockdown bracket and stages nothing to
9504
+ * commit.
9505
+ */
9506
+ readonly deregister: (root: string, section: string) => Effect.Effect<ReposDeregisterResult, ReposConfigError | GitSubmoduleError>;
9427
9507
  }
9428
9508
  declare const ReposManager_base: Context.ServiceClass<ReposManager, "@savvy-web/silk-effects/ReposManager", ReposManagerShape>;
9429
9509
  /**
@@ -9431,8 +9511,10 @@ declare const ReposManager_base: Context.ServiceClass<ReposManager, "@savvy-web/
9431
9511
  * (presence, dirtiness, stale notes), reconciles the working tree with the
9432
9512
  * manifest, vendors new entries (`add`), re-pins existing entries to a new
9433
9513
  * ref (`pin`), adds/removes/promotes agent notes (`note`), unvendors
9434
- * (`remove`), renames (`rename`), and explicitly hard-resets dirty
9435
- * checkouts back to their pinned commit (`restore`).
9514
+ * (`remove`), renames (`rename`), explicitly hard-resets dirty
9515
+ * checkouts back to their pinned commit (`restore`), and clears stale
9516
+ * submodule registrations from the superproject's local git config
9517
+ * (`deregister`).
9436
9518
  * @public
9437
9519
  */
9438
9520
  declare class ReposManager extends ReposManager_base {
@@ -9450,7 +9532,7 @@ declare class ReposManager extends ReposManager_base {
9450
9532
  static readonly layer: Layer.Layer<ReposManager, never, ReposConfigStore | Git | FileSystem.FileSystem | Path.Path | ReposLockdown>;
9451
9533
  }
9452
9534
  declare namespace index_d_exports$4 {
9453
- export { DriftKind, GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NOTE_LIMIT, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoDrift, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposConfigStoreShape, ReposDrift, ReposDriftReport, ReposDriftShape, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposLockdownShape, ReposManager, ReposManagerShape, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, resolveModuleDir };
9535
+ export { DriftKind, GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NOTE_LIMIT, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoDrift, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposConfigStoreShape, ReposDeregisterResult, ReposDrift, ReposDriftReport, ReposDriftShape, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposLockdownShape, ReposManager, ReposManagerShape, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, resolveModuleDir, submoduleNameFromKey };
9454
9536
  }
9455
9537
  //#endregion
9456
9538
  //#region src/schemas/BiomeConfig.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/silk-effects",
3
- "version": "5.8.1",
3
+ "version": "5.9.1",
4
4
  "private": false,
5
5
  "description": "Shared Effect library for Silk Suite conventions",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/silk-effects",
@@ -40,8 +40,8 @@
40
40
  "@effected/package-json": "^0.9.0",
41
41
  "@effected/templates": "^0.2.0",
42
42
  "@effected/walker": "^0.4.0",
43
- "@effected/workspaces": "^0.13.0",
44
- "@effected/yaml": "^0.8.0",
43
+ "@effected/workspaces": "^0.13.1",
44
+ "@effected/yaml": "^0.9.0",
45
45
  "@manypkg/get-packages": "^3.1.0",
46
46
  "mdast-util-heading-range": "^4.0.0",
47
47
  "mdast-util-to-string": "^4.0.0",
package/repos/index.js CHANGED
@@ -3,10 +3,10 @@ import { MANIFEST_PATH, REPOS_DIR } from "./constants.js";
3
3
  import { GitSubmoduleError, GitSubmoduleErrorBase, NoteNotFoundError, NoteNotFoundErrorBase, RepoNotFoundError, RepoNotFoundErrorBase, ReposConfigError, ReposConfigErrorBase, ReposLockdownError, ReposLockdownErrorBase } from "./errors.js";
4
4
  import { DriftKind, RepoDrift, ReposDriftReport } from "./schemas/drift.js";
5
5
  import { RepoEntry, RepoName, RepoNote, RepoOrientation, ReposManifestFile } from "./schemas/manifest.js";
6
- import { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport } from "./schemas/reports.js";
6
+ import { RepoStatusEntry, ReposAddResult, ReposDeregisterResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport } from "./schemas/reports.js";
7
7
  import { ReposConfigStore } from "./services/config-store.js";
8
8
  import { ReposLockdown, resolveModuleDir } from "./services/lockdown.js";
9
- import { ReposDrift } from "./services/drift.js";
9
+ import { ReposDrift, submoduleNameFromKey } from "./services/drift.js";
10
10
  import { ReposManager, STALE_LOCK_MAX_AGE_MS } from "./services/manager.js";
11
11
 
12
12
  //#region src/repos/index.ts
@@ -31,6 +31,7 @@ var repos_exports = /* @__PURE__ */ __exportAll({
31
31
  ReposConfigError: () => ReposConfigError,
32
32
  ReposConfigErrorBase: () => ReposConfigErrorBase,
33
33
  ReposConfigStore: () => ReposConfigStore,
34
+ ReposDeregisterResult: () => ReposDeregisterResult,
34
35
  ReposDrift: () => ReposDrift,
35
36
  ReposDriftReport: () => ReposDriftReport,
36
37
  ReposLockdown: () => ReposLockdown,
@@ -46,8 +47,9 @@ var repos_exports = /* @__PURE__ */ __exportAll({
46
47
  ReposStatusReport: () => ReposStatusReport,
47
48
  ReposSyncReport: () => ReposSyncReport,
48
49
  STALE_LOCK_MAX_AGE_MS: () => STALE_LOCK_MAX_AGE_MS,
49
- resolveModuleDir: () => resolveModuleDir
50
+ resolveModuleDir: () => resolveModuleDir,
51
+ submoduleNameFromKey: () => submoduleNameFromKey
50
52
  });
51
53
 
52
54
  //#endregion
53
- export { DriftKind, GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoDrift, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposDrift, ReposDriftReport, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposManager, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, repos_exports, resolveModuleDir };
55
+ export { DriftKind, GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoDrift, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposDeregisterResult, ReposDrift, ReposDriftReport, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposManager, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, repos_exports, resolveModuleDir, submoduleNameFromKey };
@@ -150,6 +150,23 @@ const ReposRestoreResult = Schema.Struct({
150
150
  stillDirty: Schema.Array(Schema.String)
151
151
  });
152
152
  /**
153
+ * Result of clearing a stale submodule registration from the superproject's
154
+ * LOCAL git config.
155
+ *
156
+ * @remarks
157
+ * `section` is the registration name the section was cleared under (e.g.
158
+ * `.repos/effect-old`), and `removedKeys` lists the full config keys the
159
+ * removed section actually carried — the achieved-state report, read from the
160
+ * config BEFORE the removal so a caller can see exactly what is gone. There
161
+ * is no `commitMessage`: the local config is unversioned, so a deregister
162
+ * stages nothing and leaves nothing to commit.
163
+ * @public
164
+ */
165
+ const ReposDeregisterResult = Schema.Struct({
166
+ section: Schema.String,
167
+ removedKeys: Schema.Array(Schema.String)
168
+ });
169
+ /**
153
170
  * Result of an agent-note mutation against a vendored repo.
154
171
  * @public
155
172
  */
@@ -165,4 +182,4 @@ const ReposNoteResult = Schema.Struct({
165
182
  });
166
183
 
167
184
  //#endregion
168
- export { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport };
185
+ export { RepoStatusEntry, ReposAddResult, ReposDeregisterResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport };
@@ -17,6 +17,12 @@ import { Git, Gitmodules } from "@effected/git";
17
17
  * `submodule.` prefix and the final `.<property>` segment instead; everything
18
18
  * between is the name, dots and all. Returns `undefined` for any key that is
19
19
  * not a `submodule.*.*` key.
20
+ *
21
+ * Shared with `ReposManager.deregister`, which must attribute config keys to
22
+ * a registration name the exact same way this check does — two parsers here
23
+ * would let the report and the remedy disagree about what a section is named.
24
+ *
25
+ * @internal
20
26
  */
21
27
  const submoduleNameFromKey = (key) => {
22
28
  if (!key.startsWith("submodule.")) return;
@@ -91,7 +97,7 @@ var ReposDrift = class extends Context.Service()("@savvy-web/silk-effects/ReposD
91
97
  found.push(RepoDrift.make({
92
98
  name: registeredName,
93
99
  kind: "localRegistrationDivergence",
94
- detail: `local git config registers submodule "${registeredName}", which matches no manifest entry — a stale registration left behind by a rename or an unvendoring; clear it with \`git config --remove-section submodule.${registeredName}\``,
100
+ detail: `local git config registers submodule "${registeredName}", which matches no manifest entry — a stale registration left behind by a rename or an unvendoring; clear it with \`savvy repos deregister ${registeredName}\``,
95
101
  observedValue: registeredName
96
102
  }));
97
103
  }
@@ -247,4 +253,4 @@ var ReposDrift = class extends Context.Service()("@savvy-web/silk-effects/ReposD
247
253
  };
248
254
 
249
255
  //#endregion
250
- export { ReposDrift };
256
+ export { ReposDrift, submoduleNameFromKey };
@@ -3,6 +3,7 @@ import { GitSubmoduleError, NoteNotFoundError, RepoNotFoundError, ReposConfigErr
3
3
  import { RepoName } from "../schemas/manifest.js";
4
4
  import { ReposConfigStore } from "./config-store.js";
5
5
  import { ReposLockdown, resolveModuleDir } from "./lockdown.js";
6
+ import { submoduleNameFromKey } from "./drift.js";
6
7
  import { Clock, Context, Effect, Exit, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
7
8
  import { Git, GitConfig, Gitmodules, LsRemoteEntry } from "@effected/git";
8
9
  import { createHash } from "node:crypto";
@@ -31,8 +32,10 @@ const STALE_LOCK_MAX_AGE_MS = 6e5;
31
32
  * (presence, dirtiness, stale notes), reconciles the working tree with the
32
33
  * manifest, vendors new entries (`add`), re-pins existing entries to a new
33
34
  * ref (`pin`), adds/removes/promotes agent notes (`note`), unvendors
34
- * (`remove`), renames (`rename`), and explicitly hard-resets dirty
35
- * checkouts back to their pinned commit (`restore`).
35
+ * (`remove`), renames (`rename`), explicitly hard-resets dirty
36
+ * checkouts back to their pinned commit (`restore`), and clears stale
37
+ * submodule registrations from the superproject's local git config
38
+ * (`deregister`).
36
39
  * @public
37
40
  */
38
41
  var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/ReposManager") {
@@ -158,12 +161,12 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
158
161
  yield* git.add(root, [".gitmodules", repoPathRel]).pipe(Effect.mapError(asSubmoduleError(`git add .gitmodules ${repoPathRel}`, root)));
159
162
  registered.push(name);
160
163
  } else if (!present) {
161
- yield* git.configSet(root, updateKey, "checkout").pipe(Effect.mapError(asSubmoduleError(`git config ${updateKey} checkout`, root)));
162
164
  yield* git.submoduleUpdate(root, {
163
165
  init: true,
166
+ checkout: true,
164
167
  depth: 1,
165
168
  paths: [repoPathRel]
166
- }).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${repoPathRel}`, root)), Effect.ensuring(Effect.ignore(assertBoundaryMarker)));
169
+ }).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --checkout --depth 1 -- ${repoPathRel}`, root)));
167
170
  initialized.push(name);
168
171
  } else upToDate.push(name);
169
172
  if (entry.sparse && entry.sparse.length > 0) {
@@ -647,6 +650,70 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
647
650
  stillDirty
648
651
  };
649
652
  });
653
+ /**
654
+ * Resolve the file git's `--local` scope actually targets for `root`.
655
+ * A plain checkout is `<root>/.git/config`; a LINKED WORKTREE's
656
+ * `<root>/.git` is a pointer FILE whose `gitdir:` names
657
+ * `.git/worktrees/<name>`, and the local config git uses there is the
658
+ * SHARED one at the main gitdir named by that dir's `commondir` file.
659
+ * Every unreadable/malformed step degrades to the plain
660
+ * `<root>/.git/config` path rather than failing — the same
661
+ * never-fails posture as `resolveModuleDir`.
662
+ */
663
+ const resolveLocalConfigPath = (root) => Effect.gen(function* () {
664
+ const dotGit = path.join(root, ".git");
665
+ const fallback = path.join(dotGit, "config");
666
+ const info = yield* fs.stat(dotGit).pipe(Effect.option);
667
+ if (Option.isNone(info) || info.value.type === "Directory") return fallback;
668
+ const content = yield* fs.readFileString(dotGit).pipe(Effect.option);
669
+ if (Option.isNone(content)) return fallback;
670
+ const match = /^gitdir:\s*(.+)$/m.exec(content.value);
671
+ if (!match?.[1]) return fallback;
672
+ const pointer = match[1].trim();
673
+ const gitdir = path.isAbsolute(pointer) ? pointer : path.resolve(root, pointer);
674
+ const commondir = yield* fs.readFileString(path.join(gitdir, "commondir")).pipe(Effect.option);
675
+ if (Option.isSome(commondir)) {
676
+ const common = commondir.value.trim();
677
+ return path.join(path.isAbsolute(common) ? common : path.resolve(gitdir, common), "config");
678
+ }
679
+ return path.join(gitdir, "config");
680
+ });
681
+ const deregister = (root, section) => Effect.gen(function* () {
682
+ if (!section.startsWith(`${".repos"}/`) || section === `${".repos"}/`) return yield* Effect.fail(new ReposConfigError({
683
+ path: MANIFEST_PATH,
684
+ reason: `"${section}" is not a ${REPOS_DIR}/ registration — deregister only clears stale vendored-repo sections; clear an unrelated submodule registration with git directly`,
685
+ kind: "invalid"
686
+ }));
687
+ const manifest = yield* configStore.read(root).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? Effect.succeed({ repos: {} }) : Effect.fail(error)));
688
+ for (const name of Object.keys(manifest.repos)) if (section === `${".repos"}/${name}`) return yield* Effect.fail(new ReposConfigError({
689
+ path: MANIFEST_PATH,
690
+ reason: `"${section}" is the canonical registration of manifest entry "${name}" — deregister clears STALE registrations only; use remove to unvendor, or sync to reconcile`,
691
+ kind: "invalid"
692
+ }));
693
+ const localConfigPath = yield* resolveLocalConfigPath(root);
694
+ const configEntries = yield* git.configList(root, { file: localConfigPath }).pipe(Effect.mapError(asSubmoduleError(`git config -f ${localConfigPath} --list`, root)));
695
+ const registeredNames = new Set(configEntries.map((configEntry) => submoduleNameFromKey(configEntry.key)).filter((registeredName) => registeredName !== void 0));
696
+ const removedKeys = configEntries.map((configEntry) => configEntry.key).filter((key) => submoduleNameFromKey(key) === section);
697
+ if (removedKeys.length === 0) return yield* Effect.fail(new ReposConfigError({
698
+ path: localConfigPath,
699
+ reason: `no submodule.${section} section registered in the local git config — nothing to deregister`,
700
+ kind: "invalid"
701
+ }));
702
+ const modulesRoot = path.join(root, ".git", "modules");
703
+ for (const name of Object.keys(manifest.repos)) {
704
+ const moduleDir = yield* resolveModuleDir(fs, path, root, name);
705
+ if (path.relative(modulesRoot, moduleDir) === section && !registeredNames.has(`${".repos"}/${name}`)) return yield* Effect.fail(new ReposConfigError({
706
+ path: MANIFEST_PATH,
707
+ reason: `"${section}" is the registration backing manifest entry "${name}" (its module gitdir lives there under a diverged name, and no canonical submodule.${REPOS_DIR}/${name} registration exists) — deregister clears STALE registrations only; re-vendor the entry instead (remove, then add with the orientation the remove result hands back)`,
708
+ kind: "invalid"
709
+ }));
710
+ }
711
+ yield* git.configRemoveSection(root, `submodule.${section}`).pipe(Effect.mapError(asSubmoduleError(`git config --remove-section submodule.${section}`, root)));
712
+ return {
713
+ section,
714
+ removedKeys
715
+ };
716
+ });
650
717
  return {
651
718
  status,
652
719
  sync,
@@ -655,7 +722,8 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
655
722
  note,
656
723
  remove,
657
724
  rename,
658
- restore
725
+ restore,
726
+ deregister
659
727
  };
660
728
  }));
661
729
  };