@savvy-web/silk-effects 5.3.1 → 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 +28 -1
- package/index.d.ts +251 -7
- package/package.json +4 -4
- package/repos/errors.js +12 -1
- package/repos/index.js +18 -4
- 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/lockdown.js +119 -0
- package/repos/services/manager.js +384 -71
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 };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { REPOS_DIR } from "../constants.js";
|
|
2
|
+
import { ReposLockdownError } from "../errors.js";
|
|
3
|
+
import { Context, Effect, Exit, FileSystem, Layer, Option, Path } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/repos/services/lockdown.ts
|
|
6
|
+
const FILE_LOCKED_BASE_MODE = 292;
|
|
7
|
+
const DIR_LOCKED_MODE = 365;
|
|
8
|
+
const FILE_UNLOCKED_BASE_MODE = 420;
|
|
9
|
+
const DIR_UNLOCKED_MODE = 493;
|
|
10
|
+
const EXEC_BITS = 73;
|
|
11
|
+
/**
|
|
12
|
+
* Preserves the executable bit through a lock/unlock transition: a file
|
|
13
|
+
* whose current mode has any of the owner/group/other execute bits set
|
|
14
|
+
* locks to `0o555` and unlocks to `0o755` instead of stripping execute
|
|
15
|
+
* permission entirely.
|
|
16
|
+
*/
|
|
17
|
+
const fileModeFor = (baseMode, currentMode) => baseMode | (currentMode & EXEC_BITS ? EXEC_BITS : 0);
|
|
18
|
+
/**
|
|
19
|
+
* Derives a submodule's git metadata directory (`.git/modules/...`) from the
|
|
20
|
+
* checkout itself rather than assuming it is named after the manifest key.
|
|
21
|
+
*
|
|
22
|
+
* A submodule worktree's `<root>/.repos/<name>/.git` is normally a FILE
|
|
23
|
+
* containing a single `gitdir: <path>` line pointing at the real metadata
|
|
24
|
+
* directory, which git names after whatever path/name the submodule was
|
|
25
|
+
* REGISTERED under — not necessarily the manifest key (e.g. this repo's own
|
|
26
|
+
* `effect` entry has gitdir `.git/modules/.repos/effect-smol`). This helper
|
|
27
|
+
* reads that pointer and resolves it (relative pointers are relative to the
|
|
28
|
+
* directory containing the `.git` file) so callers always land on the real
|
|
29
|
+
* metadata directory.
|
|
30
|
+
*
|
|
31
|
+
* Falls back to the name-based path `<root>/.git/modules/<REPOS_DIR>/<name>`
|
|
32
|
+
* whenever the pointer can't be read (submodule not initialized, `.git`
|
|
33
|
+
* missing) — this never fails, it only degrades to prior behavior. If
|
|
34
|
+
* `<root>/.repos/<name>/.git` is itself a directory (a plain, non-submodule
|
|
35
|
+
* checkout), it is used directly as the metadata directory.
|
|
36
|
+
*
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
const resolveModuleDir = (fs, path, root, name) => Effect.gen(function* () {
|
|
40
|
+
const fallback = path.join(root, ".git", "modules", REPOS_DIR, name);
|
|
41
|
+
const dotGit = path.join(root, REPOS_DIR, name, ".git");
|
|
42
|
+
const info = yield* fs.stat(dotGit).pipe(Effect.option);
|
|
43
|
+
if (Option.isNone(info)) return fallback;
|
|
44
|
+
if (info.value.type === "Directory") return dotGit;
|
|
45
|
+
const content = yield* fs.readFileString(dotGit).pipe(Effect.option);
|
|
46
|
+
if (Option.isNone(content)) return fallback;
|
|
47
|
+
const match = /^gitdir:\s*(.+)$/m.exec(content.value);
|
|
48
|
+
if (!match?.[1]) return fallback;
|
|
49
|
+
const pointer = match[1].trim();
|
|
50
|
+
const resolved = path.isAbsolute(pointer) ? pointer : path.resolve(path.dirname(dotGit), pointer);
|
|
51
|
+
const relative = path.relative(root, resolved);
|
|
52
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return fallback;
|
|
53
|
+
return resolved;
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Enforces OS-level read-only permissions on vendored repos so they cannot
|
|
57
|
+
* be accidentally edited outside the sync flow.
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
var ReposLockdown = class extends Context.Service()("@savvy-web/silk-effects/ReposLockdown") {
|
|
61
|
+
/**
|
|
62
|
+
* Production layer over the core FileSystem.
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
static layer = Layer.effect(this, Effect.gen(function* () {
|
|
66
|
+
const fs = yield* FileSystem.FileSystem;
|
|
67
|
+
const path = yield* Path.Path;
|
|
68
|
+
const chmod = (entryPath, mode) => fs.chmod(entryPath, mode).pipe(Effect.mapError((cause) => new ReposLockdownError({
|
|
69
|
+
path: entryPath,
|
|
70
|
+
reason: `chmod failed: ${String(cause)}`
|
|
71
|
+
})));
|
|
72
|
+
const isSymlink = (entryPath) => fs.readLink(entryPath).pipe(Effect.option, Effect.map(Option.isSome));
|
|
73
|
+
const walk = (dir, fileBaseMode, dirMode, order) => Effect.gen(function* () {
|
|
74
|
+
if (order === "unlock") yield* chmod(dir, dirMode);
|
|
75
|
+
const entries = yield* fs.readDirectory(dir).pipe(Effect.mapError((cause) => new ReposLockdownError({
|
|
76
|
+
path: dir,
|
|
77
|
+
reason: `readDirectory failed: ${String(cause)}`
|
|
78
|
+
})));
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const entryPath = path.join(dir, entry);
|
|
81
|
+
if (yield* isSymlink(entryPath)) continue;
|
|
82
|
+
const maybeInfo = yield* fs.stat(entryPath).pipe(Effect.option);
|
|
83
|
+
if (Option.isNone(maybeInfo)) continue;
|
|
84
|
+
const info = maybeInfo.value;
|
|
85
|
+
if (info.type === "Directory") yield* walk(entryPath, fileBaseMode, dirMode, order);
|
|
86
|
+
else yield* chmod(entryPath, fileModeFor(fileBaseMode, info.mode));
|
|
87
|
+
}
|
|
88
|
+
if (order === "lock") yield* chmod(dir, dirMode);
|
|
89
|
+
});
|
|
90
|
+
const walkRoot = (root, name, fileBaseMode, dirMode, order) => Effect.gen(function* () {
|
|
91
|
+
const moduleDir = yield* resolveModuleDir(fs, path, root, name);
|
|
92
|
+
for (const dir of [path.join(root, REPOS_DIR, name), moduleDir]) {
|
|
93
|
+
if (!(yield* fs.exists(dir).pipe(Effect.mapError((cause) => new ReposLockdownError({
|
|
94
|
+
path: dir,
|
|
95
|
+
reason: `stat failed: ${String(cause)}`
|
|
96
|
+
}))))) continue;
|
|
97
|
+
yield* walk(dir, fileBaseMode, dirMode, order);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const lock = (root, name) => walkRoot(root, name, FILE_LOCKED_BASE_MODE, DIR_LOCKED_MODE, "lock");
|
|
101
|
+
const unlock = (root, name) => walkRoot(root, name, FILE_UNLOCKED_BASE_MODE, DIR_UNLOCKED_MODE, "unlock");
|
|
102
|
+
const withUnlocked = (root, name, effect) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
103
|
+
const unlockExit = yield* Effect.exit(unlock(root, name));
|
|
104
|
+
const resultExit = Exit.isFailure(unlockExit) ? Exit.failCause(unlockExit.cause) : yield* Effect.exit(restore(effect));
|
|
105
|
+
const relockExit = yield* Effect.exit(lock(root, name));
|
|
106
|
+
if (Exit.isFailure(resultExit)) return yield* Exit.failCause(resultExit.cause);
|
|
107
|
+
if (Exit.isFailure(relockExit)) return yield* Exit.failCause(relockExit.cause);
|
|
108
|
+
return resultExit.value;
|
|
109
|
+
}));
|
|
110
|
+
return {
|
|
111
|
+
lock,
|
|
112
|
+
unlock,
|
|
113
|
+
withUnlocked
|
|
114
|
+
};
|
|
115
|
+
}));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
//#endregion
|
|
119
|
+
export { ReposLockdown, resolveModuleDir };
|