@savvy-web/silk-effects 5.5.2 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/index.d.ts +186 -63
- package/lint/handlers/Yaml.js +78 -66
- package/package.json +4 -6
- 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
|
@@ -4,6 +4,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
|
|
|
4
4
|
import { PackageManagerDetector, PublishConfig, PublishTarget, PublishabilityDetector, VersioningStrategy, WorkspaceDiscovery, WorkspaceDiscoveryFailure, WorkspacePackage, WorkspaceSnapshotAtFailure, WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, WorkspaceStateSnapshot, WorkspacesOptions } from "@effected/workspaces";
|
|
5
5
|
import { Git } from "@effected/git";
|
|
6
6
|
import { GlobExpansionError } from "@effected/walker";
|
|
7
|
+
import { YamlFormattingOptions } from "@effected/yaml";
|
|
7
8
|
import { Section, SectionId } from "@effected/templates";
|
|
8
9
|
import { ToolDiscovery } from "@effected/commands";
|
|
9
10
|
//#endregion
|
|
@@ -6778,9 +6779,14 @@ interface ShellScriptsOptions extends BaseHandlerOptions {
|
|
|
6778
6779
|
*/
|
|
6779
6780
|
interface YamlOptions extends BaseHandlerOptions {
|
|
6780
6781
|
/**
|
|
6781
|
-
*
|
|
6782
|
+
* Formatting options passed through to `@effected/yaml`.
|
|
6783
|
+
*
|
|
6784
|
+
* @remarks
|
|
6785
|
+
* Defaults to the handler's own `defaultFormatOptions`. There is no
|
|
6786
|
+
* config-file discovery: `@effected/yaml` is a pure tier that loads nothing
|
|
6787
|
+
* from disk, and a consumer's `.prettierrc` is NOT consulted.
|
|
6782
6788
|
*/
|
|
6783
|
-
|
|
6789
|
+
format?: YamlFormattingOptions;
|
|
6784
6790
|
/**
|
|
6785
6791
|
* Skip YAML formatting.
|
|
6786
6792
|
* @defaultValue false
|
|
@@ -7431,14 +7437,15 @@ declare class TypeScript {
|
|
|
7431
7437
|
/**
|
|
7432
7438
|
* Handler for YAML files.
|
|
7433
7439
|
*
|
|
7434
|
-
* Formats
|
|
7440
|
+
* Formats and validates with `@effected/yaml`, a bundled dependency.
|
|
7435
7441
|
*
|
|
7436
7442
|
* @remarks
|
|
7437
7443
|
* Excludes pnpm-lock.yaml and pnpm-workspace.yaml by default.
|
|
7438
7444
|
* pnpm-workspace.yaml has its own dedicated handler.
|
|
7439
7445
|
*
|
|
7440
|
-
*
|
|
7441
|
-
*
|
|
7446
|
+
* Formatting preserves comments and blank lines, and formats every document of
|
|
7447
|
+
* a multi-document stream. Validation covers the WHOLE stream: a file whose
|
|
7448
|
+
* second document is invalid fails, which a single-document parse would miss.
|
|
7442
7449
|
*
|
|
7443
7450
|
* @example
|
|
7444
7451
|
* ```typescript
|
|
@@ -7462,50 +7469,71 @@ declare class Yaml {
|
|
|
7462
7469
|
* @defaultValue `['pnpm-lock.yaml', 'pnpm-workspace.yaml', '__test__/fixtures']`
|
|
7463
7470
|
*/
|
|
7464
7471
|
static readonly defaultExcludes: readonly ["pnpm-lock.yaml", "pnpm-workspace.yaml", "__test__/fixtures"];
|
|
7472
|
+
/**
|
|
7473
|
+
* The formatting options applied when a caller supplies none.
|
|
7474
|
+
*
|
|
7475
|
+
* @remarks
|
|
7476
|
+
* `indentSequences` matches the block-sequence indentation an ex-Prettier
|
|
7477
|
+
* repository already has on disk; without it, formatting rewrites every
|
|
7478
|
+
* sequence in the tree. `quoteStyle` only governs scalars the stringifier
|
|
7479
|
+
* creates — it never re-quotes scalars already present in the source — so it
|
|
7480
|
+
* is set for consistency with `PnpmWorkspace`, not to change existing files.
|
|
7481
|
+
*/
|
|
7482
|
+
static readonly defaultFormatOptions: YamlFormattingOptions;
|
|
7465
7483
|
/**
|
|
7466
7484
|
* Pre-configured handler with default options.
|
|
7467
7485
|
*/
|
|
7468
7486
|
static readonly handler: LintStagedHandler;
|
|
7469
7487
|
/**
|
|
7470
|
-
*
|
|
7471
|
-
*
|
|
7472
|
-
* Paths are anchored to the workspace root (via {@link getWorkspaceRoot}),
|
|
7473
|
-
* falling back to `process.cwd()` when not inside a workspace.
|
|
7474
|
-
*
|
|
7475
|
-
* Searches in order:
|
|
7476
|
-
* 1. `{workspaceRoot}/lib/configs/.yaml-lint.json`
|
|
7477
|
-
* 2. `{workspaceRoot}/.yaml-lint.json`
|
|
7488
|
+
* Check if the YAML engine is available.
|
|
7478
7489
|
*
|
|
7479
|
-
* @returns
|
|
7490
|
+
* @returns Always `true` since `@effected/yaml` is a bundled dependency
|
|
7480
7491
|
*/
|
|
7481
|
-
static
|
|
7492
|
+
static isAvailable(): boolean;
|
|
7482
7493
|
/**
|
|
7483
|
-
*
|
|
7494
|
+
* Format a YAML file in-place.
|
|
7495
|
+
*
|
|
7496
|
+
* @remarks
|
|
7497
|
+
* Synchronous by contract: the engine is a pure, IO-free tier, so the only
|
|
7498
|
+
* IO here is this function's own file read and write.
|
|
7484
7499
|
*
|
|
7485
|
-
* @param filepath - Path to the
|
|
7486
|
-
* @
|
|
7500
|
+
* @param filepath - Path to the YAML file
|
|
7501
|
+
* @param options - Formatting options; defaults to {@link Yaml.defaultFormatOptions}
|
|
7487
7502
|
*/
|
|
7488
|
-
static
|
|
7503
|
+
static formatFile(filepath: string, options?: YamlFormattingOptions): void;
|
|
7489
7504
|
/**
|
|
7490
|
-
*
|
|
7505
|
+
* Validate a YAML file.
|
|
7506
|
+
*
|
|
7507
|
+
* @remarks
|
|
7508
|
+
* Validates every document of the stream, so a multi-document file whose
|
|
7509
|
+
* later documents are invalid is rejected rather than silently accepted.
|
|
7491
7510
|
*
|
|
7492
|
-
* @
|
|
7511
|
+
* @param filepath - Path to the YAML file
|
|
7512
|
+
* @throws Error if the YAML is invalid
|
|
7493
7513
|
*/
|
|
7494
|
-
static
|
|
7514
|
+
static validateFile(filepath: string): void;
|
|
7495
7515
|
/**
|
|
7496
|
-
*
|
|
7516
|
+
* Serialize formatting options for the `savvy lint fmt yaml --format` flag.
|
|
7497
7517
|
*
|
|
7498
|
-
* @
|
|
7518
|
+
* @remarks
|
|
7519
|
+
* The CLI subcommand runs in a separate process, so options set on
|
|
7520
|
+
* {@link fmtCommand} have to cross a shell boundary to reach
|
|
7521
|
+
* {@link formatFile}. Without that hop the two entry points format the same
|
|
7522
|
+
* file differently — the drift this package's shared-static rule exists to
|
|
7523
|
+
* prevent. {@link parseFormatOptions} is the inverse.
|
|
7524
|
+
*
|
|
7525
|
+
* @param options - Formatting options to encode
|
|
7526
|
+
* @returns The options as a JSON string
|
|
7499
7527
|
*/
|
|
7500
|
-
static
|
|
7528
|
+
static encodeFormatOptions(options: YamlFormattingOptions): string;
|
|
7501
7529
|
/**
|
|
7502
|
-
*
|
|
7530
|
+
* Parse formatting options serialized by {@link encodeFormatOptions}.
|
|
7503
7531
|
*
|
|
7504
|
-
* @param
|
|
7505
|
-
* @
|
|
7506
|
-
* @throws Error if the
|
|
7532
|
+
* @param encoded - JSON produced by {@link encodeFormatOptions}
|
|
7533
|
+
* @returns The decoded options
|
|
7534
|
+
* @throws Error if the JSON is malformed or fails schema validation
|
|
7507
7535
|
*/
|
|
7508
|
-
static
|
|
7536
|
+
static parseFormatOptions(encoded: string): YamlFormattingOptions;
|
|
7509
7537
|
/**
|
|
7510
7538
|
* Create a handler that returns a CLI command to format YAML files.
|
|
7511
7539
|
*
|
|
@@ -7515,6 +7543,9 @@ declare class Yaml {
|
|
|
7515
7543
|
* can detect the modification and auto-stage it.
|
|
7516
7544
|
* Use this in lint-staged array syntax for sequential execution.
|
|
7517
7545
|
*
|
|
7546
|
+
* `options.format` is forwarded to the subcommand as `--format`, so this
|
|
7547
|
+
* path and {@link create} produce identical bytes for the same options.
|
|
7548
|
+
*
|
|
7518
7549
|
* @param options - Configuration options
|
|
7519
7550
|
* @returns A lint-staged compatible handler function
|
|
7520
7551
|
*/
|
|
@@ -8298,17 +8329,17 @@ declare class ReposLockdownError extends ReposLockdownErrorBase<{
|
|
|
8298
8329
|
//#endregion
|
|
8299
8330
|
//#region src/repos/schemas/drift.d.ts
|
|
8300
8331
|
/**
|
|
8301
|
-
* The kinds of drift {@link ReposDrift} can detect between the
|
|
8302
|
-
* authorities it reconciles: the manifest, `.gitmodules`, the worktree,
|
|
8303
|
-
* `git submodule status
|
|
8332
|
+
* The kinds of drift {@link ReposDrift} can detect between the five
|
|
8333
|
+
* authorities it reconciles: the manifest, `.gitmodules`, the worktree,
|
|
8334
|
+
* `git submodule status`, and the superproject's local git config.
|
|
8304
8335
|
* @public
|
|
8305
8336
|
*/
|
|
8306
|
-
declare const DriftKind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable"]>;
|
|
8337
|
+
declare const DriftKind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable", "localRegistrationDivergence", "nestedSubmoduleDivergence"]>;
|
|
8307
8338
|
/** @public */
|
|
8308
8339
|
type DriftKind = typeof DriftKind.Type;
|
|
8309
8340
|
declare const RepoDrift_base: Schema.Class<RepoDrift, Schema.Struct<{
|
|
8310
8341
|
readonly name: Schema.String;
|
|
8311
|
-
readonly kind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable"]>;
|
|
8342
|
+
readonly kind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable", "localRegistrationDivergence", "nestedSubmoduleDivergence"]>;
|
|
8312
8343
|
readonly detail: Schema.String;
|
|
8313
8344
|
readonly manifestValue: Schema.optionalKey<Schema.String>;
|
|
8314
8345
|
readonly observedValue: Schema.optionalKey<Schema.String>;
|
|
@@ -8424,19 +8455,11 @@ type ReposManifestFile = typeof ReposManifestFile.Type;
|
|
|
8424
8455
|
* that no longer match the pinned ref.
|
|
8425
8456
|
*
|
|
8426
8457
|
* @remarks
|
|
8427
|
-
*
|
|
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.
|
|
8458
|
+
* The gitlink is reported as an index-aware TRIPLE — `stagedCommit` (the
|
|
8459
|
+
* index), `committedCommit` (`HEAD`), `checkedOutCommit` (the submodule's own
|
|
8460
|
+
* worktree) — because those three legitimately disagree during a pin, and a
|
|
8461
|
+
* single field cannot say which one it means. A deprecated `commit` alias of
|
|
8462
|
+
* `stagedCommit` bridged one release and is now gone; read `stagedCommit`.
|
|
8440
8463
|
* @public
|
|
8441
8464
|
*/
|
|
8442
8465
|
declare const RepoStatusEntry: Schema.Struct<{
|
|
@@ -8444,8 +8467,6 @@ declare const RepoStatusEntry: Schema.Struct<{
|
|
|
8444
8467
|
readonly ref: Schema.String;
|
|
8445
8468
|
readonly purpose: Schema.String;
|
|
8446
8469
|
readonly present: Schema.Boolean;
|
|
8447
|
-
/** @deprecated alias of `stagedCommit`; retained for one release. */
|
|
8448
|
-
readonly commit: Schema.NullOr<Schema.String>;
|
|
8449
8470
|
/** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
|
|
8450
8471
|
readonly stagedCommit: Schema.optionalKey<Schema.String>;
|
|
8451
8472
|
/** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
|
|
@@ -8467,8 +8488,6 @@ declare const ReposStatusReport: Schema.Struct<{
|
|
|
8467
8488
|
readonly ref: Schema.String;
|
|
8468
8489
|
readonly purpose: Schema.String;
|
|
8469
8490
|
readonly present: Schema.Boolean;
|
|
8470
|
-
/** @deprecated alias of `stagedCommit`; retained for one release. */
|
|
8471
|
-
readonly commit: Schema.NullOr<Schema.String>;
|
|
8472
8491
|
/** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
|
|
8473
8492
|
readonly stagedCommit: Schema.optionalKey<Schema.String>;
|
|
8474
8493
|
/** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
|
|
@@ -8486,7 +8505,8 @@ type ReposStatusReport = typeof ReposStatusReport.Type;
|
|
|
8486
8505
|
* Result of reconciling working-tree submodules with the manifest: missing
|
|
8487
8506
|
* repos initialized, sparse-checkout patterns re-applied, already-present
|
|
8488
8507
|
* repos left alone, stale locks cleared, drifted submodule URLs reconciled,
|
|
8489
|
-
*
|
|
8508
|
+
* orphan manifest entries (no gitlink at all) registered, and the vendored
|
|
8509
|
+
* boundary re-declared to git.
|
|
8490
8510
|
* @public
|
|
8491
8511
|
*/
|
|
8492
8512
|
declare const ReposSyncReport: Schema.Struct<{
|
|
@@ -8496,6 +8516,14 @@ declare const ReposSyncReport: Schema.Struct<{
|
|
|
8496
8516
|
readonly clearedLocks: Schema.$Array<Schema.String>;
|
|
8497
8517
|
readonly urlSynced: Schema.$Array<Schema.String>;
|
|
8498
8518
|
readonly registered: Schema.$Array<Schema.String>;
|
|
8519
|
+
/**
|
|
8520
|
+
* Repos whose `submodule.<path>.update = none` marker was (re-)asserted in
|
|
8521
|
+
* the superproject's local config — the declarative half of the vendored
|
|
8522
|
+
* boundary, telling every git client to skip these trees rather than
|
|
8523
|
+
* letting them discover the boundary by failing against a permission
|
|
8524
|
+
* error.
|
|
8525
|
+
*/
|
|
8526
|
+
readonly boundaryMarked: Schema.$Array<Schema.String>;
|
|
8499
8527
|
}>;
|
|
8500
8528
|
/** @public */
|
|
8501
8529
|
type ReposSyncReport = typeof ReposSyncReport.Type;
|
|
@@ -8526,9 +8554,23 @@ declare const ReposAddResult: Schema.Struct<{
|
|
|
8526
8554
|
type ReposAddResult = typeof ReposAddResult.Type;
|
|
8527
8555
|
/**
|
|
8528
8556
|
* Result of removing a vendored repo from the manifest: the gitlink, module
|
|
8529
|
-
* gitdir, and `.gitmodules` section are all gone, and the entry
|
|
8530
|
-
*
|
|
8531
|
-
*
|
|
8557
|
+
* gitdir, and `.gitmodules` section are all gone, and the entry that was
|
|
8558
|
+
* removed is handed back so nothing durable is lost.
|
|
8559
|
+
*
|
|
8560
|
+
* @remarks
|
|
8561
|
+
* `removedEntry` is the whole manifest entry, and it exists because
|
|
8562
|
+
* remove-then-re-add is the standing remedy for several vendored-tree
|
|
8563
|
+
* problems. `add` does not resurrect anything on its own, so without the
|
|
8564
|
+
* entry in hand a caller following that remedy silently destroys the
|
|
8565
|
+
* `orientation` block — larger and far harder to reconstruct than the notes,
|
|
8566
|
+
* and invisible afterwards in every report an agent would think to check.
|
|
8567
|
+
* Pass `removedEntry.orientation` straight back to `add` to make a re-vendor
|
|
8568
|
+
* lossless.
|
|
8569
|
+
*
|
|
8570
|
+
* `removedNotes` is retained alongside it, and is exactly
|
|
8571
|
+
* `removedEntry.notes ?? []`. Notes are ephemeral by policy — a last look
|
|
8572
|
+
* before they go, so a durable one can be promoted elsewhere — whereas
|
|
8573
|
+
* orientation is meant to survive.
|
|
8532
8574
|
* @public
|
|
8533
8575
|
*/
|
|
8534
8576
|
declare const ReposRemoveResult: Schema.Struct<{
|
|
@@ -8541,6 +8583,23 @@ declare const ReposRemoveResult: Schema.Struct<{
|
|
|
8541
8583
|
readonly ref: Schema.String;
|
|
8542
8584
|
readonly note: Schema.String;
|
|
8543
8585
|
}>>;
|
|
8586
|
+
readonly removedEntry: Schema.Struct<{
|
|
8587
|
+
readonly url: Schema.String;
|
|
8588
|
+
readonly ref: Schema.String;
|
|
8589
|
+
readonly purpose: Schema.String;
|
|
8590
|
+
readonly sparse: Schema.optional<Schema.$Array<Schema.String>>;
|
|
8591
|
+
readonly orientation: Schema.optional<Schema.Struct<{
|
|
8592
|
+
readonly layout: Schema.optional<Schema.String>;
|
|
8593
|
+
readonly keyPaths: Schema.optional<Schema.$Record<Schema.String, Schema.String>>;
|
|
8594
|
+
readonly startHere: Schema.optional<Schema.String>;
|
|
8595
|
+
}>>;
|
|
8596
|
+
readonly notes: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
8597
|
+
readonly id: Schema.String;
|
|
8598
|
+
readonly date: Schema.String;
|
|
8599
|
+
readonly ref: Schema.String;
|
|
8600
|
+
readonly note: Schema.String;
|
|
8601
|
+
}>>>;
|
|
8602
|
+
}>;
|
|
8544
8603
|
}>;
|
|
8545
8604
|
/** @public */
|
|
8546
8605
|
type ReposRemoveResult = typeof ReposRemoveResult.Type;
|
|
@@ -8569,6 +8628,13 @@ type ReposRenameResult = typeof ReposRenameResult.Type;
|
|
|
8569
8628
|
* {@link ReposManagerShape.restore} — repos left untouched because `status`
|
|
8570
8629
|
* reported them clean; an explicit-names call never skips anything (an
|
|
8571
8630
|
* explicit ask is always honored), so it always reports an empty array.
|
|
8631
|
+
*
|
|
8632
|
+
* `stillDirty` is the honesty channel: a repo that was reset but whose
|
|
8633
|
+
* worktree is STILL dirty afterwards. `restored` alone cannot express that —
|
|
8634
|
+
* it says what was attempted, not what was achieved — and a caller reading
|
|
8635
|
+
* only `restored` would take a report of a repo that never came clean as
|
|
8636
|
+
* success. Membership in both `restored` and `stillDirty` is the normal shape
|
|
8637
|
+
* for such a repo.
|
|
8572
8638
|
* @public
|
|
8573
8639
|
*/
|
|
8574
8640
|
declare const ReposRestoreResult: Schema.Struct<{
|
|
@@ -8577,6 +8643,7 @@ declare const ReposRestoreResult: Schema.Struct<{
|
|
|
8577
8643
|
readonly commit: Schema.String;
|
|
8578
8644
|
}>>;
|
|
8579
8645
|
readonly skippedClean: Schema.$Array<Schema.String>;
|
|
8646
|
+
readonly stillDirty: Schema.$Array<Schema.String>;
|
|
8580
8647
|
}>;
|
|
8581
8648
|
/** @public */
|
|
8582
8649
|
type ReposRestoreResult = typeof ReposRestoreResult.Type;
|
|
@@ -8634,11 +8701,18 @@ interface ReposDriftShape {
|
|
|
8634
8701
|
}
|
|
8635
8702
|
declare const ReposDrift_base: Context.ServiceClass<ReposDrift, "@savvy-web/silk-effects/ReposDrift", ReposDriftShape>;
|
|
8636
8703
|
/**
|
|
8637
|
-
* Reconciles the
|
|
8638
|
-
* the manifest, `.gitmodules`, the worktree,
|
|
8639
|
-
* and reports every disagreement found
|
|
8640
|
-
*
|
|
8641
|
-
*
|
|
8704
|
+
* Reconciles the five authorities a vendored repo's state is spread across —
|
|
8705
|
+
* the manifest, `.gitmodules`, the worktree, `git submodule status`, and the
|
|
8706
|
+
* superproject's LOCAL git config — and reports every disagreement found,
|
|
8707
|
+
* including one level down into a vendored repo's own submodules. Read-only:
|
|
8708
|
+
* no staging, no lockdown interaction, so it runs unmodified against a locked
|
|
8709
|
+
* (`ReposLockdown`) tree.
|
|
8710
|
+
*
|
|
8711
|
+
* @remarks
|
|
8712
|
+
* The local config is the fifth authority because the other four can all
|
|
8713
|
+
* agree while a checkout is still REGISTERED under a pre-canonicalization
|
|
8714
|
+
* section name — a state that reads clean here while `git submodule status`
|
|
8715
|
+
* reports a perfectly healthy checkout as uninitialized.
|
|
8642
8716
|
* @public
|
|
8643
8717
|
*/
|
|
8644
8718
|
declare class ReposDrift extends ReposDrift_base {
|
|
@@ -8666,8 +8740,11 @@ interface ReposLockdownShape {
|
|
|
8666
8740
|
* A submodule worktree's `<root>/.repos/<name>/.git` is normally a FILE
|
|
8667
8741
|
* containing a single `gitdir: <path>` line pointing at the real metadata
|
|
8668
8742
|
* directory, which git names after whatever path/name the submodule was
|
|
8669
|
-
* REGISTERED under — not necessarily the manifest key
|
|
8670
|
-
*
|
|
8743
|
+
* REGISTERED under — not necessarily the manifest key. Git never renames
|
|
8744
|
+
* that directory, so a manifest entry re-slugged after it was first vendored
|
|
8745
|
+
* keeps its original gitdir name indefinitely (manifest key `<new>`, gitdir
|
|
8746
|
+
* `.git/modules/.repos/<old>`). `ReposDrift` reports that state as
|
|
8747
|
+
* `localRegistrationDivergence`; re-vendoring the entry clears it. This helper
|
|
8671
8748
|
* reads that pointer and resolves it (relative pointers are relative to the
|
|
8672
8749
|
* directory containing the `.git` file) so callers always land on the real
|
|
8673
8750
|
* metadata directory.
|
|
@@ -8685,6 +8762,39 @@ declare const ReposLockdown_base: Context.ServiceClass<ReposLockdown, "@savvy-we
|
|
|
8685
8762
|
/**
|
|
8686
8763
|
* Enforces OS-level read-only permissions on vendored repos so they cannot
|
|
8687
8764
|
* be accidentally edited outside the sync flow.
|
|
8765
|
+
*
|
|
8766
|
+
* @remarks
|
|
8767
|
+
* SCOPE: the WORKTREE only. The submodule's git metadata directory
|
|
8768
|
+
* (`.git/modules/...`) is deliberately NOT locked — `unlock` still walks it
|
|
8769
|
+
* so trees locked by an older version are freed, but `lock` never re-locks
|
|
8770
|
+
* it. Do not "restore" the metadata lock without re-reading this note.
|
|
8771
|
+
*
|
|
8772
|
+
* Locking the metadata directory made the boundary enforce itself only via
|
|
8773
|
+
* `EACCES`, whose message names neither `.repos/` nor a reason, and it broke
|
|
8774
|
+
* every client that needs incidental gitdir writes: a plain `git pull` that
|
|
8775
|
+
* moves a gitlink recurses by default and dies writing `FETCH_HEAD`, and any
|
|
8776
|
+
* client keeping per-gitdir state (GitKraken writes a `gk/` directory into
|
|
8777
|
+
* every gitdir it manages, which no git setting governs) is structurally
|
|
8778
|
+
* incompatible with a read-only gitdir. Since vendored reference sources are
|
|
8779
|
+
* the overwhelming majority of submodules in this ecosystem, ordinary tooling
|
|
8780
|
+
* collided with the lockdown constantly.
|
|
8781
|
+
*
|
|
8782
|
+
* What the worktree lock still buys, verified against git 2.54:
|
|
8783
|
+
*
|
|
8784
|
+
* - editing a vendored file fails (`EACCES`) — the property the system
|
|
8785
|
+
* actually needs;
|
|
8786
|
+
* - `git reset --hard` inside a vendored tree fails (cannot unlink);
|
|
8787
|
+
* - `git checkout <other>` does NOT fail — it moves `HEAD` while leaving the
|
|
8788
|
+
* worktree stale. That is the one guarantee given up here, and it is given
|
|
8789
|
+
* up knowingly: git immediately reports the submodule as `+<oid>`, which
|
|
8790
|
+
* {@link ReposDrift} already classifies as `checkoutDiverged` and
|
|
8791
|
+
* `ReposManager.restore` repairs.
|
|
8792
|
+
*
|
|
8793
|
+
* So the invariant weakens from "the pin cannot drift" to "a drifted pin is
|
|
8794
|
+
* always detected and one command from repaired." The declarative half of the
|
|
8795
|
+
* boundary — `submodule.<path>.update = none`, so clients skip these trees
|
|
8796
|
+
* rather than discovering the boundary by failing — is asserted by
|
|
8797
|
+
* `ReposManager.sync`, not here.
|
|
8688
8798
|
* @public
|
|
8689
8799
|
*/
|
|
8690
8800
|
declare class ReposLockdown extends ReposLockdown_base {
|
|
@@ -8720,12 +8830,25 @@ declare const STALE_LOCK_MAX_AGE_MS: number;
|
|
|
8720
8830
|
interface ReposManagerShape {
|
|
8721
8831
|
readonly status: (root: string) => Effect.Effect<ReposStatusReport, ReposConfigError | GitSubmoduleError>;
|
|
8722
8832
|
readonly sync: (root: string) => Effect.Effect<ReposSyncReport, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
|
|
8833
|
+
/**
|
|
8834
|
+
* Vendors a new repo.
|
|
8835
|
+
*
|
|
8836
|
+
* `options.orientation` exists so a re-vendor can be LOSSLESS in one call.
|
|
8837
|
+
* Remove-then-re-add is the remedy for several vendored-tree problems, and
|
|
8838
|
+
* without this parameter that remedy silently destroys the entry's
|
|
8839
|
+
* orientation block — the durable, hand-curated part an agent reads to know
|
|
8840
|
+
* where to look in the tree, and the part no report mentions is gone.
|
|
8841
|
+
* Notes are ephemeral by policy and are NOT carried across a re-vendor;
|
|
8842
|
+
* orientation is, when the caller passes it back (see
|
|
8843
|
+
* {@link ReposRemoveResult.removedEntry}, which hands it to them).
|
|
8844
|
+
*/
|
|
8723
8845
|
readonly add: (root: string, options: {
|
|
8724
8846
|
readonly url: string;
|
|
8725
8847
|
readonly ref: string;
|
|
8726
8848
|
readonly purpose: string;
|
|
8727
8849
|
readonly name?: string;
|
|
8728
8850
|
readonly sparse?: ReadonlyArray<string>;
|
|
8851
|
+
readonly orientation?: RepoOrientation;
|
|
8729
8852
|
}) => Effect.Effect<ReposAddResult, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
|
|
8730
8853
|
readonly pin: (root: string, name: string, ref: string) => Effect.Effect<ReposPinResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
|
|
8731
8854
|
readonly note: (root: string, name: string, op: {
|
package/lint/handlers/Yaml.js
CHANGED
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
import { Command } from "../utils/Command.js";
|
|
2
2
|
import { Filter } from "../utils/Filter.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { format, resolveConfig } from "prettier";
|
|
7
|
-
import { lint } from "yaml-lint";
|
|
3
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { Yaml, YamlFormat, YamlFormattingOptions } from "@effected/yaml";
|
|
8
5
|
|
|
9
6
|
//#region src/lint/handlers/Yaml.ts
|
|
10
7
|
/**
|
|
11
8
|
* Handler for YAML files.
|
|
12
9
|
*
|
|
13
|
-
* Formats
|
|
10
|
+
* Formats and validates with `@effected/yaml`, a bundled dependency.
|
|
14
11
|
*/
|
|
15
12
|
/**
|
|
16
13
|
* Handler for YAML files.
|
|
17
14
|
*
|
|
18
|
-
* Formats
|
|
15
|
+
* Formats and validates with `@effected/yaml`, a bundled dependency.
|
|
19
16
|
*
|
|
20
17
|
* @remarks
|
|
21
18
|
* Excludes pnpm-lock.yaml and pnpm-workspace.yaml by default.
|
|
22
19
|
* pnpm-workspace.yaml has its own dedicated handler.
|
|
23
20
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* Formatting preserves comments and blank lines, and formats every document of
|
|
22
|
+
* a multi-document stream. Validation covers the WHOLE stream: a file whose
|
|
23
|
+
* second document is invalid fails, which a single-document parse would miss.
|
|
26
24
|
*
|
|
27
25
|
* @example
|
|
28
26
|
* ```typescript
|
|
@@ -35,7 +33,7 @@ import { lint } from "yaml-lint";
|
|
|
35
33
|
* };
|
|
36
34
|
* ```
|
|
37
35
|
*/
|
|
38
|
-
var Yaml = class Yaml {
|
|
36
|
+
var Yaml$1 = class Yaml$1 {
|
|
39
37
|
/**
|
|
40
38
|
* Glob pattern for matching YAML files.
|
|
41
39
|
* @defaultValue `'**\/*.{yml,yaml}'`
|
|
@@ -51,75 +49,86 @@ var Yaml = class Yaml {
|
|
|
51
49
|
"__test__/fixtures"
|
|
52
50
|
];
|
|
53
51
|
/**
|
|
54
|
-
*
|
|
55
|
-
*/
|
|
56
|
-
static handler = Yaml.create();
|
|
57
|
-
/**
|
|
58
|
-
* Find the yaml-lint config file.
|
|
59
|
-
*
|
|
60
|
-
* Paths are anchored to the workspace root (via {@link getWorkspaceRoot}),
|
|
61
|
-
* falling back to `process.cwd()` when not inside a workspace.
|
|
52
|
+
* The formatting options applied when a caller supplies none.
|
|
62
53
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* `indentSequences` matches the block-sequence indentation an ex-Prettier
|
|
56
|
+
* repository already has on disk; without it, formatting rewrites every
|
|
57
|
+
* sequence in the tree. `quoteStyle` only governs scalars the stringifier
|
|
58
|
+
* creates — it never re-quotes scalars already present in the source — so it
|
|
59
|
+
* is set for consistency with `PnpmWorkspace`, not to change existing files.
|
|
68
60
|
*/
|
|
69
|
-
static
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const rootPath = join(root, ".yaml-lint.json");
|
|
74
|
-
if (existsSync(rootPath)) return rootPath;
|
|
75
|
-
}
|
|
61
|
+
static defaultFormatOptions = YamlFormattingOptions.make({
|
|
62
|
+
quoteStyle: "double",
|
|
63
|
+
indentSequences: true
|
|
64
|
+
});
|
|
76
65
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* @param filepath - Path to the yaml-lint config file
|
|
80
|
-
* @returns The schema string, or undefined if not found
|
|
66
|
+
* Pre-configured handler with default options.
|
|
81
67
|
*/
|
|
82
|
-
static
|
|
83
|
-
try {
|
|
84
|
-
const content = readFileSync(filepath, "utf-8");
|
|
85
|
-
return JSON.parse(content).schema;
|
|
86
|
-
} catch {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
68
|
+
static handler = Yaml$1.create();
|
|
90
69
|
/**
|
|
91
|
-
* Check if
|
|
70
|
+
* Check if the YAML engine is available.
|
|
92
71
|
*
|
|
93
|
-
* @returns Always `true` since yaml
|
|
72
|
+
* @returns Always `true` since `@effected/yaml` is a bundled dependency
|
|
94
73
|
*/
|
|
95
74
|
static isAvailable() {
|
|
96
75
|
return true;
|
|
97
76
|
}
|
|
98
77
|
/**
|
|
99
|
-
* Format a YAML file in-place
|
|
78
|
+
* Format a YAML file in-place.
|
|
79
|
+
*
|
|
80
|
+
* @remarks
|
|
81
|
+
* Synchronous by contract: the engine is a pure, IO-free tier, so the only
|
|
82
|
+
* IO here is this function's own file read and write.
|
|
100
83
|
*
|
|
101
84
|
* @param filepath - Path to the YAML file
|
|
85
|
+
* @param options - Formatting options; defaults to {@link Yaml.defaultFormatOptions}
|
|
102
86
|
*/
|
|
103
|
-
static
|
|
87
|
+
static formatFile(filepath, options = Yaml$1.defaultFormatOptions) {
|
|
104
88
|
const content = readFileSync(filepath, "utf-8");
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
...prettierConfig,
|
|
108
|
-
filepath,
|
|
109
|
-
parser: "yaml"
|
|
110
|
-
});
|
|
111
|
-
writeFileSync(filepath, formatted, "utf-8");
|
|
89
|
+
const formatted = YamlFormat.formatToString(content, void 0, options);
|
|
90
|
+
if (formatted !== content) writeFileSync(filepath, formatted, "utf-8");
|
|
112
91
|
}
|
|
113
92
|
/**
|
|
114
|
-
* Validate a YAML file
|
|
93
|
+
* Validate a YAML file.
|
|
94
|
+
*
|
|
95
|
+
* @remarks
|
|
96
|
+
* Validates every document of the stream, so a multi-document file whose
|
|
97
|
+
* later documents are invalid is rejected rather than silently accepted.
|
|
115
98
|
*
|
|
116
99
|
* @param filepath - Path to the YAML file
|
|
117
|
-
* @param schema - The YAML schema to validate against
|
|
118
100
|
* @throws Error if the YAML is invalid
|
|
119
101
|
*/
|
|
120
|
-
static
|
|
102
|
+
static validateFile(filepath) {
|
|
121
103
|
const content = readFileSync(filepath, "utf-8");
|
|
122
|
-
|
|
104
|
+
const result = Yaml.parseAllResult(content);
|
|
105
|
+
if (!("success" in result)) throw new Error(String(result.failure));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Serialize formatting options for the `savvy lint fmt yaml --format` flag.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* The CLI subcommand runs in a separate process, so options set on
|
|
112
|
+
* {@link fmtCommand} have to cross a shell boundary to reach
|
|
113
|
+
* {@link formatFile}. Without that hop the two entry points format the same
|
|
114
|
+
* file differently — the drift this package's shared-static rule exists to
|
|
115
|
+
* prevent. {@link parseFormatOptions} is the inverse.
|
|
116
|
+
*
|
|
117
|
+
* @param options - Formatting options to encode
|
|
118
|
+
* @returns The options as a JSON string
|
|
119
|
+
*/
|
|
120
|
+
static encodeFormatOptions(options) {
|
|
121
|
+
return JSON.stringify(options);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Parse formatting options serialized by {@link encodeFormatOptions}.
|
|
125
|
+
*
|
|
126
|
+
* @param encoded - JSON produced by {@link encodeFormatOptions}
|
|
127
|
+
* @returns The decoded options
|
|
128
|
+
* @throws Error if the JSON is malformed or fails schema validation
|
|
129
|
+
*/
|
|
130
|
+
static parseFormatOptions(encoded) {
|
|
131
|
+
return YamlFormattingOptions.make(JSON.parse(encoded));
|
|
123
132
|
}
|
|
124
133
|
/**
|
|
125
134
|
* Create a handler that returns a CLI command to format YAML files.
|
|
@@ -130,15 +139,19 @@ var Yaml = class Yaml {
|
|
|
130
139
|
* can detect the modification and auto-stage it.
|
|
131
140
|
* Use this in lint-staged array syntax for sequential execution.
|
|
132
141
|
*
|
|
142
|
+
* `options.format` is forwarded to the subcommand as `--format`, so this
|
|
143
|
+
* path and {@link create} produce identical bytes for the same options.
|
|
144
|
+
*
|
|
133
145
|
* @param options - Configuration options
|
|
134
146
|
* @returns A lint-staged compatible handler function
|
|
135
147
|
*/
|
|
136
148
|
static fmtCommand(options = {}) {
|
|
137
|
-
const excludes = options.exclude ?? [...Yaml.defaultExcludes];
|
|
149
|
+
const excludes = options.exclude ?? [...Yaml$1.defaultExcludes];
|
|
150
|
+
const formatOptions = options.format;
|
|
138
151
|
return (filenames) => {
|
|
139
152
|
const filtered = Filter.exclude(filenames, excludes);
|
|
140
153
|
if (filtered.length === 0) return [];
|
|
141
|
-
return `${Command.findSavvyLint()} fmt yaml ${Filter.shellEscape(filtered)}`;
|
|
154
|
+
return `${Command.findSavvyLint()} fmt yaml${formatOptions ? ` --format ${Filter.shellEscape([Yaml$1.encodeFormatOptions(formatOptions)])}` : ""} ${Filter.shellEscape(filtered)}`;
|
|
142
155
|
};
|
|
143
156
|
}
|
|
144
157
|
/**
|
|
@@ -148,17 +161,16 @@ var Yaml = class Yaml {
|
|
|
148
161
|
* @returns A lint-staged compatible handler function
|
|
149
162
|
*/
|
|
150
163
|
static create(options = {}) {
|
|
151
|
-
const excludes = options.exclude ?? [...Yaml.defaultExcludes];
|
|
164
|
+
const excludes = options.exclude ?? [...Yaml$1.defaultExcludes];
|
|
152
165
|
const skipFormat = options.skipFormat ?? false;
|
|
153
166
|
const skipValidate = options.skipValidate ?? false;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
return async (filenames) => {
|
|
167
|
+
const formatOptions = options.format ?? Yaml$1.defaultFormatOptions;
|
|
168
|
+
return (filenames) => {
|
|
157
169
|
const filtered = Filter.exclude(filenames, excludes);
|
|
158
170
|
if (filtered.length === 0) return [];
|
|
159
|
-
if (!skipFormat) for (const filepath of filtered)
|
|
171
|
+
if (!skipFormat) for (const filepath of filtered) Yaml$1.formatFile(filepath, formatOptions);
|
|
160
172
|
if (!skipValidate) for (const filepath of filtered) try {
|
|
161
|
-
|
|
173
|
+
Yaml$1.validateFile(filepath);
|
|
162
174
|
} catch (error) {
|
|
163
175
|
throw new Error(`Invalid YAML in ${filepath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
164
176
|
}
|
|
@@ -168,4 +180,4 @@ var Yaml = class Yaml {
|
|
|
168
180
|
};
|
|
169
181
|
|
|
170
182
|
//#endregion
|
|
171
|
-
export { Yaml };
|
|
183
|
+
export { Yaml$1 as Yaml };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@savvy-web/silk-effects",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Shared Effect library for Silk Suite conventions",
|
|
6
6
|
"homepage": "https://github.com/savvy-web/systems/tree/main/packages/silk-effects",
|
|
@@ -40,20 +40,18 @@
|
|
|
40
40
|
"@effected/package-json": "^0.8.0",
|
|
41
41
|
"@effected/templates": "^0.2.0",
|
|
42
42
|
"@effected/walker": "^0.4.0",
|
|
43
|
-
"@effected/workspaces": "^0.11.
|
|
44
|
-
"@effected/yaml": "^0.
|
|
43
|
+
"@effected/workspaces": "^0.11.2",
|
|
44
|
+
"@effected/yaml": "^0.8.0",
|
|
45
45
|
"@manypkg/get-packages": "^3.1.0",
|
|
46
46
|
"mdast-util-heading-range": "^4.0.0",
|
|
47
47
|
"mdast-util-to-string": "^4.0.0",
|
|
48
|
-
"prettier": "^3.9.6",
|
|
49
48
|
"remark-gfm": "^4.0.1",
|
|
50
49
|
"remark-parse": "^11.0.0",
|
|
51
50
|
"remark-stringify": "^11.0.0",
|
|
52
51
|
"shell-quote": "^1.10.0",
|
|
53
52
|
"unified": "^11.0.5",
|
|
54
53
|
"unified-lint-rule": "^3.0.1",
|
|
55
|
-
"unist-util-visit": "^5.1.0"
|
|
56
|
-
"yaml-lint": "^1.7.0"
|
|
54
|
+
"unist-util-visit": "^5.1.0"
|
|
57
55
|
},
|
|
58
56
|
"peerDependencies": {
|
|
59
57
|
"effect": "4.0.0-beta.107"
|
package/repos/index.js
CHANGED
|
@@ -5,8 +5,8 @@ import { DriftKind, RepoDrift, ReposDriftReport } from "./schemas/drift.js";
|
|
|
5
5
|
import { RepoEntry, RepoName, RepoNote, RepoOrientation, ReposManifestFile } from "./schemas/manifest.js";
|
|
6
6
|
import { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport } from "./schemas/reports.js";
|
|
7
7
|
import { ReposConfigStore } from "./services/config-store.js";
|
|
8
|
-
import { ReposDrift } from "./services/drift.js";
|
|
9
8
|
import { ReposLockdown, resolveModuleDir } from "./services/lockdown.js";
|
|
9
|
+
import { ReposDrift } from "./services/drift.js";
|
|
10
10
|
import { ReposManager, STALE_LOCK_MAX_AGE_MS } from "./services/manager.js";
|
|
11
11
|
|
|
12
12
|
//#region src/repos/index.ts
|
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 {
|