@savvy-web/silk-effects 5.4.0 → 5.5.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 +5 -5
- package/index.d.ts +187 -3
- package/package.json +3 -3
- package/repos/index.js +11 -2
- package/repos/schemas/drift.js +49 -0
- package/repos/schemas/reports.js +74 -3
- package/repos/services/config-store.js +36 -2
- package/repos/services/drift.js +168 -0
- package/repos/services/manager.js +345 -43
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, sync,
|
|
16
|
+
- Manage the vendored reference repos declared in `.repos/config.json` — submodule status, drift detection, sync, add, pin, note, remove, rename and restore — and keep every vendored tree read-only between mutations
|
|
17
17
|
- Locate config files and keep Biome schema URLs in sync across workspaces
|
|
18
18
|
|
|
19
19
|
## Install
|
|
@@ -364,13 +364,13 @@ const diagnosis = await Effect.runPromise(
|
|
|
364
364
|
// => CacheDiagnosis: per-package HIT/MISS breakdown for the task
|
|
365
365
|
```
|
|
366
366
|
|
|
367
|
-
#### ReposManager and ReposLockdown
|
|
367
|
+
#### ReposManager, ReposDrift and ReposLockdown
|
|
368
368
|
|
|
369
|
-
The `Repos` namespace drives vendored reference repos — upstream sources checked out as git submodules under `.repos/`, declared in a `.repos/config.json` manifest. `ReposConfigStore` reads and writes that manifest, and `ReposManager` does the git work: `status(root)` reports presence, the pinned commit, working-tree dirtiness and stale notes, `sync(root)` initializes missing submodules and applies each entry's sparse-checkout, `pin(root, name, ref)` moves an entry to a new ref, `add(root, options)` vendors a new one,
|
|
369
|
+
The `Repos` namespace drives vendored reference repos — upstream sources checked out as git submodules under `.repos/`, declared in a `.repos/config.json` manifest. `ReposConfigStore` reads and writes that manifest, and `ReposManager` does the git work: `status(root)` reports presence, the pinned commit, working-tree dirtiness and stale notes, `sync(root)` initializes missing submodules and applies each entry's sparse-checkout, `pin(root, name, ref)` moves an entry to a new ref, `add(root, options)` vendors a new one, `note(root, name, op)` adds, removes or promotes an agent note, `remove(root, name)` unvendors an entry, `rename(root, oldName, newName)` renames one in place, and `restore(root, names?)` hard-resets one or more dirty checkouts back to their pinned commit. `ReposDrift.check(root)` is a read-only companion — it reconciles the manifest, `.gitmodules`, the worktree, and `git submodule status`, reporting any mismatch as a typed drift kind.
|
|
370
370
|
|
|
371
|
-
`ReposLockdown` is the permissions boundary around all of that. `lock(root, name)` chmods a vendored worktree and its submodule git metadata to files `0444` and directories `0555
|
|
371
|
+
`ReposLockdown` is the permissions boundary around all of that. `lock(root, name)` chmods a vendored worktree and its submodule git metadata to files `0444` and directories `0555` (an executable file locks at `0555` instead, preserving its executable bit); `unlock` reverses it, restoring `0644`/`0755` (`0755` for a file that was executable); `withUnlocked(root, name, effect)` brackets an effect between the two. `ReposManager`'s `sync`, `add`, `pin`, `remove` and `restore` run their git mutations inside that bracket and re-lock afterwards, and `rename` hand-rolls the same unlock/relock contract across its `oldName`→`newName` move, so a vendored tree is read-only whenever the manager is not mid-write. Reads are unaffected — a locked tree needs no special handling to open a file.
|
|
372
372
|
|
|
373
|
-
Two consequences for callers: `ReposManager.layer`
|
|
373
|
+
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
374
|
|
|
375
375
|
```typescript
|
|
376
376
|
import { Effect, Layer } from "effect";
|
package/index.d.ts
CHANGED
|
@@ -8296,6 +8296,46 @@ declare class ReposLockdownError extends ReposLockdownErrorBase<{
|
|
|
8296
8296
|
get message(): string;
|
|
8297
8297
|
}
|
|
8298
8298
|
//#endregion
|
|
8299
|
+
//#region src/repos/schemas/drift.d.ts
|
|
8300
|
+
/**
|
|
8301
|
+
* The kinds of drift {@link ReposDrift} can detect between the four
|
|
8302
|
+
* authorities it reconciles: the manifest, `.gitmodules`, the worktree, and
|
|
8303
|
+
* `git submodule status`.
|
|
8304
|
+
* @public
|
|
8305
|
+
*/
|
|
8306
|
+
declare const DriftKind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable"]>;
|
|
8307
|
+
/** @public */
|
|
8308
|
+
type DriftKind = typeof DriftKind.Type;
|
|
8309
|
+
declare const RepoDrift_base: Schema.Class<RepoDrift, Schema.Struct<{
|
|
8310
|
+
readonly name: Schema.String;
|
|
8311
|
+
readonly kind: Schema.Literals<readonly ["urlMismatch", "pathMismatch", "unregisteredManifestEntry", "orphanGitmodulesEntry", "missingWorktree", "checkoutDiverged", "missingShallow", "gitmodulesUnparsable"]>;
|
|
8312
|
+
readonly detail: Schema.String;
|
|
8313
|
+
readonly manifestValue: Schema.optionalKey<Schema.String>;
|
|
8314
|
+
readonly observedValue: Schema.optionalKey<Schema.String>;
|
|
8315
|
+
}>, {}>;
|
|
8316
|
+
/**
|
|
8317
|
+
* One detected disagreement between two (or more) of the four authorities
|
|
8318
|
+
* for a single vendored repo.
|
|
8319
|
+
*
|
|
8320
|
+
* @remarks
|
|
8321
|
+
* `manifestValue`/`observedValue` carry the two disagreeing values when the
|
|
8322
|
+
* drift is a value mismatch (`urlMismatch`, `pathMismatch`); other kinds
|
|
8323
|
+
* populate whichever side has a value to show (or neither, for
|
|
8324
|
+
* `gitmodulesUnparsable`, which is not about one repo).
|
|
8325
|
+
* @public
|
|
8326
|
+
*/
|
|
8327
|
+
declare class RepoDrift extends RepoDrift_base {}
|
|
8328
|
+
declare const ReposDriftReport_base: Schema.Class<ReposDriftReport, Schema.Struct<{
|
|
8329
|
+
readonly drifts: Schema.$Array<typeof RepoDrift>;
|
|
8330
|
+
readonly clean: Schema.Boolean;
|
|
8331
|
+
}>, {}>;
|
|
8332
|
+
/**
|
|
8333
|
+
* The result of reconciling the manifest, `.gitmodules`, the worktree, and
|
|
8334
|
+
* `git submodule status` for every vendored repo.
|
|
8335
|
+
* @public
|
|
8336
|
+
*/
|
|
8337
|
+
declare class ReposDriftReport extends ReposDriftReport_base {}
|
|
8338
|
+
//#endregion
|
|
8299
8339
|
//#region src/repos/schemas/manifest.d.ts
|
|
8300
8340
|
/**
|
|
8301
8341
|
* A vendored-repo manifest key: non-empty, contains no `/` or `\`, and is
|
|
@@ -8382,6 +8422,21 @@ type ReposManifestFile = typeof ReposManifestFile.Type;
|
|
|
8382
8422
|
/**
|
|
8383
8423
|
* Status of one vendored repo: gitlink presence and dirtiness, plus notes
|
|
8384
8424
|
* that no longer match the pinned ref.
|
|
8425
|
+
*
|
|
8426
|
+
* @remarks
|
|
8427
|
+
* `commit` is an alias of `stagedCommit`, retained for one release so an
|
|
8428
|
+
* existing consumer reading `entry.commit` keeps working while it migrates to
|
|
8429
|
+
* the index-aware triple. It carries no independent release tag beyond
|
|
8430
|
+
* `@public` (this schema has no narrower audience to gate it behind).
|
|
8431
|
+
* Removal is scheduled by a tracked issue, not gated on a renderer migration
|
|
8432
|
+
* precondition here.
|
|
8433
|
+
*
|
|
8434
|
+
* Not fully behavior-preserving: `commit: stagedCommit ?? null` reads `null`
|
|
8435
|
+
* for a gitlink committed at `HEAD` but staged for REMOVAL, where the prior
|
|
8436
|
+
* single-`commit` field showed the committed oid. `stagedCommit` is `None`
|
|
8437
|
+
* in exactly that case (nothing is staged), so the alias reports "nothing
|
|
8438
|
+
* staged" rather than "here is what HEAD still has" — a real, if narrow,
|
|
8439
|
+
* difference from the pre-triple `commit` field's behavior.
|
|
8385
8440
|
* @public
|
|
8386
8441
|
*/
|
|
8387
8442
|
declare const RepoStatusEntry: Schema.Struct<{
|
|
@@ -8389,7 +8444,14 @@ declare const RepoStatusEntry: Schema.Struct<{
|
|
|
8389
8444
|
readonly ref: Schema.String;
|
|
8390
8445
|
readonly purpose: Schema.String;
|
|
8391
8446
|
readonly present: Schema.Boolean;
|
|
8447
|
+
/** @deprecated alias of `stagedCommit`; retained for one release. */
|
|
8392
8448
|
readonly commit: Schema.NullOr<Schema.String>;
|
|
8449
|
+
/** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
|
|
8450
|
+
readonly stagedCommit: Schema.optionalKey<Schema.String>;
|
|
8451
|
+
/** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
|
|
8452
|
+
readonly committedCommit: Schema.optionalKey<Schema.String>;
|
|
8453
|
+
/** The commit actually checked out in the submodule worktree (`git rev-parse HEAD` inside it); absent when there is no checkout. */
|
|
8454
|
+
readonly checkedOutCommit: Schema.optionalKey<Schema.String>;
|
|
8393
8455
|
readonly dirty: Schema.Boolean;
|
|
8394
8456
|
readonly staleNoteIds: Schema.$Array<Schema.String>;
|
|
8395
8457
|
}>;
|
|
@@ -8405,7 +8467,14 @@ declare const ReposStatusReport: Schema.Struct<{
|
|
|
8405
8467
|
readonly ref: Schema.String;
|
|
8406
8468
|
readonly purpose: Schema.String;
|
|
8407
8469
|
readonly present: Schema.Boolean;
|
|
8470
|
+
/** @deprecated alias of `stagedCommit`; retained for one release. */
|
|
8408
8471
|
readonly commit: Schema.NullOr<Schema.String>;
|
|
8472
|
+
/** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
|
|
8473
|
+
readonly stagedCommit: Schema.optionalKey<Schema.String>;
|
|
8474
|
+
/** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
|
|
8475
|
+
readonly committedCommit: Schema.optionalKey<Schema.String>;
|
|
8476
|
+
/** The commit actually checked out in the submodule worktree (`git rev-parse HEAD` inside it); absent when there is no checkout. */
|
|
8477
|
+
readonly checkedOutCommit: Schema.optionalKey<Schema.String>;
|
|
8409
8478
|
readonly dirty: Schema.Boolean;
|
|
8410
8479
|
readonly staleNoteIds: Schema.$Array<Schema.String>;
|
|
8411
8480
|
}>>;
|
|
@@ -8416,7 +8485,8 @@ type ReposStatusReport = typeof ReposStatusReport.Type;
|
|
|
8416
8485
|
/**
|
|
8417
8486
|
* Result of reconciling working-tree submodules with the manifest: missing
|
|
8418
8487
|
* repos initialized, sparse-checkout patterns re-applied, already-present
|
|
8419
|
-
* repos left alone,
|
|
8488
|
+
* repos left alone, stale locks cleared, drifted submodule URLs reconciled,
|
|
8489
|
+
* and orphan manifest entries (no gitlink at all) registered.
|
|
8420
8490
|
* @public
|
|
8421
8491
|
*/
|
|
8422
8492
|
declare const ReposSyncReport: Schema.Struct<{
|
|
@@ -8424,6 +8494,8 @@ declare const ReposSyncReport: Schema.Struct<{
|
|
|
8424
8494
|
readonly sparseApplied: Schema.$Array<Schema.String>;
|
|
8425
8495
|
readonly upToDate: Schema.$Array<Schema.String>;
|
|
8426
8496
|
readonly clearedLocks: Schema.$Array<Schema.String>;
|
|
8497
|
+
readonly urlSynced: Schema.$Array<Schema.String>;
|
|
8498
|
+
readonly registered: Schema.$Array<Schema.String>;
|
|
8427
8499
|
}>;
|
|
8428
8500
|
/** @public */
|
|
8429
8501
|
type ReposSyncReport = typeof ReposSyncReport.Type;
|
|
@@ -8452,6 +8524,62 @@ declare const ReposAddResult: Schema.Struct<{
|
|
|
8452
8524
|
}>;
|
|
8453
8525
|
/** @public */
|
|
8454
8526
|
type ReposAddResult = typeof ReposAddResult.Type;
|
|
8527
|
+
/**
|
|
8528
|
+
* Result of removing a vendored repo from the manifest: the gitlink, module
|
|
8529
|
+
* gitdir, and `.gitmodules` section are all gone, and the entry's notes are
|
|
8530
|
+
* surfaced so any durable ones can be promoted elsewhere before this result
|
|
8531
|
+
* is committed.
|
|
8532
|
+
* @public
|
|
8533
|
+
*/
|
|
8534
|
+
declare const ReposRemoveResult: Schema.Struct<{
|
|
8535
|
+
readonly name: Schema.String;
|
|
8536
|
+
readonly path: Schema.String;
|
|
8537
|
+
readonly commitMessage: Schema.String;
|
|
8538
|
+
readonly removedNotes: Schema.$Array<Schema.Struct<{
|
|
8539
|
+
readonly id: Schema.String;
|
|
8540
|
+
readonly date: Schema.String;
|
|
8541
|
+
readonly ref: Schema.String;
|
|
8542
|
+
readonly note: Schema.String;
|
|
8543
|
+
}>>;
|
|
8544
|
+
}>;
|
|
8545
|
+
/** @public */
|
|
8546
|
+
type ReposRemoveResult = typeof ReposRemoveResult.Type;
|
|
8547
|
+
/**
|
|
8548
|
+
* Result of renaming a vendored repo's manifest key: the `.repos/<name>`
|
|
8549
|
+
* worktree moved, the module gitdir's `core.worktree` values re-pointed, the
|
|
8550
|
+
* `.gitmodules` section canonicalized to the new name, and the manifest key
|
|
8551
|
+
* renamed.
|
|
8552
|
+
* @public
|
|
8553
|
+
*/
|
|
8554
|
+
declare const ReposRenameResult: Schema.Struct<{
|
|
8555
|
+
readonly oldName: Schema.String;
|
|
8556
|
+
readonly newName: Schema.String;
|
|
8557
|
+
readonly path: Schema.String;
|
|
8558
|
+
readonly commitMessage: Schema.String;
|
|
8559
|
+
}>;
|
|
8560
|
+
/** @public */
|
|
8561
|
+
type ReposRenameResult = typeof ReposRenameResult.Type;
|
|
8562
|
+
/**
|
|
8563
|
+
* Result of hard-resetting one or more vendored repos to their staged (or
|
|
8564
|
+
* committed) gitlink commit and re-applying sparse-checkout paths.
|
|
8565
|
+
*
|
|
8566
|
+
* @remarks
|
|
8567
|
+
* `restored` lists every repo actually reset, paired with the commit it was
|
|
8568
|
+
* reset to. `skippedClean` is populated ONLY by the names-omitted form of
|
|
8569
|
+
* {@link ReposManagerShape.restore} — repos left untouched because `status`
|
|
8570
|
+
* reported them clean; an explicit-names call never skips anything (an
|
|
8571
|
+
* explicit ask is always honored), so it always reports an empty array.
|
|
8572
|
+
* @public
|
|
8573
|
+
*/
|
|
8574
|
+
declare const ReposRestoreResult: Schema.Struct<{
|
|
8575
|
+
readonly restored: Schema.$Array<Schema.Struct<{
|
|
8576
|
+
readonly name: Schema.String;
|
|
8577
|
+
readonly commit: Schema.String;
|
|
8578
|
+
}>>;
|
|
8579
|
+
readonly skippedClean: Schema.$Array<Schema.String>;
|
|
8580
|
+
}>;
|
|
8581
|
+
/** @public */
|
|
8582
|
+
type ReposRestoreResult = typeof ReposRestoreResult.Type;
|
|
8455
8583
|
/**
|
|
8456
8584
|
* Result of an agent-note mutation against a vendored repo.
|
|
8457
8585
|
* @public
|
|
@@ -8474,6 +8602,14 @@ interface ReposConfigStoreShape {
|
|
|
8474
8602
|
readonly exists: (root: string) => Effect.Effect<boolean>;
|
|
8475
8603
|
readonly read: (root: string) => Effect.Effect<ReposManifestFile, ReposConfigError>;
|
|
8476
8604
|
readonly write: (root: string, manifest: ReposManifestFile) => Effect.Effect<void, ReposConfigError>;
|
|
8605
|
+
/**
|
|
8606
|
+
* Serialized read-modify-write: acquires an exclusive-create lock file
|
|
8607
|
+
* beside the manifest, reads the current manifest (an absent manifest is
|
|
8608
|
+
* passed to `fn` as `{ repos: {} }` — `update` can initialize), runs `fn`,
|
|
8609
|
+
* writes the result, and always releases the lock. Concurrent callers
|
|
8610
|
+
* queue behind the lock rather than racing a lost update.
|
|
8611
|
+
*/
|
|
8612
|
+
readonly update: (root: string, fn: (manifest: ReposManifestFile) => ReposManifestFile | Effect.Effect<ReposManifestFile, ReposConfigError>) => Effect.Effect<ReposManifestFile, ReposConfigError>;
|
|
8477
8613
|
}
|
|
8478
8614
|
declare const ReposConfigStore_base: Context.ServiceClass<ReposConfigStore, "@savvy-web/silk-effects/ReposConfigStore", ReposConfigStoreShape>;
|
|
8479
8615
|
/**
|
|
@@ -8488,6 +8624,31 @@ declare class ReposConfigStore extends ReposConfigStore_base {
|
|
|
8488
8624
|
static readonly layer: Layer.Layer<ReposConfigStore, never, FileSystem.FileSystem | Path.Path>;
|
|
8489
8625
|
}
|
|
8490
8626
|
//#endregion
|
|
8627
|
+
//#region src/repos/services/drift.d.ts
|
|
8628
|
+
/**
|
|
8629
|
+
* The {@link ReposDrift} service shape.
|
|
8630
|
+
* @public
|
|
8631
|
+
*/
|
|
8632
|
+
interface ReposDriftShape {
|
|
8633
|
+
readonly check: (root: string) => Effect.Effect<ReposDriftReport, ReposConfigError | GitSubmoduleError>;
|
|
8634
|
+
}
|
|
8635
|
+
declare const ReposDrift_base: Context.ServiceClass<ReposDrift, "@savvy-web/silk-effects/ReposDrift", ReposDriftShape>;
|
|
8636
|
+
/**
|
|
8637
|
+
* Reconciles the four authorities a vendored repo's state is spread across —
|
|
8638
|
+
* the manifest, `.gitmodules`, the worktree, and `git submodule status` —
|
|
8639
|
+
* and reports every disagreement found. Read-only: no staging, no lockdown
|
|
8640
|
+
* interaction, so it runs unmodified against a locked (`ReposLockdown`)
|
|
8641
|
+
* tree.
|
|
8642
|
+
* @public
|
|
8643
|
+
*/
|
|
8644
|
+
declare class ReposDrift extends ReposDrift_base {
|
|
8645
|
+
/**
|
|
8646
|
+
* Production implementation of {@link ReposDrift}.
|
|
8647
|
+
* @public
|
|
8648
|
+
*/
|
|
8649
|
+
static readonly layer: Layer.Layer<ReposDrift, never, ReposConfigStore | Git | FileSystem.FileSystem | Path.Path>;
|
|
8650
|
+
}
|
|
8651
|
+
//#endregion
|
|
8491
8652
|
//#region src/repos/services/lockdown.d.ts
|
|
8492
8653
|
/**
|
|
8493
8654
|
* The {@link ReposLockdown} service shape.
|
|
@@ -8578,13 +8739,36 @@ interface ReposManagerShape {
|
|
|
8578
8739
|
readonly id: string;
|
|
8579
8740
|
readonly into: "layout" | "startHere";
|
|
8580
8741
|
}) => Effect.Effect<ReposNoteResult, ReposConfigError | RepoNotFoundError | NoteNotFoundError>;
|
|
8742
|
+
readonly remove: (root: string, name: string) => Effect.Effect<ReposRemoveResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
|
|
8743
|
+
/**
|
|
8744
|
+
* Crash contract: unlike {@link add}, `rename` has no compensating
|
|
8745
|
+
* rollback. `Effect.uninterruptibleMask` guards fiber interruption, not a
|
|
8746
|
+
* hard process kill, so a `kill -9` between `git mv` and the manifest
|
|
8747
|
+
* write can leave the tree renamed in git, the manifest still holding
|
|
8748
|
+
* the old key, and the tree unlocked (the relock finalizer never runs).
|
|
8749
|
+
* This is a deliberate asymmetry with `add`, which DOES roll back —
|
|
8750
|
+
* `rename` does not, because unlike a fresh vendor there is no "nothing
|
|
8751
|
+
* happened yet" state to unwind back to.
|
|
8752
|
+
*
|
|
8753
|
+
* Recovery is NOT a guaranteed clean "just run it again": `git mv` is not
|
|
8754
|
+
* idempotent (a real-git probe confirms a second `git mv <old> <new>`
|
|
8755
|
+
* after the first already succeeded fails with "bad source"), so a crash
|
|
8756
|
+
* after `git mv` lands makes the next `rename` call fail at that same
|
|
8757
|
+
* step rather than resume past it. A crash in that window needs manual
|
|
8758
|
+
* inspection (`git status`, `.repos/config.json`, `.gitmodules`) before
|
|
8759
|
+
* retrying, not a blind re-invocation.
|
|
8760
|
+
*/
|
|
8761
|
+
readonly rename: (root: string, oldName: string, newName: string) => Effect.Effect<ReposRenameResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
|
|
8762
|
+
readonly restore: (root: string, names?: ReadonlyArray<string>) => Effect.Effect<ReposRestoreResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
|
|
8581
8763
|
}
|
|
8582
8764
|
declare const ReposManager_base: Context.ServiceClass<ReposManager, "@savvy-web/silk-effects/ReposManager", ReposManagerShape>;
|
|
8583
8765
|
/**
|
|
8584
8766
|
* Drives the vendored `.repos/` submodules over git: reports status
|
|
8585
8767
|
* (presence, dirtiness, stale notes), reconciles the working tree with the
|
|
8586
8768
|
* manifest, vendors new entries (`add`), re-pins existing entries to a new
|
|
8587
|
-
* ref (`pin`),
|
|
8769
|
+
* ref (`pin`), adds/removes/promotes agent notes (`note`), unvendors
|
|
8770
|
+
* (`remove`), renames (`rename`), and explicitly hard-resets dirty
|
|
8771
|
+
* checkouts back to their pinned commit (`restore`).
|
|
8588
8772
|
* @public
|
|
8589
8773
|
*/
|
|
8590
8774
|
declare class ReposManager extends ReposManager_base {
|
|
@@ -8602,7 +8786,7 @@ declare class ReposManager extends ReposManager_base {
|
|
|
8602
8786
|
static readonly layer: Layer.Layer<ReposManager, never, ReposConfigStore | Git | FileSystem.FileSystem | Path.Path | ReposLockdown>;
|
|
8603
8787
|
}
|
|
8604
8788
|
declare namespace index_d_exports$3 {
|
|
8605
|
-
export { GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NOTE_LIMIT, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposConfigStoreShape, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposLockdownShape, ReposManager, ReposManagerShape, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, resolveModuleDir };
|
|
8789
|
+
export { DriftKind, GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NOTE_LIMIT, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoDrift, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposConfigStoreShape, ReposDrift, ReposDriftReport, ReposDriftShape, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposLockdownShape, ReposManager, ReposManagerShape, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, resolveModuleDir };
|
|
8606
8790
|
}
|
|
8607
8791
|
//#endregion
|
|
8608
8792
|
//#region src/schemas/BiomeConfig.d.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@savvy-web/silk-effects",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.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",
|
|
@@ -34,13 +34,13 @@
|
|
|
34
34
|
"@changesets/get-github-info": "^1.0.0-next.4",
|
|
35
35
|
"@changesets/get-release-plan": "^5.0.0-next.9",
|
|
36
36
|
"@effected/commands": "^0.3.1",
|
|
37
|
-
"@effected/git": "^0.
|
|
37
|
+
"@effected/git": "^0.6.0",
|
|
38
38
|
"@effected/glob": "^0.2.2",
|
|
39
39
|
"@effected/jsonc": "^0.5.2",
|
|
40
40
|
"@effected/package-json": "^0.7.3",
|
|
41
41
|
"@effected/templates": "^0.1.1",
|
|
42
42
|
"@effected/walker": "^0.3.4",
|
|
43
|
-
"@effected/workspaces": "^0.10.
|
|
43
|
+
"@effected/workspaces": "^0.10.2",
|
|
44
44
|
"@effected/yaml": "^0.6.1",
|
|
45
45
|
"@manypkg/get-packages": "^3.1.0",
|
|
46
46
|
"mdast-util-heading-range": "^4.0.0",
|
package/repos/index.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { __exportAll } from "../_virtual/_rolldown/runtime.js";
|
|
2
2
|
import { MANIFEST_PATH, REPOS_DIR } from "./constants.js";
|
|
3
3
|
import { GitSubmoduleError, GitSubmoduleErrorBase, NoteNotFoundError, NoteNotFoundErrorBase, RepoNotFoundError, RepoNotFoundErrorBase, ReposConfigError, ReposConfigErrorBase, ReposLockdownError, ReposLockdownErrorBase } from "./errors.js";
|
|
4
|
+
import { DriftKind, RepoDrift, ReposDriftReport } from "./schemas/drift.js";
|
|
4
5
|
import { RepoEntry, RepoName, RepoNote, RepoOrientation, ReposManifestFile } from "./schemas/manifest.js";
|
|
5
|
-
import { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport } from "./schemas/reports.js";
|
|
6
|
+
import { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport } from "./schemas/reports.js";
|
|
6
7
|
import { ReposConfigStore } from "./services/config-store.js";
|
|
8
|
+
import { ReposDrift } from "./services/drift.js";
|
|
7
9
|
import { ReposLockdown, resolveModuleDir } from "./services/lockdown.js";
|
|
8
10
|
import { ReposManager, STALE_LOCK_MAX_AGE_MS } from "./services/manager.js";
|
|
9
11
|
|
|
10
12
|
//#region src/repos/index.ts
|
|
11
13
|
var repos_exports = /* @__PURE__ */ __exportAll({
|
|
14
|
+
DriftKind: () => DriftKind,
|
|
12
15
|
GitSubmoduleError: () => GitSubmoduleError,
|
|
13
16
|
GitSubmoduleErrorBase: () => GitSubmoduleErrorBase,
|
|
14
17
|
MANIFEST_PATH: () => MANIFEST_PATH,
|
|
@@ -16,6 +19,7 @@ var repos_exports = /* @__PURE__ */ __exportAll({
|
|
|
16
19
|
NoteNotFoundError: () => NoteNotFoundError,
|
|
17
20
|
NoteNotFoundErrorBase: () => NoteNotFoundErrorBase,
|
|
18
21
|
REPOS_DIR: () => REPOS_DIR,
|
|
22
|
+
RepoDrift: () => RepoDrift,
|
|
19
23
|
RepoEntry: () => RepoEntry,
|
|
20
24
|
RepoName: () => RepoName,
|
|
21
25
|
RepoNotFoundError: () => RepoNotFoundError,
|
|
@@ -27,6 +31,8 @@ var repos_exports = /* @__PURE__ */ __exportAll({
|
|
|
27
31
|
ReposConfigError: () => ReposConfigError,
|
|
28
32
|
ReposConfigErrorBase: () => ReposConfigErrorBase,
|
|
29
33
|
ReposConfigStore: () => ReposConfigStore,
|
|
34
|
+
ReposDrift: () => ReposDrift,
|
|
35
|
+
ReposDriftReport: () => ReposDriftReport,
|
|
30
36
|
ReposLockdown: () => ReposLockdown,
|
|
31
37
|
ReposLockdownError: () => ReposLockdownError,
|
|
32
38
|
ReposLockdownErrorBase: () => ReposLockdownErrorBase,
|
|
@@ -34,6 +40,9 @@ var repos_exports = /* @__PURE__ */ __exportAll({
|
|
|
34
40
|
ReposManifestFile: () => ReposManifestFile,
|
|
35
41
|
ReposNoteResult: () => ReposNoteResult,
|
|
36
42
|
ReposPinResult: () => ReposPinResult,
|
|
43
|
+
ReposRemoveResult: () => ReposRemoveResult,
|
|
44
|
+
ReposRenameResult: () => ReposRenameResult,
|
|
45
|
+
ReposRestoreResult: () => ReposRestoreResult,
|
|
37
46
|
ReposStatusReport: () => ReposStatusReport,
|
|
38
47
|
ReposSyncReport: () => ReposSyncReport,
|
|
39
48
|
STALE_LOCK_MAX_AGE_MS: () => STALE_LOCK_MAX_AGE_MS,
|
|
@@ -41,4 +50,4 @@ var repos_exports = /* @__PURE__ */ __exportAll({
|
|
|
41
50
|
});
|
|
42
51
|
|
|
43
52
|
//#endregion
|
|
44
|
-
export { GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposManager, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, repos_exports, resolveModuleDir };
|
|
53
|
+
export { DriftKind, GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoDrift, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposDrift, ReposDriftReport, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposManager, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, repos_exports, resolveModuleDir };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/repos/schemas/drift.ts
|
|
4
|
+
/**
|
|
5
|
+
* The kinds of drift {@link ReposDrift} can detect between the four
|
|
6
|
+
* authorities it reconciles: the manifest, `.gitmodules`, the worktree, and
|
|
7
|
+
* `git submodule status`.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
const DriftKind = Schema.Literals([
|
|
11
|
+
"urlMismatch",
|
|
12
|
+
"pathMismatch",
|
|
13
|
+
"unregisteredManifestEntry",
|
|
14
|
+
"orphanGitmodulesEntry",
|
|
15
|
+
"missingWorktree",
|
|
16
|
+
"checkoutDiverged",
|
|
17
|
+
"missingShallow",
|
|
18
|
+
"gitmodulesUnparsable"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* One detected disagreement between two (or more) of the four authorities
|
|
22
|
+
* for a single vendored repo.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* `manifestValue`/`observedValue` carry the two disagreeing values when the
|
|
26
|
+
* drift is a value mismatch (`urlMismatch`, `pathMismatch`); other kinds
|
|
27
|
+
* populate whichever side has a value to show (or neither, for
|
|
28
|
+
* `gitmodulesUnparsable`, which is not about one repo).
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
var RepoDrift = class extends Schema.Class("RepoDrift")({
|
|
32
|
+
name: Schema.String,
|
|
33
|
+
kind: DriftKind,
|
|
34
|
+
detail: Schema.String,
|
|
35
|
+
manifestValue: Schema.optionalKey(Schema.String),
|
|
36
|
+
observedValue: Schema.optionalKey(Schema.String)
|
|
37
|
+
}) {};
|
|
38
|
+
/**
|
|
39
|
+
* The result of reconciling the manifest, `.gitmodules`, the worktree, and
|
|
40
|
+
* `git submodule status` for every vendored repo.
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
var ReposDriftReport = class extends Schema.Class("ReposDriftReport")({
|
|
44
|
+
drifts: Schema.Array(RepoDrift),
|
|
45
|
+
clean: Schema.Boolean
|
|
46
|
+
}) {};
|
|
47
|
+
|
|
48
|
+
//#endregion
|
|
49
|
+
export { DriftKind, RepoDrift, ReposDriftReport };
|
package/repos/schemas/reports.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
+
import { RepoNote } from "./manifest.js";
|
|
1
2
|
import { Schema } from "effect";
|
|
2
3
|
|
|
3
4
|
//#region src/repos/schemas/reports.ts
|
|
4
5
|
/**
|
|
5
6
|
* Status of one vendored repo: gitlink presence and dirtiness, plus notes
|
|
6
7
|
* that no longer match the pinned ref.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* `commit` is an alias of `stagedCommit`, retained for one release so an
|
|
11
|
+
* existing consumer reading `entry.commit` keeps working while it migrates to
|
|
12
|
+
* the index-aware triple. It carries no independent release tag beyond
|
|
13
|
+
* `@public` (this schema has no narrower audience to gate it behind).
|
|
14
|
+
* Removal is scheduled by a tracked issue, not gated on a renderer migration
|
|
15
|
+
* precondition here.
|
|
16
|
+
*
|
|
17
|
+
* Not fully behavior-preserving: `commit: stagedCommit ?? null` reads `null`
|
|
18
|
+
* for a gitlink committed at `HEAD` but staged for REMOVAL, where the prior
|
|
19
|
+
* single-`commit` field showed the committed oid. `stagedCommit` is `None`
|
|
20
|
+
* in exactly that case (nothing is staged), so the alias reports "nothing
|
|
21
|
+
* staged" rather than "here is what HEAD still has" — a real, if narrow,
|
|
22
|
+
* difference from the pre-triple `commit` field's behavior.
|
|
7
23
|
* @public
|
|
8
24
|
*/
|
|
9
25
|
const RepoStatusEntry = Schema.Struct({
|
|
@@ -11,7 +27,14 @@ const RepoStatusEntry = Schema.Struct({
|
|
|
11
27
|
ref: Schema.String,
|
|
12
28
|
purpose: Schema.String,
|
|
13
29
|
present: Schema.Boolean,
|
|
30
|
+
/** @deprecated alias of `stagedCommit`; retained for one release. */
|
|
14
31
|
commit: Schema.NullOr(Schema.String),
|
|
32
|
+
/** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
|
|
33
|
+
stagedCommit: Schema.optionalKey(Schema.String),
|
|
34
|
+
/** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
|
|
35
|
+
committedCommit: Schema.optionalKey(Schema.String),
|
|
36
|
+
/** The commit actually checked out in the submodule worktree (`git rev-parse HEAD` inside it); absent when there is no checkout. */
|
|
37
|
+
checkedOutCommit: Schema.optionalKey(Schema.String),
|
|
15
38
|
dirty: Schema.Boolean,
|
|
16
39
|
staleNoteIds: Schema.Array(Schema.String)
|
|
17
40
|
});
|
|
@@ -26,14 +49,17 @@ const ReposStatusReport = Schema.Struct({
|
|
|
26
49
|
/**
|
|
27
50
|
* Result of reconciling working-tree submodules with the manifest: missing
|
|
28
51
|
* repos initialized, sparse-checkout patterns re-applied, already-present
|
|
29
|
-
* repos left alone,
|
|
52
|
+
* repos left alone, stale locks cleared, drifted submodule URLs reconciled,
|
|
53
|
+
* and orphan manifest entries (no gitlink at all) registered.
|
|
30
54
|
* @public
|
|
31
55
|
*/
|
|
32
56
|
const ReposSyncReport = Schema.Struct({
|
|
33
57
|
initialized: Schema.Array(Schema.String),
|
|
34
58
|
sparseApplied: Schema.Array(Schema.String),
|
|
35
59
|
upToDate: Schema.Array(Schema.String),
|
|
36
|
-
clearedLocks: Schema.Array(Schema.String)
|
|
60
|
+
clearedLocks: Schema.Array(Schema.String),
|
|
61
|
+
urlSynced: Schema.Array(Schema.String),
|
|
62
|
+
registered: Schema.Array(Schema.String)
|
|
37
63
|
});
|
|
38
64
|
/**
|
|
39
65
|
* Result of re-pinning a vendored repo to a new ref.
|
|
@@ -57,6 +83,51 @@ const ReposAddResult = Schema.Struct({
|
|
|
57
83
|
path: Schema.String
|
|
58
84
|
});
|
|
59
85
|
/**
|
|
86
|
+
* Result of removing a vendored repo from the manifest: the gitlink, module
|
|
87
|
+
* gitdir, and `.gitmodules` section are all gone, and the entry's notes are
|
|
88
|
+
* surfaced so any durable ones can be promoted elsewhere before this result
|
|
89
|
+
* is committed.
|
|
90
|
+
* @public
|
|
91
|
+
*/
|
|
92
|
+
const ReposRemoveResult = Schema.Struct({
|
|
93
|
+
name: Schema.String,
|
|
94
|
+
path: Schema.String,
|
|
95
|
+
commitMessage: Schema.String,
|
|
96
|
+
removedNotes: Schema.Array(RepoNote)
|
|
97
|
+
});
|
|
98
|
+
/**
|
|
99
|
+
* Result of renaming a vendored repo's manifest key: the `.repos/<name>`
|
|
100
|
+
* worktree moved, the module gitdir's `core.worktree` values re-pointed, the
|
|
101
|
+
* `.gitmodules` section canonicalized to the new name, and the manifest key
|
|
102
|
+
* renamed.
|
|
103
|
+
* @public
|
|
104
|
+
*/
|
|
105
|
+
const ReposRenameResult = Schema.Struct({
|
|
106
|
+
oldName: Schema.String,
|
|
107
|
+
newName: Schema.String,
|
|
108
|
+
path: Schema.String,
|
|
109
|
+
commitMessage: Schema.String
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* Result of hard-resetting one or more vendored repos to their staged (or
|
|
113
|
+
* committed) gitlink commit and re-applying sparse-checkout paths.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* `restored` lists every repo actually reset, paired with the commit it was
|
|
117
|
+
* reset to. `skippedClean` is populated ONLY by the names-omitted form of
|
|
118
|
+
* {@link ReposManagerShape.restore} — repos left untouched because `status`
|
|
119
|
+
* reported them clean; an explicit-names call never skips anything (an
|
|
120
|
+
* explicit ask is always honored), so it always reports an empty array.
|
|
121
|
+
* @public
|
|
122
|
+
*/
|
|
123
|
+
const ReposRestoreResult = Schema.Struct({
|
|
124
|
+
restored: Schema.Array(Schema.Struct({
|
|
125
|
+
name: Schema.String,
|
|
126
|
+
commit: Schema.String
|
|
127
|
+
})),
|
|
128
|
+
skippedClean: Schema.Array(Schema.String)
|
|
129
|
+
});
|
|
130
|
+
/**
|
|
60
131
|
* Result of an agent-note mutation against a vendored repo.
|
|
61
132
|
* @public
|
|
62
133
|
*/
|
|
@@ -72,4 +143,4 @@ const ReposNoteResult = Schema.Struct({
|
|
|
72
143
|
});
|
|
73
144
|
|
|
74
145
|
//#endregion
|
|
75
|
-
export { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport };
|
|
146
|
+
export { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MANIFEST_PATH, REPOS_DIR } from "../constants.js";
|
|
2
2
|
import { ReposConfigError } from "../errors.js";
|
|
3
3
|
import { ReposManifestFile } from "../schemas/manifest.js";
|
|
4
|
-
import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
|
|
4
|
+
import { Clock, Context, Effect, FileSystem, Layer, Option, Path, Schedule, Schema } from "effect";
|
|
5
5
|
|
|
6
6
|
//#region src/repos/services/config-store.ts
|
|
7
7
|
/**
|
|
@@ -71,10 +71,44 @@ var ReposConfigStore = class extends Context.Service()("@savvy-web/silk-effects/
|
|
|
71
71
|
kind: "invalid"
|
|
72
72
|
})));
|
|
73
73
|
});
|
|
74
|
+
const lockSchedule = Schedule.exponential("25 millis").pipe(Schedule.upTo({ duration: "2 seconds" }));
|
|
75
|
+
const LOCK_MAX_AGE_MS = 6e4;
|
|
76
|
+
const acquireLock = (root) => Effect.gen(function* () {
|
|
77
|
+
const lockPath = `${manifestPath(root)}.lock`;
|
|
78
|
+
const dir = path.join(root, REPOS_DIR);
|
|
79
|
+
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.mapError((cause) => new ReposConfigError({
|
|
80
|
+
path: lockPath,
|
|
81
|
+
reason: `mkdir failed: ${String(cause)}`,
|
|
82
|
+
kind: "invalid"
|
|
83
|
+
})));
|
|
84
|
+
yield* Effect.gen(function* () {
|
|
85
|
+
const info = yield* fs.stat(lockPath).pipe(Effect.option);
|
|
86
|
+
if (Option.isSome(info) && Option.isSome(info.value.mtime)) {
|
|
87
|
+
if ((yield* Clock.currentTimeMillis) - info.value.mtime.value.getTime() >= LOCK_MAX_AGE_MS) yield* fs.remove(lockPath).pipe(Effect.ignore);
|
|
88
|
+
}
|
|
89
|
+
yield* Effect.scoped(fs.open(lockPath, { flag: "wx" })).pipe(Effect.asVoid);
|
|
90
|
+
}).pipe(Effect.retry({
|
|
91
|
+
schedule: lockSchedule,
|
|
92
|
+
while: (error) => error.reason._tag === "AlreadyExists"
|
|
93
|
+
}), Effect.mapError((cause) => new ReposConfigError({
|
|
94
|
+
path: lockPath,
|
|
95
|
+
reason: `timed out acquiring lock after 2s at ${lockPath}: ${String(cause)}. If no other savvy process is running, a previous run likely crashed while holding it -- remove ${lockPath} and retry.`,
|
|
96
|
+
kind: "invalid"
|
|
97
|
+
})));
|
|
98
|
+
});
|
|
99
|
+
const releaseLock = (root) => fs.remove(`${manifestPath(root)}.lock`).pipe(Effect.ignore);
|
|
100
|
+
const update = (root, fn) => Effect.scoped(Effect.gen(function* () {
|
|
101
|
+
yield* Effect.acquireRelease(acquireLock(root), () => releaseLock(root));
|
|
102
|
+
const result = fn(yield* read(root).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? Effect.succeed({ repos: {} }) : Effect.fail(error))));
|
|
103
|
+
const next = Effect.isEffect(result) ? yield* result : result;
|
|
104
|
+
yield* write(root, next);
|
|
105
|
+
return next;
|
|
106
|
+
}));
|
|
74
107
|
return {
|
|
75
108
|
exists,
|
|
76
109
|
read,
|
|
77
|
-
write
|
|
110
|
+
write,
|
|
111
|
+
update
|
|
78
112
|
};
|
|
79
113
|
}));
|
|
80
114
|
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { REPOS_DIR } from "../constants.js";
|
|
2
|
+
import { GitSubmoduleError } from "../errors.js";
|
|
3
|
+
import { RepoDrift, ReposDriftReport } from "../schemas/drift.js";
|
|
4
|
+
import { ReposConfigStore } from "./config-store.js";
|
|
5
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
|
|
6
|
+
import { Git, Gitmodules } from "@effected/git";
|
|
7
|
+
|
|
8
|
+
//#region src/repos/services/drift.ts
|
|
9
|
+
/**
|
|
10
|
+
* Reconciles the four authorities a vendored repo's state is spread across —
|
|
11
|
+
* the manifest, `.gitmodules`, the worktree, and `git submodule status` —
|
|
12
|
+
* and reports every disagreement found. Read-only: no staging, no lockdown
|
|
13
|
+
* interaction, so it runs unmodified against a locked (`ReposLockdown`)
|
|
14
|
+
* tree.
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var ReposDrift = class extends Context.Service()("@savvy-web/silk-effects/ReposDrift") {
|
|
18
|
+
/**
|
|
19
|
+
* Production implementation of {@link ReposDrift}.
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
static layer = Layer.effect(this, Effect.gen(function* () {
|
|
23
|
+
const configStore = yield* ReposConfigStore;
|
|
24
|
+
const fs = yield* FileSystem.FileSystem;
|
|
25
|
+
const path = yield* Path.Path;
|
|
26
|
+
const git = yield* Git;
|
|
27
|
+
/** Map any typed `@effected/git` failure onto this module's `GitSubmoduleError`. */
|
|
28
|
+
const asSubmoduleError = (command, cwd) => (error) => new GitSubmoduleError({
|
|
29
|
+
command,
|
|
30
|
+
cwd,
|
|
31
|
+
reason: error.message
|
|
32
|
+
});
|
|
33
|
+
const isPresent = (repoPath) => fs.readDirectory(repoPath).pipe(Effect.map((files) => files.length > 0), Effect.orElseSucceed(() => false));
|
|
34
|
+
const check = (root) => Effect.gen(function* () {
|
|
35
|
+
const manifest = yield* configStore.read(root);
|
|
36
|
+
const manifestEntries = Object.entries(manifest.repos);
|
|
37
|
+
const gitmodulesPath = path.join(root, ".gitmodules");
|
|
38
|
+
const gitmodulesText = yield* fs.readFileString(gitmodulesPath).pipe(Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(asSubmoduleError("read .gitmodules", gitmodulesPath)(error))));
|
|
39
|
+
if (Option.isNone(gitmodulesText)) {
|
|
40
|
+
const drifts = manifestEntries.map(([name, entry]) => RepoDrift.make({
|
|
41
|
+
name,
|
|
42
|
+
kind: "unregisteredManifestEntry",
|
|
43
|
+
detail: `manifest entry "${name}" has no corresponding .gitmodules section (.gitmodules is absent)`,
|
|
44
|
+
manifestValue: entry.url
|
|
45
|
+
}));
|
|
46
|
+
return ReposDriftReport.make({
|
|
47
|
+
drifts,
|
|
48
|
+
clean: drifts.length === 0
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const parsed = Gitmodules.parseResult(gitmodulesText.value);
|
|
52
|
+
if (Result.isFailure(parsed)) return ReposDriftReport.make({
|
|
53
|
+
drifts: [RepoDrift.make({
|
|
54
|
+
name: ".gitmodules",
|
|
55
|
+
kind: "gitmodulesUnparsable",
|
|
56
|
+
detail: `.gitmodules failed to parse: ${parsed.failure.message}`
|
|
57
|
+
})],
|
|
58
|
+
clean: false
|
|
59
|
+
});
|
|
60
|
+
const gitmodules = parsed.success;
|
|
61
|
+
const statuses = yield* git.submoduleStatus(root).pipe(Effect.mapError(asSubmoduleError("git submodule status", root)));
|
|
62
|
+
const statusByPath = new Map(statuses.map((status) => [status.path, status]));
|
|
63
|
+
const drifts = [];
|
|
64
|
+
const matchedSectionNames = /* @__PURE__ */ new Set();
|
|
65
|
+
const pairedByName = /* @__PURE__ */ new Map();
|
|
66
|
+
for (const [name] of manifestEntries) {
|
|
67
|
+
const expectedPath = `${REPOS_DIR}/${name}`;
|
|
68
|
+
const nameMatch = gitmodules.entries.find((candidate) => candidate.name === expectedPath && !matchedSectionNames.has(candidate.name));
|
|
69
|
+
if (nameMatch) {
|
|
70
|
+
matchedSectionNames.add(nameMatch.name);
|
|
71
|
+
pairedByName.set(name, {
|
|
72
|
+
section: nameMatch,
|
|
73
|
+
viaPath: false
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const [name] of manifestEntries) {
|
|
78
|
+
if (pairedByName.has(name)) continue;
|
|
79
|
+
const expectedPath = `${REPOS_DIR}/${name}`;
|
|
80
|
+
const pathMatch = gitmodules.entries.find((candidate) => candidate.path === expectedPath && !matchedSectionNames.has(candidate.name));
|
|
81
|
+
if (pathMatch) {
|
|
82
|
+
matchedSectionNames.add(pathMatch.name);
|
|
83
|
+
pairedByName.set(name, {
|
|
84
|
+
section: pathMatch,
|
|
85
|
+
viaPath: true
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const [name, entry] of manifestEntries) {
|
|
90
|
+
const expectedPath = `${REPOS_DIR}/${name}`;
|
|
91
|
+
const paired = pairedByName.get(name);
|
|
92
|
+
if (!paired) {
|
|
93
|
+
drifts.push(RepoDrift.make({
|
|
94
|
+
name,
|
|
95
|
+
kind: "unregisteredManifestEntry",
|
|
96
|
+
detail: `manifest entry "${name}" has no corresponding .gitmodules section`,
|
|
97
|
+
manifestValue: entry.url
|
|
98
|
+
}));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const { section, viaPath } = paired;
|
|
102
|
+
const pathMatch = viaPath ? section : void 0;
|
|
103
|
+
if (pathMatch) drifts.push(RepoDrift.make({
|
|
104
|
+
name,
|
|
105
|
+
kind: "pathMismatch",
|
|
106
|
+
detail: `manifest entry "${name}" paired with .gitmodules section "${pathMatch.name}" by its path "${expectedPath}" -- the section name diverges from the manifest entry name`,
|
|
107
|
+
manifestValue: name,
|
|
108
|
+
observedValue: pathMatch.name
|
|
109
|
+
}));
|
|
110
|
+
else if (section.path !== expectedPath) drifts.push(RepoDrift.make({
|
|
111
|
+
name,
|
|
112
|
+
kind: "pathMismatch",
|
|
113
|
+
detail: `manifest entry "${name}" expects path "${expectedPath}" but .gitmodules records "${section.path}"`,
|
|
114
|
+
manifestValue: expectedPath,
|
|
115
|
+
observedValue: section.path
|
|
116
|
+
}));
|
|
117
|
+
if (section.url !== entry.url) drifts.push(RepoDrift.make({
|
|
118
|
+
name,
|
|
119
|
+
kind: "urlMismatch",
|
|
120
|
+
detail: `manifest entry "${name}" expects url "${entry.url}" but .gitmodules records "${section.url}"`,
|
|
121
|
+
manifestValue: entry.url,
|
|
122
|
+
observedValue: section.url
|
|
123
|
+
}));
|
|
124
|
+
if (section.shallow !== true) drifts.push(RepoDrift.make({
|
|
125
|
+
name,
|
|
126
|
+
kind: "missingShallow",
|
|
127
|
+
detail: `.gitmodules does not record submodule.${section.name}.shallow = true`,
|
|
128
|
+
observedValue: section.shallow === void 0 ? "unset" : String(section.shallow)
|
|
129
|
+
}));
|
|
130
|
+
const repoPath = path.join(root, section.path);
|
|
131
|
+
const present = yield* isPresent(repoPath);
|
|
132
|
+
const status = statusByPath.get(section.path);
|
|
133
|
+
if (!present || status?.state === "uninitialized") drifts.push(RepoDrift.make({
|
|
134
|
+
name,
|
|
135
|
+
kind: "missingWorktree",
|
|
136
|
+
detail: `manifest entry "${name}" expects a checked-out worktree at "${section.path}" but none is present`
|
|
137
|
+
}));
|
|
138
|
+
else if (status?.state === "outOfSync") drifts.push(RepoDrift.make({
|
|
139
|
+
name,
|
|
140
|
+
kind: "checkoutDiverged",
|
|
141
|
+
detail: `the worktree checked out at "${section.path}" no longer matches the commit recorded in the superproject index`
|
|
142
|
+
}));
|
|
143
|
+
else if (status?.state === "conflict") drifts.push(RepoDrift.make({
|
|
144
|
+
name,
|
|
145
|
+
kind: "checkoutDiverged",
|
|
146
|
+
detail: `submodule index entry for "${section.path}" is in an unresolved merge conflict`
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
for (const section of gitmodules.entries) {
|
|
150
|
+
if (matchedSectionNames.has(section.name)) continue;
|
|
151
|
+
drifts.push(RepoDrift.make({
|
|
152
|
+
name: section.name,
|
|
153
|
+
kind: "orphanGitmodulesEntry",
|
|
154
|
+
detail: `.gitmodules section "${section.name}" has no corresponding manifest entry`,
|
|
155
|
+
observedValue: section.path
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
return ReposDriftReport.make({
|
|
159
|
+
drifts,
|
|
160
|
+
clean: drifts.length === 0
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
return { check };
|
|
164
|
+
}));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
//#endregion
|
|
168
|
+
export { ReposDrift };
|
|
@@ -3,8 +3,8 @@ import { GitSubmoduleError, NoteNotFoundError, RepoNotFoundError, ReposConfigErr
|
|
|
3
3
|
import { RepoName } from "../schemas/manifest.js";
|
|
4
4
|
import { ReposConfigStore } from "./config-store.js";
|
|
5
5
|
import { ReposLockdown, resolveModuleDir } from "./lockdown.js";
|
|
6
|
-
import { Clock, Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
7
|
-
import { Git } from "@effected/git";
|
|
6
|
+
import { Clock, Context, Effect, Exit, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
|
|
7
|
+
import { Git, GitConfig, Gitmodules, LsRemoteEntry } from "@effected/git";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
|
|
10
10
|
//#region src/repos/services/manager.ts
|
|
@@ -30,7 +30,9 @@ const STALE_LOCK_MAX_AGE_MS = 6e5;
|
|
|
30
30
|
* Drives the vendored `.repos/` submodules over git: reports status
|
|
31
31
|
* (presence, dirtiness, stale notes), reconciles the working tree with the
|
|
32
32
|
* manifest, vendors new entries (`add`), re-pins existing entries to a new
|
|
33
|
-
* ref (`pin`),
|
|
33
|
+
* ref (`pin`), adds/removes/promotes agent notes (`note`), unvendors
|
|
34
|
+
* (`remove`), renames (`rename`), and explicitly hard-resets dirty
|
|
35
|
+
* checkouts back to their pinned commit (`restore`).
|
|
34
36
|
* @public
|
|
35
37
|
*/
|
|
36
38
|
var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/ReposManager") {
|
|
@@ -58,12 +60,25 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
58
60
|
reason: error.message
|
|
59
61
|
});
|
|
60
62
|
const isPresent = (repoPath) => fs.readDirectory(repoPath).pipe(Effect.map((files) => files.length > 0), Effect.orElseSucceed(() => false));
|
|
63
|
+
/**
|
|
64
|
+
* `Object.hasOwn`-guarded membership read for the manifest's `repos`
|
|
65
|
+
* map. A bare bracket read (`repos[name]`) resolves an INHERITED
|
|
66
|
+
* `Object.prototype` member for a name like `"constructor"` or
|
|
67
|
+
* `"toString"` instead of reporting absence, letting a crafted repo
|
|
68
|
+
* name read back a function rather than fail typed as
|
|
69
|
+
* `RepoNotFoundError` (the same prototype-pollution-read hazard
|
|
70
|
+
* `ReleasePlanner` guards against for changelog module ids).
|
|
71
|
+
*/
|
|
72
|
+
const getRepoEntry = (repos, name) => Object.hasOwn(repos, name) ? repos[name] : void 0;
|
|
61
73
|
const status = (root) => Effect.gen(function* () {
|
|
62
74
|
const manifest = yield* configStore.read(root);
|
|
63
75
|
const repos = yield* Effect.forEach(Object.entries(manifest.repos), ([name, entry]) => Effect.gen(function* () {
|
|
64
76
|
const repoPath = path.join(root, REPOS_DIR, name);
|
|
77
|
+
const repoPathRel = `${REPOS_DIR}/${name}`;
|
|
65
78
|
const present = yield* isPresent(repoPath);
|
|
66
|
-
const
|
|
79
|
+
const committedCommit = (yield* git.lsTree(root, "HEAD", { pathspec: [repoPathRel] }).pipe(Effect.mapError(asSubmoduleError(`git ls-tree HEAD -- ${repoPathRel}`, root))))[0]?.oid;
|
|
80
|
+
const stagedCommit = (yield* git.lsFiles(root, { pathspec: [repoPathRel] }).pipe(Effect.mapError(asSubmoduleError(`git ls-files --stage -- ${repoPathRel}`, root)))).find((lsFilesEntry) => lsFilesEntry.mode === "160000")?.oid;
|
|
81
|
+
const checkedOutCommit = present ? yield* git.revParse(repoPath, "HEAD").pipe(Effect.catchTag("NotARepositoryError", () => Effect.succeed(void 0)), Effect.mapError(asSubmoduleError("git rev-parse HEAD", repoPath))) : void 0;
|
|
67
82
|
let dirty = false;
|
|
68
83
|
if (present) dirty = (yield* git.status(repoPath).pipe(Effect.mapError(asSubmoduleError("git status --porcelain", repoPath)))).length > 0;
|
|
69
84
|
const staleNoteIds = (entry.notes ?? []).filter((note) => note.ref !== entry.ref).map((note) => note.id);
|
|
@@ -72,7 +87,10 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
72
87
|
ref: entry.ref,
|
|
73
88
|
purpose: entry.purpose,
|
|
74
89
|
present,
|
|
75
|
-
commit,
|
|
90
|
+
commit: stagedCommit ?? null,
|
|
91
|
+
...stagedCommit !== void 0 ? { stagedCommit } : {},
|
|
92
|
+
...committedCommit !== void 0 ? { committedCommit } : {},
|
|
93
|
+
...checkedOutCommit !== void 0 ? { checkedOutCommit } : {},
|
|
76
94
|
dirty,
|
|
77
95
|
staleNoteIds
|
|
78
96
|
};
|
|
@@ -88,8 +106,12 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
88
106
|
const sparseApplied = [];
|
|
89
107
|
const upToDate = [];
|
|
90
108
|
const clearedLocks = [];
|
|
109
|
+
const urlSynced = [];
|
|
110
|
+
const registered = [];
|
|
111
|
+
const gitmodulesPath = path.join(root, ".gitmodules");
|
|
91
112
|
for (const [name, entry] of Object.entries(manifest.repos)) {
|
|
92
113
|
const repoPath = path.join(root, REPOS_DIR, name);
|
|
114
|
+
const repoPathRel = `${REPOS_DIR}/${name}`;
|
|
93
115
|
const moduleDir = yield* resolveModuleDir(fs, path, root, name);
|
|
94
116
|
yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
|
|
95
117
|
let clearedAnyLock = false;
|
|
@@ -106,12 +128,38 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
106
128
|
}))) clearedAnyLock = true;
|
|
107
129
|
}
|
|
108
130
|
if (clearedAnyLock) clearedLocks.push(name);
|
|
109
|
-
|
|
131
|
+
const present = yield* isPresent(repoPath);
|
|
132
|
+
const gitmodulesText = yield* fs.readFileString(gitmodulesPath).pipe(Effect.option);
|
|
133
|
+
let gitmodulesEntry;
|
|
134
|
+
if (Option.isSome(gitmodulesText)) gitmodulesEntry = (yield* Gitmodules.parse(gitmodulesText.value).pipe(Effect.mapError(asSubmoduleError("parse .gitmodules", gitmodulesPath)))).entries.find((candidate) => candidate.path === repoPathRel);
|
|
135
|
+
if (present && gitmodulesEntry) {
|
|
136
|
+
const currentUrl = yield* git.configGet(repoPath, "remote.origin.url").pipe(Effect.mapError(asSubmoduleError("git config --get remote.origin.url", repoPath)));
|
|
137
|
+
if (Option.isSome(currentUrl) && currentUrl.value !== entry.url) {
|
|
138
|
+
const configResult = GitConfig.parseResult(Option.getOrThrow(gitmodulesText));
|
|
139
|
+
if (Result.isFailure(configResult)) return yield* Effect.fail(asSubmoduleError("parse .gitmodules", gitmodulesPath)(configResult.failure));
|
|
140
|
+
const setResult = Gitmodules.setUrl(configResult.success, gitmodulesEntry.name, entry.url);
|
|
141
|
+
if (Result.isFailure(setResult)) return yield* Effect.fail(asSubmoduleError(`gitmodules set-url ${gitmodulesEntry.name}`, gitmodulesPath)(setResult.failure));
|
|
142
|
+
yield* fs.writeFileString(gitmodulesPath, setResult.success.stringify()).pipe(Effect.mapError(asSubmoduleError("write .gitmodules", gitmodulesPath)));
|
|
143
|
+
yield* git.submoduleSync(root, { paths: [repoPathRel] }).pipe(Effect.mapError(asSubmoduleError(`git submodule sync -- ${repoPathRel}`, root)));
|
|
144
|
+
urlSynced.push(name);
|
|
145
|
+
} else upToDate.push(name);
|
|
146
|
+
} else if (!present && !gitmodulesEntry) {
|
|
147
|
+
yield* git.submoduleAdd(root, {
|
|
148
|
+
url: entry.url,
|
|
149
|
+
path: repoPathRel,
|
|
150
|
+
depth: 1
|
|
151
|
+
}).pipe(Effect.mapError(asSubmoduleError(`git submodule add --depth 1 ${entry.url} ${repoPathRel}`, root)));
|
|
152
|
+
yield* git.configSet(root, `submodule.${repoPathRel}.shallow`, "true", { file: ".gitmodules" }).pipe(Effect.mapError(asSubmoduleError(`git config -f .gitmodules submodule.${repoPathRel}.shallow true`, root)));
|
|
153
|
+
yield* fetchRef(repoPath, entry.ref);
|
|
154
|
+
yield* git.checkout(repoPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", repoPath)));
|
|
155
|
+
yield* git.add(root, [".gitmodules", repoPathRel]).pipe(Effect.mapError(asSubmoduleError(`git add .gitmodules ${repoPathRel}`, root)));
|
|
156
|
+
registered.push(name);
|
|
157
|
+
} else if (!present) {
|
|
110
158
|
yield* git.submoduleUpdate(root, {
|
|
111
159
|
init: true,
|
|
112
160
|
depth: 1,
|
|
113
|
-
paths: [
|
|
114
|
-
}).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${
|
|
161
|
+
paths: [repoPathRel]
|
|
162
|
+
}).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${repoPathRel}`, root)));
|
|
115
163
|
initialized.push(name);
|
|
116
164
|
} else upToDate.push(name);
|
|
117
165
|
if (entry.sparse && entry.sparse.length > 0) {
|
|
@@ -124,7 +172,9 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
124
172
|
initialized,
|
|
125
173
|
sparseApplied,
|
|
126
174
|
upToDate,
|
|
127
|
-
clearedLocks
|
|
175
|
+
clearedLocks,
|
|
176
|
+
urlSynced,
|
|
177
|
+
registered
|
|
128
178
|
};
|
|
129
179
|
});
|
|
130
180
|
/** Last path segment of a repo URL, with a trailing `.git` stripped. */
|
|
@@ -154,23 +204,82 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
154
204
|
kind: "invalid"
|
|
155
205
|
})));
|
|
156
206
|
const manifest = yield* configStore.read(root).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? Effect.succeed({ repos: {} }) : Effect.fail(error)));
|
|
157
|
-
if (manifest.repos
|
|
207
|
+
if (getRepoEntry(manifest.repos, name)) return yield* Effect.fail(new ReposConfigError({
|
|
158
208
|
path: MANIFEST_PATH,
|
|
159
209
|
reason: `"${name}" is already vendored — use pin to change its ref`,
|
|
160
210
|
kind: "invalid"
|
|
161
211
|
}));
|
|
162
212
|
const repoPath = `${REPOS_DIR}/${name}`;
|
|
163
213
|
const subPath = path.join(root, repoPath);
|
|
214
|
+
const remoteRefs = yield* git.lsRemote(root, options.url, {
|
|
215
|
+
heads: true,
|
|
216
|
+
tags: true
|
|
217
|
+
}).pipe(Effect.mapError(asSubmoduleError(`git ls-remote --heads --tags ${options.url}`, root)));
|
|
218
|
+
if (!remoteRefs.some((remoteRef) => LsRemoteEntry.shortName(remoteRef.ref) === options.ref)) {
|
|
219
|
+
const suggestionNames = LsRemoteEntry.nearMatches(remoteRefs, options.ref).map((match) => LsRemoteEntry.shortName(match.ref));
|
|
220
|
+
const suggestions = [...new Set(suggestionNames)].slice(0, 5);
|
|
221
|
+
const reason = suggestions.length > 0 ? `ref "${options.ref}" not found at ${options.url}; did you mean: ${suggestions.join(", ")}?` : `ref "${options.ref}" not found at ${options.url}`;
|
|
222
|
+
return yield* Effect.fail(new GitSubmoduleError({
|
|
223
|
+
command: `git ls-remote --heads --tags ${options.url}`,
|
|
224
|
+
cwd: root,
|
|
225
|
+
reason
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
const gitmodulesPath = path.join(root, ".gitmodules");
|
|
164
229
|
yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
|
|
165
|
-
yield*
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
230
|
+
const gitmodulesTextOption = yield* fs.readFileString(gitmodulesPath).pipe(Effect.option);
|
|
231
|
+
let existingGitmodulesEntry;
|
|
232
|
+
if (Option.isSome(gitmodulesTextOption)) existingGitmodulesEntry = (yield* Gitmodules.parse(gitmodulesTextOption.value).pipe(Effect.mapError(asSubmoduleError("parse .gitmodules", gitmodulesPath)))).entries.find((candidate) => candidate.path === repoPath);
|
|
233
|
+
const gitlinkStaged = (yield* git.lsFiles(root, { pathspec: [repoPath] }).pipe(Effect.mapError(asSubmoduleError(`git ls-files --stage -- ${repoPath}`, root)))).some((stagedEntry) => stagedEntry.mode === "160000");
|
|
234
|
+
let resuming = false;
|
|
235
|
+
if (existingGitmodulesEntry && gitlinkStaged) {
|
|
236
|
+
if (existingGitmodulesEntry.url !== options.url) return yield* Effect.fail(new ReposConfigError({
|
|
237
|
+
path: gitmodulesPath,
|
|
238
|
+
reason: `"${name}" already has a partial submodule at ${repoPath} registered to a different url (${existingGitmodulesEntry.url}) than requested (${options.url}) — resolve the conflict manually before retrying add`,
|
|
239
|
+
kind: "invalid"
|
|
240
|
+
}));
|
|
241
|
+
resuming = true;
|
|
242
|
+
}
|
|
243
|
+
const rollbackStep = (step, effect) => effect.pipe(Effect.asVoid, Effect.catch((rollbackError) => Effect.logWarning(`repos add rollback for "${name}" — ${step} failed: ${String(rollbackError)}`)));
|
|
244
|
+
const rollback = (cause) => Effect.gen(function* () {
|
|
245
|
+
const moduleDir = yield* resolveModuleDir(fs, path, root, name);
|
|
246
|
+
yield* rollbackStep("submodule deinit", git.submoduleDeinit(root, {
|
|
247
|
+
paths: [repoPath],
|
|
248
|
+
force: true
|
|
249
|
+
}));
|
|
250
|
+
yield* rollbackStep("rm --cached", git.rm(root, [repoPath], { cached: true }));
|
|
251
|
+
yield* rollbackStep("remove worktree", fs.remove(subPath, {
|
|
252
|
+
recursive: true,
|
|
253
|
+
force: true
|
|
254
|
+
}));
|
|
255
|
+
yield* rollbackStep("remove module gitdir", fs.remove(moduleDir, {
|
|
256
|
+
recursive: true,
|
|
257
|
+
force: true
|
|
258
|
+
}));
|
|
259
|
+
const currentGitmodulesText = yield* fs.readFileString(gitmodulesPath).pipe(Effect.option);
|
|
260
|
+
if (Option.isSome(currentGitmodulesText)) {
|
|
261
|
+
const parsedForRollback = yield* Gitmodules.parse(currentGitmodulesText.value).pipe(Effect.option);
|
|
262
|
+
const section = Option.isSome(parsedForRollback) ? parsedForRollback.value.entries.find((candidate) => candidate.name === repoPath) ?? parsedForRollback.value.entries.find((candidate) => candidate.path === repoPath) : void 0;
|
|
263
|
+
if (section) {
|
|
264
|
+
const configResult = GitConfig.parseResult(currentGitmodulesText.value);
|
|
265
|
+
if (Result.isSuccess(configResult)) {
|
|
266
|
+
const removeResult = Gitmodules.remove(configResult.success, section.name);
|
|
267
|
+
if (Result.isSuccess(removeResult)) yield* rollbackStep("restore .gitmodules", fs.writeFileString(gitmodulesPath, removeResult.success.stringify()));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}).pipe(Effect.andThen(Effect.fail(cause)));
|
|
272
|
+
yield* Effect.gen(function* () {
|
|
273
|
+
if (!resuming) yield* git.submoduleAdd(root, {
|
|
274
|
+
url: options.url,
|
|
275
|
+
path: repoPath,
|
|
276
|
+
depth: 1
|
|
277
|
+
}).pipe(Effect.mapError(asSubmoduleError(`git submodule add --depth 1 ${options.url} ${repoPath}`, root)));
|
|
278
|
+
yield* git.configSet(root, `submodule.${repoPath}.shallow`, "true", { file: ".gitmodules" }).pipe(Effect.mapError(asSubmoduleError(`git config -f .gitmodules submodule.${repoPath}.shallow true`, root)));
|
|
279
|
+
yield* fetchRef(subPath, options.ref);
|
|
280
|
+
yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
|
|
281
|
+
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
|
+
}).pipe(Effect.catch(rollback));
|
|
174
283
|
}));
|
|
175
284
|
const entry = {
|
|
176
285
|
url: options.url,
|
|
@@ -178,10 +287,10 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
178
287
|
purpose: options.purpose,
|
|
179
288
|
...options.sparse && options.sparse.length > 0 ? { sparse: options.sparse } : {}
|
|
180
289
|
};
|
|
181
|
-
yield* configStore.
|
|
182
|
-
...
|
|
290
|
+
yield* configStore.update(root, (fresh) => ({ repos: {
|
|
291
|
+
...fresh.repos,
|
|
183
292
|
[name]: entry
|
|
184
|
-
} });
|
|
293
|
+
} }));
|
|
185
294
|
yield* git.add(root, [
|
|
186
295
|
".gitmodules",
|
|
187
296
|
MANIFEST_PATH,
|
|
@@ -195,7 +304,7 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
195
304
|
});
|
|
196
305
|
const pin = (root, name, ref) => Effect.gen(function* () {
|
|
197
306
|
const manifest = yield* configStore.read(root);
|
|
198
|
-
const entry = manifest.repos
|
|
307
|
+
const entry = getRepoEntry(manifest.repos, name);
|
|
199
308
|
if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
|
|
200
309
|
const repoPath = `${REPOS_DIR}/${name}`;
|
|
201
310
|
const subPath = path.join(root, repoPath);
|
|
@@ -208,14 +317,27 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
208
317
|
newCommit: yield* git.revParse(subPath, "HEAD").pipe(Effect.mapError(asSubmoduleError("git rev-parse HEAD", subPath)))
|
|
209
318
|
};
|
|
210
319
|
}));
|
|
211
|
-
yield* configStore.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
320
|
+
yield* configStore.update(root, (fresh) => {
|
|
321
|
+
const freshEntry = getRepoEntry(fresh.repos, name);
|
|
322
|
+
if (!freshEntry) return Effect.fail(new ReposConfigError({
|
|
323
|
+
path: path.join(root, MANIFEST_PATH),
|
|
324
|
+
reason: `pin applied to git but manifest entry "${name}" is gone; manifest and checkout now disagree`,
|
|
325
|
+
kind: "invalid"
|
|
326
|
+
}));
|
|
327
|
+
return Effect.succeed({ repos: {
|
|
328
|
+
...fresh.repos,
|
|
329
|
+
[name]: {
|
|
330
|
+
...freshEntry,
|
|
331
|
+
ref
|
|
332
|
+
}
|
|
333
|
+
} });
|
|
334
|
+
});
|
|
335
|
+
const gitmodulesChanged = (yield* git.status(root).pipe(Effect.mapError(asSubmoduleError("git status --porcelain", root)))).some((s) => s.path === ".gitmodules");
|
|
336
|
+
yield* git.add(root, gitmodulesChanged ? [
|
|
337
|
+
MANIFEST_PATH,
|
|
338
|
+
repoPath,
|
|
339
|
+
".gitmodules"
|
|
340
|
+
] : [MANIFEST_PATH, repoPath]).pipe(Effect.mapError(asSubmoduleError(gitmodulesChanged ? `git add ${MANIFEST_PATH} ${repoPath} .gitmodules` : `git add ${MANIFEST_PATH} ${repoPath}`, root)));
|
|
219
341
|
const staleNoteIds = (entry.notes ?? []).filter((note) => note.ref !== ref).map((note) => note.id);
|
|
220
342
|
return {
|
|
221
343
|
name,
|
|
@@ -228,7 +350,7 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
228
350
|
});
|
|
229
351
|
const note = (root, name, op) => Effect.gen(function* () {
|
|
230
352
|
const manifest = yield* configStore.read(root);
|
|
231
|
-
const entry = manifest.repos
|
|
353
|
+
const entry = getRepoEntry(manifest.repos, name);
|
|
232
354
|
if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
|
|
233
355
|
const notes = entry.notes ?? [];
|
|
234
356
|
if (op.op === "add") {
|
|
@@ -269,10 +391,10 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
269
391
|
...entry,
|
|
270
392
|
notes: updatedNotes
|
|
271
393
|
};
|
|
272
|
-
yield* configStore.
|
|
273
|
-
...
|
|
394
|
+
yield* configStore.update(root, (fresh) => ({ repos: {
|
|
395
|
+
...fresh.repos,
|
|
274
396
|
[name]: updatedEntry
|
|
275
|
-
} });
|
|
397
|
+
} }));
|
|
276
398
|
return {
|
|
277
399
|
name,
|
|
278
400
|
op: "add",
|
|
@@ -291,10 +413,10 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
291
413
|
...entry,
|
|
292
414
|
notes: updatedNotes
|
|
293
415
|
};
|
|
294
|
-
yield* configStore.
|
|
295
|
-
...
|
|
416
|
+
yield* configStore.update(root, (fresh) => ({ repos: {
|
|
417
|
+
...fresh.repos,
|
|
296
418
|
[name]: updatedEntry
|
|
297
|
-
} });
|
|
419
|
+
} }));
|
|
298
420
|
return {
|
|
299
421
|
name,
|
|
300
422
|
op: "remove",
|
|
@@ -302,19 +424,21 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
302
424
|
noteCount: updatedNotes.length
|
|
303
425
|
};
|
|
304
426
|
}
|
|
427
|
+
const existing = entry.orientation?.[op.into];
|
|
428
|
+
const promoted = existing === void 0 ? target.note : `${existing}\n\n${target.note}`;
|
|
305
429
|
const updatedOrientation = {
|
|
306
430
|
...entry.orientation,
|
|
307
|
-
[op.into]:
|
|
431
|
+
[op.into]: promoted
|
|
308
432
|
};
|
|
309
433
|
const updatedEntry = {
|
|
310
434
|
...entry,
|
|
311
435
|
orientation: updatedOrientation,
|
|
312
436
|
notes: updatedNotes
|
|
313
437
|
};
|
|
314
|
-
yield* configStore.
|
|
315
|
-
...
|
|
438
|
+
yield* configStore.update(root, (fresh) => ({ repos: {
|
|
439
|
+
...fresh.repos,
|
|
316
440
|
[name]: updatedEntry
|
|
317
|
-
} });
|
|
441
|
+
} }));
|
|
318
442
|
return {
|
|
319
443
|
name,
|
|
320
444
|
op: "promote",
|
|
@@ -322,12 +446,190 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
322
446
|
noteCount: updatedNotes.length
|
|
323
447
|
};
|
|
324
448
|
});
|
|
449
|
+
const remove = (root, name) => Effect.gen(function* () {
|
|
450
|
+
const manifest = yield* configStore.read(root);
|
|
451
|
+
const entry = getRepoEntry(manifest.repos, name);
|
|
452
|
+
if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
|
|
453
|
+
const repoPath = `${REPOS_DIR}/${name}`;
|
|
454
|
+
const subPath = path.join(root, repoPath);
|
|
455
|
+
const moduleDir = yield* resolveModuleDir(fs, path, root, name);
|
|
456
|
+
yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
|
|
457
|
+
yield* git.submoduleDeinit(root, {
|
|
458
|
+
paths: [repoPath],
|
|
459
|
+
force: true
|
|
460
|
+
}).pipe(Effect.mapError(asSubmoduleError(`git submodule deinit --force -- ${repoPath}`, root)));
|
|
461
|
+
yield* git.rm(root, [repoPath], { cached: true }).pipe(Effect.mapError(asSubmoduleError(`git rm --cached ${repoPath}`, root)));
|
|
462
|
+
yield* fs.remove(subPath, {
|
|
463
|
+
recursive: true,
|
|
464
|
+
force: true
|
|
465
|
+
}).pipe(Effect.mapError((cause) => new GitSubmoduleError({
|
|
466
|
+
command: "remove worktree",
|
|
467
|
+
cwd: subPath,
|
|
468
|
+
reason: String(cause)
|
|
469
|
+
})));
|
|
470
|
+
yield* fs.remove(moduleDir, {
|
|
471
|
+
recursive: true,
|
|
472
|
+
force: true
|
|
473
|
+
}).pipe(Effect.mapError((cause) => new GitSubmoduleError({
|
|
474
|
+
command: "remove module gitdir",
|
|
475
|
+
cwd: moduleDir,
|
|
476
|
+
reason: String(cause)
|
|
477
|
+
})));
|
|
478
|
+
}));
|
|
479
|
+
const gitmodulesPath = path.join(root, ".gitmodules");
|
|
480
|
+
const gitmodulesText = yield* fs.readFileString(gitmodulesPath).pipe(Effect.mapError(asSubmoduleError("read .gitmodules", gitmodulesPath)));
|
|
481
|
+
const parsedGitmodules = yield* Gitmodules.parse(gitmodulesText).pipe(Effect.mapError(asSubmoduleError("parse .gitmodules", gitmodulesPath)));
|
|
482
|
+
const section = parsedGitmodules.entries.find((candidate) => candidate.name === repoPath) ?? parsedGitmodules.entries.find((candidate) => candidate.path === repoPath);
|
|
483
|
+
if (!section) return yield* Effect.fail(new ReposConfigError({
|
|
484
|
+
path: gitmodulesPath,
|
|
485
|
+
reason: `no .gitmodules section found for "${name}" (looked for name or path "${repoPath}")`,
|
|
486
|
+
kind: "invalid"
|
|
487
|
+
}));
|
|
488
|
+
const configResult = GitConfig.parseResult(gitmodulesText);
|
|
489
|
+
if (Result.isFailure(configResult)) return yield* Effect.fail(asSubmoduleError("parse .gitmodules", gitmodulesPath)(configResult.failure));
|
|
490
|
+
const removeResult = Gitmodules.remove(configResult.success, section.name);
|
|
491
|
+
if (Result.isFailure(removeResult)) return yield* Effect.fail(asSubmoduleError(`gitmodules remove ${section.name}`, gitmodulesPath)(removeResult.failure));
|
|
492
|
+
yield* fs.writeFileString(gitmodulesPath, removeResult.success.stringify()).pipe(Effect.mapError(asSubmoduleError("write .gitmodules", gitmodulesPath)));
|
|
493
|
+
yield* configStore.update(root, (fresh) => {
|
|
494
|
+
const { [name]: _dropped, ...rest } = fresh.repos;
|
|
495
|
+
return { repos: rest };
|
|
496
|
+
});
|
|
497
|
+
yield* git.add(root, [".gitmodules", MANIFEST_PATH]).pipe(Effect.mapError(asSubmoduleError(`git add .gitmodules ${MANIFEST_PATH}`, root)));
|
|
498
|
+
return {
|
|
499
|
+
name,
|
|
500
|
+
path: repoPath,
|
|
501
|
+
commitMessage: `chore(repos): remove ${name}`,
|
|
502
|
+
removedNotes: entry.notes ?? []
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
const rename = (root, oldName, newName) => Effect.gen(function* () {
|
|
506
|
+
const manifest = yield* configStore.read(root);
|
|
507
|
+
if (!getRepoEntry(manifest.repos, oldName)) return yield* Effect.fail(new RepoNotFoundError({ name: oldName }));
|
|
508
|
+
yield* Schema.decodeUnknownEffect(RepoName)(newName).pipe(Effect.mapError(() => new ReposConfigError({
|
|
509
|
+
path: MANIFEST_PATH,
|
|
510
|
+
reason: `invalid repo name "${newName}": must be non-empty, contain no "/" or "\\", and not be "." or ".."`,
|
|
511
|
+
kind: "invalid"
|
|
512
|
+
})));
|
|
513
|
+
if (getRepoEntry(manifest.repos, newName)) return yield* Effect.fail(new ReposConfigError({
|
|
514
|
+
path: MANIFEST_PATH,
|
|
515
|
+
reason: `"${newName}" is already vendored — choose a different name`,
|
|
516
|
+
kind: "invalid"
|
|
517
|
+
}));
|
|
518
|
+
const oldRepoPath = `${REPOS_DIR}/${oldName}`;
|
|
519
|
+
const newRepoPath = `${REPOS_DIR}/${newName}`;
|
|
520
|
+
const gitmodulesPath = path.join(root, ".gitmodules");
|
|
521
|
+
const moduleDir = yield* resolveModuleDir(fs, path, root, oldName);
|
|
522
|
+
yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
523
|
+
const unlockExit = yield* Effect.exit(lockdown.unlock(root, oldName));
|
|
524
|
+
let lockName = oldName;
|
|
525
|
+
const resultExit = Exit.isFailure(unlockExit) ? Exit.failCause(unlockExit.cause) : yield* Effect.exit(restore(Effect.gen(function* () {
|
|
526
|
+
yield* git.mv(root, oldRepoPath, newRepoPath).pipe(Effect.mapError(asSubmoduleError(`git mv ${oldRepoPath} ${newRepoPath}`, root)));
|
|
527
|
+
lockName = newName;
|
|
528
|
+
const absoluteNewSubPath = path.resolve(root, newRepoPath);
|
|
529
|
+
const worktreeValue = path.relative(moduleDir, absoluteNewSubPath);
|
|
530
|
+
yield* git.configSet(root, "core.worktree", worktreeValue, { file: path.join(moduleDir, "config") }).pipe(Effect.mapError(asSubmoduleError("git config core.worktree", moduleDir)));
|
|
531
|
+
const worktreeConfigPath = path.join(moduleDir, "config.worktree");
|
|
532
|
+
if (yield* fs.exists(worktreeConfigPath).pipe(Effect.orElseSucceed(() => false))) yield* git.configSet(root, "core.worktree", worktreeValue, { file: worktreeConfigPath }).pipe(Effect.mapError(asSubmoduleError("git config -f config.worktree core.worktree", moduleDir)));
|
|
533
|
+
const gitmodulesText = yield* fs.readFileString(gitmodulesPath).pipe(Effect.mapError(asSubmoduleError("read .gitmodules", gitmodulesPath)));
|
|
534
|
+
const section = (yield* Gitmodules.parse(gitmodulesText).pipe(Effect.mapError(asSubmoduleError("parse .gitmodules", gitmodulesPath)))).entries.find((candidate) => candidate.path === newRepoPath);
|
|
535
|
+
if (!section) return yield* Effect.fail(new ReposConfigError({
|
|
536
|
+
path: gitmodulesPath,
|
|
537
|
+
reason: `no .gitmodules section found for "${newName}" after mv (looked for path "${newRepoPath}")`,
|
|
538
|
+
kind: "invalid"
|
|
539
|
+
}));
|
|
540
|
+
if (section.name !== newRepoPath) {
|
|
541
|
+
const oldSectionName = section.name;
|
|
542
|
+
const configResult = GitConfig.parseResult(gitmodulesText);
|
|
543
|
+
if (Result.isFailure(configResult)) return yield* Effect.fail(asSubmoduleError("parse .gitmodules", gitmodulesPath)(configResult.failure));
|
|
544
|
+
const renameResult = Gitmodules.rename(configResult.success, oldSectionName, newRepoPath);
|
|
545
|
+
if (Result.isFailure(renameResult)) return yield* Effect.fail(asSubmoduleError(`gitmodules rename ${oldSectionName} -> ${newRepoPath}`, gitmodulesPath)(renameResult.failure));
|
|
546
|
+
yield* fs.writeFileString(gitmodulesPath, renameResult.success.stringify()).pipe(Effect.mapError(asSubmoduleError("write .gitmodules", gitmodulesPath)));
|
|
547
|
+
yield* git.add(root, [".gitmodules"]).pipe(Effect.mapError(asSubmoduleError("git add .gitmodules", root)));
|
|
548
|
+
const unsetIfPresent = (key) => git.configGet(root, key).pipe(Effect.mapError(asSubmoduleError(`git config --get ${key}`, root)), Effect.flatMap((current) => Option.isSome(current) ? git.configUnset(root, key).pipe(Effect.mapError(asSubmoduleError(`git config --unset ${key}`, root))) : Effect.void));
|
|
549
|
+
yield* unsetIfPresent(`submodule.${oldSectionName}.url`);
|
|
550
|
+
yield* unsetIfPresent(`submodule.${oldSectionName}.active`);
|
|
551
|
+
}
|
|
552
|
+
yield* git.submoduleInit(root, { paths: [newRepoPath] }).pipe(Effect.mapError(asSubmoduleError(`git submodule init -- ${newRepoPath}`, root)));
|
|
553
|
+
yield* configStore.update(root, (fresh) => {
|
|
554
|
+
const renamedEntry = getRepoEntry(fresh.repos, oldName);
|
|
555
|
+
if (!renamedEntry) return Effect.fail(new ReposConfigError({
|
|
556
|
+
path: MANIFEST_PATH,
|
|
557
|
+
reason: `rename applied to git but manifest entry "${oldName}" is gone; manifest and .gitmodules now disagree`,
|
|
558
|
+
kind: "invalid"
|
|
559
|
+
}));
|
|
560
|
+
const { [oldName]: _dropped, ...rest } = fresh.repos;
|
|
561
|
+
return { repos: {
|
|
562
|
+
...rest,
|
|
563
|
+
[newName]: renamedEntry
|
|
564
|
+
} };
|
|
565
|
+
});
|
|
566
|
+
yield* git.add(root, [MANIFEST_PATH]).pipe(Effect.mapError(asSubmoduleError(`git add ${MANIFEST_PATH}`, root)));
|
|
567
|
+
yield* git.submoduleStatus(root).pipe(Effect.mapError(asSubmoduleError("git submodule status", root)));
|
|
568
|
+
})));
|
|
569
|
+
const relockExit = yield* Effect.exit(lockdown.lock(root, lockName));
|
|
570
|
+
if (Exit.isFailure(resultExit)) return yield* Exit.failCause(resultExit.cause);
|
|
571
|
+
if (Exit.isFailure(relockExit)) return yield* Exit.failCause(relockExit.cause);
|
|
572
|
+
}));
|
|
573
|
+
return {
|
|
574
|
+
oldName,
|
|
575
|
+
newName,
|
|
576
|
+
path: newRepoPath,
|
|
577
|
+
commitMessage: `chore(repos): rename ${oldName} to ${newName}`
|
|
578
|
+
};
|
|
579
|
+
});
|
|
580
|
+
const restore = (root, names) => Effect.gen(function* () {
|
|
581
|
+
const manifest = yield* configStore.read(root);
|
|
582
|
+
let targetNames;
|
|
583
|
+
let skippedClean = [];
|
|
584
|
+
if (names && names.length > 0) {
|
|
585
|
+
for (const name of names) if (!getRepoEntry(manifest.repos, name)) return yield* Effect.fail(new RepoNotFoundError({ name }));
|
|
586
|
+
targetNames = names;
|
|
587
|
+
} else {
|
|
588
|
+
const report = yield* status(root);
|
|
589
|
+
targetNames = report.repos.filter((entry) => entry.dirty).map((entry) => entry.name);
|
|
590
|
+
skippedClean = report.repos.filter((entry) => !entry.dirty).map((entry) => entry.name);
|
|
591
|
+
}
|
|
592
|
+
const restored = [];
|
|
593
|
+
for (const name of targetNames) {
|
|
594
|
+
const entry = getRepoEntry(manifest.repos, name);
|
|
595
|
+
if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
|
|
596
|
+
const repoPath = `${REPOS_DIR}/${name}`;
|
|
597
|
+
const subPath = path.join(root, repoPath);
|
|
598
|
+
const commit = yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
|
|
599
|
+
let targetCommit = (yield* git.lsFiles(root, { pathspec: [repoPath] }).pipe(Effect.mapError(asSubmoduleError(`git ls-files --stage -- ${repoPath}`, root)))).find((lsFilesEntry) => lsFilesEntry.mode === "160000")?.oid;
|
|
600
|
+
if (targetCommit === void 0) targetCommit = (yield* git.lsTree(root, "HEAD", { pathspec: [repoPath] }).pipe(Effect.mapError(asSubmoduleError(`git ls-tree HEAD -- ${repoPath}`, root))))[0]?.oid;
|
|
601
|
+
if (targetCommit === void 0) return yield* Effect.fail(new GitSubmoduleError({
|
|
602
|
+
command: "resolve restore target commit",
|
|
603
|
+
cwd: subPath,
|
|
604
|
+
reason: `no staged or committed gitlink commit found for "${name}" -- nothing to restore to`
|
|
605
|
+
}));
|
|
606
|
+
yield* git.reset(subPath, {
|
|
607
|
+
mode: "hard",
|
|
608
|
+
ref: targetCommit
|
|
609
|
+
}).pipe(Effect.mapError(asSubmoduleError(`git reset --hard ${targetCommit}`, subPath)));
|
|
610
|
+
yield* git.clean(subPath, { directories: true }).pipe(Effect.mapError(asSubmoduleError("git clean --force -d", subPath)));
|
|
611
|
+
if (entry.sparse && entry.sparse.length > 0) yield* git.sparseCheckoutSet(subPath, entry.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", subPath)));
|
|
612
|
+
return targetCommit;
|
|
613
|
+
}));
|
|
614
|
+
restored.push({
|
|
615
|
+
name,
|
|
616
|
+
commit
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
restored,
|
|
621
|
+
skippedClean
|
|
622
|
+
};
|
|
623
|
+
});
|
|
325
624
|
return {
|
|
326
625
|
status,
|
|
327
626
|
sync,
|
|
328
627
|
add,
|
|
329
628
|
pin,
|
|
330
|
-
note
|
|
629
|
+
note,
|
|
630
|
+
remove,
|
|
631
|
+
rename,
|
|
632
|
+
restore
|
|
331
633
|
};
|
|
332
634
|
}));
|
|
333
635
|
};
|