@savvy-web/silk-effects 5.4.0 → 5.5.1

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.
@@ -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 };