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