@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.
@@ -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 { Clock, Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
6
- import { Git } from "@effected/git";
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`), and adds, removes, or promotes agent notes (`note`).
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 commit = (yield* git.lsTree(root, "HEAD", { pathspec: [`${".repos"}/${name}`] }).pipe(Effect.mapError(asSubmoduleError(`git ls-tree HEAD -- ${".repos"}/${name}`, root))))[0]?.oid ?? null;
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 moduleDir = path.join(root, ".git", "modules", REPOS_DIR, name);
92
- let clearedAnyLock = false;
93
- for (const lock of STALE_LOCKS) {
94
- const lockPath = path.join(moduleDir, lock);
95
- const info = yield* fs.stat(lockPath).pipe(Effect.option);
96
- if (Option.isNone(info)) continue;
97
- const mtime = info.value.mtime;
98
- if (Option.isNone(mtime)) continue;
99
- if ((yield* Clock.currentTimeMillis) - mtime.value.getTime() < 6e5) continue;
100
- if (yield* fs.remove(lockPath).pipe(Effect.match({
101
- onSuccess: () => true,
102
- onFailure: () => false
103
- }))) clearedAnyLock = true;
104
- }
105
- if (clearedAnyLock) clearedLocks.push(name);
106
- if (!(yield* isPresent(repoPath))) {
107
- yield* git.submoduleUpdate(root, {
108
- init: true,
109
- depth: 1,
110
- paths: [`${REPOS_DIR}/${name}`]
111
- }).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${REPOS_DIR}/${name}`, root)));
112
- initialized.push(name);
113
- } else upToDate.push(name);
114
- if (entry.sparse && entry.sparse.length > 0) {
115
- yield* git.sparseCheckoutSet(repoPath, entry.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", repoPath)));
116
- sparseApplied.push(name);
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[name]) return yield* Effect.fail(new ReposConfigError({
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.submoduleAdd(root, {
161
- url: options.url,
162
- path: repoPath,
163
- depth: 1
164
- }).pipe(Effect.mapError(asSubmoduleError(`git submodule add --depth 1 ${options.url} ${repoPath}`, root)));
165
- yield* git.configSet(root, `submodule.${repoPath}.shallow`, "true", { file: ".gitmodules" }).pipe(Effect.mapError(asSubmoduleError(`git config -f .gitmodules submodule.${repoPath}.shallow true`, root)));
166
- yield* fetchRef(subPath, options.ref);
167
- yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
168
- 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)));
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.write(root, { repos: {
176
- ...manifest.repos,
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[name];
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* git.revParse(subPath, "HEAD").pipe(Effect.orElseSucceed(() => null));
197
- yield* fetchRef(subPath, ref);
198
- yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
199
- const newCommit = yield* git.revParse(subPath, "HEAD").pipe(Effect.mapError(asSubmoduleError("git rev-parse HEAD", subPath)));
200
- yield* configStore.write(root, { repos: {
201
- ...manifest.repos,
202
- [name]: {
203
- ...entry,
204
- ref
205
- }
206
- } });
207
- yield* git.add(root, [MANIFEST_PATH, repoPath]).pipe(Effect.mapError(asSubmoduleError(`git add ${MANIFEST_PATH} ${repoPath}`, root)));
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[name];
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.write(root, { repos: {
262
- ...manifest.repos,
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.write(root, { repos: {
284
- ...manifest.repos,
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]: target.note
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.write(root, { repos: {
304
- ...manifest.repos,
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
  };