@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.
- package/README.md +5 -5
- package/changesets/changelog/formatting.js +6 -4
- package/commitlint/hook/diagnostics/signing.js +14 -12
- package/index.d.ts +187 -3
- package/package.json +11 -11
- 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
|
@@ -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 };
|