@newrelic/preflight 1.48.5 → 1.50.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 (61) hide show
  1. package/dist/dashboard/routes/api-handler.d.ts +14 -0
  2. package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
  3. package/dist/dashboard/routes/api-handler.js +31 -32
  4. package/dist/dashboard/routes/api-handler.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +15 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/metrics/git-activity-recorder.d.ts +51 -0
  9. package/dist/metrics/git-activity-recorder.d.ts.map +1 -0
  10. package/dist/metrics/git-activity-recorder.js +178 -0
  11. package/dist/metrics/git-activity-recorder.js.map +1 -0
  12. package/dist/metrics/git-activity-store.d.ts +63 -0
  13. package/dist/metrics/git-activity-store.d.ts.map +1 -0
  14. package/dist/metrics/git-activity-store.js +115 -0
  15. package/dist/metrics/git-activity-store.js.map +1 -0
  16. package/dist/metrics/git-efficiency-tracker.d.ts +2 -15
  17. package/dist/metrics/git-efficiency-tracker.d.ts.map +1 -1
  18. package/dist/metrics/git-efficiency-tracker.js +12 -118
  19. package/dist/metrics/git-efficiency-tracker.js.map +1 -1
  20. package/dist/metrics/git-event-classifier.d.ts +36 -0
  21. package/dist/metrics/git-event-classifier.d.ts.map +1 -0
  22. package/dist/metrics/git-event-classifier.js +138 -0
  23. package/dist/metrics/git-event-classifier.js.map +1 -0
  24. package/dist/metrics/git-window-params.d.ts +22 -0
  25. package/dist/metrics/git-window-params.d.ts.map +1 -0
  26. package/dist/metrics/git-window-params.js +65 -0
  27. package/dist/metrics/git-window-params.js.map +1 -0
  28. package/dist/metrics/git-workspace-identity.d.ts +38 -0
  29. package/dist/metrics/git-workspace-identity.d.ts.map +1 -0
  30. package/dist/metrics/git-workspace-identity.js +136 -0
  31. package/dist/metrics/git-workspace-identity.js.map +1 -0
  32. package/dist/metrics/git-workspace-report.d.ts +114 -0
  33. package/dist/metrics/git-workspace-report.d.ts.map +1 -0
  34. package/dist/metrics/git-workspace-report.js +1187 -0
  35. package/dist/metrics/git-workspace-report.js.map +1 -0
  36. package/dist/metrics/git-workspace-reporter.d.ts +138 -0
  37. package/dist/metrics/git-workspace-reporter.d.ts.map +1 -0
  38. package/dist/metrics/git-workspace-reporter.js +326 -0
  39. package/dist/metrics/git-workspace-reporter.js.map +1 -0
  40. package/dist/metrics/local-session-aggregator.d.ts.map +1 -1
  41. package/dist/metrics/local-session-aggregator.js +1 -0
  42. package/dist/metrics/local-session-aggregator.js.map +1 -1
  43. package/dist/storage/session-store.d.ts.map +1 -1
  44. package/dist/storage/session-store.js +2 -0
  45. package/dist/storage/session-store.js.map +1 -1
  46. package/dist/storage/types.d.ts +1 -0
  47. package/dist/storage/types.d.ts.map +1 -1
  48. package/dist/tools/session-stats.d.ts +27 -0
  49. package/dist/tools/session-stats.d.ts.map +1 -1
  50. package/dist/tools/session-stats.js +29 -7
  51. package/dist/tools/session-stats.js.map +1 -1
  52. package/dist/transport/nr-ingest.d.ts +6 -0
  53. package/dist/transport/nr-ingest.d.ts.map +1 -1
  54. package/dist/transport/nr-ingest.js +10 -0
  55. package/dist/transport/nr-ingest.js.map +1 -1
  56. package/dist/web/assets/index-4h4AORA2.js +64 -0
  57. package/dist/web/assets/index-qULXLTB2.css +2 -0
  58. package/dist/web/index.html +2 -2
  59. package/package.json +1 -1
  60. package/dist/web/assets/index-C14Y4BRS.js +0 -64
  61. package/dist/web/assets/index-CQvdP3BR.css +0 -2
@@ -0,0 +1,1187 @@
1
+ import { createLogger } from '../shared/index.js';
2
+ const logger = createLogger('git-workspace-report');
3
+ // ---------------------------------------------------------------------------
4
+ // Regexes needed to reproduce GitEfficiencyTracker's processEvent() switch.
5
+ // Copied from git-efficiency-tracker.ts rather than exported from there,
6
+ // since that file is a frozen transplant source, not a shared dependency.
7
+ // ---------------------------------------------------------------------------
8
+ const GIT_CHECKOUT_OURS_RE = /\bgit\s+checkout\s+--ours\b/;
9
+ const GIT_CHECKOUT_THEIRS_RE = /\bgit\s+checkout\s+--theirs\b/;
10
+ const GIT_CHERRY_PICK_RE = /\bgit\s+cherry-pick\b/;
11
+ const CHERRY_PICK_ABORT_RE = /\bgit\s+cherry-pick\s+--abort\b/;
12
+ const GIT_WORKTREE_ADD_REMOVE_RE = /\bgit\s+worktree\s+(?:add|remove)\b/;
13
+ const GIT_PULL_RE = /\bgit\s+pull\b/;
14
+ // Render order for suggestions/best-practices — most severe first — rather
15
+ // than fixed source-code push order, which could put a critical item below
16
+ // a milder one. Duplicated from git-efficiency-tracker.ts (see note above).
17
+ const SUGGESTION_SEVERITY_RANK = {
18
+ critical: 0,
19
+ warning: 1,
20
+ info: 2,
21
+ };
22
+ const BEST_PRACTICE_STATUS_RANK = {
23
+ fail: 0,
24
+ warn: 1,
25
+ pass: 2,
26
+ unknown: 3,
27
+ 'n/a': 4,
28
+ };
29
+ /**
30
+ * Adapted from `GitEfficiencyTracker.evaluateBestPractices`. Coaching
31
+ * threshold/copy text is preserved verbatim. The `use_worktrees` check is
32
+ * removed entirely — it's a repo-scope concept (see `parallel_isolation` in
33
+ * `buildGitWorkspaceReport`), not a single-workspace one.
34
+ */
35
+ function evaluateBestPractices(inputs) {
36
+ const { riskIndicators: risk } = inputs;
37
+ const practices = [];
38
+ // 1. Sync before editing
39
+ if (risk.syncedBeforeEditing === null) {
40
+ practices.push({
41
+ id: 'sync_before_edit',
42
+ label: 'Sync before editing',
43
+ status: 'unknown',
44
+ detail: 'No edits detected yet.',
45
+ });
46
+ }
47
+ else if (risk.syncedBeforeEditing) {
48
+ practices.push({
49
+ id: 'sync_before_edit',
50
+ label: 'Sync before editing',
51
+ status: 'pass',
52
+ detail: 'Pulled/fetched before first file edit — branch was up to date.',
53
+ });
54
+ }
55
+ else {
56
+ practices.push({
57
+ id: 'sync_before_edit',
58
+ label: 'Sync before editing',
59
+ status: 'fail',
60
+ detail: 'Started editing files without pulling first. Always run `git pull --rebase` or `git fetch` before beginning work to avoid conflicts.',
61
+ });
62
+ }
63
+ // 2. Frequent syncing (pull/fetch every ~5 commits)
64
+ if (inputs.commitCount < 3) {
65
+ practices.push({
66
+ id: 'frequent_sync',
67
+ label: 'Sync frequently',
68
+ status: 'unknown',
69
+ detail: 'Not enough commits yet to evaluate sync frequency.',
70
+ });
71
+ }
72
+ else if (risk.commitsSinceLastSync > 8) {
73
+ practices.push({
74
+ id: 'frequent_sync',
75
+ label: 'Sync frequently',
76
+ status: 'fail',
77
+ detail: `${risk.commitsSinceLastSync} commits since last sync. Pull/rebase at least every 5 commits to catch divergence early. The longer you drift, the worse the conflicts.`,
78
+ });
79
+ }
80
+ else if (risk.commitsSinceLastSync > 5) {
81
+ practices.push({
82
+ id: 'frequent_sync',
83
+ label: 'Sync frequently',
84
+ status: 'warn',
85
+ detail: `${risk.commitsSinceLastSync} commits since last sync. Consider pulling soon to minimize conflict risk.`,
86
+ });
87
+ }
88
+ else {
89
+ practices.push({
90
+ id: 'frequent_sync',
91
+ label: 'Sync frequently',
92
+ status: inputs.pullCount > 0 ? 'pass' : 'unknown',
93
+ detail: inputs.pullCount > 0
94
+ ? 'Good sync cadence — pulling regularly between commits.'
95
+ : 'No syncs detected yet.',
96
+ });
97
+ }
98
+ // 3. Use rebase over merge (avoids merge commits that complicate history)
99
+ if (inputs.mergeEventCount === 0 && inputs.rebaseEventCount === 0) {
100
+ practices.push({
101
+ id: 'prefer_rebase',
102
+ label: 'Prefer rebase over merge',
103
+ status: 'unknown',
104
+ detail: 'No merge or rebase operations yet.',
105
+ });
106
+ }
107
+ else if (inputs.mergeEventCount > inputs.rebaseEventCount) {
108
+ practices.push({
109
+ id: 'prefer_rebase',
110
+ label: 'Prefer rebase over merge',
111
+ status: 'warn',
112
+ detail: 'Using merge more than rebase. Rebasing keeps history linear and makes conflicts smaller and more localized. Use `git pull --rebase` instead of `git pull`.',
113
+ });
114
+ }
115
+ else {
116
+ practices.push({
117
+ id: 'prefer_rebase',
118
+ label: 'Prefer rebase over merge',
119
+ status: 'pass',
120
+ detail: 'Good — using rebase to stay in sync, keeping history linear.',
121
+ });
122
+ }
123
+ // 4. Use --force-with-lease instead of --force. Gated on
124
+ // hasUsedBareForcePush rather than forcePushes/usesForceWithLease alone —
125
+ // those two count safe and unsafe force-pushes together, so checking
126
+ // usesForceWithLease alone would let one safe `--force-with-lease` mask a
127
+ // dangerous bare `--force` in the same session with a fully-passing
128
+ // status.
129
+ if (inputs.forcePushes === 0) {
130
+ practices.push({
131
+ id: 'force_with_lease',
132
+ label: 'Use --force-with-lease',
133
+ status: 'unknown',
134
+ detail: 'No force pushes yet.',
135
+ });
136
+ }
137
+ else if (inputs.hasUsedBareForcePush && risk.usesForceWithLease) {
138
+ practices.push({
139
+ id: 'force_with_lease',
140
+ label: 'Use --force-with-lease',
141
+ status: 'warn',
142
+ detail: 'Mixed usage in this window — some force pushes used --force-with-lease, but at least one bare --force (unsafe) push also occurred. Always use --force-with-lease; it refuses to push if someone else has pushed to the branch since your last fetch.',
143
+ });
144
+ }
145
+ else if (inputs.hasUsedBareForcePush) {
146
+ practices.push({
147
+ id: 'force_with_lease',
148
+ label: 'Use --force-with-lease',
149
+ status: 'fail',
150
+ detail: 'Using bare --force instead of --force-with-lease. The --force-with-lease flag is a safety net: it refuses to push if someone else has pushed to the branch since your last fetch. Always prefer it.',
151
+ });
152
+ }
153
+ else {
154
+ practices.push({
155
+ id: 'force_with_lease',
156
+ label: 'Use --force-with-lease',
157
+ status: 'pass',
158
+ detail: "Good — using --force-with-lease which refuses to overwrite remote commits you haven't seen.",
159
+ });
160
+ }
161
+ // 5. Keep PRs small (proxy: many commits without pushing)
162
+ if (risk.commitsSinceLastSync > 15) {
163
+ practices.push({
164
+ id: 'small_increments',
165
+ label: 'Push in small increments',
166
+ status: 'fail',
167
+ detail: `${risk.commitsSinceLastSync} local commits without pushing. Large batches create massive diffs that are more likely to conflict and harder to review. Push and open PRs early and often.`,
168
+ });
169
+ }
170
+ else if (inputs.commitCount >= 3) {
171
+ practices.push({
172
+ id: 'small_increments',
173
+ label: 'Push in small increments',
174
+ status: 'pass',
175
+ detail: 'Good — committing and syncing in small batches.',
176
+ });
177
+ }
178
+ // 6. Avoid editing hot files
179
+ if (risk.hotFiles.length > 0) {
180
+ practices.push({
181
+ id: 'avoid_hot_files',
182
+ label: 'Avoid re-editing conflicted files',
183
+ status: 'warn',
184
+ detail: `Editing files that previously conflicted: ${risk.hotFiles.slice(0, 3).join(', ')}${risk.hotFiles.length > 3 ? ` (+${risk.hotFiles.length - 3} more)` : ''}. These are "hot" files with active upstream changes — edits here are likely to conflict again. Consider coordinating or waiting for upstream to stabilize.`,
185
+ });
186
+ }
187
+ // 7. Build/test before pushing
188
+ if (inputs.buildBeforePush === null && inputs.lastPushTimestamp === null) {
189
+ practices.push({
190
+ id: 'verify_before_push',
191
+ label: 'Build/test before pushing',
192
+ status: 'unknown',
193
+ detail: 'No pushes yet.',
194
+ });
195
+ }
196
+ else if (inputs.buildBeforePush === true) {
197
+ practices.push({
198
+ id: 'verify_before_push',
199
+ label: 'Build/test before pushing',
200
+ status: 'pass',
201
+ detail: 'Good — ran build or tests before pushing. This catches errors before they reach CI and avoids wasted review cycles.',
202
+ });
203
+ }
204
+ else if (inputs.buildBeforePush === false) {
205
+ practices.push({
206
+ id: 'verify_before_push',
207
+ label: 'Build/test before pushing',
208
+ status: 'fail',
209
+ detail: "Pushed without running build or tests first. Always run `npm run build && npm test` before pushing to catch issues locally — it's faster than waiting for CI.",
210
+ });
211
+ }
212
+ return practices;
213
+ }
214
+ /** Adapted from `GitEfficiencyTracker.computePreventionScore`, unchanged. */
215
+ function computePreventionScore(practices) {
216
+ const scorable = practices.filter((p) => p.status !== 'unknown' && p.status !== 'n/a');
217
+ if (scorable.length < 2)
218
+ return null;
219
+ let points = 0;
220
+ let total = 0;
221
+ for (const p of scorable) {
222
+ total += 1;
223
+ if (p.status === 'pass')
224
+ points += 1;
225
+ else if (p.status === 'warn')
226
+ points += 0.5;
227
+ }
228
+ return Math.round((points / total) * 100);
229
+ }
230
+ /** Adapted from `GitEfficiencyTracker.generateSuggestions`. Coaching copy is
231
+ * preserved verbatim. */
232
+ function generateSuggestions(inputs) {
233
+ const suggestions = [];
234
+ const risk = inputs.riskIndicators;
235
+ // --- Proactive prevention suggestions (fire BEFORE conflicts happen) ---
236
+ if (risk.forceAfterReject > 0) {
237
+ suggestions.push({
238
+ severity: 'critical',
239
+ category: 'force_after_reject',
240
+ message: 'Push was rejected and then force-pushed — this overwrites upstream changes. The correct response to a rejected push is: `git fetch`, then `git rebase origin/<branch>`, resolve any conflicts, then push normally. Force push is a last resort, not a first response.',
241
+ evidence: `${risk.forceAfterReject} force push(es) within 5 min of a rejection`,
242
+ });
243
+ }
244
+ // --- Reactive suggestions (fire after problems occur) ---
245
+ if (inputs.mergeConflicts + inputs.rebaseConflicts >= 3) {
246
+ suggestions.push({
247
+ severity: 'critical',
248
+ category: 'merge_conflicts',
249
+ message: "Frequent merge conflicts in this window. Root causes for AI assistants: (1) not pulling at session start, (2) working on stale branches too long, (3) editing files with active upstream changes. Fix: sync every 3–5 commits, use worktrees for parallel tasks, and check `git log origin/main..HEAD` to see how far you've drifted.",
250
+ evidence: `${inputs.mergeConflicts + inputs.rebaseConflicts} conflicts in this window`,
251
+ });
252
+ }
253
+ else if (inputs.mergeConflicts + inputs.rebaseConflicts >= 1) {
254
+ suggestions.push({
255
+ severity: 'warning',
256
+ category: 'merge_conflicts',
257
+ message: 'Merge conflict encountered. For future prevention: `git fetch && git rebase origin/main` before starting work and after every ~5 commits. If this is a busy repo, consider shorter-lived branches and smaller PRs.',
258
+ evidence: `${inputs.mergeConflicts + inputs.rebaseConflicts} conflict(s) in this window`,
259
+ });
260
+ }
261
+ if (inputs.abortedOperations >= 2) {
262
+ suggestions.push({
263
+ severity: 'warning',
264
+ category: 'aborted_operations',
265
+ message: 'Multiple aborted merge/rebase operations suggest the branch has diverged too far. Strategy: (1) break the rebase into smaller steps with `git rebase --onto`, (2) cherry-pick only your commits onto a fresh branch, or (3) do an interactive rebase squashing first to reduce conflict surface area.',
266
+ evidence: `${inputs.abortedOperations} aborted operations`,
267
+ });
268
+ }
269
+ // Severity is gated on hasUsedBareForcePush/bareForcePushCount — the same
270
+ // shared signal the force_with_lease best-practice check uses — rather
271
+ // than the raw forcePushes count, which sums bare AND lease-protected
272
+ // pushes together. A bare push to the shared default branch is escalated
273
+ // to 'critical' outright, regardless of count.
274
+ if (inputs.hasUsedBareForcePush) {
275
+ suggestions.push({
276
+ severity: inputs.hasForcePushedToDefaultBranch
277
+ ? 'critical'
278
+ : inputs.bareForcePushCount >= 2
279
+ ? 'critical'
280
+ : 'warning',
281
+ category: 'force_push',
282
+ message: inputs.hasForcePushedToDefaultBranch
283
+ ? `Bare --force push used on the shared default branch (${inputs.defaultBranchName ?? 'default branch'}) — this can overwrite history other collaborators are building on. Always use --force-with-lease, and avoid force-pushing the default branch entirely if possible.`
284
+ : 'Bare --force push used. Always use --force-with-lease instead — it refuses to push if someone else has pushed to the branch since your last fetch. If you need to rewrite history, coordinate with collaborators first and ensure your local refs are up to date with `git fetch` before force pushing.',
285
+ evidence: inputs.hasForcePushedToDefaultBranch
286
+ ? `${inputs.bareForcePushCount} bare --force push(es) in this window, including at least one on the default branch`
287
+ : `${inputs.bareForcePushCount} bare --force push(es) in this window`,
288
+ });
289
+ }
290
+ else if (risk.usesForceWithLease && inputs.forcePushes >= 2) {
291
+ suggestions.push({
292
+ severity: 'info',
293
+ category: 'force_push',
294
+ message: 'Multiple force pushes in this window, all using --force-with-lease — the safe pattern. Repeated history rewrites can still be worth a second look if they indicate a workflow issue upstream.',
295
+ evidence: `${inputs.forcePushes} lease-protected force pushes in this window`,
296
+ });
297
+ }
298
+ if (inputs.resetHards >= 2) {
299
+ suggestions.push({
300
+ severity: 'warning',
301
+ category: 'reset_hard',
302
+ message: 'Multiple hard resets. Consider `git stash` to save work before resetting, or `git reset --mixed` to unstage without losing working tree changes.',
303
+ evidence: `${inputs.resetHards} hard resets`,
304
+ });
305
+ }
306
+ if (inputs.staleBranchPulls >= 2) {
307
+ suggestions.push({
308
+ severity: 'warning',
309
+ category: 'stale_branch',
310
+ message: "Pulls repeatedly cause conflicts — the branch has significantly diverged. Prevention: (1) rebase onto target branch at the START of each session, (2) use `git fetch` + `git log ..origin/main` to check divergence before pulling, (3) for long-lived branches, rebase daily even if you're not done.",
311
+ evidence: `${inputs.staleBranchPulls} pulls that led directly to conflicts`,
312
+ });
313
+ }
314
+ if (inputs.discardedChanges >= 3) {
315
+ suggestions.push({
316
+ severity: 'info',
317
+ category: 'discarded_changes',
318
+ message: "Frequently discarding changes. Use a scratch branch (`git checkout -b scratch/experiment`) instead — you can always delete it later, but you can't recover discarded changes.",
319
+ evidence: `${inputs.discardedChanges} discard operations`,
320
+ });
321
+ }
322
+ if (inputs.totalGitCommands > 10 && inputs.pullCount === 0) {
323
+ suggestions.push({
324
+ severity: 'info',
325
+ category: 'sync_frequency',
326
+ message: 'No pulls detected in this window despite significant git activity. On shared branches, pull at least every 15 minutes or every 5 commits — whichever comes first.',
327
+ evidence: `${inputs.totalGitCommands} git commands, 0 pulls`,
328
+ });
329
+ }
330
+ if (inputs.commitCount > 10 &&
331
+ inputs.pullCount === 0 &&
332
+ inputs.mergeConflicts + inputs.rebaseConflicts === 0) {
333
+ suggestions.push({
334
+ severity: 'warning',
335
+ category: 'divergence_risk',
336
+ message: "You've made many commits without syncing. Even though there are no conflicts YET, you're accumulating divergence that makes future conflicts larger and harder to resolve. Sync now while it's easy: `git fetch && git rebase origin/main`.",
337
+ evidence: `${inputs.commitCount} commits, 0 syncs`,
338
+ });
339
+ }
340
+ // --- Field guide: branch divergence from main ---
341
+ if (risk.commitsBehindMain !== null && risk.commitsBehindMain > 20) {
342
+ suggestions.push({
343
+ severity: 'warning',
344
+ category: 'behind_main',
345
+ message: `Branch is ${risk.commitsBehindMain} commits behind main. The longer you wait to rebase, the more painful it gets. Run \`git fetch origin && git rebase origin/main\` before it gets worse. On an active repo, main can move 20+ commits per day.`,
346
+ evidence: `${risk.commitsBehindMain} commits behind origin/main`,
347
+ });
348
+ }
349
+ else if (risk.commitsBehindMain !== null && risk.commitsBehindMain > 5) {
350
+ suggestions.push({
351
+ severity: 'info',
352
+ category: 'behind_main',
353
+ message: `Branch is ${risk.commitsBehindMain} commits behind main. Consider rebasing soon to stay current.`,
354
+ evidence: `${risk.commitsBehindMain} commits behind origin/main`,
355
+ });
356
+ }
357
+ // --- Field guide: session duration as PR size risk ---
358
+ if (risk.sessionDurationMs !== null &&
359
+ risk.sessionDurationMs > 2 * 3600_000 &&
360
+ inputs.commitCount > 15) {
361
+ suggestions.push({
362
+ severity: 'info',
363
+ category: 'session_length',
364
+ message: 'Long-running activity with many commits. The single biggest predictor of merge pain is how long a branch lives. Consider breaking this into smaller PRs that merge incrementally — a 200-line PR that ships in 30 minutes almost never conflicts.',
365
+ evidence: `Active ${Math.round(risk.sessionDurationMs / 3600_000)}h with ${inputs.commitCount} commits`,
366
+ });
367
+ }
368
+ // --- Field guide: blind conflict resolution warning ---
369
+ if (risk.quickConflictResolutions > 0) {
370
+ suggestions.push({
371
+ severity: 'warning',
372
+ category: 'quick_resolution',
373
+ message: 'Conflicts were resolved very quickly (under 30 seconds). AI-generated conflict resolutions should be reviewed line by line — they handle syntactic conflicts well but can miss semantic conflicts where two PRs modified the same logic with different intent. Run the test suite after every resolution.',
374
+ evidence: `${risk.quickConflictResolutions} conflict(s) resolved in under 30s`,
375
+ });
376
+ }
377
+ // --- Field guide: suggest SessionStart hook ---
378
+ if (risk.syncedBeforeEditing === false && inputs.totalGitCommands > 3) {
379
+ suggestions.push({
380
+ severity: 'info',
381
+ category: 'session_hook',
382
+ message: 'Tip: Add a SessionStart hook to ~/.claude/settings.json that auto-runs `git fetch --all --prune` at the start of every session. Claude Code does not auto-fetch — it operates on whatever git state is on disk. The hook ensures you always start fresh without having to remember.',
383
+ evidence: 'No sync before first edit in this window',
384
+ });
385
+ }
386
+ return suggestions;
387
+ }
388
+ /** Adapted from `GitEfficiencyTracker.computeScore`, unchanged. */
389
+ function computeScore(inputs) {
390
+ if (inputs.totalGitCommands < 3)
391
+ return null;
392
+ let score = 100;
393
+ const conflictPenalty = Math.min((inputs.mergeConflicts + inputs.rebaseConflicts) * 10, 40);
394
+ score -= conflictPenalty;
395
+ score -= Math.min(inputs.abortedOperations * 15, 30);
396
+ score -= Math.min(inputs.forcePushes * 10, 20);
397
+ score -= Math.min(inputs.resetHards * 5, 15);
398
+ score -= Math.min(inputs.discardedChanges * 3, 15);
399
+ if (inputs.conflictResolutionRate !== null && inputs.conflictResolutionRate >= 0.8) {
400
+ score += 5;
401
+ }
402
+ return Math.max(0, Math.min(100, score));
403
+ }
404
+ /** Runs the full coaching pipeline (best practices, suggestions, scores)
405
+ * against one pre-aggregated stats object — used identically by a single
406
+ * workspace's own metrics and by a rollup's summed totals. */
407
+ function runCoaching(inputs) {
408
+ const bestPractices = evaluateBestPractices(inputs);
409
+ const suggestions = generateSuggestions(inputs);
410
+ return {
411
+ suggestions: [...suggestions].sort((a, b) => SUGGESTION_SEVERITY_RANK[a.severity] - SUGGESTION_SEVERITY_RANK[b.severity]),
412
+ bestPractices: [...bestPractices].sort((a, b) => BEST_PRACTICE_STATUS_RANK[a.status] - BEST_PRACTICE_STATUS_RANK[b.status]),
413
+ efficiencyScore: computeScore(inputs),
414
+ preventionScore: computePreventionScore(bestPractices),
415
+ };
416
+ }
417
+ // ---------------------------------------------------------------------------
418
+ // Velocity / conflict-strategy / PR-metric / stale-pull helpers
419
+ // ---------------------------------------------------------------------------
420
+ /** Adapted from `GitEfficiencyTracker.computeVelocityMetrics`, minus
421
+ * `worktreeCount` — callers set that themselves (it means something
422
+ * different at rollup scope than "worktree add/remove commands issued"). */
423
+ function computeVelocityCore(commitTimestamps, buildBeforePush,
424
+ // The open-ended "since last commit" gap below is capped at this instead
425
+ // of always reaching for real wall-clock time — for a bounded PAST window
426
+ // (e.g. "yesterday"), the caller passes that window's own `until` so the
427
+ // gap doesn't extend into activity outside the range being reported.
428
+ // Defaults to `Date.now()` so a caller reporting a live/current window
429
+ // (where `until` already IS roughly now) gets the exact prior behavior.
430
+ nowMs = Date.now()) {
431
+ const sorted = [...commitTimestamps].sort((a, b) => a - b);
432
+ let avgTimeBetweenCommitsMs = null;
433
+ let longestGapMs = null;
434
+ let commitBurstCount = 0;
435
+ if (sorted.length >= 2) {
436
+ const gaps = [];
437
+ for (let i = 1; i < sorted.length; i++) {
438
+ gaps.push(sorted[i] - sorted[i - 1]);
439
+ }
440
+ avgTimeBetweenCommitsMs = gaps.reduce((a, b) => a + b, 0) / gaps.length;
441
+ longestGapMs = gaps.reduce((max, g) => (g > max ? g : max), 0);
442
+ // A "burst" is 3+ commits within 2 minutes of each other; count once per burst
443
+ let consecutive = 1;
444
+ for (let i = 1; i < sorted.length; i++) {
445
+ if (sorted[i] - sorted[i - 1] < 120_000) {
446
+ consecutive++;
447
+ if (consecutive === 3)
448
+ commitBurstCount++;
449
+ }
450
+ else {
451
+ consecutive = 1;
452
+ }
453
+ }
454
+ }
455
+ // The gaps above only ever measure BETWEEN two existing commits — a quiet
456
+ // stretch that started with your most recent commit and is still ongoing
457
+ // right now (e.g. a weekend with no commits at all) has no "next" commit
458
+ // to pair it with, so it silently never became a candidate. Folding in
459
+ // "now minus the last commit" as one more candidate is what makes a
460
+ // multi-day break since your last commit actually show up here.
461
+ if (sorted.length >= 1) {
462
+ const sinceLastCommitMs = nowMs - sorted[sorted.length - 1];
463
+ longestGapMs =
464
+ longestGapMs === null ? sinceLastCommitMs : Math.max(longestGapMs, sinceLastCommitMs);
465
+ }
466
+ return {
467
+ avgTimeBetweenCommitsMs,
468
+ commitBurstCount,
469
+ longestGapMs,
470
+ buildBeforePush,
471
+ // Same value as buildBeforePush, not a separate signal — see the
472
+ // matching comment in GitEfficiencyTracker.computeVelocityMetrics.
473
+ testBeforePush: buildBeforePush,
474
+ };
475
+ }
476
+ /** Adapted from `GitEfficiencyTracker.computeConflictStrategy`, unchanged. */
477
+ function computeConflictStrategy(conflictRecords, oursCount, theirsCount, cherryPickCount) {
478
+ const manualMergeCount = Math.max(0, conflictRecords.filter((c) => c.resolution === 'resolved').length -
479
+ oursCount -
480
+ theirsCount -
481
+ cherryPickCount);
482
+ return {
483
+ oursCount,
484
+ theirsCount,
485
+ manualMergeCount,
486
+ cherryPickCount,
487
+ totalResolutions: oursCount + theirsCount + cherryPickCount + manualMergeCount,
488
+ };
489
+ }
490
+ /** Adapted from `GitEfficiencyTracker.computePrMetrics`, unchanged. */
491
+ function computePrMetrics(prEvents, commitTimestamps) {
492
+ const created = prEvents.filter((e) => e.action === 'create').length;
493
+ const merged = prEvents.filter((e) => e.action === 'merge').length;
494
+ const checksViewed = prEvents.filter((e) => e.action === 'checks').length;
495
+ const prsUpdated = prEvents.filter((e) => e.action === 'edit' || e.action === 'ready').length;
496
+ const sortedCommitTimestamps = [...commitTimestamps].sort((a, b) => a - b);
497
+ const timesToCreate = [];
498
+ for (const prEvent of prEvents) {
499
+ if (prEvent.action !== 'create')
500
+ continue;
501
+ let precedingCommitTimestamp = null;
502
+ for (const commitTimestamp of sortedCommitTimestamps) {
503
+ if (commitTimestamp > prEvent.timestamp)
504
+ break;
505
+ precedingCommitTimestamp = commitTimestamp;
506
+ }
507
+ if (precedingCommitTimestamp !== null) {
508
+ timesToCreate.push(Math.max(0, prEvent.timestamp - precedingCommitTimestamp));
509
+ }
510
+ }
511
+ const avgTimeToCreateMs = timesToCreate.length > 0
512
+ ? timesToCreate.reduce((a, b) => a + b, 0) / timesToCreate.length
513
+ : null;
514
+ return {
515
+ created,
516
+ merged,
517
+ checksViewed,
518
+ prsUpdated,
519
+ prActivity: prEvents.slice(-20),
520
+ avgTimeToCreateMs,
521
+ };
522
+ }
523
+ /** Adapted from `GitEfficiencyTracker.countStaleBranchPulls`, unchanged. */
524
+ function countStaleBranchPulls(events) {
525
+ let staleCount = 0;
526
+ for (let i = 0; i < events.length; i++) {
527
+ const event = events[i];
528
+ if (event.type !== 'merge_conflict' && event.type !== 'rebase_conflict')
529
+ continue;
530
+ if (GIT_PULL_RE.test(event.command ?? '')) {
531
+ staleCount++;
532
+ }
533
+ else if (i > 0 && events[i - 1].type === 'pull') {
534
+ staleCount++;
535
+ }
536
+ }
537
+ return staleCount;
538
+ }
539
+ function computeBuildBeforePush(lastBuildOrTestTimestamp, commitTimestamps) {
540
+ const lastCommitTs = commitTimestamps.length > 0 ? commitTimestamps[commitTimestamps.length - 1] : null;
541
+ return (lastBuildOrTestTimestamp !== null &&
542
+ (lastCommitTs === null || lastBuildOrTestTimestamp > lastCommitTs));
543
+ }
544
+ export function computeWorkspaceMetrics(records,
545
+ // Accepted (and required) so every call site is explicit about which
546
+ // workspace this is for, and so the signature matches `rollupWorkspaceMetrics`'s
547
+ // `{ identity, metrics }` pairing — but the computation itself has nothing
548
+ // of its own to derive from it. The default-branch comparison below reads
549
+ // `liveState.branch`/`liveState.defaultBranch` (both sampled together),
550
+ // not `identity.branch`, since `identity.branch` has its own independent
551
+ // (TTL-cached) refresh cadence that isn't guaranteed to line up with a
552
+ // specific push's live divergence sample.
553
+ _identity, liveState,
554
+ // Forwarded to computeVelocityCore's longestGapMs cap — see its own doc
555
+ // comment. Not used for anything else here (riskIndicators' own
556
+ // Date.now()-based fields, e.g. timeSinceLastSyncMs, stay tied to real
557
+ // wall-clock time regardless of window — those describe present-moment
558
+ // staleness, not a fact about the reported window's contents).
559
+ nowMs = Date.now()) {
560
+ const events = [];
561
+ const conflictRecords = [];
562
+ const pendingConflicts = [];
563
+ let lastSyncTimestamp = null;
564
+ let firstEditTimestamp = null;
565
+ let firstSyncTimestamp = null;
566
+ let commitsSinceLastSync = 0;
567
+ const syncIntervalCommitCounts = [];
568
+ let pushRejections = 0;
569
+ let forceAfterReject = 0;
570
+ let lastPushRejectedTimestamp = null;
571
+ const conflictedFiles = new Set();
572
+ const editedFiles = new Set();
573
+ let hasUsedWorktree = false;
574
+ let hasUsedForceWithLease = false;
575
+ let hasUsedBareForcePush = false;
576
+ let bareForcePushCount = 0;
577
+ let hasForcePushedToDefaultBranch = false;
578
+ let sessionStartTimestamp = null;
579
+ const commitTimestamps = [];
580
+ let worktreeCommands = 0;
581
+ let oursCount = 0;
582
+ let theirsCount = 0;
583
+ let cherryPickCount = 0;
584
+ let lastBuildOrTestTimestamp = null;
585
+ let lastPushTimestamp = null;
586
+ let buildBeforePush = null;
587
+ let quickConflictResolutions = 0;
588
+ const prEvents = [];
589
+ let lastActivityMs = null;
590
+ const sessionIds = new Set();
591
+ for (const record of records) {
592
+ if (sessionStartTimestamp === null)
593
+ sessionStartTimestamp = record.timestamp;
594
+ if (lastActivityMs === null || record.timestamp > lastActivityMs) {
595
+ lastActivityMs = record.timestamp;
596
+ }
597
+ sessionIds.add(record.sessionId);
598
+ if (record.kind === 'edit') {
599
+ editedFiles.add(record.filePath);
600
+ if (firstEditTimestamp === null)
601
+ firstEditTimestamp = record.timestamp;
602
+ continue;
603
+ }
604
+ if (record.kind === 'verify') {
605
+ lastBuildOrTestTimestamp = record.timestamp;
606
+ continue;
607
+ }
608
+ if (record.kind === 'pr') {
609
+ prEvents.push(record.prEvent);
610
+ continue;
611
+ }
612
+ // record.kind === 'git'
613
+ const event = record.gitEvent;
614
+ // Commit dedup against `git log`-hydrated history (hydratedThroughMs in
615
+ // the old tracker) isn't wired in yet — only hook-observed commits exist
616
+ // as an input today, so there's nothing to dedup against.
617
+ events.push(event);
618
+ const command = event.command ?? '';
619
+ // Attribute ours/theirs/cherry-pick resolution strategy to the oldest
620
+ // still-open conflict, not to every matching command.
621
+ const oldestPending = pendingConflicts[0];
622
+ if (oldestPending) {
623
+ if (GIT_CHECKOUT_OURS_RE.test(command))
624
+ oldestPending.usedOurs = true;
625
+ if (GIT_CHECKOUT_THEIRS_RE.test(command))
626
+ oldestPending.usedTheirs = true;
627
+ if (GIT_CHERRY_PICK_RE.test(command) && !CHERRY_PICK_ABORT_RE.test(command)) {
628
+ oldestPending.usedCherryPick = true;
629
+ }
630
+ }
631
+ switch (event.type) {
632
+ case 'merge_conflict':
633
+ case 'rebase_conflict': {
634
+ const files = event.files ? [...event.files] : [];
635
+ for (const f of files)
636
+ conflictedFiles.add(f);
637
+ pendingConflicts.push({
638
+ timestamp: event.timestamp,
639
+ command,
640
+ files,
641
+ usedOurs: false,
642
+ usedTheirs: false,
643
+ usedCherryPick: false,
644
+ });
645
+ break;
646
+ }
647
+ case 'merge_abort':
648
+ case 'rebase_abort':
649
+ case 'cherry_pick_abort': {
650
+ const pending = pendingConflicts.shift();
651
+ if (pending) {
652
+ conflictRecords.push({
653
+ timestamp: pending.timestamp,
654
+ resolution: 'aborted',
655
+ resolutionTimeMs: event.timestamp - pending.timestamp,
656
+ command: pending.command,
657
+ files: pending.files,
658
+ });
659
+ }
660
+ break;
661
+ }
662
+ case 'commit': {
663
+ // git commit --amend fixes a prior commit, not a merge conflict —
664
+ // drop the oldest pending conflict without recording a resolution.
665
+ if (command.includes('--amend')) {
666
+ pendingConflicts.shift();
667
+ }
668
+ else {
669
+ const pending = pendingConflicts.shift();
670
+ if (pending) {
671
+ const resolutionMs = event.timestamp - pending.timestamp;
672
+ conflictRecords.push({
673
+ timestamp: pending.timestamp,
674
+ resolution: 'resolved',
675
+ resolutionTimeMs: resolutionMs,
676
+ command: pending.command,
677
+ files: pending.files,
678
+ });
679
+ if (pending.usedOurs)
680
+ oursCount++;
681
+ if (pending.usedTheirs)
682
+ theirsCount++;
683
+ if (pending.usedCherryPick)
684
+ cherryPickCount++;
685
+ if (resolutionMs < 30_000 && pending.files.length > 1) {
686
+ quickConflictResolutions++;
687
+ }
688
+ }
689
+ }
690
+ commitTimestamps.push(event.timestamp);
691
+ commitsSinceLastSync++;
692
+ break;
693
+ }
694
+ case 'pull':
695
+ case 'fetch':
696
+ case 'rebase':
697
+ if (firstSyncTimestamp === null)
698
+ firstSyncTimestamp = event.timestamp;
699
+ lastSyncTimestamp = event.timestamp;
700
+ if (commitsSinceLastSync > 0)
701
+ syncIntervalCommitCounts.push(commitsSinceLastSync);
702
+ commitsSinceLastSync = 0;
703
+ break;
704
+ case 'push':
705
+ lastPushTimestamp = event.timestamp;
706
+ buildBeforePush = computeBuildBeforePush(lastBuildOrTestTimestamp, commitTimestamps);
707
+ break;
708
+ case 'push_rejected':
709
+ pushRejections++;
710
+ lastPushRejectedTimestamp = event.timestamp;
711
+ break;
712
+ case 'force_push':
713
+ hasUsedBareForcePush = true;
714
+ bareForcePushCount++;
715
+ // BUG FIX: compare against THIS workspace's own live branch/default
716
+ // branch, not a process-global singleton (the old tracker compared
717
+ // against a single repoContext shared across every workspace it
718
+ // ever saw, so one workspace's push could wrongly be judged against
719
+ // another's branch). Undetermined (no live sample yet) is false,
720
+ // never a guess.
721
+ if (liveState?.branch != null &&
722
+ liveState?.defaultBranch != null &&
723
+ liveState.branch === liveState.defaultBranch) {
724
+ hasForcePushedToDefaultBranch = true;
725
+ }
726
+ if (lastPushRejectedTimestamp !== null &&
727
+ event.timestamp - lastPushRejectedTimestamp < 300_000) {
728
+ forceAfterReject++;
729
+ }
730
+ lastPushTimestamp = event.timestamp;
731
+ buildBeforePush = computeBuildBeforePush(lastBuildOrTestTimestamp, commitTimestamps);
732
+ break;
733
+ case 'force_push_lease':
734
+ hasUsedForceWithLease = true;
735
+ lastPushTimestamp = event.timestamp;
736
+ buildBeforePush = computeBuildBeforePush(lastBuildOrTestTimestamp, commitTimestamps);
737
+ break;
738
+ case 'worktree':
739
+ if (GIT_WORKTREE_ADD_REMOVE_RE.test(command)) {
740
+ worktreeCommands++;
741
+ hasUsedWorktree = true;
742
+ }
743
+ break;
744
+ default:
745
+ break;
746
+ }
747
+ }
748
+ const totalGitCommands = events.length;
749
+ const mergeConflicts = events.filter((e) => e.type === 'merge_conflict').length;
750
+ const rebaseConflicts = events.filter((e) => e.type === 'rebase_conflict').length;
751
+ const abortedOperations = events.filter((e) => e.type === 'merge_abort' || e.type === 'rebase_abort' || e.type === 'cherry_pick_abort').length;
752
+ const forcePushes = events.filter((e) => e.type === 'force_push' || e.type === 'force_push_lease').length;
753
+ const resetHards = events.filter((e) => e.type === 'reset_hard').length;
754
+ const discardedChanges = events.filter((e) => e.type === 'discard_changes').length;
755
+ const pullCount = events.filter((e) => e.type === 'pull').length;
756
+ const pushCount = events.filter((e) => e.type === 'push' || e.type === 'force_push' || e.type === 'force_push_lease').length;
757
+ // Only hook-observed commit events count today — `git log` hydration
758
+ // (hydrateGitLog's hash-based dedup in the old tracker) isn't wired in
759
+ // yet, so there's no second source to reconcile against.
760
+ const commitCount = events.filter((e) => e.type === 'commit').length;
761
+ const branchOperations = events.filter((e) => e.type === 'branch').length;
762
+ const mergeEventCount = events.filter((e) => e.type === 'merge').length;
763
+ const rebaseEventCount = events.filter((e) => e.type === 'rebase').length;
764
+ const allConflictRecords = [
765
+ ...conflictRecords,
766
+ ...pendingConflicts.map((p) => ({
767
+ timestamp: p.timestamp,
768
+ resolution: 'pending',
769
+ resolutionTimeMs: null,
770
+ command: p.command,
771
+ files: p.files,
772
+ })),
773
+ ];
774
+ const resolved = allConflictRecords.filter((c) => c.resolution === 'resolved');
775
+ const conflictResolutionRate = allConflictRecords.length > 0 ? resolved.length / allConflictRecords.length : null;
776
+ const resolutionTimes = resolved
777
+ .filter((c) => c.resolutionTimeMs !== null)
778
+ .map((c) => c.resolutionTimeMs);
779
+ const avgConflictResolutionMs = resolutionTimes.length > 0
780
+ ? resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length
781
+ : null;
782
+ const staleBranchPulls = countStaleBranchPulls(events);
783
+ const now = Date.now();
784
+ const syncedBeforeEditing = firstEditTimestamp !== null
785
+ ? firstSyncTimestamp !== null && firstSyncTimestamp < firstEditTimestamp
786
+ : null;
787
+ const hotFiles = [...conflictedFiles].filter((f) => editedFiles.has(f));
788
+ const avgCommitsBetweenSyncs = syncIntervalCommitCounts.length > 0
789
+ ? syncIntervalCommitCounts.reduce((a, b) => a + b, 0) / syncIntervalCommitCounts.length
790
+ : null;
791
+ const riskIndicators = {
792
+ syncedBeforeEditing,
793
+ timeSinceLastSyncMs: lastSyncTimestamp !== null ? now - lastSyncTimestamp : null,
794
+ commitsSinceLastSync,
795
+ pushRejections,
796
+ forceAfterReject,
797
+ hotFiles,
798
+ usesWorktrees: hasUsedWorktree,
799
+ usesForceWithLease: hasUsedForceWithLease,
800
+ avgCommitsBetweenSyncs,
801
+ commitsAheadOfMain: liveState?.ahead ?? null,
802
+ commitsBehindMain: liveState?.behind ?? null,
803
+ sessionDurationMs: sessionStartTimestamp !== null ? now - sessionStartTimestamp : null,
804
+ quickConflictResolutions,
805
+ };
806
+ const coachingInputs = {
807
+ totalGitCommands,
808
+ mergeConflicts,
809
+ rebaseConflicts,
810
+ abortedOperations,
811
+ forcePushes,
812
+ resetHards,
813
+ discardedChanges,
814
+ pullCount,
815
+ commitCount,
816
+ staleBranchPulls,
817
+ mergeEventCount,
818
+ rebaseEventCount,
819
+ hasUsedBareForcePush,
820
+ bareForcePushCount,
821
+ hasForcePushedToDefaultBranch,
822
+ defaultBranchName: liveState?.defaultBranch ?? null,
823
+ buildBeforePush,
824
+ lastPushTimestamp,
825
+ conflictResolutionRate,
826
+ riskIndicators,
827
+ };
828
+ const { suggestions, bestPractices, efficiencyScore, preventionScore } = runCoaching(coachingInputs);
829
+ const velocityMetrics = {
830
+ ...computeVelocityCore(commitTimestamps, buildBeforePush, nowMs),
831
+ worktreeCount: worktreeCommands,
832
+ };
833
+ const conflictResolutionStrategy = computeConflictStrategy(conflictRecords, oursCount, theirsCount, cherryPickCount);
834
+ const prMetrics = computePrMetrics(prEvents, commitTimestamps);
835
+ return {
836
+ totalGitCommands,
837
+ mergeConflicts,
838
+ rebaseConflicts,
839
+ abortedOperations,
840
+ forcePushes,
841
+ resetHards,
842
+ discardedChanges,
843
+ pullCount,
844
+ pushCount,
845
+ commitCount,
846
+ branchOperations,
847
+ conflictResolutionRate,
848
+ avgConflictResolutionMs,
849
+ staleBranchPulls,
850
+ gitCommandTimeline: [...events].sort((a, b) => a.timestamp - b.timestamp).slice(-50),
851
+ conflictHistory: [...allConflictRecords].sort((a, b) => a.timestamp - b.timestamp),
852
+ suggestions,
853
+ bestPractices,
854
+ preventionScore,
855
+ efficiencyScore,
856
+ riskIndicators,
857
+ velocityMetrics,
858
+ conflictResolutionStrategy,
859
+ prMetrics,
860
+ liveState,
861
+ commitTimestamps: [...commitTimestamps],
862
+ lastPushTimestamp,
863
+ editedFiles: [...editedFiles],
864
+ hasUsedBareForcePush,
865
+ bareForcePushCount,
866
+ hasForcePushedToDefaultBranch,
867
+ mergeEventCount,
868
+ rebaseEventCount,
869
+ lastActivityMs,
870
+ sessionIds: [...sessionIds],
871
+ };
872
+ }
873
+ // ---------------------------------------------------------------------------
874
+ // Step 2: rollupWorkspaceMetrics — combines ALREADY-COMPUTED per-workspace
875
+ // metrics. Never touches raw records, never re-runs the sequential reducer.
876
+ // ---------------------------------------------------------------------------
877
+ export function rollupWorkspaceMetrics(nodes,
878
+ // See computeWorkspaceMetrics's matching parameter.
879
+ nowMs = Date.now()) {
880
+ const sum = (get) => nodes.reduce((acc, n) => acc + get(n.metrics), 0);
881
+ const any = (get) => nodes.some((n) => get(n.metrics));
882
+ const totalGitCommands = sum((m) => m.totalGitCommands);
883
+ const mergeConflicts = sum((m) => m.mergeConflicts);
884
+ const rebaseConflicts = sum((m) => m.rebaseConflicts);
885
+ const abortedOperations = sum((m) => m.abortedOperations);
886
+ const forcePushes = sum((m) => m.forcePushes);
887
+ const resetHards = sum((m) => m.resetHards);
888
+ const discardedChanges = sum((m) => m.discardedChanges);
889
+ const pullCount = sum((m) => m.pullCount);
890
+ const pushCount = sum((m) => m.pushCount);
891
+ const commitCount = sum((m) => m.commitCount);
892
+ const branchOperations = sum((m) => m.branchOperations);
893
+ const staleBranchPulls = sum((m) => m.staleBranchPulls);
894
+ const mergeEventCount = sum((m) => m.mergeEventCount);
895
+ const rebaseEventCount = sum((m) => m.rebaseEventCount);
896
+ const bareForcePushCount = sum((m) => m.bareForcePushCount);
897
+ const hasUsedBareForcePush = any((m) => m.hasUsedBareForcePush);
898
+ const hasForcePushedToDefaultBranch = any((m) => m.hasForcePushedToDefaultBranch);
899
+ const oursCount = sum((m) => m.conflictResolutionStrategy.oursCount);
900
+ const theirsCount = sum((m) => m.conflictResolutionStrategy.theirsCount);
901
+ const manualMergeCount = sum((m) => m.conflictResolutionStrategy.manualMergeCount);
902
+ const cherryPickCount = sum((m) => m.conflictResolutionStrategy.cherryPickCount);
903
+ const conflictResolutionStrategy = {
904
+ oursCount,
905
+ theirsCount,
906
+ manualMergeCount,
907
+ cherryPickCount,
908
+ totalResolutions: oursCount + theirsCount + manualMergeCount + cherryPickCount,
909
+ };
910
+ const prActivity = nodes
911
+ .flatMap((n) => n.metrics.prMetrics.prActivity)
912
+ .sort((a, b) => a.timestamp - b.timestamp)
913
+ .slice(-20);
914
+ const prMetrics = {
915
+ created: sum((m) => m.prMetrics.created),
916
+ merged: sum((m) => m.prMetrics.merged),
917
+ checksViewed: sum((m) => m.prMetrics.checksViewed),
918
+ prsUpdated: sum((m) => m.prMetrics.prsUpdated),
919
+ prActivity,
920
+ // Each workspace's own avgTimeToCreateMs is anchored to that workspace's
921
+ // own commit/PR pairing — averaging the averages (or re-deriving it
922
+ // without each workspace's raw commit timestamps) wouldn't be honest, so
923
+ // this is left null at rollup, like the other per-workspace timing
924
+ // concepts nulled below.
925
+ avgTimeToCreateMs: null,
926
+ };
927
+ const allConflictRecords = nodes.flatMap((n) => n.metrics.conflictHistory);
928
+ const resolved = allConflictRecords.filter((c) => c.resolution === 'resolved');
929
+ const conflictResolutionRate = allConflictRecords.length > 0 ? resolved.length / allConflictRecords.length : null;
930
+ const resolutionTimes = resolved
931
+ .filter((c) => c.resolutionTimeMs !== null)
932
+ .map((c) => c.resolutionTimeMs);
933
+ const avgConflictResolutionMs = resolutionTimes.length > 0
934
+ ? resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length
935
+ : null;
936
+ const gitCommandTimeline = nodes
937
+ .flatMap((n) => n.metrics.gitCommandTimeline)
938
+ .sort((a, b) => a.timestamp - b.timestamp)
939
+ .slice(-50);
940
+ const conflictHistory = [...allConflictRecords].sort((a, b) => a.timestamp - b.timestamp);
941
+ const riskIndicators = {
942
+ // Inherently single-workspace concepts — null/empty rather than summed
943
+ // or averaged, since blending them across workspaces would misrepresent
944
+ // a real per-workspace state as a rollup fact.
945
+ syncedBeforeEditing: null,
946
+ timeSinceLastSyncMs: null,
947
+ commitsSinceLastSync: 0,
948
+ hotFiles: [],
949
+ avgCommitsBetweenSyncs: null,
950
+ commitsAheadOfMain: null,
951
+ commitsBehindMain: null,
952
+ sessionDurationMs: null,
953
+ // Genuinely summable/OR-able across workspaces.
954
+ pushRejections: sum((m) => m.riskIndicators.pushRejections),
955
+ forceAfterReject: sum((m) => m.riskIndicators.forceAfterReject),
956
+ usesWorktrees: any((m) => m.riskIndicators.usesWorktrees),
957
+ usesForceWithLease: any((m) => m.riskIndicators.usesForceWithLease),
958
+ quickConflictResolutions: sum((m) => m.riskIndicators.quickConflictResolutions),
959
+ };
960
+ const allCommitTimestamps = nodes
961
+ .flatMap((n) => n.metrics.commitTimestamps)
962
+ .sort((a, b) => a - b);
963
+ let mostRecentPush = null;
964
+ for (const n of nodes) {
965
+ const ts = n.metrics.lastPushTimestamp;
966
+ if (ts !== null && (mostRecentPush === null || ts > mostRecentPush.ts)) {
967
+ mostRecentPush = { ts, buildBeforePush: n.metrics.velocityMetrics.buildBeforePush };
968
+ }
969
+ }
970
+ const velocityMetrics = {
971
+ ...computeVelocityCore(allCommitTimestamps, mostRecentPush?.buildBeforePush ?? null, nowMs),
972
+ worktreeCount: nodes.length,
973
+ };
974
+ const coachingInputs = {
975
+ totalGitCommands,
976
+ mergeConflicts,
977
+ rebaseConflicts,
978
+ abortedOperations,
979
+ forcePushes,
980
+ resetHards,
981
+ discardedChanges,
982
+ pullCount,
983
+ commitCount,
984
+ staleBranchPulls,
985
+ mergeEventCount,
986
+ rebaseEventCount,
987
+ hasUsedBareForcePush,
988
+ bareForcePushCount,
989
+ hasForcePushedToDefaultBranch,
990
+ defaultBranchName: null,
991
+ buildBeforePush: velocityMetrics.buildBeforePush,
992
+ lastPushTimestamp: mostRecentPush?.ts ?? null,
993
+ conflictResolutionRate,
994
+ riskIndicators,
995
+ };
996
+ const { suggestions, bestPractices, efficiencyScore, preventionScore } = runCoaching(coachingInputs);
997
+ return {
998
+ totalGitCommands,
999
+ mergeConflicts,
1000
+ rebaseConflicts,
1001
+ abortedOperations,
1002
+ forcePushes,
1003
+ resetHards,
1004
+ discardedChanges,
1005
+ pullCount,
1006
+ pushCount,
1007
+ commitCount,
1008
+ branchOperations,
1009
+ conflictResolutionRate,
1010
+ avgConflictResolutionMs,
1011
+ staleBranchPulls,
1012
+ gitCommandTimeline,
1013
+ conflictHistory,
1014
+ suggestions,
1015
+ bestPractices,
1016
+ preventionScore,
1017
+ efficiencyScore,
1018
+ riskIndicators,
1019
+ velocityMetrics,
1020
+ conflictResolutionStrategy,
1021
+ prMetrics,
1022
+ // Only meaningful for one specific workspace. See buildGitWorkspaceReport's
1023
+ // `worstBehind` for the rollup-scope equivalent, named to a workspace.
1024
+ liveState: null,
1025
+ commitTimestamps: allCommitTimestamps,
1026
+ lastPushTimestamp: mostRecentPush?.ts ?? null,
1027
+ editedFiles: [...new Set(nodes.flatMap((n) => n.metrics.editedFiles))],
1028
+ hasUsedBareForcePush,
1029
+ bareForcePushCount,
1030
+ hasForcePushedToDefaultBranch,
1031
+ mergeEventCount,
1032
+ rebaseEventCount,
1033
+ lastActivityMs: nodes.reduce((max, n) => {
1034
+ const ts = n.metrics.lastActivityMs;
1035
+ if (ts === null)
1036
+ return max;
1037
+ return max === null || ts > max ? ts : max;
1038
+ }, null),
1039
+ sessionIds: [...new Set(nodes.flatMap((n) => n.metrics.sessionIds))],
1040
+ };
1041
+ }
1042
+ // ---------------------------------------------------------------------------
1043
+ // Step 3: buildGitWorkspaceReport — public entry point
1044
+ // ---------------------------------------------------------------------------
1045
+ const UNATTRIBUTED_KEY = 'unattributed';
1046
+ function resolveIdentityForGroup(key, identities) {
1047
+ const known = identities.get(key);
1048
+ if (known)
1049
+ return known;
1050
+ if (key === UNATTRIBUTED_KEY) {
1051
+ return {
1052
+ repoKey: key,
1053
+ worktreeKey: key,
1054
+ repoName: null,
1055
+ worktreeRoot: null,
1056
+ worktreeLabel: key,
1057
+ branch: null,
1058
+ };
1059
+ }
1060
+ return null;
1061
+ }
1062
+ function computeWorstBehind(rows) {
1063
+ let best = null;
1064
+ for (const row of rows) {
1065
+ const behind = row.metrics.liveState?.behind;
1066
+ if (behind == null)
1067
+ continue;
1068
+ if (best === null || behind > best.behind) {
1069
+ best = { identity: row.identity, behind };
1070
+ }
1071
+ }
1072
+ return best;
1073
+ }
1074
+ /**
1075
+ * New at rollup: isolation is a property of a repo with multiple active
1076
+ * worktrees, not of one worktree alone, so this only runs at repo scope
1077
+ * (see buildGitWorkspaceReport). Never claims conflicts "cannot happen" —
1078
+ * worktrees isolate working directories, not branches, so two worktrees on
1079
+ * a shared branch can still collide at merge time even with zero file
1080
+ * overlap here.
1081
+ */
1082
+ function buildParallelIsolationCheck(activeWorktrees) {
1083
+ const label = 'Isolate parallel work across worktrees';
1084
+ if (activeWorktrees.length < 2) {
1085
+ return {
1086
+ id: 'parallel_isolation',
1087
+ label,
1088
+ status: 'n/a',
1089
+ detail: 'Only one worktree active this window — nothing to isolate from.',
1090
+ };
1091
+ }
1092
+ const worktreesByFile = new Map();
1093
+ for (const row of activeWorktrees) {
1094
+ for (const file of row.metrics.editedFiles) {
1095
+ let keys = worktreesByFile.get(file);
1096
+ if (!keys) {
1097
+ keys = new Set();
1098
+ worktreesByFile.set(file, keys);
1099
+ }
1100
+ keys.add(row.identity.worktreeKey);
1101
+ }
1102
+ }
1103
+ const overlapping = [...worktreesByFile.entries()]
1104
+ .filter(([, keys]) => keys.size > 1)
1105
+ .map(([file]) => file);
1106
+ if (overlapping.length === 0) {
1107
+ return {
1108
+ id: 'parallel_isolation',
1109
+ label,
1110
+ status: 'pass',
1111
+ detail: `${activeWorktrees.length} worktrees active in parallel this window, no files touched in more than one.`,
1112
+ };
1113
+ }
1114
+ const named = overlapping.slice(0, 3).join(', ');
1115
+ const extra = overlapping.length > 3 ? ` (+${overlapping.length - 3} more)` : '';
1116
+ return {
1117
+ id: 'parallel_isolation',
1118
+ label,
1119
+ status: 'warn',
1120
+ detail: `Files edited in more than one active worktree this window: ${named}${extra}. Worktrees isolate working directories, not branches — a shared-branch conflict at merge time is still possible when the same file is touched in parallel.`,
1121
+ };
1122
+ }
1123
+ function resolveScopeMetrics(scope, rows, identities, liveStates,
1124
+ // See computeWorkspaceMetrics's matching parameter.
1125
+ nowMs = Date.now()) {
1126
+ if (scope.kind === 'worktree') {
1127
+ if (scope.id === undefined)
1128
+ return rollupWorkspaceMetrics([], nowMs);
1129
+ const existing = rows.find((r) => r.identity.worktreeKey === scope.id);
1130
+ if (existing)
1131
+ return existing.metrics;
1132
+ const identity = identities.get(scope.id) ?? null;
1133
+ if (!identity)
1134
+ return rollupWorkspaceMetrics([], nowMs);
1135
+ return computeWorkspaceMetrics([], identity, liveStates.get(scope.id) ?? null, nowMs);
1136
+ }
1137
+ if (scope.kind === 'repo') {
1138
+ const activeWorktrees = scope.id === undefined ? [] : rows.filter((r) => r.identity.repoKey === scope.id);
1139
+ const rolled = rollupWorkspaceMetrics(activeWorktrees, nowMs);
1140
+ // Repo-scope-only coaching check — see buildParallelIsolationCheck's
1141
+ // doc comment for why this doesn't run at 'all' scope (no single repo
1142
+ // to ask "did these worktrees isolate you" about).
1143
+ const isolationCheck = buildParallelIsolationCheck(activeWorktrees);
1144
+ return { ...rolled, bestPractices: [isolationCheck, ...rolled.bestPractices] };
1145
+ }
1146
+ // scope.kind === 'all'. Whether a cross-REPO rollup should get its own
1147
+ // isolation-style check is an open question, not a settled rule — a
1148
+ // rollup spanning different repos doesn't have one coherent "isolation"
1149
+ // story the way one repo's own worktrees do, so this deliberately doesn't
1150
+ // attempt one rather than guess at what it would mean.
1151
+ return rollupWorkspaceMetrics(rows, nowMs);
1152
+ }
1153
+ export function buildGitWorkspaceReport(input) {
1154
+ const { scope, records, identities, liveStates, nowMs = Date.now() } = input;
1155
+ const byWorkspace = new Map();
1156
+ for (const record of records) {
1157
+ let bucket = byWorkspace.get(record.workspaceKey);
1158
+ if (!bucket) {
1159
+ bucket = [];
1160
+ byWorkspace.set(record.workspaceKey, bucket);
1161
+ }
1162
+ bucket.push(record);
1163
+ }
1164
+ const rows = [];
1165
+ for (const [key, groupRecords] of byWorkspace) {
1166
+ const identity = resolveIdentityForGroup(key, identities);
1167
+ if (!identity) {
1168
+ logger.warn('skipping activity for a workspace key with no known identity', {
1169
+ workspaceKey: key,
1170
+ });
1171
+ continue;
1172
+ }
1173
+ // Per-workspace records only, sorted ascending — the correctness rule
1174
+ // this whole design exists for: never interleave two workspaces'
1175
+ // records into one sequential reducer.
1176
+ const sorted = [...groupRecords].sort((a, b) => a.timestamp - b.timestamp);
1177
+ const metrics = computeWorkspaceMetrics(sorted, identity, liveStates.get(key) ?? null, nowMs);
1178
+ rows.push({ identity, metrics });
1179
+ }
1180
+ return {
1181
+ scope,
1182
+ metrics: resolveScopeMetrics(scope, rows, identities, liveStates, nowMs),
1183
+ rows,
1184
+ worstBehind: computeWorstBehind(rows),
1185
+ };
1186
+ }
1187
+ //# sourceMappingURL=git-workspace-report.js.map