baldart 3.25.0 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils/git.js CHANGED
@@ -177,6 +177,205 @@ class GitUtils {
177
177
  }
178
178
  }
179
179
 
180
+ // ────────────────────────────────────────────────────────────────────
181
+ // SSOT update-status check (v3.25.0+).
182
+ //
183
+ // Before v3.25.0 three commands answered the same question — "is this
184
+ // consumer up-to-date with upstream BALDART?" — with three different
185
+ // queries, none of them right:
186
+ //
187
+ // - `update.js` used `hasChangesToPush()` (origin/main..HEAD — wrong
188
+ // remote AND wrong direction).
189
+ // - `version.js` and `doctor.js` used `HEAD...FETCH_HEAD` full-repo
190
+ // symmetric diff, which counts every subtree-merge commit ever
191
+ // generated by `git subtree pull --squash`. That count never
192
+ // decreases (squash merges are never "equal" on both sides), so
193
+ // even a perfectly-synced consumer is shown "N commits ahead".
194
+ //
195
+ // The authority for "aligned or not" is the VERSION file. The upstream
196
+ // BALDART repo has `VERSION` at root, so `git show FETCH_HEAD:VERSION`
197
+ // gives the remote version directly. Local `.framework/VERSION` gives
198
+ // the installed version. Identical strings → aligned, full stop.
199
+ //
200
+ // The commit count remains in the return value for diagnostic display
201
+ // (behind `--verbose` flags), explicitly labeled as subtree-merge noise
202
+ // so users don't misread it as a problem.
203
+ // ────────────────────────────────────────────────────────────────────
204
+ // ────────────────────────────────────────────────────────────────────
205
+ // Divergence classifier (v3.25.0+).
206
+ //
207
+ // When `aheadScoped > 0` (consumer has commits on `.framework/` not in
208
+ // upstream), we need to know WHETHER those commits are git plumbing
209
+ // noise (auto-resolve OK) or real user content (must prompt before
210
+ // anything destructive).
211
+ //
212
+ // Categories (in matching order — first match wins):
213
+ // - subtree-merge: `git subtree pull --squash` merge commits
214
+ // - subtree-squash: the squash payload commits themselves
215
+ // - chore-wrapper: CLI-generated [CHORE]/chore(baldart): commits
216
+ // - custom-overlay-able: user edited a framework agent/skill/command
217
+ // → should migrate to .baldart/overlays/
218
+ // - custom-other: anything else (src/, CHANGELOG, ad-hoc fixes)
219
+ //
220
+ // Aggregate `class`:
221
+ // - all-noise: every commit is subtree-* / chore-wrapper → auto-resolve
222
+ // - overlay-able: every non-noise commit is overlay-able → suggest /overlay
223
+ // - real-custom: every non-noise commit is custom-other → prompt 3-way
224
+ // - mixed: non-noise commits span both → prompt 3-way
225
+ //
226
+ // Default-conservative: any commit whose subject + touched-paths don't
227
+ // match a known pattern is classified `custom-other`, never auto-resolved.
228
+ // ────────────────────────────────────────────────────────────────────
229
+ classifyCommitSubject(subject) {
230
+ if (/^Merge (commit '[a-f0-9]+'|branch '[^']+') into \S+$/.test(subject)) {
231
+ return 'subtree-merge';
232
+ }
233
+ if (/^Squashed '\.framework\/' (changes from|content from)/.test(subject)) {
234
+ return 'subtree-squash';
235
+ }
236
+ // Two separate conditions: must look like a chore commit AND mention
237
+ // baldart anywhere (subject or scope). `chore(baldart): foo` is a
238
+ // common case — the keyword is in the scope, not after the colon.
239
+ const isChorePrefix = /^(\[CHORE\]|chore(\([^)]*\))?:)/.test(subject);
240
+ const hasBaldartKeyword = /baldart/i.test(subject);
241
+ if (isChorePrefix && hasBaldartKeyword) {
242
+ return 'chore-wrapper';
243
+ }
244
+ return null;
245
+ }
246
+
247
+ classifyCommitByPaths(touched) {
248
+ if (!touched.length) return 'custom-other';
249
+ const overlayablePrefix = '.framework/framework/.claude/';
250
+ const overlayableDirs = ['agents/', 'skills/', 'commands/'];
251
+ const allOverlayable = touched.every((p) => {
252
+ if (!p.startsWith(overlayablePrefix)) return false;
253
+ const rest = p.slice(overlayablePrefix.length);
254
+ return overlayableDirs.some((d) => rest.startsWith(d));
255
+ });
256
+ return allOverlayable ? 'custom-overlay-able' : 'custom-other';
257
+ }
258
+
259
+ async classifyDivergence() {
260
+ // Inspect aheadScoped commits — those not on FETCH_HEAD that touch .framework/.
261
+ const commits = [];
262
+ try {
263
+ const raw = await this.git.raw([
264
+ 'log',
265
+ 'FETCH_HEAD..HEAD',
266
+ '--pretty=format:%H%x00%s',
267
+ '--',
268
+ FRAMEWORK_DIR
269
+ ]);
270
+ for (const line of raw.split('\n')) {
271
+ if (!line.trim()) continue;
272
+ const [sha, ...rest] = line.split('');
273
+ const subject = rest.join('');
274
+ let category = this.classifyCommitSubject(subject);
275
+ if (!category) {
276
+ // Need file list to disambiguate custom-overlay-able vs custom-other.
277
+ let touched = [];
278
+ try {
279
+ const filesRaw = await this.git.raw(['diff-tree', '--no-commit-id', '--name-only', '-r', sha]);
280
+ touched = filesRaw.split('\n').filter((l) => l.trim());
281
+ } catch (_) { /* leave empty → custom-other */ }
282
+ category = this.classifyCommitByPaths(touched);
283
+ }
284
+ commits.push({ sha, subject, category });
285
+ }
286
+ } catch (_) {
287
+ return { class: 'unknown', commits: [] };
288
+ }
289
+
290
+ if (commits.length === 0) return { class: 'all-noise', commits: [] };
291
+
292
+ const noiseCategories = new Set(['subtree-merge', 'subtree-squash', 'chore-wrapper']);
293
+ const nonNoise = commits.filter((c) => !noiseCategories.has(c.category));
294
+ if (nonNoise.length === 0) return { class: 'all-noise', commits };
295
+
296
+ const hasOverlayable = nonNoise.some((c) => c.category === 'custom-overlay-able');
297
+ const hasOther = nonNoise.some((c) => c.category === 'custom-other');
298
+ let aggregate = 'real-custom';
299
+ if (hasOverlayable && !hasOther) aggregate = 'overlay-able';
300
+ else if (hasOverlayable && hasOther) aggregate = 'mixed';
301
+
302
+ return { class: aggregate, commits };
303
+ }
304
+
305
+ async getUpdateStatus(repo, branch = 'main') {
306
+ const result = {
307
+ installedVersion: 'unknown',
308
+ remoteVersion: 'unknown',
309
+ isAligned: false,
310
+ commitCount: {
311
+ behind: 0,
312
+ ahead: 0,
313
+ note: 'Includes subtree-merge commits autogenerated by `git subtree pull --squash`. Does not reflect real content drift; use `isAligned` for update status.'
314
+ },
315
+ aheadScoped: 0,
316
+ hasDivergence: false,
317
+ hasFetchHead: false,
318
+ fetched: false,
319
+ };
320
+
321
+ try {
322
+ result.installedVersion = await this.getFrameworkVersion();
323
+ } catch (_) { /* leave 'unknown' */ }
324
+
325
+ try {
326
+ await this.fetch(repo, branch);
327
+ result.fetched = true;
328
+ } catch (_) {
329
+ // Offline — leave fetched=false, fall through with best-effort.
330
+ }
331
+
332
+ try {
333
+ const head = await this.git.raw(['rev-parse', 'FETCH_HEAD']);
334
+ result.hasFetchHead = head.trim().length > 0;
335
+ } catch (_) { /* no FETCH_HEAD */ }
336
+
337
+ if (result.hasFetchHead) {
338
+ try {
339
+ const v = await this.git.raw(['show', 'FETCH_HEAD:VERSION']);
340
+ result.remoteVersion = v.trim();
341
+ } catch (_) { /* upstream missing VERSION — leave 'unknown' */ }
342
+
343
+ try {
344
+ const out = await this.git.raw(['rev-list', '--left-right', '--count', 'HEAD...FETCH_HEAD']);
345
+ const [ahead, behind] = out.trim().split(/\s+/).map(Number);
346
+ if (!Number.isNaN(ahead)) result.commitCount.ahead = ahead;
347
+ if (!Number.isNaN(behind)) result.commitCount.behind = behind;
348
+ } catch (_) { /* leave 0/0 */ }
349
+
350
+ try {
351
+ const out = await this.git.raw(['rev-list', '--count', 'FETCH_HEAD..HEAD', '--', FRAMEWORK_DIR]);
352
+ result.aheadScoped = Number(out.trim()) || 0;
353
+ } catch (_) { /* leave 0 */ }
354
+ }
355
+
356
+ if (result.installedVersion !== 'unknown' && result.remoteVersion !== 'unknown') {
357
+ result.isAligned = result.installedVersion === result.remoteVersion;
358
+ }
359
+
360
+ result.hasDivergence = !result.isAligned && result.aheadScoped > 0;
361
+
362
+ if (result.hasDivergence) {
363
+ try {
364
+ const d = await this.classifyDivergence();
365
+ result.divergenceClass = d.class;
366
+ result.divergenceCommits = d.commits;
367
+ } catch (_) {
368
+ result.divergenceClass = 'unknown';
369
+ result.divergenceCommits = [];
370
+ }
371
+ } else {
372
+ result.divergenceClass = 'none';
373
+ result.divergenceCommits = [];
374
+ }
375
+
376
+ return result;
377
+ }
378
+
180
379
  // ────────────────────────────────────────────────────────────────────
181
380
  // `.framework/` is a git subtree — it MUST be tracked. If anything in
182
381
  // the gitignore chain (project .gitignore, .git/info/exclude, global