@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
|
@@ -2,8 +2,9 @@ import { MANIFEST_PATH, REPOS_DIR } from "../constants.js";
|
|
|
2
2
|
import { GitSubmoduleError, NoteNotFoundError, RepoNotFoundError, ReposConfigError } from "../errors.js";
|
|
3
3
|
import { RepoName } from "../schemas/manifest.js";
|
|
4
4
|
import { ReposConfigStore } from "./config-store.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { ReposLockdown, resolveModuleDir } from "./lockdown.js";
|
|
6
|
+
import { Clock, Context, Effect, Exit, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
|
|
7
|
+
import { Git, GitConfig, Gitmodules, LsRemoteEntry } from "@effected/git";
|
|
7
8
|
import { createHash } from "node:crypto";
|
|
8
9
|
|
|
9
10
|
//#region src/repos/services/manager.ts
|
|
@@ -29,7 +30,9 @@ const STALE_LOCK_MAX_AGE_MS = 6e5;
|
|
|
29
30
|
* Drives the vendored `.repos/` submodules over git: reports status
|
|
30
31
|
* (presence, dirtiness, stale notes), reconciles the working tree with the
|
|
31
32
|
* manifest, vendors new entries (`add`), re-pins existing entries to a new
|
|
32
|
-
* 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`).
|
|
33
36
|
* @public
|
|
34
37
|
*/
|
|
35
38
|
var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/ReposManager") {
|
|
@@ -49,6 +52,7 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
49
52
|
const fs = yield* FileSystem.FileSystem;
|
|
50
53
|
const path = yield* Path.Path;
|
|
51
54
|
const git = yield* Git;
|
|
55
|
+
const lockdown = yield* ReposLockdown;
|
|
52
56
|
/** Map any typed `@effected/git` failure onto this module's `GitSubmoduleError`. */
|
|
53
57
|
const asSubmoduleError = (command, cwd) => (error) => new GitSubmoduleError({
|
|
54
58
|
command,
|
|
@@ -56,12 +60,25 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
56
60
|
reason: error.message
|
|
57
61
|
});
|
|
58
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;
|
|
59
73
|
const status = (root) => Effect.gen(function* () {
|
|
60
74
|
const manifest = yield* configStore.read(root);
|
|
61
75
|
const repos = yield* Effect.forEach(Object.entries(manifest.repos), ([name, entry]) => Effect.gen(function* () {
|
|
62
76
|
const repoPath = path.join(root, REPOS_DIR, name);
|
|
77
|
+
const repoPathRel = `${REPOS_DIR}/${name}`;
|
|
63
78
|
const present = yield* isPresent(repoPath);
|
|
64
|
-
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;
|
|
65
82
|
let dirty = false;
|
|
66
83
|
if (present) dirty = (yield* git.status(repoPath).pipe(Effect.mapError(asSubmoduleError("git status --porcelain", repoPath)))).length > 0;
|
|
67
84
|
const staleNoteIds = (entry.notes ?? []).filter((note) => note.ref !== entry.ref).map((note) => note.id);
|
|
@@ -70,7 +87,10 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
70
87
|
ref: entry.ref,
|
|
71
88
|
purpose: entry.purpose,
|
|
72
89
|
present,
|
|
73
|
-
commit,
|
|
90
|
+
commit: stagedCommit ?? null,
|
|
91
|
+
...stagedCommit !== void 0 ? { stagedCommit } : {},
|
|
92
|
+
...committedCommit !== void 0 ? { committedCommit } : {},
|
|
93
|
+
...checkedOutCommit !== void 0 ? { checkedOutCommit } : {},
|
|
74
94
|
dirty,
|
|
75
95
|
staleNoteIds
|
|
76
96
|
};
|
|
@@ -86,41 +106,75 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
86
106
|
const sparseApplied = [];
|
|
87
107
|
const upToDate = [];
|
|
88
108
|
const clearedLocks = [];
|
|
109
|
+
const urlSynced = [];
|
|
110
|
+
const registered = [];
|
|
111
|
+
const gitmodulesPath = path.join(root, ".gitmodules");
|
|
89
112
|
for (const [name, entry] of Object.entries(manifest.repos)) {
|
|
90
113
|
const repoPath = path.join(root, REPOS_DIR, name);
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
const repoPathRel = `${REPOS_DIR}/${name}`;
|
|
115
|
+
const moduleDir = yield* resolveModuleDir(fs, path, root, name);
|
|
116
|
+
yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
|
|
117
|
+
let clearedAnyLock = false;
|
|
118
|
+
for (const lock of STALE_LOCKS) {
|
|
119
|
+
const lockPath = path.join(moduleDir, lock);
|
|
120
|
+
const info = yield* fs.stat(lockPath).pipe(Effect.option);
|
|
121
|
+
if (Option.isNone(info)) continue;
|
|
122
|
+
const mtime = info.value.mtime;
|
|
123
|
+
if (Option.isNone(mtime)) continue;
|
|
124
|
+
if ((yield* Clock.currentTimeMillis) - mtime.value.getTime() < 6e5) continue;
|
|
125
|
+
if (yield* fs.remove(lockPath).pipe(Effect.match({
|
|
126
|
+
onSuccess: () => true,
|
|
127
|
+
onFailure: () => false
|
|
128
|
+
}))) clearedAnyLock = true;
|
|
129
|
+
}
|
|
130
|
+
if (clearedAnyLock) clearedLocks.push(name);
|
|
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) {
|
|
158
|
+
yield* git.submoduleUpdate(root, {
|
|
159
|
+
init: true,
|
|
160
|
+
depth: 1,
|
|
161
|
+
paths: [repoPathRel]
|
|
162
|
+
}).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${repoPathRel}`, root)));
|
|
163
|
+
initialized.push(name);
|
|
164
|
+
} else upToDate.push(name);
|
|
165
|
+
if (entry.sparse && entry.sparse.length > 0) {
|
|
166
|
+
yield* git.sparseCheckoutSet(repoPath, entry.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", repoPath)));
|
|
167
|
+
sparseApplied.push(name);
|
|
168
|
+
}
|
|
169
|
+
}));
|
|
118
170
|
}
|
|
119
171
|
return {
|
|
120
172
|
initialized,
|
|
121
173
|
sparseApplied,
|
|
122
174
|
upToDate,
|
|
123
|
-
clearedLocks
|
|
175
|
+
clearedLocks,
|
|
176
|
+
urlSynced,
|
|
177
|
+
registered
|
|
124
178
|
};
|
|
125
179
|
});
|
|
126
180
|
/** Last path segment of a repo URL, with a trailing `.git` stripped. */
|
|
@@ -150,32 +204,93 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
150
204
|
kind: "invalid"
|
|
151
205
|
})));
|
|
152
206
|
const manifest = yield* configStore.read(root).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? Effect.succeed({ repos: {} }) : Effect.fail(error)));
|
|
153
|
-
if (manifest.repos
|
|
207
|
+
if (getRepoEntry(manifest.repos, name)) return yield* Effect.fail(new ReposConfigError({
|
|
154
208
|
path: MANIFEST_PATH,
|
|
155
209
|
reason: `"${name}" is already vendored — use pin to change its ref`,
|
|
156
210
|
kind: "invalid"
|
|
157
211
|
}));
|
|
158
212
|
const repoPath = `${REPOS_DIR}/${name}`;
|
|
159
213
|
const subPath = path.join(root, repoPath);
|
|
160
|
-
yield* git.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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");
|
|
229
|
+
yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
|
|
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));
|
|
283
|
+
}));
|
|
169
284
|
const entry = {
|
|
170
285
|
url: options.url,
|
|
171
286
|
ref: options.ref,
|
|
172
287
|
purpose: options.purpose,
|
|
173
288
|
...options.sparse && options.sparse.length > 0 ? { sparse: options.sparse } : {}
|
|
174
289
|
};
|
|
175
|
-
yield* configStore.
|
|
176
|
-
...
|
|
290
|
+
yield* configStore.update(root, (fresh) => ({ repos: {
|
|
291
|
+
...fresh.repos,
|
|
177
292
|
[name]: entry
|
|
178
|
-
} });
|
|
293
|
+
} }));
|
|
179
294
|
yield* git.add(root, [
|
|
180
295
|
".gitmodules",
|
|
181
296
|
MANIFEST_PATH,
|
|
@@ -189,22 +304,40 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
189
304
|
});
|
|
190
305
|
const pin = (root, name, ref) => Effect.gen(function* () {
|
|
191
306
|
const manifest = yield* configStore.read(root);
|
|
192
|
-
const entry = manifest.repos
|
|
307
|
+
const entry = getRepoEntry(manifest.repos, name);
|
|
193
308
|
if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
|
|
194
309
|
const repoPath = `${REPOS_DIR}/${name}`;
|
|
195
310
|
const subPath = path.join(root, repoPath);
|
|
196
|
-
const oldCommit = yield*
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
311
|
+
const { oldCommit, newCommit } = yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
|
|
312
|
+
const oldCommit = yield* git.revParse(subPath, "HEAD").pipe(Effect.orElseSucceed(() => null));
|
|
313
|
+
yield* fetchRef(subPath, ref);
|
|
314
|
+
yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
|
|
315
|
+
return {
|
|
316
|
+
oldCommit,
|
|
317
|
+
newCommit: yield* git.revParse(subPath, "HEAD").pipe(Effect.mapError(asSubmoduleError("git rev-parse HEAD", subPath)))
|
|
318
|
+
};
|
|
319
|
+
}));
|
|
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)));
|
|
208
341
|
const staleNoteIds = (entry.notes ?? []).filter((note) => note.ref !== ref).map((note) => note.id);
|
|
209
342
|
return {
|
|
210
343
|
name,
|
|
@@ -217,7 +350,7 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
217
350
|
});
|
|
218
351
|
const note = (root, name, op) => Effect.gen(function* () {
|
|
219
352
|
const manifest = yield* configStore.read(root);
|
|
220
|
-
const entry = manifest.repos
|
|
353
|
+
const entry = getRepoEntry(manifest.repos, name);
|
|
221
354
|
if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
|
|
222
355
|
const notes = entry.notes ?? [];
|
|
223
356
|
if (op.op === "add") {
|
|
@@ -258,10 +391,10 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
258
391
|
...entry,
|
|
259
392
|
notes: updatedNotes
|
|
260
393
|
};
|
|
261
|
-
yield* configStore.
|
|
262
|
-
...
|
|
394
|
+
yield* configStore.update(root, (fresh) => ({ repos: {
|
|
395
|
+
...fresh.repos,
|
|
263
396
|
[name]: updatedEntry
|
|
264
|
-
} });
|
|
397
|
+
} }));
|
|
265
398
|
return {
|
|
266
399
|
name,
|
|
267
400
|
op: "add",
|
|
@@ -280,10 +413,10 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
280
413
|
...entry,
|
|
281
414
|
notes: updatedNotes
|
|
282
415
|
};
|
|
283
|
-
yield* configStore.
|
|
284
|
-
...
|
|
416
|
+
yield* configStore.update(root, (fresh) => ({ repos: {
|
|
417
|
+
...fresh.repos,
|
|
285
418
|
[name]: updatedEntry
|
|
286
|
-
} });
|
|
419
|
+
} }));
|
|
287
420
|
return {
|
|
288
421
|
name,
|
|
289
422
|
op: "remove",
|
|
@@ -291,19 +424,21 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
291
424
|
noteCount: updatedNotes.length
|
|
292
425
|
};
|
|
293
426
|
}
|
|
427
|
+
const existing = entry.orientation?.[op.into];
|
|
428
|
+
const promoted = existing === void 0 ? target.note : `${existing}\n\n${target.note}`;
|
|
294
429
|
const updatedOrientation = {
|
|
295
430
|
...entry.orientation,
|
|
296
|
-
[op.into]:
|
|
431
|
+
[op.into]: promoted
|
|
297
432
|
};
|
|
298
433
|
const updatedEntry = {
|
|
299
434
|
...entry,
|
|
300
435
|
orientation: updatedOrientation,
|
|
301
436
|
notes: updatedNotes
|
|
302
437
|
};
|
|
303
|
-
yield* configStore.
|
|
304
|
-
...
|
|
438
|
+
yield* configStore.update(root, (fresh) => ({ repos: {
|
|
439
|
+
...fresh.repos,
|
|
305
440
|
[name]: updatedEntry
|
|
306
|
-
} });
|
|
441
|
+
} }));
|
|
307
442
|
return {
|
|
308
443
|
name,
|
|
309
444
|
op: "promote",
|
|
@@ -311,12 +446,190 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
|
|
|
311
446
|
noteCount: updatedNotes.length
|
|
312
447
|
};
|
|
313
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
|
+
});
|
|
314
624
|
return {
|
|
315
625
|
status,
|
|
316
626
|
sync,
|
|
317
627
|
add,
|
|
318
628
|
pin,
|
|
319
|
-
note
|
|
629
|
+
note,
|
|
630
|
+
remove,
|
|
631
|
+
rename,
|
|
632
|
+
restore
|
|
320
633
|
};
|
|
321
634
|
}));
|
|
322
635
|
};
|