@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.
@@ -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`), 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`).
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 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;
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
- if (!(yield* isPresent(repoPath))) {
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: [`${REPOS_DIR}/${name}`]
114
- }).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${REPOS_DIR}/${name}`, root)));
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[name]) return yield* Effect.fail(new ReposConfigError({
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* git.submoduleAdd(root, {
166
- url: options.url,
167
- path: repoPath,
168
- depth: 1
169
- }).pipe(Effect.mapError(asSubmoduleError(`git submodule add --depth 1 ${options.url} ${repoPath}`, root)));
170
- yield* git.configSet(root, `submodule.${repoPath}.shallow`, "true", { file: ".gitmodules" }).pipe(Effect.mapError(asSubmoduleError(`git config -f .gitmodules submodule.${repoPath}.shallow true`, root)));
171
- yield* fetchRef(subPath, options.ref);
172
- yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
173
- 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)));
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.write(root, { repos: {
182
- ...manifest.repos,
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[name];
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.write(root, { repos: {
212
- ...manifest.repos,
213
- [name]: {
214
- ...entry,
215
- ref
216
- }
217
- } });
218
- yield* git.add(root, [MANIFEST_PATH, repoPath]).pipe(Effect.mapError(asSubmoduleError(`git add ${MANIFEST_PATH} ${repoPath}`, root)));
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[name];
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.write(root, { repos: {
273
- ...manifest.repos,
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.write(root, { repos: {
295
- ...manifest.repos,
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]: target.note
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.write(root, { repos: {
315
- ...manifest.repos,
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
  };