@savvy-web/silk-effects 5.5.2 → 5.7.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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ Shared [Effect](https://effect.website/) library providing Silk Suite convention
13
13
  - Supply the Silk commitlint config, its prompt and formatter, and the `silk/body-no-markdown` rule
14
14
  - Run the lint-staged handlers and the `savvy lint fmt` formatters from a single implementation so the hook and the CLI cannot drift
15
15
  - Inspect a Turborepo read-only — diagnose per-package cache hits, derive the task graph and compute affected packages, all over `turbo --dry`
16
- - Manage the vendored reference repos declared in `.repos/config.json` — submodule status, drift detection, sync, add, pin, note, remove, rename and restore — and keep every vendored tree read-only between mutations
16
+ - Manage the vendored reference repos declared in `.repos/config.json` — submodule status, drift detection, sync, add, pin, note, remove, rename and restore — and keep every vendored worktree read-only between mutations
17
17
  - Locate config files and keep Biome schema URLs in sync across workspaces
18
18
 
19
19
  ## Install
@@ -366,9 +366,17 @@ const diagnosis = await Effect.runPromise(
366
366
 
367
367
  #### ReposManager, ReposDrift and ReposLockdown
368
368
 
369
- The `Repos` namespace drives vendored reference repos — upstream sources checked out as git submodules under `.repos/`, declared in a `.repos/config.json` manifest. `ReposConfigStore` reads and writes that manifest, and `ReposManager` does the git work: `status(root)` reports presence, the pinned commit, working-tree dirtiness and stale notes, `sync(root)` initializes missing submodules and applies each entry's sparse-checkout, `pin(root, name, ref)` moves an entry to a new ref, `add(root, options)` vendors a new one, `note(root, name, op)` adds, removes or promotes an agent note, `remove(root, name)` unvendors an entry, `rename(root, oldName, newName)` renames one in place, and `restore(root, names?)` hard-resets one or more dirty checkouts back to their pinned commit. `ReposDrift.check(root)` is a read-only companion — it reconciles the manifest, `.gitmodules`, the worktree, and `git submodule status`, reporting any mismatch as a typed drift kind.
369
+ The `Repos` namespace drives vendored reference repos — upstream sources checked out as git submodules under `.repos/`, declared in a `.repos/config.json` manifest. `ReposConfigStore` reads and writes that manifest, and `ReposManager` does the git work: `status(root)` reports presence, the gitlink commit, working-tree dirtiness and stale notes, `sync(root)` initializes missing submodules and applies each entry's sparse-checkout, `pin(root, name, ref)` moves an entry to a new ref, `add(root, options)` vendors a new one, `note(root, name, op)` adds, removes or promotes an agent note, `remove(root, name)` unvendors an entry, `rename(root, oldName, newName)` renames one in place, and `restore(root, names?)` hard-resets one or more dirty checkouts back to their pinned commit. `ReposDrift.check(root)` is a read-only companion — it reconciles the manifest, `.gitmodules`, the worktree, `git submodule status` and the superproject's local git config, reporting any mismatch as a typed drift kind.
370
370
 
371
- `ReposLockdown` is the permissions boundary around all of that. `lock(root, name)` chmods a vendored worktree and its submodule git metadata to files `0444` and directories `0555` (an executable file locks at `0555` instead, preserving its executable bit); `unlock` reverses it, restoring `0644`/`0755` (`0755` for a file that was executable); `withUnlocked(root, name, effect)` brackets an effect between the two. `ReposManager`'s `sync`, `add`, `pin`, `remove` and `restore` run their git mutations inside that bracket and re-lock afterwards, and `rename` hand-rolls the same unlock/relock contract across its `oldName`→`newName` move, so a vendored tree is read-only whenever the manager is not mid-write. Reads are unaffected — a locked tree needs no special handling to open a file.
371
+ `status` reports the gitlink as a triple, because the index, `HEAD` and the submodule's own checkout legitimately disagree mid-pin: `stagedCommit` (the index), `committedCommit` (`HEAD`) and `checkedOutCommit` (the worktree). The deprecated single `commit` field that aliased `stagedCommit` has been removed read `stagedCommit` instead.
372
+
373
+ `ReposLockdown` is the permissions boundary around all of that, and it covers the vendored WORKTREE only. `lock(root, name)` chmods that worktree to files `0444` and directories `0555` (an executable file locks at `0555` instead, preserving its executable bit); `unlock` reverses it, restoring `0644`/`0755` (`0755` for a file that was executable); `withUnlocked(root, name, effect)` brackets an effect between the two. `ReposManager`'s `sync`, `add`, `pin`, `remove` and `restore` run their git mutations inside that bracket and re-lock afterwards, and `rename` hand-rolls the same unlock/relock contract across its `oldName`→`newName` move, so a vendored worktree is read-only whenever the manager is not mid-write. Reads are unaffected — a locked tree needs no special handling to open a file.
374
+
375
+ The submodule's git metadata directory under `.git/modules/` is deliberately left writable, so a plain `git pull` and any GUI client that keeps per-gitdir state keep working against a repo that uses this mechanism. `unlock` still walks that directory, freeing one an older release locked; `lock` never re-locks it. The trade is that a `git checkout` inside a vendored worktree still succeeds, moving `HEAD` while leaving the files stale — `ReposDrift` classifies that as `checkoutDiverged` and `restore` repairs it, so a drifted pin is always detected rather than prevented.
376
+
377
+ The declarative half of the boundary is written by `sync` and `add`, into the superproject's local git config rather than `.gitmodules`: `submodule.<path>.update = none` per entry and `fetch.recurseSubmodules = false`, so ordinary git clients skip these trees instead of discovering the boundary by failing on a permission error. `ReposSyncReport.boundaryMarked` lists the entries whose marker was asserted on that run.
378
+
379
+ Two report fields exist to say what was achieved rather than attempted. `ReposRestoreResult.stillDirty` names repos that were reset but whose worktree is dirty afterwards, so a caller reading `restored` alone cannot mistake an incomplete restore for a clean one. `ReposRemoveResult.removedEntry` hands back the whole manifest entry — pass its `orientation` block straight to `add`'s `orientation` option to make a remove-then-re-add lossless, since `add` resurrects nothing on its own.
372
380
 
373
381
  Two consequences for callers: `ReposManager.layer` requires `ReposLockdown` alongside `ReposConfigStore`, `Git`, `FileSystem` and `Path`, and `sync`, `add`, `pin`, `remove`, `rename` and `restore` widen their error channel with `ReposLockdownError`, which carries the offending `path` and a `reason`.
374
382
 
@@ -388,7 +396,8 @@ const report = await Effect.runPromise(
388
396
  Effect.provide(NodeServices.layer),
389
397
  ),
390
398
  );
391
- // => ReposSyncReport: per-repo initialization and sparse-checkout outcome, trees left read-only
399
+ // => ReposSyncReport: per-repo initialization and sparse-checkout outcome,
400
+ // plus boundaryMarked; worktrees left read-only
392
401
  ```
393
402
 
394
403
  ## Documentation
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 { YamlFormattingOptions } from "@effected/yaml";
7
8
  import { Section, SectionId } from "@effected/templates";
8
9
  import { ToolDiscovery } from "@effected/commands";
9
10
  //#endregion
@@ -6778,9 +6779,14 @@ interface ShellScriptsOptions extends BaseHandlerOptions {
6778
6779
  */
6779
6780
  interface YamlOptions extends BaseHandlerOptions {
6780
6781
  /**
6781
- * Path to yaml-lint config file (.yaml-lint.json).
6782
+ * Formatting options passed through to `@effected/yaml`.
6783
+ *
6784
+ * @remarks
6785
+ * Defaults to the handler's own `defaultFormatOptions`. There is no
6786
+ * config-file discovery: `@effected/yaml` is a pure tier that loads nothing
6787
+ * from disk, and a consumer's `.prettierrc` is NOT consulted.
6782
6788
  */
6783
- config?: string;
6789
+ format?: YamlFormattingOptions;
6784
6790
  /**
6785
6791
  * Skip YAML formatting.
6786
6792
  * @defaultValue false
@@ -7431,14 +7437,15 @@ declare class TypeScript {
7431
7437
  /**
7432
7438
  * Handler for YAML files.
7433
7439
  *
7434
- * Formats with Prettier and validates with yaml-lint, both as bundled dependencies.
7440
+ * Formats and validates with `@effected/yaml`, a bundled dependency.
7435
7441
  *
7436
7442
  * @remarks
7437
7443
  * Excludes pnpm-lock.yaml and pnpm-workspace.yaml by default.
7438
7444
  * pnpm-workspace.yaml has its own dedicated handler.
7439
7445
  *
7440
- * Uses Prettier for formatting and yaml-lint for validation.
7441
- * Both are bundled dependencies (no CLI spawning required).
7446
+ * Formatting preserves comments and blank lines, and formats every document of
7447
+ * a multi-document stream. Validation covers the WHOLE stream: a file whose
7448
+ * second document is invalid fails, which a single-document parse would miss.
7442
7449
  *
7443
7450
  * @example
7444
7451
  * ```typescript
@@ -7462,50 +7469,71 @@ declare class Yaml {
7462
7469
  * @defaultValue `['pnpm-lock.yaml', 'pnpm-workspace.yaml', '__test__/fixtures']`
7463
7470
  */
7464
7471
  static readonly defaultExcludes: readonly ["pnpm-lock.yaml", "pnpm-workspace.yaml", "__test__/fixtures"];
7472
+ /**
7473
+ * The formatting options applied when a caller supplies none.
7474
+ *
7475
+ * @remarks
7476
+ * `indentSequences` matches the block-sequence indentation an ex-Prettier
7477
+ * repository already has on disk; without it, formatting rewrites every
7478
+ * sequence in the tree. `quoteStyle` only governs scalars the stringifier
7479
+ * creates — it never re-quotes scalars already present in the source — so it
7480
+ * is set for consistency with `PnpmWorkspace`, not to change existing files.
7481
+ */
7482
+ static readonly defaultFormatOptions: YamlFormattingOptions;
7465
7483
  /**
7466
7484
  * Pre-configured handler with default options.
7467
7485
  */
7468
7486
  static readonly handler: LintStagedHandler;
7469
7487
  /**
7470
- * Find the yaml-lint config file.
7471
- *
7472
- * Paths are anchored to the workspace root (via {@link getWorkspaceRoot}),
7473
- * falling back to `process.cwd()` when not inside a workspace.
7474
- *
7475
- * Searches in order:
7476
- * 1. `{workspaceRoot}/lib/configs/.yaml-lint.json`
7477
- * 2. `{workspaceRoot}/.yaml-lint.json`
7488
+ * Check if the YAML engine is available.
7478
7489
  *
7479
- * @returns The config file path, or undefined if not found
7490
+ * @returns Always `true` since `@effected/yaml` is a bundled dependency
7480
7491
  */
7481
- static findConfig(): string | undefined;
7492
+ static isAvailable(): boolean;
7482
7493
  /**
7483
- * Load the yaml-lint schema from a config file.
7494
+ * Format a YAML file in-place.
7495
+ *
7496
+ * @remarks
7497
+ * Synchronous by contract: the engine is a pure, IO-free tier, so the only
7498
+ * IO here is this function's own file read and write.
7484
7499
  *
7485
- * @param filepath - Path to the yaml-lint config file
7486
- * @returns The schema string, or undefined if not found
7500
+ * @param filepath - Path to the YAML file
7501
+ * @param options - Formatting options; defaults to {@link Yaml.defaultFormatOptions}
7487
7502
  */
7488
- static loadConfig(filepath: string): string | undefined;
7503
+ static formatFile(filepath: string, options?: YamlFormattingOptions): void;
7489
7504
  /**
7490
- * Check if yaml-lint is available.
7505
+ * Validate a YAML file.
7506
+ *
7507
+ * @remarks
7508
+ * Validates every document of the stream, so a multi-document file whose
7509
+ * later documents are invalid is rejected rather than silently accepted.
7491
7510
  *
7492
- * @returns Always `true` since yaml-lint is a bundled dependency
7511
+ * @param filepath - Path to the YAML file
7512
+ * @throws Error if the YAML is invalid
7493
7513
  */
7494
- static isAvailable(): boolean;
7514
+ static validateFile(filepath: string): void;
7495
7515
  /**
7496
- * Format a YAML file in-place using Prettier.
7516
+ * Serialize formatting options for the `savvy lint fmt yaml --format` flag.
7497
7517
  *
7498
- * @param filepath - Path to the YAML file
7518
+ * @remarks
7519
+ * The CLI subcommand runs in a separate process, so options set on
7520
+ * {@link fmtCommand} have to cross a shell boundary to reach
7521
+ * {@link formatFile}. Without that hop the two entry points format the same
7522
+ * file differently — the drift this package's shared-static rule exists to
7523
+ * prevent. {@link parseFormatOptions} is the inverse.
7524
+ *
7525
+ * @param options - Formatting options to encode
7526
+ * @returns The options as a JSON string
7499
7527
  */
7500
- static formatFile(filepath: string): Promise<void>;
7528
+ static encodeFormatOptions(options: YamlFormattingOptions): string;
7501
7529
  /**
7502
- * Validate a YAML file using yaml-lint.
7530
+ * Parse formatting options serialized by {@link encodeFormatOptions}.
7503
7531
  *
7504
- * @param filepath - Path to the YAML file
7505
- * @param schema - The YAML schema to validate against
7506
- * @throws Error if the YAML is invalid
7532
+ * @param encoded - JSON produced by {@link encodeFormatOptions}
7533
+ * @returns The decoded options
7534
+ * @throws Error if the JSON is malformed or fails schema validation
7507
7535
  */
7508
- static validateFile(filepath: string, schema?: string): Promise<void>;
7536
+ static parseFormatOptions(encoded: string): YamlFormattingOptions;
7509
7537
  /**
7510
7538
  * Create a handler that returns a CLI command to format YAML files.
7511
7539
  *
@@ -7515,6 +7543,9 @@ declare class Yaml {
7515
7543
  * can detect the modification and auto-stage it.
7516
7544
  * Use this in lint-staged array syntax for sequential execution.
7517
7545
  *
7546
+ * `options.format` is forwarded to the subcommand as `--format`, so this
7547
+ * path and {@link create} produce identical bytes for the same options.
7548
+ *
7518
7549
  * @param options - Configuration options
7519
7550
  * @returns A lint-staged compatible handler function
7520
7551
  */
@@ -8298,17 +8329,17 @@ declare class ReposLockdownError extends ReposLockdownErrorBase<{
8298
8329
  //#endregion
8299
8330
  //#region src/repos/schemas/drift.d.ts
8300
8331
  /**
8301
- * The kinds of drift {@link ReposDrift} can detect between the four
8302
- * authorities it reconciles: the manifest, `.gitmodules`, the worktree, and
8303
- * `git submodule status`.
8332
+ * The kinds of drift {@link ReposDrift} can detect between the five
8333
+ * authorities it reconciles: the manifest, `.gitmodules`, the worktree,
8334
+ * `git submodule status`, and the superproject's local git config.
8304
8335
  * @public
8305
8336
  */
8306
- declare const DriftKind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable"]>;
8337
+ declare const DriftKind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable", "localRegistrationDivergence", "nestedSubmoduleDivergence"]>;
8307
8338
  /** @public */
8308
8339
  type DriftKind = typeof DriftKind.Type;
8309
8340
  declare const RepoDrift_base: Schema.Class<RepoDrift, Schema.Struct<{
8310
8341
  readonly name: Schema.String;
8311
- readonly kind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable"]>;
8342
+ readonly kind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable", "localRegistrationDivergence", "nestedSubmoduleDivergence"]>;
8312
8343
  readonly detail: Schema.String;
8313
8344
  readonly manifestValue: Schema.optionalKey<Schema.String>;
8314
8345
  readonly observedValue: Schema.optionalKey<Schema.String>;
@@ -8424,19 +8455,11 @@ type ReposManifestFile = typeof ReposManifestFile.Type;
8424
8455
  * that no longer match the pinned ref.
8425
8456
  *
8426
8457
  * @remarks
8427
- * `commit` is an alias of `stagedCommit`, retained for one release so an
8428
- * existing consumer reading `entry.commit` keeps working while it migrates to
8429
- * the index-aware triple. It carries no independent release tag beyond
8430
- * `@public` (this schema has no narrower audience to gate it behind).
8431
- * Removal is scheduled by a tracked issue, not gated on a renderer migration
8432
- * precondition here.
8433
- *
8434
- * Not fully behavior-preserving: `commit: stagedCommit ?? null` reads `null`
8435
- * for a gitlink committed at `HEAD` but staged for REMOVAL, where the prior
8436
- * single-`commit` field showed the committed oid. `stagedCommit` is `None`
8437
- * in exactly that case (nothing is staged), so the alias reports "nothing
8438
- * staged" rather than "here is what HEAD still has" — a real, if narrow,
8439
- * difference from the pre-triple `commit` field's behavior.
8458
+ * The gitlink is reported as an index-aware TRIPLE `stagedCommit` (the
8459
+ * index), `committedCommit` (`HEAD`), `checkedOutCommit` (the submodule's own
8460
+ * worktree) because those three legitimately disagree during a pin, and a
8461
+ * single field cannot say which one it means. A deprecated `commit` alias of
8462
+ * `stagedCommit` bridged one release and is now gone; read `stagedCommit`.
8440
8463
  * @public
8441
8464
  */
8442
8465
  declare const RepoStatusEntry: Schema.Struct<{
@@ -8444,8 +8467,6 @@ declare const RepoStatusEntry: Schema.Struct<{
8444
8467
  readonly ref: Schema.String;
8445
8468
  readonly purpose: Schema.String;
8446
8469
  readonly present: Schema.Boolean;
8447
- /** @deprecated alias of `stagedCommit`; retained for one release. */
8448
- readonly commit: Schema.NullOr<Schema.String>;
8449
8470
  /** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
8450
8471
  readonly stagedCommit: Schema.optionalKey<Schema.String>;
8451
8472
  /** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
@@ -8467,8 +8488,6 @@ declare const ReposStatusReport: Schema.Struct<{
8467
8488
  readonly ref: Schema.String;
8468
8489
  readonly purpose: Schema.String;
8469
8490
  readonly present: Schema.Boolean;
8470
- /** @deprecated alias of `stagedCommit`; retained for one release. */
8471
- readonly commit: Schema.NullOr<Schema.String>;
8472
8491
  /** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
8473
8492
  readonly stagedCommit: Schema.optionalKey<Schema.String>;
8474
8493
  /** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
@@ -8486,7 +8505,8 @@ type ReposStatusReport = typeof ReposStatusReport.Type;
8486
8505
  * Result of reconciling working-tree submodules with the manifest: missing
8487
8506
  * repos initialized, sparse-checkout patterns re-applied, already-present
8488
8507
  * repos left alone, stale locks cleared, drifted submodule URLs reconciled,
8489
- * and orphan manifest entries (no gitlink at all) registered.
8508
+ * orphan manifest entries (no gitlink at all) registered, and the vendored
8509
+ * boundary re-declared to git.
8490
8510
  * @public
8491
8511
  */
8492
8512
  declare const ReposSyncReport: Schema.Struct<{
@@ -8496,6 +8516,14 @@ declare const ReposSyncReport: Schema.Struct<{
8496
8516
  readonly clearedLocks: Schema.$Array<Schema.String>;
8497
8517
  readonly urlSynced: Schema.$Array<Schema.String>;
8498
8518
  readonly registered: Schema.$Array<Schema.String>;
8519
+ /**
8520
+ * Repos whose `submodule.<path>.update = none` marker was (re-)asserted in
8521
+ * the superproject's local config — the declarative half of the vendored
8522
+ * boundary, telling every git client to skip these trees rather than
8523
+ * letting them discover the boundary by failing against a permission
8524
+ * error.
8525
+ */
8526
+ readonly boundaryMarked: Schema.$Array<Schema.String>;
8499
8527
  }>;
8500
8528
  /** @public */
8501
8529
  type ReposSyncReport = typeof ReposSyncReport.Type;
@@ -8526,9 +8554,23 @@ declare const ReposAddResult: Schema.Struct<{
8526
8554
  type ReposAddResult = typeof ReposAddResult.Type;
8527
8555
  /**
8528
8556
  * Result of removing a vendored repo from the manifest: the gitlink, module
8529
- * gitdir, and `.gitmodules` section are all gone, and the entry's notes are
8530
- * surfaced so any durable ones can be promoted elsewhere before this result
8531
- * is committed.
8557
+ * gitdir, and `.gitmodules` section are all gone, and the entry that was
8558
+ * removed is handed back so nothing durable is lost.
8559
+ *
8560
+ * @remarks
8561
+ * `removedEntry` is the whole manifest entry, and it exists because
8562
+ * remove-then-re-add is the standing remedy for several vendored-tree
8563
+ * problems. `add` does not resurrect anything on its own, so without the
8564
+ * entry in hand a caller following that remedy silently destroys the
8565
+ * `orientation` block — larger and far harder to reconstruct than the notes,
8566
+ * and invisible afterwards in every report an agent would think to check.
8567
+ * Pass `removedEntry.orientation` straight back to `add` to make a re-vendor
8568
+ * lossless.
8569
+ *
8570
+ * `removedNotes` is retained alongside it, and is exactly
8571
+ * `removedEntry.notes ?? []`. Notes are ephemeral by policy — a last look
8572
+ * before they go, so a durable one can be promoted elsewhere — whereas
8573
+ * orientation is meant to survive.
8532
8574
  * @public
8533
8575
  */
8534
8576
  declare const ReposRemoveResult: Schema.Struct<{
@@ -8541,6 +8583,23 @@ declare const ReposRemoveResult: Schema.Struct<{
8541
8583
  readonly ref: Schema.String;
8542
8584
  readonly note: Schema.String;
8543
8585
  }>>;
8586
+ readonly removedEntry: Schema.Struct<{
8587
+ readonly url: Schema.String;
8588
+ readonly ref: Schema.String;
8589
+ readonly purpose: Schema.String;
8590
+ readonly sparse: Schema.optional<Schema.$Array<Schema.String>>;
8591
+ readonly orientation: Schema.optional<Schema.Struct<{
8592
+ readonly layout: Schema.optional<Schema.String>;
8593
+ readonly keyPaths: Schema.optional<Schema.$Record<Schema.String, Schema.String>>;
8594
+ readonly startHere: Schema.optional<Schema.String>;
8595
+ }>>;
8596
+ readonly notes: Schema.optional<Schema.$Array<Schema.Struct<{
8597
+ readonly id: Schema.String;
8598
+ readonly date: Schema.String;
8599
+ readonly ref: Schema.String;
8600
+ readonly note: Schema.String;
8601
+ }>>>;
8602
+ }>;
8544
8603
  }>;
8545
8604
  /** @public */
8546
8605
  type ReposRemoveResult = typeof ReposRemoveResult.Type;
@@ -8569,6 +8628,13 @@ type ReposRenameResult = typeof ReposRenameResult.Type;
8569
8628
  * {@link ReposManagerShape.restore} — repos left untouched because `status`
8570
8629
  * reported them clean; an explicit-names call never skips anything (an
8571
8630
  * explicit ask is always honored), so it always reports an empty array.
8631
+ *
8632
+ * `stillDirty` is the honesty channel: a repo that was reset but whose
8633
+ * worktree is STILL dirty afterwards. `restored` alone cannot express that —
8634
+ * it says what was attempted, not what was achieved — and a caller reading
8635
+ * only `restored` would take a report of a repo that never came clean as
8636
+ * success. Membership in both `restored` and `stillDirty` is the normal shape
8637
+ * for such a repo.
8572
8638
  * @public
8573
8639
  */
8574
8640
  declare const ReposRestoreResult: Schema.Struct<{
@@ -8577,6 +8643,7 @@ declare const ReposRestoreResult: Schema.Struct<{
8577
8643
  readonly commit: Schema.String;
8578
8644
  }>>;
8579
8645
  readonly skippedClean: Schema.$Array<Schema.String>;
8646
+ readonly stillDirty: Schema.$Array<Schema.String>;
8580
8647
  }>;
8581
8648
  /** @public */
8582
8649
  type ReposRestoreResult = typeof ReposRestoreResult.Type;
@@ -8634,11 +8701,18 @@ interface ReposDriftShape {
8634
8701
  }
8635
8702
  declare const ReposDrift_base: Context.ServiceClass<ReposDrift, "@savvy-web/silk-effects/ReposDrift", ReposDriftShape>;
8636
8703
  /**
8637
- * Reconciles the four authorities a vendored repo's state is spread across —
8638
- * the manifest, `.gitmodules`, the worktree, and `git submodule status`
8639
- * and reports every disagreement found. Read-only: no staging, no lockdown
8640
- * interaction, so it runs unmodified against a locked (`ReposLockdown`)
8641
- * tree.
8704
+ * Reconciles the five authorities a vendored repo's state is spread across —
8705
+ * the manifest, `.gitmodules`, the worktree, `git submodule status`, and the
8706
+ * superproject's LOCAL git config — and reports every disagreement found,
8707
+ * including one level down into a vendored repo's own submodules. Read-only:
8708
+ * no staging, no lockdown interaction, so it runs unmodified against a locked
8709
+ * (`ReposLockdown`) tree.
8710
+ *
8711
+ * @remarks
8712
+ * The local config is the fifth authority because the other four can all
8713
+ * agree while a checkout is still REGISTERED under a pre-canonicalization
8714
+ * section name — a state that reads clean here while `git submodule status`
8715
+ * reports a perfectly healthy checkout as uninitialized.
8642
8716
  * @public
8643
8717
  */
8644
8718
  declare class ReposDrift extends ReposDrift_base {
@@ -8666,8 +8740,11 @@ interface ReposLockdownShape {
8666
8740
  * A submodule worktree's `<root>/.repos/<name>/.git` is normally a FILE
8667
8741
  * containing a single `gitdir: <path>` line pointing at the real metadata
8668
8742
  * directory, which git names after whatever path/name the submodule was
8669
- * REGISTERED under — not necessarily the manifest key (e.g. this repo's own
8670
- * `effect` entry has gitdir `.git/modules/.repos/effect-smol`). This helper
8743
+ * REGISTERED under — not necessarily the manifest key. Git never renames
8744
+ * that directory, so a manifest entry re-slugged after it was first vendored
8745
+ * keeps its original gitdir name indefinitely (manifest key `<new>`, gitdir
8746
+ * `.git/modules/.repos/<old>`). `ReposDrift` reports that state as
8747
+ * `localRegistrationDivergence`; re-vendoring the entry clears it. This helper
8671
8748
  * reads that pointer and resolves it (relative pointers are relative to the
8672
8749
  * directory containing the `.git` file) so callers always land on the real
8673
8750
  * metadata directory.
@@ -8685,6 +8762,39 @@ declare const ReposLockdown_base: Context.ServiceClass<ReposLockdown, "@savvy-we
8685
8762
  /**
8686
8763
  * Enforces OS-level read-only permissions on vendored repos so they cannot
8687
8764
  * be accidentally edited outside the sync flow.
8765
+ *
8766
+ * @remarks
8767
+ * SCOPE: the WORKTREE only. The submodule's git metadata directory
8768
+ * (`.git/modules/...`) is deliberately NOT locked — `unlock` still walks it
8769
+ * so trees locked by an older version are freed, but `lock` never re-locks
8770
+ * it. Do not "restore" the metadata lock without re-reading this note.
8771
+ *
8772
+ * Locking the metadata directory made the boundary enforce itself only via
8773
+ * `EACCES`, whose message names neither `.repos/` nor a reason, and it broke
8774
+ * every client that needs incidental gitdir writes: a plain `git pull` that
8775
+ * moves a gitlink recurses by default and dies writing `FETCH_HEAD`, and any
8776
+ * client keeping per-gitdir state (GitKraken writes a `gk/` directory into
8777
+ * every gitdir it manages, which no git setting governs) is structurally
8778
+ * incompatible with a read-only gitdir. Since vendored reference sources are
8779
+ * the overwhelming majority of submodules in this ecosystem, ordinary tooling
8780
+ * collided with the lockdown constantly.
8781
+ *
8782
+ * What the worktree lock still buys, verified against git 2.54:
8783
+ *
8784
+ * - editing a vendored file fails (`EACCES`) — the property the system
8785
+ * actually needs;
8786
+ * - `git reset --hard` inside a vendored tree fails (cannot unlink);
8787
+ * - `git checkout <other>` does NOT fail — it moves `HEAD` while leaving the
8788
+ * worktree stale. That is the one guarantee given up here, and it is given
8789
+ * up knowingly: git immediately reports the submodule as `+<oid>`, which
8790
+ * {@link ReposDrift} already classifies as `checkoutDiverged` and
8791
+ * `ReposManager.restore` repairs.
8792
+ *
8793
+ * So the invariant weakens from "the pin cannot drift" to "a drifted pin is
8794
+ * always detected and one command from repaired." The declarative half of the
8795
+ * boundary — `submodule.<path>.update = none`, so clients skip these trees
8796
+ * rather than discovering the boundary by failing — is asserted by
8797
+ * `ReposManager.sync`, not here.
8688
8798
  * @public
8689
8799
  */
8690
8800
  declare class ReposLockdown extends ReposLockdown_base {
@@ -8720,12 +8830,25 @@ declare const STALE_LOCK_MAX_AGE_MS: number;
8720
8830
  interface ReposManagerShape {
8721
8831
  readonly status: (root: string) => Effect.Effect<ReposStatusReport, ReposConfigError | GitSubmoduleError>;
8722
8832
  readonly sync: (root: string) => Effect.Effect<ReposSyncReport, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
8833
+ /**
8834
+ * Vendors a new repo.
8835
+ *
8836
+ * `options.orientation` exists so a re-vendor can be LOSSLESS in one call.
8837
+ * Remove-then-re-add is the remedy for several vendored-tree problems, and
8838
+ * without this parameter that remedy silently destroys the entry's
8839
+ * orientation block — the durable, hand-curated part an agent reads to know
8840
+ * where to look in the tree, and the part no report mentions is gone.
8841
+ * Notes are ephemeral by policy and are NOT carried across a re-vendor;
8842
+ * orientation is, when the caller passes it back (see
8843
+ * {@link ReposRemoveResult.removedEntry}, which hands it to them).
8844
+ */
8723
8845
  readonly add: (root: string, options: {
8724
8846
  readonly url: string;
8725
8847
  readonly ref: string;
8726
8848
  readonly purpose: string;
8727
8849
  readonly name?: string;
8728
8850
  readonly sparse?: ReadonlyArray<string>;
8851
+ readonly orientation?: RepoOrientation;
8729
8852
  }) => Effect.Effect<ReposAddResult, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
8730
8853
  readonly pin: (root: string, name: string, ref: string) => Effect.Effect<ReposPinResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
8731
8854
  readonly note: (root: string, name: string, op: {
@@ -1,28 +1,26 @@
1
1
  import { Command } from "../utils/Command.js";
2
2
  import { Filter } from "../utils/Filter.js";
3
- import { getWorkspaceRoot } from "../utils/Workspace.js";
4
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { format, resolveConfig } from "prettier";
7
- import { lint } from "yaml-lint";
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
+ import { Yaml, YamlFormat, YamlFormattingOptions } from "@effected/yaml";
8
5
 
9
6
  //#region src/lint/handlers/Yaml.ts
10
7
  /**
11
8
  * Handler for YAML files.
12
9
  *
13
- * Formats with Prettier and validates with yaml-lint, both as bundled dependencies.
10
+ * Formats and validates with `@effected/yaml`, a bundled dependency.
14
11
  */
15
12
  /**
16
13
  * Handler for YAML files.
17
14
  *
18
- * Formats with Prettier and validates with yaml-lint, both as bundled dependencies.
15
+ * Formats and validates with `@effected/yaml`, a bundled dependency.
19
16
  *
20
17
  * @remarks
21
18
  * Excludes pnpm-lock.yaml and pnpm-workspace.yaml by default.
22
19
  * pnpm-workspace.yaml has its own dedicated handler.
23
20
  *
24
- * Uses Prettier for formatting and yaml-lint for validation.
25
- * Both are bundled dependencies (no CLI spawning required).
21
+ * Formatting preserves comments and blank lines, and formats every document of
22
+ * a multi-document stream. Validation covers the WHOLE stream: a file whose
23
+ * second document is invalid fails, which a single-document parse would miss.
26
24
  *
27
25
  * @example
28
26
  * ```typescript
@@ -35,7 +33,7 @@ import { lint } from "yaml-lint";
35
33
  * };
36
34
  * ```
37
35
  */
38
- var Yaml = class Yaml {
36
+ var Yaml$1 = class Yaml$1 {
39
37
  /**
40
38
  * Glob pattern for matching YAML files.
41
39
  * @defaultValue `'**\/*.{yml,yaml}'`
@@ -51,75 +49,86 @@ var Yaml = class Yaml {
51
49
  "__test__/fixtures"
52
50
  ];
53
51
  /**
54
- * Pre-configured handler with default options.
55
- */
56
- static handler = Yaml.create();
57
- /**
58
- * Find the yaml-lint config file.
59
- *
60
- * Paths are anchored to the workspace root (via {@link getWorkspaceRoot}),
61
- * falling back to `process.cwd()` when not inside a workspace.
52
+ * The formatting options applied when a caller supplies none.
62
53
  *
63
- * Searches in order:
64
- * 1. `{workspaceRoot}/lib/configs/.yaml-lint.json`
65
- * 2. `{workspaceRoot}/.yaml-lint.json`
66
- *
67
- * @returns The config file path, or undefined if not found
54
+ * @remarks
55
+ * `indentSequences` matches the block-sequence indentation an ex-Prettier
56
+ * repository already has on disk; without it, formatting rewrites every
57
+ * sequence in the tree. `quoteStyle` only governs scalars the stringifier
58
+ * creates it never re-quotes scalars already present in the source — so it
59
+ * is set for consistency with `PnpmWorkspace`, not to change existing files.
68
60
  */
69
- static findConfig() {
70
- const root = getWorkspaceRoot() ?? process.cwd();
71
- const libPath = join(root, "lib/configs/.yaml-lint.json");
72
- if (existsSync(libPath)) return libPath;
73
- const rootPath = join(root, ".yaml-lint.json");
74
- if (existsSync(rootPath)) return rootPath;
75
- }
61
+ static defaultFormatOptions = YamlFormattingOptions.make({
62
+ quoteStyle: "double",
63
+ indentSequences: true
64
+ });
76
65
  /**
77
- * Load the yaml-lint schema from a config file.
78
- *
79
- * @param filepath - Path to the yaml-lint config file
80
- * @returns The schema string, or undefined if not found
66
+ * Pre-configured handler with default options.
81
67
  */
82
- static loadConfig(filepath) {
83
- try {
84
- const content = readFileSync(filepath, "utf-8");
85
- return JSON.parse(content).schema;
86
- } catch {
87
- return;
88
- }
89
- }
68
+ static handler = Yaml$1.create();
90
69
  /**
91
- * Check if yaml-lint is available.
70
+ * Check if the YAML engine is available.
92
71
  *
93
- * @returns Always `true` since yaml-lint is a bundled dependency
72
+ * @returns Always `true` since `@effected/yaml` is a bundled dependency
94
73
  */
95
74
  static isAvailable() {
96
75
  return true;
97
76
  }
98
77
  /**
99
- * Format a YAML file in-place using Prettier.
78
+ * Format a YAML file in-place.
79
+ *
80
+ * @remarks
81
+ * Synchronous by contract: the engine is a pure, IO-free tier, so the only
82
+ * IO here is this function's own file read and write.
100
83
  *
101
84
  * @param filepath - Path to the YAML file
85
+ * @param options - Formatting options; defaults to {@link Yaml.defaultFormatOptions}
102
86
  */
103
- static async formatFile(filepath) {
87
+ static formatFile(filepath, options = Yaml$1.defaultFormatOptions) {
104
88
  const content = readFileSync(filepath, "utf-8");
105
- const prettierConfig = await resolveConfig(filepath);
106
- const formatted = await format(content, {
107
- ...prettierConfig,
108
- filepath,
109
- parser: "yaml"
110
- });
111
- writeFileSync(filepath, formatted, "utf-8");
89
+ const formatted = YamlFormat.formatToString(content, void 0, options);
90
+ if (formatted !== content) writeFileSync(filepath, formatted, "utf-8");
112
91
  }
113
92
  /**
114
- * Validate a YAML file using yaml-lint.
93
+ * Validate a YAML file.
94
+ *
95
+ * @remarks
96
+ * Validates every document of the stream, so a multi-document file whose
97
+ * later documents are invalid is rejected rather than silently accepted.
115
98
  *
116
99
  * @param filepath - Path to the YAML file
117
- * @param schema - The YAML schema to validate against
118
100
  * @throws Error if the YAML is invalid
119
101
  */
120
- static async validateFile(filepath, schema) {
102
+ static validateFile(filepath) {
121
103
  const content = readFileSync(filepath, "utf-8");
122
- await lint(content, schema ? { schema } : void 0);
104
+ const result = Yaml.parseAllResult(content);
105
+ if (!("success" in result)) throw new Error(String(result.failure));
106
+ }
107
+ /**
108
+ * Serialize formatting options for the `savvy lint fmt yaml --format` flag.
109
+ *
110
+ * @remarks
111
+ * The CLI subcommand runs in a separate process, so options set on
112
+ * {@link fmtCommand} have to cross a shell boundary to reach
113
+ * {@link formatFile}. Without that hop the two entry points format the same
114
+ * file differently — the drift this package's shared-static rule exists to
115
+ * prevent. {@link parseFormatOptions} is the inverse.
116
+ *
117
+ * @param options - Formatting options to encode
118
+ * @returns The options as a JSON string
119
+ */
120
+ static encodeFormatOptions(options) {
121
+ return JSON.stringify(options);
122
+ }
123
+ /**
124
+ * Parse formatting options serialized by {@link encodeFormatOptions}.
125
+ *
126
+ * @param encoded - JSON produced by {@link encodeFormatOptions}
127
+ * @returns The decoded options
128
+ * @throws Error if the JSON is malformed or fails schema validation
129
+ */
130
+ static parseFormatOptions(encoded) {
131
+ return YamlFormattingOptions.make(JSON.parse(encoded));
123
132
  }
124
133
  /**
125
134
  * Create a handler that returns a CLI command to format YAML files.
@@ -130,15 +139,19 @@ var Yaml = class Yaml {
130
139
  * can detect the modification and auto-stage it.
131
140
  * Use this in lint-staged array syntax for sequential execution.
132
141
  *
142
+ * `options.format` is forwarded to the subcommand as `--format`, so this
143
+ * path and {@link create} produce identical bytes for the same options.
144
+ *
133
145
  * @param options - Configuration options
134
146
  * @returns A lint-staged compatible handler function
135
147
  */
136
148
  static fmtCommand(options = {}) {
137
- const excludes = options.exclude ?? [...Yaml.defaultExcludes];
149
+ const excludes = options.exclude ?? [...Yaml$1.defaultExcludes];
150
+ const formatOptions = options.format;
138
151
  return (filenames) => {
139
152
  const filtered = Filter.exclude(filenames, excludes);
140
153
  if (filtered.length === 0) return [];
141
- return `${Command.findSavvyLint()} fmt yaml ${Filter.shellEscape(filtered)}`;
154
+ return `${Command.findSavvyLint()} fmt yaml${formatOptions ? ` --format ${Filter.shellEscape([Yaml$1.encodeFormatOptions(formatOptions)])}` : ""} ${Filter.shellEscape(filtered)}`;
142
155
  };
143
156
  }
144
157
  /**
@@ -148,17 +161,16 @@ var Yaml = class Yaml {
148
161
  * @returns A lint-staged compatible handler function
149
162
  */
150
163
  static create(options = {}) {
151
- const excludes = options.exclude ?? [...Yaml.defaultExcludes];
164
+ const excludes = options.exclude ?? [...Yaml$1.defaultExcludes];
152
165
  const skipFormat = options.skipFormat ?? false;
153
166
  const skipValidate = options.skipValidate ?? false;
154
- const configPath = options.config ?? Yaml.findConfig();
155
- const schema = configPath ? Yaml.loadConfig(configPath) : void 0;
156
- return async (filenames) => {
167
+ const formatOptions = options.format ?? Yaml$1.defaultFormatOptions;
168
+ return (filenames) => {
157
169
  const filtered = Filter.exclude(filenames, excludes);
158
170
  if (filtered.length === 0) return [];
159
- if (!skipFormat) for (const filepath of filtered) await Yaml.formatFile(filepath);
171
+ if (!skipFormat) for (const filepath of filtered) Yaml$1.formatFile(filepath, formatOptions);
160
172
  if (!skipValidate) for (const filepath of filtered) try {
161
- await Yaml.validateFile(filepath, schema);
173
+ Yaml$1.validateFile(filepath);
162
174
  } catch (error) {
163
175
  throw new Error(`Invalid YAML in ${filepath}: ${error instanceof Error ? error.message : String(error)}`);
164
176
  }
@@ -168,4 +180,4 @@ var Yaml = class Yaml {
168
180
  };
169
181
 
170
182
  //#endregion
171
- export { Yaml };
183
+ export { Yaml$1 as Yaml };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/silk-effects",
3
- "version": "5.5.2",
3
+ "version": "5.7.0",
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,20 +40,18 @@
40
40
  "@effected/package-json": "^0.8.0",
41
41
  "@effected/templates": "^0.2.0",
42
42
  "@effected/walker": "^0.4.0",
43
- "@effected/workspaces": "^0.11.1",
44
- "@effected/yaml": "^0.7.0",
43
+ "@effected/workspaces": "^0.11.2",
44
+ "@effected/yaml": "^0.8.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",
48
- "prettier": "^3.9.6",
49
48
  "remark-gfm": "^4.0.1",
50
49
  "remark-parse": "^11.0.0",
51
50
  "remark-stringify": "^11.0.0",
52
51
  "shell-quote": "^1.10.0",
53
52
  "unified": "^11.0.5",
54
53
  "unified-lint-rule": "^3.0.1",
55
- "unist-util-visit": "^5.1.0",
56
- "yaml-lint": "^1.7.0"
54
+ "unist-util-visit": "^5.1.0"
57
55
  },
58
56
  "peerDependencies": {
59
57
  "effect": "4.0.0-beta.107"
package/repos/index.js CHANGED
@@ -5,8 +5,8 @@ import { DriftKind, RepoDrift, ReposDriftReport } from "./schemas/drift.js";
5
5
  import { RepoEntry, RepoName, RepoNote, RepoOrientation, ReposManifestFile } from "./schemas/manifest.js";
6
6
  import { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport } from "./schemas/reports.js";
7
7
  import { ReposConfigStore } from "./services/config-store.js";
8
- import { ReposDrift } from "./services/drift.js";
9
8
  import { ReposLockdown, resolveModuleDir } from "./services/lockdown.js";
9
+ import { ReposDrift } 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
@@ -2,9 +2,9 @@ import { Schema } from "effect";
2
2
 
3
3
  //#region src/repos/schemas/drift.ts
4
4
  /**
5
- * The kinds of drift {@link ReposDrift} can detect between the four
6
- * authorities it reconciles: the manifest, `.gitmodules`, the worktree, and
7
- * `git submodule status`.
5
+ * The kinds of drift {@link ReposDrift} can detect between the five
6
+ * authorities it reconciles: the manifest, `.gitmodules`, the worktree,
7
+ * `git submodule status`, and the superproject's local git config.
8
8
  * @public
9
9
  */
10
10
  const DriftKind = Schema.Literals([
@@ -15,7 +15,9 @@ const DriftKind = Schema.Literals([
15
15
  "missingWorktree",
16
16
  "checkoutDiverged",
17
17
  "missingShallow",
18
- "gitmodulesUnparsable"
18
+ "gitmodulesUnparsable",
19
+ "localRegistrationDivergence",
20
+ "nestedSubmoduleDivergence"
19
21
  ]);
20
22
  /**
21
23
  * One detected disagreement between two (or more) of the four authorities
@@ -1,4 +1,4 @@
1
- import { RepoNote } from "./manifest.js";
1
+ import { RepoEntry, RepoNote } from "./manifest.js";
2
2
  import { Schema } from "effect";
3
3
 
4
4
  //#region src/repos/schemas/reports.ts
@@ -7,19 +7,11 @@ import { Schema } from "effect";
7
7
  * that no longer match the pinned ref.
8
8
  *
9
9
  * @remarks
10
- * `commit` is an alias of `stagedCommit`, retained for one release so an
11
- * existing consumer reading `entry.commit` keeps working while it migrates to
12
- * the index-aware triple. It carries no independent release tag beyond
13
- * `@public` (this schema has no narrower audience to gate it behind).
14
- * Removal is scheduled by a tracked issue, not gated on a renderer migration
15
- * precondition here.
16
- *
17
- * Not fully behavior-preserving: `commit: stagedCommit ?? null` reads `null`
18
- * for a gitlink committed at `HEAD` but staged for REMOVAL, where the prior
19
- * single-`commit` field showed the committed oid. `stagedCommit` is `None`
20
- * in exactly that case (nothing is staged), so the alias reports "nothing
21
- * staged" rather than "here is what HEAD still has" — a real, if narrow,
22
- * difference from the pre-triple `commit` field's behavior.
10
+ * The gitlink is reported as an index-aware TRIPLE `stagedCommit` (the
11
+ * index), `committedCommit` (`HEAD`), `checkedOutCommit` (the submodule's own
12
+ * worktree) because those three legitimately disagree during a pin, and a
13
+ * single field cannot say which one it means. A deprecated `commit` alias of
14
+ * `stagedCommit` bridged one release and is now gone; read `stagedCommit`.
23
15
  * @public
24
16
  */
25
17
  const RepoStatusEntry = Schema.Struct({
@@ -27,8 +19,6 @@ const RepoStatusEntry = Schema.Struct({
27
19
  ref: Schema.String,
28
20
  purpose: Schema.String,
29
21
  present: Schema.Boolean,
30
- /** @deprecated alias of `stagedCommit`; retained for one release. */
31
- commit: Schema.NullOr(Schema.String),
32
22
  /** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
33
23
  stagedCommit: Schema.optionalKey(Schema.String),
34
24
  /** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
@@ -50,7 +40,8 @@ const ReposStatusReport = Schema.Struct({
50
40
  * Result of reconciling working-tree submodules with the manifest: missing
51
41
  * repos initialized, sparse-checkout patterns re-applied, already-present
52
42
  * repos left alone, stale locks cleared, drifted submodule URLs reconciled,
53
- * and orphan manifest entries (no gitlink at all) registered.
43
+ * orphan manifest entries (no gitlink at all) registered, and the vendored
44
+ * boundary re-declared to git.
54
45
  * @public
55
46
  */
56
47
  const ReposSyncReport = Schema.Struct({
@@ -59,7 +50,15 @@ const ReposSyncReport = Schema.Struct({
59
50
  upToDate: Schema.Array(Schema.String),
60
51
  clearedLocks: Schema.Array(Schema.String),
61
52
  urlSynced: Schema.Array(Schema.String),
62
- registered: Schema.Array(Schema.String)
53
+ registered: Schema.Array(Schema.String),
54
+ /**
55
+ * Repos whose `submodule.<path>.update = none` marker was (re-)asserted in
56
+ * the superproject's local config — the declarative half of the vendored
57
+ * boundary, telling every git client to skip these trees rather than
58
+ * letting them discover the boundary by failing against a permission
59
+ * error.
60
+ */
61
+ boundaryMarked: Schema.Array(Schema.String)
63
62
  });
64
63
  /**
65
64
  * Result of re-pinning a vendored repo to a new ref.
@@ -84,16 +83,31 @@ const ReposAddResult = Schema.Struct({
84
83
  });
85
84
  /**
86
85
  * Result of removing a vendored repo from the manifest: the gitlink, module
87
- * gitdir, and `.gitmodules` section are all gone, and the entry's notes are
88
- * surfaced so any durable ones can be promoted elsewhere before this result
89
- * is committed.
86
+ * gitdir, and `.gitmodules` section are all gone, and the entry that was
87
+ * removed is handed back so nothing durable is lost.
88
+ *
89
+ * @remarks
90
+ * `removedEntry` is the whole manifest entry, and it exists because
91
+ * remove-then-re-add is the standing remedy for several vendored-tree
92
+ * problems. `add` does not resurrect anything on its own, so without the
93
+ * entry in hand a caller following that remedy silently destroys the
94
+ * `orientation` block — larger and far harder to reconstruct than the notes,
95
+ * and invisible afterwards in every report an agent would think to check.
96
+ * Pass `removedEntry.orientation` straight back to `add` to make a re-vendor
97
+ * lossless.
98
+ *
99
+ * `removedNotes` is retained alongside it, and is exactly
100
+ * `removedEntry.notes ?? []`. Notes are ephemeral by policy — a last look
101
+ * before they go, so a durable one can be promoted elsewhere — whereas
102
+ * orientation is meant to survive.
90
103
  * @public
91
104
  */
92
105
  const ReposRemoveResult = Schema.Struct({
93
106
  name: Schema.String,
94
107
  path: Schema.String,
95
108
  commitMessage: Schema.String,
96
- removedNotes: Schema.Array(RepoNote)
109
+ removedNotes: Schema.Array(RepoNote),
110
+ removedEntry: RepoEntry
97
111
  });
98
112
  /**
99
113
  * Result of renaming a vendored repo's manifest key: the `.repos/<name>`
@@ -118,6 +132,13 @@ const ReposRenameResult = Schema.Struct({
118
132
  * {@link ReposManagerShape.restore} — repos left untouched because `status`
119
133
  * reported them clean; an explicit-names call never skips anything (an
120
134
  * explicit ask is always honored), so it always reports an empty array.
135
+ *
136
+ * `stillDirty` is the honesty channel: a repo that was reset but whose
137
+ * worktree is STILL dirty afterwards. `restored` alone cannot express that —
138
+ * it says what was attempted, not what was achieved — and a caller reading
139
+ * only `restored` would take a report of a repo that never came clean as
140
+ * success. Membership in both `restored` and `stillDirty` is the normal shape
141
+ * for such a repo.
121
142
  * @public
122
143
  */
123
144
  const ReposRestoreResult = Schema.Struct({
@@ -125,7 +146,8 @@ const ReposRestoreResult = Schema.Struct({
125
146
  name: Schema.String,
126
147
  commit: Schema.String
127
148
  })),
128
- skippedClean: Schema.Array(Schema.String)
149
+ skippedClean: Schema.Array(Schema.String),
150
+ stillDirty: Schema.Array(Schema.String)
129
151
  });
130
152
  /**
131
153
  * Result of an agent-note mutation against a vendored repo.
@@ -2,16 +2,41 @@ import { REPOS_DIR } from "../constants.js";
2
2
  import { GitSubmoduleError } from "../errors.js";
3
3
  import { RepoDrift, ReposDriftReport } from "../schemas/drift.js";
4
4
  import { ReposConfigStore } from "./config-store.js";
5
+ import { resolveModuleDir } from "./lockdown.js";
5
6
  import { Context, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
6
7
  import { Git, Gitmodules } from "@effected/git";
7
8
 
8
9
  //#region src/repos/services/drift.ts
9
10
  /**
10
- * Reconciles the four authorities a vendored repo's state is spread across —
11
- * the manifest, `.gitmodules`, the worktree, and `git submodule status` —
12
- * and reports every disagreement found. Read-only: no staging, no lockdown
13
- * interaction, so it runs unmodified against a locked (`ReposLockdown`)
14
- * tree.
11
+ * Extracts the submodule NAME from a `submodule.<name>.<property>` config key.
12
+ *
13
+ * @remarks
14
+ * A submodule's name is routinely a PATH (`git submodule add` derives the
15
+ * section name from the path), so the name itself contains dots — which means
16
+ * the key cannot be split on `.` and read positionally. Strip the fixed
17
+ * `submodule.` prefix and the final `.<property>` segment instead; everything
18
+ * between is the name, dots and all. Returns `undefined` for any key that is
19
+ * not a `submodule.*.*` key.
20
+ */
21
+ const submoduleNameFromKey = (key) => {
22
+ if (!key.startsWith("submodule.")) return;
23
+ const rest = key.slice(10);
24
+ const lastDot = rest.lastIndexOf(".");
25
+ return lastDot <= 0 ? void 0 : rest.slice(0, lastDot);
26
+ };
27
+ /**
28
+ * Reconciles the five authorities a vendored repo's state is spread across —
29
+ * the manifest, `.gitmodules`, the worktree, `git submodule status`, and the
30
+ * superproject's LOCAL git config — and reports every disagreement found,
31
+ * including one level down into a vendored repo's own submodules. Read-only:
32
+ * no staging, no lockdown interaction, so it runs unmodified against a locked
33
+ * (`ReposLockdown`) tree.
34
+ *
35
+ * @remarks
36
+ * The local config is the fifth authority because the other four can all
37
+ * agree while a checkout is still REGISTERED under a pre-canonicalization
38
+ * section name — a state that reads clean here while `git submodule status`
39
+ * reports a perfectly healthy checkout as uninitialized.
15
40
  * @public
16
41
  */
17
42
  var ReposDrift = class extends Context.Service()("@savvy-web/silk-effects/ReposDrift") {
@@ -31,6 +56,47 @@ var ReposDrift = class extends Context.Service()("@savvy-web/silk-effects/ReposD
31
56
  reason: error.message
32
57
  });
33
58
  const isPresent = (repoPath) => fs.readDirectory(repoPath).pipe(Effect.map((files) => files.length > 0), Effect.orElseSucceed(() => false));
59
+ /**
60
+ * Reconciles the superproject's LOCAL git config for every manifest
61
+ * entry. Extracted from `check`'s main path so it also runs when
62
+ * `.gitmodules` is ABSENT: that early return short-circuits the whole
63
+ * reconciliation, so a repo with no `.gitmodules` but a stale
64
+ * `submodule.<name>.*` section reported clean while `git submodule
65
+ * status` still listed the phantom entry.
66
+ */
67
+ const localRegistrationDrifts = (root, manifestEntries) => Effect.gen(function* () {
68
+ const found = [];
69
+ const configEntries = yield* git.configList(root).pipe(Effect.mapError(asSubmoduleError("git config --list", root)));
70
+ const registeredNames = new Set(configEntries.map((configEntry) => submoduleNameFromKey(configEntry.key)).filter((registeredName) => registeredName !== void 0));
71
+ const modulesRoot = path.join(root, ".git", "modules");
72
+ const canonicalNames = new Set(manifestEntries.map(([name]) => `${REPOS_DIR}/${name}`));
73
+ const explained = /* @__PURE__ */ new Set();
74
+ for (const [name] of manifestEntries) {
75
+ const expectedPath = `${REPOS_DIR}/${name}`;
76
+ const moduleDir = yield* resolveModuleDir(fs, path, root, name);
77
+ const relativeToModules = path.relative(modulesRoot, moduleDir);
78
+ if (relativeToModules.startsWith("..") || path.isAbsolute(relativeToModules) || relativeToModules === "") continue;
79
+ if (relativeToModules === expectedPath) continue;
80
+ explained.add(relativeToModules);
81
+ found.push(RepoDrift.make({
82
+ name,
83
+ kind: "localRegistrationDivergence",
84
+ detail: `manifest entry "${name}" is canonically "${expectedPath}" but this checkout's module gitdir is registered as "${relativeToModules}"${registeredNames.has(relativeToModules) ? ` (local git config still carries submodule.${relativeToModules}.*)` : ""} — re-vendor the entry to clear it: \`savvy repos remove ${name}\`, then \`savvy repos add\` with the orientation the remove result hands back`,
85
+ manifestValue: expectedPath,
86
+ observedValue: relativeToModules
87
+ }));
88
+ }
89
+ for (const registeredName of registeredNames) {
90
+ if (!registeredName.startsWith(`${".repos"}/`) || canonicalNames.has(registeredName) || explained.has(registeredName)) continue;
91
+ found.push(RepoDrift.make({
92
+ name: registeredName,
93
+ 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}\``,
95
+ observedValue: registeredName
96
+ }));
97
+ }
98
+ return found;
99
+ });
34
100
  const check = (root) => Effect.gen(function* () {
35
101
  const manifest = yield* configStore.read(root);
36
102
  const manifestEntries = Object.entries(manifest.repos);
@@ -43,6 +109,7 @@ var ReposDrift = class extends Context.Service()("@savvy-web/silk-effects/ReposD
43
109
  detail: `manifest entry "${name}" has no corresponding .gitmodules section (.gitmodules is absent)`,
44
110
  manifestValue: entry.url
45
111
  }));
112
+ drifts.push(...yield* localRegistrationDrifts(root, manifestEntries));
46
113
  return ReposDriftReport.make({
47
114
  drifts,
48
115
  clean: drifts.length === 0
@@ -155,6 +222,21 @@ var ReposDrift = class extends Context.Service()("@savvy-web/silk-effects/ReposD
155
222
  observedValue: section.path
156
223
  }));
157
224
  }
225
+ drifts.push(...yield* localRegistrationDrifts(root, manifestEntries));
226
+ for (const [name] of manifestEntries) {
227
+ const repoPath = path.join(root, REPOS_DIR, name);
228
+ if (!(yield* isPresent(repoPath))) continue;
229
+ const nested = yield* git.submoduleStatus(repoPath).pipe(Effect.orElseSucceed(() => []));
230
+ for (const nestedEntry of nested) {
231
+ if (nestedEntry.state === "current" || nestedEntry.state === "uninitialized") continue;
232
+ drifts.push(RepoDrift.make({
233
+ name,
234
+ kind: "nestedSubmoduleDivergence",
235
+ detail: `vendored repo "${name}" has its own submodule "${nestedEntry.path}" checked out at ${nestedEntry.sha}, which does not match what "${name}"'s pinned commit records — reading it would consult a stale authority; \`savvy repos restore ${name}\` deinitializes it`,
236
+ observedValue: nestedEntry.sha
237
+ }));
238
+ }
239
+ }
158
240
  return ReposDriftReport.make({
159
241
  drifts,
160
242
  clean: drifts.length === 0
@@ -22,8 +22,11 @@ const fileModeFor = (baseMode, currentMode) => baseMode | (currentMode & EXEC_BI
22
22
  * A submodule worktree's `<root>/.repos/<name>/.git` is normally a FILE
23
23
  * containing a single `gitdir: <path>` line pointing at the real metadata
24
24
  * directory, which git names after whatever path/name the submodule was
25
- * REGISTERED under — not necessarily the manifest key (e.g. this repo's own
26
- * `effect` entry has gitdir `.git/modules/.repos/effect-smol`). This helper
25
+ * REGISTERED under — not necessarily the manifest key. Git never renames
26
+ * that directory, so a manifest entry re-slugged after it was first vendored
27
+ * keeps its original gitdir name indefinitely (manifest key `<new>`, gitdir
28
+ * `.git/modules/.repos/<old>`). `ReposDrift` reports that state as
29
+ * `localRegistrationDivergence`; re-vendoring the entry clears it. This helper
27
30
  * reads that pointer and resolves it (relative pointers are relative to the
28
31
  * directory containing the `.git` file) so callers always land on the real
29
32
  * metadata directory.
@@ -55,6 +58,39 @@ const resolveModuleDir = (fs, path, root, name) => Effect.gen(function* () {
55
58
  /**
56
59
  * Enforces OS-level read-only permissions on vendored repos so they cannot
57
60
  * be accidentally edited outside the sync flow.
61
+ *
62
+ * @remarks
63
+ * SCOPE: the WORKTREE only. The submodule's git metadata directory
64
+ * (`.git/modules/...`) is deliberately NOT locked — `unlock` still walks it
65
+ * so trees locked by an older version are freed, but `lock` never re-locks
66
+ * it. Do not "restore" the metadata lock without re-reading this note.
67
+ *
68
+ * Locking the metadata directory made the boundary enforce itself only via
69
+ * `EACCES`, whose message names neither `.repos/` nor a reason, and it broke
70
+ * every client that needs incidental gitdir writes: a plain `git pull` that
71
+ * moves a gitlink recurses by default and dies writing `FETCH_HEAD`, and any
72
+ * client keeping per-gitdir state (GitKraken writes a `gk/` directory into
73
+ * every gitdir it manages, which no git setting governs) is structurally
74
+ * incompatible with a read-only gitdir. Since vendored reference sources are
75
+ * the overwhelming majority of submodules in this ecosystem, ordinary tooling
76
+ * collided with the lockdown constantly.
77
+ *
78
+ * What the worktree lock still buys, verified against git 2.54:
79
+ *
80
+ * - editing a vendored file fails (`EACCES`) — the property the system
81
+ * actually needs;
82
+ * - `git reset --hard` inside a vendored tree fails (cannot unlink);
83
+ * - `git checkout <other>` does NOT fail — it moves `HEAD` while leaving the
84
+ * worktree stale. That is the one guarantee given up here, and it is given
85
+ * up knowingly: git immediately reports the submodule as `+<oid>`, which
86
+ * {@link ReposDrift} already classifies as `checkoutDiverged` and
87
+ * `ReposManager.restore` repairs.
88
+ *
89
+ * So the invariant weakens from "the pin cannot drift" to "a drifted pin is
90
+ * always detected and one command from repaired." The declarative half of the
91
+ * boundary — `submodule.<path>.update = none`, so clients skip these trees
92
+ * rather than discovering the boundary by failing — is asserted by
93
+ * `ReposManager.sync`, not here.
58
94
  * @public
59
95
  */
60
96
  var ReposLockdown = class extends Context.Service()("@savvy-web/silk-effects/ReposLockdown") {
@@ -88,8 +124,9 @@ var ReposLockdown = class extends Context.Service()("@savvy-web/silk-effects/Rep
88
124
  if (order === "lock") yield* chmod(dir, dirMode);
89
125
  });
90
126
  const walkRoot = (root, name, fileBaseMode, dirMode, order) => Effect.gen(function* () {
91
- const moduleDir = yield* resolveModuleDir(fs, path, root, name);
92
- for (const dir of [path.join(root, REPOS_DIR, name), moduleDir]) {
127
+ const worktree = path.join(root, REPOS_DIR, name);
128
+ const targets = order === "unlock" ? [worktree, yield* resolveModuleDir(fs, path, root, name)] : [worktree];
129
+ for (const dir of targets) {
93
130
  if (!(yield* fs.exists(dir).pipe(Effect.mapError((cause) => new ReposLockdownError({
94
131
  path: dir,
95
132
  reason: `stat failed: ${String(cause)}`
@@ -87,7 +87,6 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
87
87
  ref: entry.ref,
88
88
  purpose: entry.purpose,
89
89
  present,
90
- commit: stagedCommit ?? null,
91
90
  ...stagedCommit !== void 0 ? { stagedCommit } : {},
92
91
  ...committedCommit !== void 0 ? { committedCommit } : {},
93
92
  ...checkedOutCommit !== void 0 ? { checkedOutCommit } : {},
@@ -108,12 +107,16 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
108
107
  const clearedLocks = [];
109
108
  const urlSynced = [];
110
109
  const registered = [];
110
+ const boundaryMarked = [];
111
111
  const gitmodulesPath = path.join(root, ".gitmodules");
112
+ yield* git.configSet(root, "fetch.recurseSubmodules", "false").pipe(Effect.mapError(asSubmoduleError("git config fetch.recurseSubmodules false", root)));
112
113
  for (const [name, entry] of Object.entries(manifest.repos)) {
113
114
  const repoPath = path.join(root, REPOS_DIR, name);
114
115
  const repoPathRel = `${REPOS_DIR}/${name}`;
115
116
  const moduleDir = yield* resolveModuleDir(fs, path, root, name);
116
117
  yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
118
+ const updateKey = `submodule.${repoPathRel}.update`;
119
+ const assertBoundaryMarker = git.configSet(root, updateKey, "none").pipe(Effect.mapError(asSubmoduleError(`git config ${updateKey} none`, root)));
117
120
  let clearedAnyLock = false;
118
121
  for (const lock of STALE_LOCKS) {
119
122
  const lockPath = path.join(moduleDir, lock);
@@ -155,17 +158,24 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
155
158
  yield* git.add(root, [".gitmodules", repoPathRel]).pipe(Effect.mapError(asSubmoduleError(`git add .gitmodules ${repoPathRel}`, root)));
156
159
  registered.push(name);
157
160
  } else if (!present) {
161
+ yield* git.configSet(root, updateKey, "checkout").pipe(Effect.mapError(asSubmoduleError(`git config ${updateKey} checkout`, root)));
158
162
  yield* git.submoduleUpdate(root, {
159
163
  init: true,
160
164
  depth: 1,
161
165
  paths: [repoPathRel]
162
- }).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${repoPathRel}`, root)));
166
+ }).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${repoPathRel}`, root)), Effect.ensuring(Effect.ignore(assertBoundaryMarker)));
163
167
  initialized.push(name);
164
168
  } else upToDate.push(name);
165
169
  if (entry.sparse && entry.sparse.length > 0) {
170
+ if ((yield* git.submoduleStatus(repoPath).pipe(Effect.orElseSucceed(() => []))).some((nestedEntry) => nestedEntry.state !== "uninitialized")) yield* git.submoduleDeinit(repoPath, {
171
+ all: true,
172
+ force: true
173
+ }).pipe(Effect.mapError(asSubmoduleError("git submodule deinit --all --force", repoPath)));
166
174
  yield* git.sparseCheckoutSet(repoPath, entry.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", repoPath)));
167
175
  sparseApplied.push(name);
168
176
  }
177
+ yield* assertBoundaryMarker;
178
+ boundaryMarked.push(name);
169
179
  }));
170
180
  }
171
181
  return {
@@ -174,7 +184,8 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
174
184
  upToDate,
175
185
  clearedLocks,
176
186
  urlSynced,
177
- registered
187
+ registered,
188
+ boundaryMarked
178
189
  };
179
190
  });
180
191
  /** Last path segment of a repo URL, with a trailing `.git` stripped. */
@@ -281,11 +292,14 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
281
292
  if (options.sparse && options.sparse.length > 0) yield* git.sparseCheckoutSet(subPath, options.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", subPath)));
282
293
  }).pipe(Effect.catch(rollback));
283
294
  }));
295
+ yield* git.configSet(root, "fetch.recurseSubmodules", "false").pipe(Effect.mapError(asSubmoduleError("git config fetch.recurseSubmodules false", root)));
296
+ yield* git.configSet(root, `submodule.${repoPath}.update`, "none").pipe(Effect.mapError(asSubmoduleError(`git config submodule.${repoPath}.update none`, root)));
284
297
  const entry = {
285
298
  url: options.url,
286
299
  ref: options.ref,
287
300
  purpose: options.purpose,
288
- ...options.sparse && options.sparse.length > 0 ? { sparse: options.sparse } : {}
301
+ ...options.sparse && options.sparse.length > 0 ? { sparse: options.sparse } : {},
302
+ ...options.orientation ? { orientation: options.orientation } : {}
289
303
  };
290
304
  yield* configStore.update(root, (fresh) => ({ repos: {
291
305
  ...fresh.repos,
@@ -499,7 +513,8 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
499
513
  name,
500
514
  path: repoPath,
501
515
  commitMessage: `chore(repos): remove ${name}`,
502
- removedNotes: entry.notes ?? []
516
+ removedNotes: entry.notes ?? [],
517
+ removedEntry: entry
503
518
  };
504
519
  });
505
520
  const rename = (root, oldName, newName) => Effect.gen(function* () {
@@ -590,6 +605,7 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
590
605
  skippedClean = report.repos.filter((entry) => !entry.dirty).map((entry) => entry.name);
591
606
  }
592
607
  const restored = [];
608
+ const stillDirty = [];
593
609
  for (const name of targetNames) {
594
610
  const entry = getRepoEntry(manifest.repos, name);
595
611
  if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
@@ -603,22 +619,32 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
603
619
  cwd: subPath,
604
620
  reason: `no staged or committed gitlink commit found for "${name}" -- nothing to restore to`
605
621
  }));
622
+ if ((yield* git.submoduleStatus(subPath).pipe(Effect.orElseSucceed(() => []))).some((nestedEntry) => nestedEntry.state !== "uninitialized")) yield* git.submoduleDeinit(subPath, {
623
+ all: true,
624
+ force: true
625
+ }).pipe(Effect.mapError(asSubmoduleError("git submodule deinit --all --force", subPath)));
606
626
  yield* git.reset(subPath, {
607
627
  mode: "hard",
608
628
  ref: targetCommit
609
629
  }).pipe(Effect.mapError(asSubmoduleError(`git reset --hard ${targetCommit}`, subPath)));
610
630
  yield* git.clean(subPath, { directories: true }).pipe(Effect.mapError(asSubmoduleError("git clean --force -d", subPath)));
611
631
  if (entry.sparse && entry.sparse.length > 0) yield* git.sparseCheckoutSet(subPath, entry.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", subPath)));
612
- return targetCommit;
632
+ const afterStatus = yield* git.status(subPath).pipe(Effect.mapError(asSubmoduleError("git status --porcelain", subPath)));
633
+ return {
634
+ targetCommit,
635
+ dirty: afterStatus.length > 0
636
+ };
613
637
  }));
614
638
  restored.push({
615
639
  name,
616
- commit
640
+ commit: commit.targetCommit
617
641
  });
642
+ if (commit.dirty) stillDirty.push(name);
618
643
  }
619
644
  return {
620
645
  restored,
621
- skippedClean
646
+ skippedClean,
647
+ stillDirty
622
648
  };
623
649
  });
624
650
  return {