@savvy-web/silk-effects 5.1.1 → 5.2.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.
Files changed (34) hide show
  1. package/README.md +22 -22
  2. package/changesets/api/transformer.js +2 -1
  3. package/changesets/changelog/getDependencyReleaseLine.js +24 -5
  4. package/changesets/changelog/getReleaseLine.js +7 -4
  5. package/changesets/changelog/index.js +8 -6
  6. package/changesets/index.js +6 -11
  7. package/changesets/services/branch-analyzer.js +20 -19
  8. package/changesets/services/changelog.js +1 -1
  9. package/changesets/services/config-inspector.js +17 -17
  10. package/changesets/services/deps-regen.js +36 -34
  11. package/changesets/services/github.js +39 -40
  12. package/changesets/services/maintenance-reason.js +5 -1
  13. package/changesets/services/release-planner.js +7 -6
  14. package/changesets/utils/dependency-table.js +2 -1
  15. package/changesets/utils/jsonpath.js +5 -7
  16. package/changesets/utils/publishability.js +2 -2
  17. package/commitlint/detection/dco.js +2 -1
  18. package/commitlint/index.js +2 -1
  19. package/index.d.ts +254 -218
  20. package/index.js +7 -7
  21. package/lint/handlers/PnpmWorkspace.js +2 -1
  22. package/lint/handlers/Yaml.js +8 -4
  23. package/package.json +7 -7
  24. package/repos/index.js +3 -5
  25. package/repos/services/config-store.js +68 -67
  26. package/repos/services/manager.js +259 -258
  27. package/services/BiomeSchemaSync.js +65 -64
  28. package/services/ChangesetConfig.js +50 -50
  29. package/services/ChangesetConfigReader.js +59 -58
  30. package/services/ConfigDiscovery.js +42 -41
  31. package/services/SilkPublishability.js +54 -53
  32. package/services/SilkWorkspaceAnalyzer.js +110 -109
  33. package/turbo/index.js +2 -3
  34. package/turbo/services/TurboInspector.js +80 -79
@@ -24,7 +24,7 @@ const STALE_LOCKS = ["index.lock", "shallow.lock"];
24
24
  * and that failure already propagates.
25
25
  * @public
26
26
  */
27
- const STALE_LOCK_MAX_AGE_MS = 10 * 6e4;
27
+ const STALE_LOCK_MAX_AGE_MS = 6e5;
28
28
  /**
29
29
  * Drives the vendored `.repos/` submodules over git: reports status
30
30
  * (presence, dirtiness, stale notes), reconciles the working tree with the
@@ -32,252 +32,272 @@ const STALE_LOCK_MAX_AGE_MS = 10 * 6e4;
32
32
  * ref (`pin`), and adds, removes, or promotes agent notes (`note`).
33
33
  * @public
34
34
  */
35
- var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/ReposManager") {};
36
- /**
37
- * Live implementation of {@link ReposManager}.
38
- *
39
- * @remarks
40
- * All git plumbing runs through `@effected/git`'s `Git` service (captured once
41
- * at layer construction; `Git.layer` requires `ChildProcessSpawner` at the app
42
- * edge), so the public method effects stay at `R = never`. The kit classifies
43
- * git's exit-code/stderr taxonomy into typed failures, which are mapped onto
44
- * this module's {@link GitSubmoduleError} to keep the declared error unions.
45
- * @public
46
- */
47
- const ReposManagerLive = Layer.effect(ReposManager, Effect.gen(function* () {
48
- const configStore = yield* ReposConfigStore;
49
- const fs = yield* FileSystem.FileSystem;
50
- const path = yield* Path.Path;
51
- const git = yield* Git;
52
- /** Map any typed `@effected/git` failure onto this module's `GitSubmoduleError`. */
53
- const asSubmoduleError = (command, cwd) => (error) => new GitSubmoduleError({
54
- command,
55
- cwd,
56
- reason: error.message
57
- });
58
- const isPresent = (repoPath) => fs.readDirectory(repoPath).pipe(Effect.map((files) => files.length > 0), Effect.orElseSucceed(() => false));
59
- const status = (root) => Effect.gen(function* () {
60
- const manifest = yield* configStore.read(root);
61
- const repos = yield* Effect.forEach(Object.entries(manifest.repos), ([name, entry]) => Effect.gen(function* () {
62
- const repoPath = path.join(root, REPOS_DIR, name);
63
- 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;
65
- let dirty = false;
66
- if (present) dirty = (yield* git.status(repoPath).pipe(Effect.mapError(asSubmoduleError("git status --porcelain", repoPath)))).length > 0;
67
- const staleNoteIds = (entry.notes ?? []).filter((note) => note.ref !== entry.ref).map((note) => note.id);
35
+ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/ReposManager") {
36
+ /**
37
+ * Production implementation of {@link ReposManager}.
38
+ *
39
+ * @remarks
40
+ * All git plumbing runs through `@effected/git`'s `Git` service (captured once
41
+ * at layer construction; `Git.layer` requires `ChildProcessSpawner` at the app
42
+ * edge), so the public method effects stay at `R = never`. The kit classifies
43
+ * git's exit-code/stderr taxonomy into typed failures, which are mapped onto
44
+ * this module's {@link GitSubmoduleError} to keep the declared error unions.
45
+ * @public
46
+ */
47
+ static layer = Layer.effect(this, Effect.gen(function* () {
48
+ const configStore = yield* ReposConfigStore;
49
+ const fs = yield* FileSystem.FileSystem;
50
+ const path = yield* Path.Path;
51
+ const git = yield* Git;
52
+ /** Map any typed `@effected/git` failure onto this module's `GitSubmoduleError`. */
53
+ const asSubmoduleError = (command, cwd) => (error) => new GitSubmoduleError({
54
+ command,
55
+ cwd,
56
+ reason: error.message
57
+ });
58
+ const isPresent = (repoPath) => fs.readDirectory(repoPath).pipe(Effect.map((files) => files.length > 0), Effect.orElseSucceed(() => false));
59
+ const status = (root) => Effect.gen(function* () {
60
+ const manifest = yield* configStore.read(root);
61
+ const repos = yield* Effect.forEach(Object.entries(manifest.repos), ([name, entry]) => Effect.gen(function* () {
62
+ const repoPath = path.join(root, REPOS_DIR, name);
63
+ 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;
65
+ let dirty = false;
66
+ if (present) dirty = (yield* git.status(repoPath).pipe(Effect.mapError(asSubmoduleError("git status --porcelain", repoPath)))).length > 0;
67
+ const staleNoteIds = (entry.notes ?? []).filter((note) => note.ref !== entry.ref).map((note) => note.id);
68
+ return {
69
+ name,
70
+ ref: entry.ref,
71
+ purpose: entry.purpose,
72
+ present,
73
+ commit,
74
+ dirty,
75
+ staleNoteIds
76
+ };
77
+ }));
68
78
  return {
69
- name,
70
- ref: entry.ref,
71
- purpose: entry.purpose,
72
- present,
73
- commit,
74
- dirty,
75
- staleNoteIds
79
+ repos,
80
+ clean: repos.every((entry) => entry.present && !entry.dirty && entry.staleNoteIds.length === 0)
76
81
  };
77
- }));
78
- return {
79
- repos,
80
- clean: repos.every((entry) => entry.present && !entry.dirty && entry.staleNoteIds.length === 0)
81
- };
82
- });
83
- const sync = (root) => Effect.gen(function* () {
84
- const manifest = yield* configStore.read(root);
85
- const initialized = [];
86
- const sparseApplied = [];
87
- const upToDate = [];
88
- const clearedLocks = [];
89
- for (const [name, entry] of Object.entries(manifest.repos)) {
90
- 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);
82
+ });
83
+ const sync = (root) => Effect.gen(function* () {
84
+ const manifest = yield* configStore.read(root);
85
+ const initialized = [];
86
+ const sparseApplied = [];
87
+ const upToDate = [];
88
+ const clearedLocks = [];
89
+ for (const [name, entry] of Object.entries(manifest.repos)) {
90
+ 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
+ }
117
118
  }
118
- }
119
- return {
120
- initialized,
121
- sparseApplied,
122
- upToDate,
123
- clearedLocks
124
- };
125
- });
126
- /** Last path segment of a repo URL, with a trailing `.git` stripped. */
127
- const repoSlug = (url) => {
128
- const last = url.split("/").filter((segment) => segment.length > 0).pop() ?? url;
129
- return last.endsWith(".git") ? last.slice(0, -4) : last;
130
- };
131
- /**
132
- * Fetch `ref` shallow into a submodule via the kit's `Git.fetchAny`:
133
- * tag-form fetch first (tags need the explicit `fetch origin tag <ref>`
134
- * form), falling back to a plain `fetch origin <ref>` on
135
- * `UnknownRefError` OR `GitCommandError` (unclassified tag-form stderr
136
- * keeps the v3 any-failure fallback). `NotARepositoryError` deliberately
137
- * does NOT fall back — a plain fetch in a non-repo fails identically, so
138
- * retrying only doubled the latency of a certain failure. When both
139
- * attempts fail, the plain fetch's error surfaces.
140
- */
141
- const fetchRef = (sub, ref) => git.fetchAny(sub, {
142
- ref,
143
- depth: 1
144
- }).pipe(Effect.mapError(asSubmoduleError(`git fetch --depth 1 origin ${ref}`, sub)));
145
- const add = (root, options) => Effect.gen(function* () {
146
- const name = options.name ?? repoSlug(options.url);
147
- yield* Schema.decodeUnknownEffect(RepoName)(name).pipe(Effect.mapError(() => new ReposConfigError({
148
- path: MANIFEST_PATH,
149
- reason: `invalid repo name "${name}": must be non-empty, contain no "/" or "\\", and not be "." or ".."`,
150
- kind: "invalid"
151
- })));
152
- 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({
154
- path: MANIFEST_PATH,
155
- reason: `"${name}" is already vendored — use pin to change its ref`,
156
- kind: "invalid"
157
- }));
158
- const repoPath = `${REPOS_DIR}/${name}`;
159
- 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)));
169
- const entry = {
170
- url: options.url,
171
- ref: options.ref,
172
- purpose: options.purpose,
173
- ...options.sparse && options.sparse.length > 0 ? { sparse: options.sparse } : {}
174
- };
175
- yield* configStore.write(root, { repos: {
176
- ...manifest.repos,
177
- [name]: entry
178
- } });
179
- yield* git.add(root, [
180
- ".gitmodules",
181
- MANIFEST_PATH,
182
- repoPath
183
- ]).pipe(Effect.mapError(asSubmoduleError(`git add .gitmodules ${MANIFEST_PATH} ${repoPath}`, root)));
184
- return {
185
- name,
186
- ref: options.ref,
187
- path: repoPath
119
+ return {
120
+ initialized,
121
+ sparseApplied,
122
+ upToDate,
123
+ clearedLocks
124
+ };
125
+ });
126
+ /** Last path segment of a repo URL, with a trailing `.git` stripped. */
127
+ const repoSlug = (url) => {
128
+ const last = url.split("/").filter((segment) => segment.length > 0).pop() ?? url;
129
+ return last.endsWith(".git") ? last.slice(0, -4) : last;
188
130
  };
189
- });
190
- const pin = (root, name, ref) => Effect.gen(function* () {
191
- const manifest = yield* configStore.read(root);
192
- const entry = manifest.repos[name];
193
- if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
194
- const repoPath = `${REPOS_DIR}/${name}`;
195
- 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)));
208
- const staleNoteIds = (entry.notes ?? []).filter((note) => note.ref !== ref).map((note) => note.id);
209
- return {
210
- name,
131
+ /**
132
+ * Fetch `ref` shallow into a submodule via the kit's `Git.fetchAny`:
133
+ * tag-form fetch first (tags need the explicit `fetch origin tag <ref>`
134
+ * form), falling back to a plain `fetch origin <ref>` on
135
+ * `UnknownRefError` OR `GitCommandError` (unclassified tag-form stderr
136
+ * keeps the v3 any-failure fallback). `NotARepositoryError` deliberately
137
+ * does NOT fall back — a plain fetch in a non-repo fails identically, so
138
+ * retrying only doubled the latency of a certain failure. When both
139
+ * attempts fail, the plain fetch's error surfaces.
140
+ */
141
+ const fetchRef = (sub, ref) => git.fetchAny(sub, {
211
142
  ref,
212
- oldCommit,
213
- newCommit,
214
- commitMessage: `chore(repos): pin ${name} to ${ref}`,
215
- staleNoteIds
216
- };
217
- });
218
- const note = (root, name, op) => Effect.gen(function* () {
219
- const manifest = yield* configStore.read(root);
220
- const entry = manifest.repos[name];
221
- if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
222
- const notes = entry.notes ?? [];
223
- if (op.op === "add") {
224
- if (notes.length >= 10) return yield* Effect.fail(new ReposConfigError({
143
+ depth: 1
144
+ }).pipe(Effect.mapError(asSubmoduleError(`git fetch --depth 1 origin ${ref}`, sub)));
145
+ const add = (root, options) => Effect.gen(function* () {
146
+ const name = options.name ?? repoSlug(options.url);
147
+ yield* Schema.decodeUnknownEffect(RepoName)(name).pipe(Effect.mapError(() => new ReposConfigError({
148
+ path: MANIFEST_PATH,
149
+ reason: `invalid repo name "${name}": must be non-empty, contain no "/" or "\\", and not be "." or ".."`,
150
+ kind: "invalid"
151
+ })));
152
+ 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({
225
154
  path: MANIFEST_PATH,
226
- reason: `note limit (${10}) reached for ${name}; promote or remove notes first`,
155
+ reason: `"${name}" is already vendored use pin to change its ref`,
227
156
  kind: "invalid"
228
157
  }));
229
- const existingIds = new Set(notes.map((existing) => existing.id));
230
- const hash = createHash("sha256").update(op.note).digest("hex");
231
- let id;
232
- for (let len = 4; len <= hash.length; len += 4) {
233
- const candidate = `n-${hash.slice(0, len)}`;
234
- if (!existingIds.has(candidate)) {
235
- id = candidate;
236
- break;
237
- }
238
- }
239
- if (id === void 0) {
240
- let counter = 2;
241
- let candidate = `n-${hash}-${counter}`;
242
- while (existingIds.has(candidate)) {
243
- counter += 1;
244
- candidate = `n-${hash}-${counter}`;
245
- }
246
- id = candidate;
247
- }
248
- const millis = yield* Clock.currentTimeMillis;
249
- const date = new Date(millis).toISOString().slice(0, 10);
250
- const newNote = {
251
- id,
252
- date,
253
- ref: entry.ref,
254
- note: op.note
158
+ const repoPath = `${REPOS_DIR}/${name}`;
159
+ 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)));
169
+ const entry = {
170
+ url: options.url,
171
+ ref: options.ref,
172
+ purpose: options.purpose,
173
+ ...options.sparse && options.sparse.length > 0 ? { sparse: options.sparse } : {}
255
174
  };
256
- const updatedNotes = [...notes, newNote];
257
- const updatedEntry = {
258
- ...entry,
259
- notes: updatedNotes
175
+ yield* configStore.write(root, { repos: {
176
+ ...manifest.repos,
177
+ [name]: entry
178
+ } });
179
+ yield* git.add(root, [
180
+ ".gitmodules",
181
+ MANIFEST_PATH,
182
+ repoPath
183
+ ]).pipe(Effect.mapError(asSubmoduleError(`git add .gitmodules ${MANIFEST_PATH} ${repoPath}`, root)));
184
+ return {
185
+ name,
186
+ ref: options.ref,
187
+ path: repoPath
260
188
  };
189
+ });
190
+ const pin = (root, name, ref) => Effect.gen(function* () {
191
+ const manifest = yield* configStore.read(root);
192
+ const entry = manifest.repos[name];
193
+ if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
194
+ const repoPath = `${REPOS_DIR}/${name}`;
195
+ 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)));
261
200
  yield* configStore.write(root, { repos: {
262
201
  ...manifest.repos,
263
- [name]: updatedEntry
202
+ [name]: {
203
+ ...entry,
204
+ ref
205
+ }
264
206
  } });
207
+ yield* git.add(root, [MANIFEST_PATH, repoPath]).pipe(Effect.mapError(asSubmoduleError(`git add ${MANIFEST_PATH} ${repoPath}`, root)));
208
+ const staleNoteIds = (entry.notes ?? []).filter((note) => note.ref !== ref).map((note) => note.id);
265
209
  return {
266
210
  name,
267
- op: "add",
268
- id,
269
- noteCount: updatedNotes.length
211
+ ref,
212
+ oldCommit,
213
+ newCommit,
214
+ commitMessage: `chore(repos): pin ${name} to ${ref}`,
215
+ staleNoteIds
216
+ };
217
+ });
218
+ const note = (root, name, op) => Effect.gen(function* () {
219
+ const manifest = yield* configStore.read(root);
220
+ const entry = manifest.repos[name];
221
+ if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
222
+ const notes = entry.notes ?? [];
223
+ if (op.op === "add") {
224
+ if (notes.length >= 10) return yield* Effect.fail(new ReposConfigError({
225
+ path: MANIFEST_PATH,
226
+ reason: `note limit (${10}) reached for ${name}; promote or remove notes first`,
227
+ kind: "invalid"
228
+ }));
229
+ const existingIds = new Set(notes.map((existing) => existing.id));
230
+ const hash = createHash("sha256").update(op.note).digest("hex");
231
+ let id;
232
+ for (let len = 4; len <= hash.length; len += 4) {
233
+ const candidate = `n-${hash.slice(0, len)}`;
234
+ if (!existingIds.has(candidate)) {
235
+ id = candidate;
236
+ break;
237
+ }
238
+ }
239
+ if (id === void 0) {
240
+ let counter = 2;
241
+ let candidate = `n-${hash}-${counter}`;
242
+ while (existingIds.has(candidate)) {
243
+ counter += 1;
244
+ candidate = `n-${hash}-${counter}`;
245
+ }
246
+ id = candidate;
247
+ }
248
+ const millis = yield* Clock.currentTimeMillis;
249
+ const date = new Date(millis).toISOString().slice(0, 10);
250
+ const newNote = {
251
+ id,
252
+ date,
253
+ ref: entry.ref,
254
+ note: op.note
255
+ };
256
+ const updatedNotes = [...notes, newNote];
257
+ const updatedEntry = {
258
+ ...entry,
259
+ notes: updatedNotes
260
+ };
261
+ yield* configStore.write(root, { repos: {
262
+ ...manifest.repos,
263
+ [name]: updatedEntry
264
+ } });
265
+ return {
266
+ name,
267
+ op: "add",
268
+ id,
269
+ noteCount: updatedNotes.length
270
+ };
271
+ }
272
+ const target = notes.find((existing) => existing.id === op.id);
273
+ if (!target) return yield* Effect.fail(new NoteNotFoundError({
274
+ name,
275
+ id: op.id
276
+ }));
277
+ const updatedNotes = notes.filter((existing) => existing.id !== op.id);
278
+ if (op.op === "remove") {
279
+ const updatedEntry = {
280
+ ...entry,
281
+ notes: updatedNotes
282
+ };
283
+ yield* configStore.write(root, { repos: {
284
+ ...manifest.repos,
285
+ [name]: updatedEntry
286
+ } });
287
+ return {
288
+ name,
289
+ op: "remove",
290
+ id: op.id,
291
+ noteCount: updatedNotes.length
292
+ };
293
+ }
294
+ const updatedOrientation = {
295
+ ...entry.orientation,
296
+ [op.into]: target.note
270
297
  };
271
- }
272
- const target = notes.find((existing) => existing.id === op.id);
273
- if (!target) return yield* Effect.fail(new NoteNotFoundError({
274
- name,
275
- id: op.id
276
- }));
277
- const updatedNotes = notes.filter((existing) => existing.id !== op.id);
278
- if (op.op === "remove") {
279
298
  const updatedEntry = {
280
299
  ...entry,
300
+ orientation: updatedOrientation,
281
301
  notes: updatedNotes
282
302
  };
283
303
  yield* configStore.write(root, { repos: {
@@ -286,39 +306,20 @@ const ReposManagerLive = Layer.effect(ReposManager, Effect.gen(function* () {
286
306
  } });
287
307
  return {
288
308
  name,
289
- op: "remove",
309
+ op: "promote",
290
310
  id: op.id,
291
311
  noteCount: updatedNotes.length
292
312
  };
293
- }
294
- const updatedOrientation = {
295
- ...entry.orientation,
296
- [op.into]: target.note
297
- };
298
- const updatedEntry = {
299
- ...entry,
300
- orientation: updatedOrientation,
301
- notes: updatedNotes
302
- };
303
- yield* configStore.write(root, { repos: {
304
- ...manifest.repos,
305
- [name]: updatedEntry
306
- } });
313
+ });
307
314
  return {
308
- name,
309
- op: "promote",
310
- id: op.id,
311
- noteCount: updatedNotes.length
315
+ status,
316
+ sync,
317
+ add,
318
+ pin,
319
+ note
312
320
  };
313
- });
314
- return {
315
- status,
316
- sync,
317
- add,
318
- pin,
319
- note
320
- };
321
- }));
321
+ }));
322
+ };
322
323
 
323
324
  //#endregion
324
- export { ReposManager, ReposManagerLive, STALE_LOCK_MAX_AGE_MS };
325
+ export { ReposManager, STALE_LOCK_MAX_AGE_MS };