@savvy-web/silk-effects 5.5.2 → 5.6.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
@@ -8298,17 +8298,17 @@ declare class ReposLockdownError extends ReposLockdownErrorBase<{
8298
8298
  //#endregion
8299
8299
  //#region src/repos/schemas/drift.d.ts
8300
8300
  /**
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`.
8301
+ * The kinds of drift {@link ReposDrift} can detect between the five
8302
+ * authorities it reconciles: the manifest, `.gitmodules`, the worktree,
8303
+ * `git submodule status`, and the superproject's local git config.
8304
8304
  * @public
8305
8305
  */
8306
- declare const DriftKind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable"]>;
8306
+ declare const DriftKind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable", "localRegistrationDivergence", "nestedSubmoduleDivergence"]>;
8307
8307
  /** @public */
8308
8308
  type DriftKind = typeof DriftKind.Type;
8309
8309
  declare const RepoDrift_base: Schema.Class<RepoDrift, Schema.Struct<{
8310
8310
  readonly name: Schema.String;
8311
- readonly kind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable"]>;
8311
+ readonly kind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable", "localRegistrationDivergence", "nestedSubmoduleDivergence"]>;
8312
8312
  readonly detail: Schema.String;
8313
8313
  readonly manifestValue: Schema.optionalKey<Schema.String>;
8314
8314
  readonly observedValue: Schema.optionalKey<Schema.String>;
@@ -8424,19 +8424,11 @@ type ReposManifestFile = typeof ReposManifestFile.Type;
8424
8424
  * that no longer match the pinned ref.
8425
8425
  *
8426
8426
  * @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.
8427
+ * The gitlink is reported as an index-aware TRIPLE `stagedCommit` (the
8428
+ * index), `committedCommit` (`HEAD`), `checkedOutCommit` (the submodule's own
8429
+ * worktree) because those three legitimately disagree during a pin, and a
8430
+ * single field cannot say which one it means. A deprecated `commit` alias of
8431
+ * `stagedCommit` bridged one release and is now gone; read `stagedCommit`.
8440
8432
  * @public
8441
8433
  */
8442
8434
  declare const RepoStatusEntry: Schema.Struct<{
@@ -8444,8 +8436,6 @@ declare const RepoStatusEntry: Schema.Struct<{
8444
8436
  readonly ref: Schema.String;
8445
8437
  readonly purpose: Schema.String;
8446
8438
  readonly present: Schema.Boolean;
8447
- /** @deprecated alias of `stagedCommit`; retained for one release. */
8448
- readonly commit: Schema.NullOr<Schema.String>;
8449
8439
  /** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
8450
8440
  readonly stagedCommit: Schema.optionalKey<Schema.String>;
8451
8441
  /** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
@@ -8467,8 +8457,6 @@ declare const ReposStatusReport: Schema.Struct<{
8467
8457
  readonly ref: Schema.String;
8468
8458
  readonly purpose: Schema.String;
8469
8459
  readonly present: Schema.Boolean;
8470
- /** @deprecated alias of `stagedCommit`; retained for one release. */
8471
- readonly commit: Schema.NullOr<Schema.String>;
8472
8460
  /** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
8473
8461
  readonly stagedCommit: Schema.optionalKey<Schema.String>;
8474
8462
  /** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
@@ -8486,7 +8474,8 @@ type ReposStatusReport = typeof ReposStatusReport.Type;
8486
8474
  * Result of reconciling working-tree submodules with the manifest: missing
8487
8475
  * repos initialized, sparse-checkout patterns re-applied, already-present
8488
8476
  * repos left alone, stale locks cleared, drifted submodule URLs reconciled,
8489
- * and orphan manifest entries (no gitlink at all) registered.
8477
+ * orphan manifest entries (no gitlink at all) registered, and the vendored
8478
+ * boundary re-declared to git.
8490
8479
  * @public
8491
8480
  */
8492
8481
  declare const ReposSyncReport: Schema.Struct<{
@@ -8496,6 +8485,14 @@ declare const ReposSyncReport: Schema.Struct<{
8496
8485
  readonly clearedLocks: Schema.$Array<Schema.String>;
8497
8486
  readonly urlSynced: Schema.$Array<Schema.String>;
8498
8487
  readonly registered: Schema.$Array<Schema.String>;
8488
+ /**
8489
+ * Repos whose `submodule.<path>.update = none` marker was (re-)asserted in
8490
+ * the superproject's local config — the declarative half of the vendored
8491
+ * boundary, telling every git client to skip these trees rather than
8492
+ * letting them discover the boundary by failing against a permission
8493
+ * error.
8494
+ */
8495
+ readonly boundaryMarked: Schema.$Array<Schema.String>;
8499
8496
  }>;
8500
8497
  /** @public */
8501
8498
  type ReposSyncReport = typeof ReposSyncReport.Type;
@@ -8526,9 +8523,23 @@ declare const ReposAddResult: Schema.Struct<{
8526
8523
  type ReposAddResult = typeof ReposAddResult.Type;
8527
8524
  /**
8528
8525
  * 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.
8526
+ * gitdir, and `.gitmodules` section are all gone, and the entry that was
8527
+ * removed is handed back so nothing durable is lost.
8528
+ *
8529
+ * @remarks
8530
+ * `removedEntry` is the whole manifest entry, and it exists because
8531
+ * remove-then-re-add is the standing remedy for several vendored-tree
8532
+ * problems. `add` does not resurrect anything on its own, so without the
8533
+ * entry in hand a caller following that remedy silently destroys the
8534
+ * `orientation` block — larger and far harder to reconstruct than the notes,
8535
+ * and invisible afterwards in every report an agent would think to check.
8536
+ * Pass `removedEntry.orientation` straight back to `add` to make a re-vendor
8537
+ * lossless.
8538
+ *
8539
+ * `removedNotes` is retained alongside it, and is exactly
8540
+ * `removedEntry.notes ?? []`. Notes are ephemeral by policy — a last look
8541
+ * before they go, so a durable one can be promoted elsewhere — whereas
8542
+ * orientation is meant to survive.
8532
8543
  * @public
8533
8544
  */
8534
8545
  declare const ReposRemoveResult: Schema.Struct<{
@@ -8541,6 +8552,23 @@ declare const ReposRemoveResult: Schema.Struct<{
8541
8552
  readonly ref: Schema.String;
8542
8553
  readonly note: Schema.String;
8543
8554
  }>>;
8555
+ readonly removedEntry: Schema.Struct<{
8556
+ readonly url: Schema.String;
8557
+ readonly ref: Schema.String;
8558
+ readonly purpose: Schema.String;
8559
+ readonly sparse: Schema.optional<Schema.$Array<Schema.String>>;
8560
+ readonly orientation: Schema.optional<Schema.Struct<{
8561
+ readonly layout: Schema.optional<Schema.String>;
8562
+ readonly keyPaths: Schema.optional<Schema.$Record<Schema.String, Schema.String>>;
8563
+ readonly startHere: Schema.optional<Schema.String>;
8564
+ }>>;
8565
+ readonly notes: Schema.optional<Schema.$Array<Schema.Struct<{
8566
+ readonly id: Schema.String;
8567
+ readonly date: Schema.String;
8568
+ readonly ref: Schema.String;
8569
+ readonly note: Schema.String;
8570
+ }>>>;
8571
+ }>;
8544
8572
  }>;
8545
8573
  /** @public */
8546
8574
  type ReposRemoveResult = typeof ReposRemoveResult.Type;
@@ -8569,6 +8597,13 @@ type ReposRenameResult = typeof ReposRenameResult.Type;
8569
8597
  * {@link ReposManagerShape.restore} — repos left untouched because `status`
8570
8598
  * reported them clean; an explicit-names call never skips anything (an
8571
8599
  * explicit ask is always honored), so it always reports an empty array.
8600
+ *
8601
+ * `stillDirty` is the honesty channel: a repo that was reset but whose
8602
+ * worktree is STILL dirty afterwards. `restored` alone cannot express that —
8603
+ * it says what was attempted, not what was achieved — and a caller reading
8604
+ * only `restored` would take a report of a repo that never came clean as
8605
+ * success. Membership in both `restored` and `stillDirty` is the normal shape
8606
+ * for such a repo.
8572
8607
  * @public
8573
8608
  */
8574
8609
  declare const ReposRestoreResult: Schema.Struct<{
@@ -8577,6 +8612,7 @@ declare const ReposRestoreResult: Schema.Struct<{
8577
8612
  readonly commit: Schema.String;
8578
8613
  }>>;
8579
8614
  readonly skippedClean: Schema.$Array<Schema.String>;
8615
+ readonly stillDirty: Schema.$Array<Schema.String>;
8580
8616
  }>;
8581
8617
  /** @public */
8582
8618
  type ReposRestoreResult = typeof ReposRestoreResult.Type;
@@ -8634,11 +8670,18 @@ interface ReposDriftShape {
8634
8670
  }
8635
8671
  declare const ReposDrift_base: Context.ServiceClass<ReposDrift, "@savvy-web/silk-effects/ReposDrift", ReposDriftShape>;
8636
8672
  /**
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.
8673
+ * Reconciles the five authorities a vendored repo's state is spread across —
8674
+ * the manifest, `.gitmodules`, the worktree, `git submodule status`, and the
8675
+ * superproject's LOCAL git config — and reports every disagreement found,
8676
+ * including one level down into a vendored repo's own submodules. Read-only:
8677
+ * no staging, no lockdown interaction, so it runs unmodified against a locked
8678
+ * (`ReposLockdown`) tree.
8679
+ *
8680
+ * @remarks
8681
+ * The local config is the fifth authority because the other four can all
8682
+ * agree while a checkout is still REGISTERED under a pre-canonicalization
8683
+ * section name — a state that reads clean here while `git submodule status`
8684
+ * reports a perfectly healthy checkout as uninitialized.
8642
8685
  * @public
8643
8686
  */
8644
8687
  declare class ReposDrift extends ReposDrift_base {
@@ -8666,8 +8709,11 @@ interface ReposLockdownShape {
8666
8709
  * A submodule worktree's `<root>/.repos/<name>/.git` is normally a FILE
8667
8710
  * containing a single `gitdir: <path>` line pointing at the real metadata
8668
8711
  * 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
8712
+ * REGISTERED under — not necessarily the manifest key. Git never renames
8713
+ * that directory, so a manifest entry re-slugged after it was first vendored
8714
+ * keeps its original gitdir name indefinitely (manifest key `<new>`, gitdir
8715
+ * `.git/modules/.repos/<old>`). `ReposDrift` reports that state as
8716
+ * `localRegistrationDivergence`; re-vendoring the entry clears it. This helper
8671
8717
  * reads that pointer and resolves it (relative pointers are relative to the
8672
8718
  * directory containing the `.git` file) so callers always land on the real
8673
8719
  * metadata directory.
@@ -8685,6 +8731,39 @@ declare const ReposLockdown_base: Context.ServiceClass<ReposLockdown, "@savvy-we
8685
8731
  /**
8686
8732
  * Enforces OS-level read-only permissions on vendored repos so they cannot
8687
8733
  * be accidentally edited outside the sync flow.
8734
+ *
8735
+ * @remarks
8736
+ * SCOPE: the WORKTREE only. The submodule's git metadata directory
8737
+ * (`.git/modules/...`) is deliberately NOT locked — `unlock` still walks it
8738
+ * so trees locked by an older version are freed, but `lock` never re-locks
8739
+ * it. Do not "restore" the metadata lock without re-reading this note.
8740
+ *
8741
+ * Locking the metadata directory made the boundary enforce itself only via
8742
+ * `EACCES`, whose message names neither `.repos/` nor a reason, and it broke
8743
+ * every client that needs incidental gitdir writes: a plain `git pull` that
8744
+ * moves a gitlink recurses by default and dies writing `FETCH_HEAD`, and any
8745
+ * client keeping per-gitdir state (GitKraken writes a `gk/` directory into
8746
+ * every gitdir it manages, which no git setting governs) is structurally
8747
+ * incompatible with a read-only gitdir. Since vendored reference sources are
8748
+ * the overwhelming majority of submodules in this ecosystem, ordinary tooling
8749
+ * collided with the lockdown constantly.
8750
+ *
8751
+ * What the worktree lock still buys, verified against git 2.54:
8752
+ *
8753
+ * - editing a vendored file fails (`EACCES`) — the property the system
8754
+ * actually needs;
8755
+ * - `git reset --hard` inside a vendored tree fails (cannot unlink);
8756
+ * - `git checkout <other>` does NOT fail — it moves `HEAD` while leaving the
8757
+ * worktree stale. That is the one guarantee given up here, and it is given
8758
+ * up knowingly: git immediately reports the submodule as `+<oid>`, which
8759
+ * {@link ReposDrift} already classifies as `checkoutDiverged` and
8760
+ * `ReposManager.restore` repairs.
8761
+ *
8762
+ * So the invariant weakens from "the pin cannot drift" to "a drifted pin is
8763
+ * always detected and one command from repaired." The declarative half of the
8764
+ * boundary — `submodule.<path>.update = none`, so clients skip these trees
8765
+ * rather than discovering the boundary by failing — is asserted by
8766
+ * `ReposManager.sync`, not here.
8688
8767
  * @public
8689
8768
  */
8690
8769
  declare class ReposLockdown extends ReposLockdown_base {
@@ -8720,12 +8799,25 @@ declare const STALE_LOCK_MAX_AGE_MS: number;
8720
8799
  interface ReposManagerShape {
8721
8800
  readonly status: (root: string) => Effect.Effect<ReposStatusReport, ReposConfigError | GitSubmoduleError>;
8722
8801
  readonly sync: (root: string) => Effect.Effect<ReposSyncReport, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
8802
+ /**
8803
+ * Vendors a new repo.
8804
+ *
8805
+ * `options.orientation` exists so a re-vendor can be LOSSLESS in one call.
8806
+ * Remove-then-re-add is the remedy for several vendored-tree problems, and
8807
+ * without this parameter that remedy silently destroys the entry's
8808
+ * orientation block — the durable, hand-curated part an agent reads to know
8809
+ * where to look in the tree, and the part no report mentions is gone.
8810
+ * Notes are ephemeral by policy and are NOT carried across a re-vendor;
8811
+ * orientation is, when the caller passes it back (see
8812
+ * {@link ReposRemoveResult.removedEntry}, which hands it to them).
8813
+ */
8723
8814
  readonly add: (root: string, options: {
8724
8815
  readonly url: string;
8725
8816
  readonly ref: string;
8726
8817
  readonly purpose: string;
8727
8818
  readonly name?: string;
8728
8819
  readonly sparse?: ReadonlyArray<string>;
8820
+ readonly orientation?: RepoOrientation;
8729
8821
  }) => Effect.Effect<ReposAddResult, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
8730
8822
  readonly pin: (root: string, name: string, ref: string) => Effect.Effect<ReposPinResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
8731
8823
  readonly note: (root: string, name: string, op: {
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.6.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",
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 {