@savvy-web/silk-effects 5.5.1 → 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 +13 -4
- package/index.d.ts +125 -33
- package/package.json +2 -2
- package/repos/index.js +1 -1
- package/repos/schemas/drift.js +6 -4
- package/repos/schemas/reports.js +45 -23
- package/repos/services/drift.js +87 -5
- package/repos/services/lockdown.js +41 -4
- package/repos/services/manager.js +34 -8
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
|
|
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
|
|
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
|
-
`
|
|
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,
|
|
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
|
|
8302
|
-
* authorities it reconciles: the manifest, `.gitmodules`, the worktree,
|
|
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
|
-
*
|
|
8428
|
-
*
|
|
8429
|
-
*
|
|
8430
|
-
*
|
|
8431
|
-
*
|
|
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
|
-
*
|
|
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
|
|
8530
|
-
*
|
|
8531
|
-
*
|
|
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
|
|
8638
|
-
* the manifest, `.gitmodules`, the worktree,
|
|
8639
|
-
* and reports every disagreement found
|
|
8640
|
-
*
|
|
8641
|
-
*
|
|
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
|
|
8670
|
-
*
|
|
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.
|
|
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",
|
|
@@ -40,7 +40,7 @@
|
|
|
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.
|
|
43
|
+
"@effected/workspaces": "^0.11.1",
|
|
44
44
|
"@effected/yaml": "^0.7.0",
|
|
45
45
|
"@manypkg/get-packages": "^3.1.0",
|
|
46
46
|
"mdast-util-heading-range": "^4.0.0",
|
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
|
package/repos/schemas/drift.js
CHANGED
|
@@ -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
|
|
6
|
-
* authorities it reconciles: the manifest, `.gitmodules`, the worktree,
|
|
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
|
package/repos/schemas/reports.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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.
|
package/repos/services/drift.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
26
|
-
*
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
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 {
|