@newrelic/preflight 1.14.36 → 1.14.38

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.
@@ -48,6 +48,10 @@ const GIT_DIFF_RE = /\bgit\s+diff\b/;
48
48
  const GIT_LOG_RE = /\bgit\s+log\b/;
49
49
  const GIT_COMMIT_RE = /\bgit\s+commit\b/;
50
50
  const GIT_WORKTREE_RE = /\bgit\s+worktree\b/;
51
+ // Only `add`/`remove` create or tear down real isolation — `list`/`prune`/
52
+ // `lock`/etc. are read-only inspection and shouldn't inflate the "worktree
53
+ // ops" count with commands that don't reflect any parallel-isolation work.
54
+ const GIT_WORKTREE_ADD_REMOVE_RE = /\bgit\s+worktree\s+(?:add|remove)\b/;
51
55
  const GIT_CHECKOUT_OURS_RE = /\bgit\s+checkout\s+--ours\b/;
52
56
  const GIT_CHECKOUT_THEIRS_RE = /\bgit\s+checkout\s+--theirs\b/;
53
57
  const GIT_CHERRY_PICK_RE = /\bgit\s+cherry-pick\b/;
@@ -61,6 +65,26 @@ const GH_PR_CHECKS_RE = /\bgh\s+pr\s+checks\b/;
61
65
  const GH_COMMAND_RE = /\bgh\s+/;
62
66
  // Extract PR number from gh commands
63
67
  const GH_PR_NUMBER_RE = /\bgh\s+pr\s+\w+\s+(\d+)/;
68
+ // hydrateGitLog()'s dedup window, used only against a hook-observed commit
69
+ // event (one with no hash in its command text): `git log`'s %ct has 1-second
70
+ // resolution and a hook-observed commit event's timestamp is recorded when
71
+ // the tool call completes, so the two timestamps for the same commit are
72
+ // close but never exactly equal — and the hook event's command text is the
73
+ // raw pre-execution shell string, which never contains the resulting hash,
74
+ // so dedup against it can't key on a hash match. Treat any existing
75
+ // hook-observed commit event within this window as the same commit.
76
+ //
77
+ // A hydrated commit ALWAYS carries a real hash from `git log`, so comparing
78
+ // it against another hydrated event uses exact hash equality (via
79
+ // HYDRATED_COMMIT_HASH_RE below) instead of this proximity window — two
80
+ // genuinely distinct commits landing within the window (e.g. rapid
81
+ // sequential commits in the same `git log` batch) must not collapse into
82
+ // one just because their timestamps are close.
83
+ const COMMIT_DEDUP_WINDOW_MS = 5_000;
84
+ // Matches the synthetic command text hydrateGitLog() gives its own events
85
+ // (see below), letting isDuplicate() tell a hydrated event apart from a
86
+ // hook-observed one and recover its hash.
87
+ const HYDRATED_COMMIT_HASH_RE = /^git commit \((.+)\)$/;
64
88
  const REJECT_INDICATORS = [
65
89
  /\[rejected\]/i,
66
90
  /non-fast-forward/i,
@@ -69,15 +93,28 @@ const REJECT_INDICATORS = [
69
93
  ];
70
94
  // Conflict file path extraction: "CONFLICT (content): Merge conflict in <path>"
71
95
  const CONFLICT_FILE_RE = /Merge conflict in (.+)/g;
96
+ // Render order for suggestions/best-practices — most severe first — rather
97
+ // than fixed source-code push order, which could put a critical item below
98
+ // a milder one.
99
+ const SUGGESTION_SEVERITY_RANK = {
100
+ critical: 0,
101
+ warning: 1,
102
+ info: 2,
103
+ };
104
+ const BEST_PRACTICE_STATUS_RANK = {
105
+ fail: 0,
106
+ warn: 1,
107
+ pass: 2,
108
+ unknown: 3,
109
+ 'n/a': 4,
110
+ };
72
111
  // ---------------------------------------------------------------------------
73
112
  // Tracker
74
113
  // ---------------------------------------------------------------------------
75
114
  export class GitEfficiencyTracker {
76
115
  events = [];
77
116
  conflictRecords = [];
78
- pendingConflictTimestamp = null;
79
- pendingConflictCommand = '';
80
- pendingConflictFiles = [];
117
+ pendingConflicts = [];
81
118
  lastSyncTimestamp = null;
82
119
  pullsSinceLastConflict = 0;
83
120
  consecutiveFailedPushes = 0;
@@ -93,6 +130,24 @@ export class GitEfficiencyTracker {
93
130
  editedFiles = new Set();
94
131
  hasUsedWorktree = false;
95
132
  hasUsedForceWithLease = false;
133
+ // Tracks whether ANY bare (non-lease) force-push occurred this session,
134
+ // independent of whether --force-with-lease was ever also used. This is
135
+ // the single shared signal both the force_with_lease best-practice check
136
+ // and the force_push suggestion's severity gate on, so the two checks
137
+ // agree on what counts as "was this session's force-push usage safe."
138
+ hasUsedBareForcePush = false;
139
+ // Count of bare (non-lease) force-pushes specifically — kept separate from
140
+ // stats.forcePushes (which sums bare AND lease-protected pushes) so
141
+ // severity scales with how much *unsafe* force-pushing happened, not with
142
+ // total force-push volume. A safe --force-with-lease push must never be
143
+ // able to escalate severity that a bare push alone already set.
144
+ bareForcePushCount = 0;
145
+ // Snapshot of whether any bare force-push landed while repoContext.branch
146
+ // matched repoContext.defaultBranch — a bare push to the shared default
147
+ // branch can clobber other collaborators' work, unlike an identical push
148
+ // to a personal feature branch only one person is using, so the two must
149
+ // not scale to the same severity.
150
+ hasForcePushedToDefaultBranch = false;
96
151
  totalToolCalls = 0;
97
152
  sessionStartTimestamp = null;
98
153
  commitTimestamps = [];
@@ -113,7 +168,6 @@ export class GitEfficiencyTracker {
113
168
  commitsBehindMain = null;
114
169
  quickConflictResolutions = 0;
115
170
  prEvents = [];
116
- firstCommitTimestamp = null;
117
171
  repoContext = {
118
172
  repoName: null,
119
173
  branch: null,
@@ -142,10 +196,18 @@ export class GitEfficiencyTracker {
142
196
  const command = record.command;
143
197
  if (!command)
144
198
  return;
145
- // Track GitHub CLI PR commands (skip if `git commit` is the *command*, not
146
- // text that happens to appear inside a gh argument like --title).
147
- if (GH_COMMAND_RE.test(command) && !command.trimStart().startsWith('git ')) {
148
- this.processGhCommand(command, record.timestamp);
199
+ // Track GitHub CLI PR commands. Split on shell separators first so a
200
+ // `gh` invocation chained after a `git` command (e.g. `git push && gh pr
201
+ // create --fill`) is still detected — checking the git-prefix guard
202
+ // against the whole compound string would skip it even though only the
203
+ // first segment is a `git` command. Each segment still skips the case
204
+ // where "gh" is just text inside a git argument, e.g. `git commit -m "gh
205
+ // pr create note"`.
206
+ for (const segment of command.split(/&&|;|\|/)) {
207
+ const trimmedSegment = segment.trim();
208
+ if (GH_COMMAND_RE.test(trimmedSegment) && !trimmedSegment.startsWith('git ')) {
209
+ this.processGhCommand(trimmedSegment, record.timestamp);
210
+ }
149
211
  }
150
212
  if (!GIT_COMMAND_RE.test(command))
151
213
  return;
@@ -196,14 +258,29 @@ export class GitEfficiencyTracker {
196
258
  const merged = this.prEvents.filter((e) => e.action === 'merge').length;
197
259
  const checksViewed = this.prEvents.filter((e) => e.action === 'checks').length;
198
260
  const prsUpdated = this.prEvents.filter((e) => e.action === 'edit' || e.action === 'ready').length;
199
- // Time from first commit to first PR creation
200
- let avgTimeToCreateMs = null;
201
- if (created > 0 && this.firstCommitTimestamp !== null) {
202
- const firstCreate = this.prEvents.find((e) => e.action === 'create');
203
- if (firstCreate) {
204
- avgTimeToCreateMs = Math.max(0, firstCreate.timestamp - this.firstCommitTimestamp);
261
+ // Time from each PR's most recent preceding commit to its `gh pr
262
+ // create`, averaged across every PR opened this session — not a single
263
+ // delta anchored to whichever commit happened to be the very first one
264
+ // this tracker ever saw, which would go stale after the first PR and
265
+ // ignore every PR opened later in the same session.
266
+ const sortedCommitTimestamps = [...this.commitTimestamps].sort((a, b) => a - b);
267
+ const timesToCreate = [];
268
+ for (const prEvent of this.prEvents) {
269
+ if (prEvent.action !== 'create')
270
+ continue;
271
+ let precedingCommitTimestamp = null;
272
+ for (const commitTimestamp of sortedCommitTimestamps) {
273
+ if (commitTimestamp > prEvent.timestamp)
274
+ break;
275
+ precedingCommitTimestamp = commitTimestamp;
276
+ }
277
+ if (precedingCommitTimestamp !== null) {
278
+ timesToCreate.push(Math.max(0, prEvent.timestamp - precedingCommitTimestamp));
205
279
  }
206
280
  }
281
+ const avgTimeToCreateMs = timesToCreate.length > 0
282
+ ? timesToCreate.reduce((a, b) => a + b, 0) / timesToCreate.length
283
+ : null;
207
284
  return {
208
285
  created,
209
286
  merged,
@@ -224,8 +301,24 @@ export class GitEfficiencyTracker {
224
301
  success: true,
225
302
  durationMs: null,
226
303
  };
227
- // Only add if we don't already have this commit tracked
228
- const isDuplicate = this.events.some((e) => e.type === 'commit' && e.command?.includes(commit.hash));
304
+ // Only add if we don't already have this commit tracked. Against an
305
+ // existing hydrated event (one carrying its own real hash), match by
306
+ // exact hash equality — precise, and avoids collapsing two genuinely
307
+ // distinct commits that just happen to land within the proximity
308
+ // window. Against an existing hook-observed event, fall back to
309
+ // timestamp proximity: a prior session's hook-observed `commit` event,
310
+ // replayed via replayTimeline() before this method ever runs, has no
311
+ // hash in its command text at all, so a hash match would never catch
312
+ // it and every restart would double-count that commit.
313
+ const isDuplicate = this.events.some((e) => {
314
+ if (e.type !== 'commit')
315
+ return false;
316
+ const existingHash = e.command ? HYDRATED_COMMIT_HASH_RE.exec(e.command)?.[1] : undefined;
317
+ if (existingHash !== undefined) {
318
+ return existingHash === commit.hash;
319
+ }
320
+ return Math.abs(e.timestamp - commit.timestamp) < COMMIT_DEDUP_WINDOW_MS;
321
+ });
229
322
  if (!isDuplicate) {
230
323
  this.events.push(event);
231
324
  this.commitTimestamps.push(commit.timestamp);
@@ -289,8 +382,27 @@ export class GitEfficiencyTracker {
289
382
  const pushCount = this.events.filter((e) => e.type === 'push' || e.type === 'force_push' || e.type === 'force_push_lease').length;
290
383
  const commitCount = this.events.filter((e) => e.type === 'commit').length;
291
384
  const branchOperations = this.events.filter((e) => e.type === 'branch').length;
292
- const resolved = this.conflictRecords.filter((c) => c.resolution === 'resolved');
293
- const conflictResolutionRate = this.conflictRecords.length > 0 ? resolved.length / this.conflictRecords.length : null;
385
+ // A conflict that's currently open (mid-merge, not yet aborted or
386
+ // resolved) is counted in the mergeConflicts/rebaseConflicts KPI above,
387
+ // but this.conflictRecords only gains an entry once it's aborted or
388
+ // resolved — so it would otherwise never enter the resolution-rate
389
+ // denominator, letting a session with one resolved and one still-open
390
+ // conflict show a "perfect" 100% rate. Synthesizing (not persisting) a
391
+ // 'pending' record per still-queued conflict keeps the denominator
392
+ // honest without ever double-counting once that conflict does resolve —
393
+ // at that point it leaves the queue and gets a real entry instead.
394
+ const allConflictRecords = [
395
+ ...this.conflictRecords,
396
+ ...this.pendingConflicts.map((p) => ({
397
+ timestamp: p.timestamp,
398
+ resolution: 'pending',
399
+ resolutionTimeMs: null,
400
+ command: p.command,
401
+ files: p.files,
402
+ })),
403
+ ];
404
+ const resolved = allConflictRecords.filter((c) => c.resolution === 'resolved');
405
+ const conflictResolutionRate = allConflictRecords.length > 0 ? resolved.length / allConflictRecords.length : null;
294
406
  const resolutionTimes = resolved
295
407
  .filter((c) => c.resolutionTimeMs !== null)
296
408
  .map((c) => c.resolutionTimeMs);
@@ -356,9 +468,13 @@ export class GitEfficiencyTracker {
356
468
  // (GitEfficiency.tsx's `[...timeline].reverse().slice(0, 30)`) picks
357
469
  // the true newest 30, not whichever 30 happened to be pushed last.
358
470
  gitCommandTimeline: [...this.events].sort((a, b) => a.timestamp - b.timestamp).slice(-50),
359
- conflictHistory: [...this.conflictRecords].sort((a, b) => a.timestamp - b.timestamp),
360
- suggestions,
361
- bestPractices,
471
+ conflictHistory: [...allConflictRecords].sort((a, b) => a.timestamp - b.timestamp),
472
+ // Both arrays render in whatever order the checks above happen to
473
+ // .push() in — fixed source-code order, not severity order — so a
474
+ // critical-severity item could render below a milder one. Sort by
475
+ // severity (most severe first) before exposing.
476
+ suggestions: [...suggestions].sort((a, b) => SUGGESTION_SEVERITY_RANK[a.severity] - SUGGESTION_SEVERITY_RANK[b.severity]),
477
+ bestPractices: [...bestPractices].sort((a, b) => BEST_PRACTICE_STATUS_RANK[a.status] - BEST_PRACTICE_STATUS_RANK[b.status]),
362
478
  preventionScore,
363
479
  efficiencyScore,
364
480
  riskIndicators,
@@ -371,9 +487,7 @@ export class GitEfficiencyTracker {
371
487
  reset(_sessionId) {
372
488
  this.events = [];
373
489
  this.conflictRecords = [];
374
- this.pendingConflictTimestamp = null;
375
- this.pendingConflictCommand = '';
376
- this.pendingConflictFiles = [];
490
+ this.pendingConflicts = [];
377
491
  this.lastSyncTimestamp = null;
378
492
  this.pullsSinceLastConflict = 0;
379
493
  this.consecutiveFailedPushes = 0;
@@ -389,6 +503,9 @@ export class GitEfficiencyTracker {
389
503
  this.editedFiles.clear();
390
504
  this.hasUsedWorktree = false;
391
505
  this.hasUsedForceWithLease = false;
506
+ this.hasUsedBareForcePush = false;
507
+ this.bareForcePushCount = 0;
508
+ this.hasForcePushedToDefaultBranch = false;
392
509
  this.totalToolCalls = 0;
393
510
  this.sessionStartTimestamp = null;
394
511
  this.commitTimestamps = [];
@@ -404,7 +521,6 @@ export class GitEfficiencyTracker {
404
521
  this.commitsBehindMain = null;
405
522
  this.quickConflictResolutions = 0;
406
523
  this.prEvents = [];
407
- this.firstCommitTimestamp = null;
408
524
  this.repoContext = { repoName: null, branch: null, remoteName: null, defaultBranch: null };
409
525
  }
410
526
  // -------------------------------------------------------------------------
@@ -472,18 +588,26 @@ export class GitEfficiencyTracker {
472
588
  return { ...base, type: 'other_git' };
473
589
  }
474
590
  processEvent(event, command, record) {
475
- // Track conflict resolution strategies regardless of event type
476
- if (GIT_CHECKOUT_OURS_RE.test(command))
477
- this.oursCount++;
478
- if (GIT_CHECKOUT_THEIRS_RE.test(command))
479
- this.theirsCount++;
480
- if (GIT_CHERRY_PICK_RE.test(command))
481
- this.cherryPickCount++;
591
+ // Attribute ours/theirs/cherry-pick resolution strategy to the oldest
592
+ // still-open conflict, not to every matching command — a multi-file
593
+ // conflict resolved with one `--ours` per file must count once toward
594
+ // that conflict, not once per file, and a strategy command run with no
595
+ // conflict pending isn't resolving anything. `--abort` also matches the
596
+ // bare cherry-pick pattern, so it's excluded explicitly — aborting isn't
597
+ // a resolution strategy.
598
+ const oldestPending = this.pendingConflicts[0];
599
+ if (oldestPending) {
600
+ if (GIT_CHECKOUT_OURS_RE.test(command))
601
+ oldestPending.usedOurs = true;
602
+ if (GIT_CHECKOUT_THEIRS_RE.test(command))
603
+ oldestPending.usedTheirs = true;
604
+ if (GIT_CHERRY_PICK_RE.test(command) && !CHERRY_PICK_ABORT_RE.test(command)) {
605
+ oldestPending.usedCherryPick = true;
606
+ }
607
+ }
482
608
  switch (event.type) {
483
609
  case 'merge_conflict':
484
610
  case 'rebase_conflict': {
485
- this.pendingConflictTimestamp = event.timestamp;
486
- this.pendingConflictCommand = command;
487
611
  const output = record.error ?? '';
488
612
  const files = [];
489
613
  let match;
@@ -492,56 +616,64 @@ export class GitEfficiencyTracker {
492
616
  files.push(match[1].trim());
493
617
  this.conflictedFiles.add(match[1].trim());
494
618
  }
495
- this.pendingConflictFiles = files;
619
+ this.pendingConflicts.push({
620
+ timestamp: event.timestamp,
621
+ command,
622
+ files,
623
+ usedOurs: false,
624
+ usedTheirs: false,
625
+ usedCherryPick: false,
626
+ });
496
627
  this.pullsSinceLastConflict = 0;
497
628
  break;
498
629
  }
499
630
  case 'merge_abort':
500
631
  case 'rebase_abort':
501
- case 'cherry_pick_abort':
502
- if (this.pendingConflictTimestamp !== null) {
632
+ case 'cherry_pick_abort': {
633
+ const pending = this.pendingConflicts.shift();
634
+ if (pending) {
503
635
  this.conflictRecords.push({
504
- timestamp: this.pendingConflictTimestamp,
636
+ timestamp: pending.timestamp,
505
637
  resolution: 'aborted',
506
- resolutionTimeMs: event.timestamp - this.pendingConflictTimestamp,
507
- command: this.pendingConflictCommand,
508
- files: this.pendingConflictFiles,
638
+ resolutionTimeMs: event.timestamp - pending.timestamp,
639
+ command: pending.command,
640
+ files: pending.files,
509
641
  });
510
- this.pendingConflictTimestamp = null;
511
- this.pendingConflictCommand = '';
512
- this.pendingConflictFiles = [];
513
642
  }
514
643
  break;
644
+ }
515
645
  case 'commit': {
516
646
  // git commit --amend fixes a prior commit, not a merge conflict.
517
- // Clear the pending conflict on amend so the *next* normal commit
518
- // doesn't see a stale pendingConflictTimestamp (potentially hours old).
647
+ // Drop the oldest pending conflict on amend (without recording a
648
+ // resolution) so a later, unrelated commit doesn't retroactively
649
+ // "resolve" it.
519
650
  if (command.includes('--amend')) {
520
- this.pendingConflictTimestamp = null;
521
- this.pendingConflictCommand = '';
522
- this.pendingConflictFiles = [];
651
+ this.pendingConflicts.shift();
523
652
  }
524
- if (this.pendingConflictTimestamp !== null && !command.includes('--amend')) {
525
- const resolutionMs = event.timestamp - this.pendingConflictTimestamp;
526
- this.conflictRecords.push({
527
- timestamp: this.pendingConflictTimestamp,
528
- resolution: 'resolved',
529
- resolutionTimeMs: resolutionMs,
530
- command: this.pendingConflictCommand,
531
- files: this.pendingConflictFiles,
532
- });
533
- // Under 30s resolution with multiple conflicted files is suspiciously fast
534
- if (resolutionMs < 30_000 && this.pendingConflictFiles.length > 1) {
535
- this.quickConflictResolutions++;
653
+ else {
654
+ const pending = this.pendingConflicts.shift();
655
+ if (pending) {
656
+ const resolutionMs = event.timestamp - pending.timestamp;
657
+ this.conflictRecords.push({
658
+ timestamp: pending.timestamp,
659
+ resolution: 'resolved',
660
+ resolutionTimeMs: resolutionMs,
661
+ command: pending.command,
662
+ files: pending.files,
663
+ });
664
+ if (pending.usedOurs)
665
+ this.oursCount++;
666
+ if (pending.usedTheirs)
667
+ this.theirsCount++;
668
+ if (pending.usedCherryPick)
669
+ this.cherryPickCount++;
670
+ // Under 30s resolution with multiple conflicted files is suspiciously fast
671
+ if (resolutionMs < 30_000 && pending.files.length > 1) {
672
+ this.quickConflictResolutions++;
673
+ }
536
674
  }
537
- this.pendingConflictTimestamp = null;
538
- this.pendingConflictCommand = '';
539
- this.pendingConflictFiles = [];
540
675
  }
541
676
  this.commitTimestamps.push(event.timestamp);
542
- if (this.firstCommitTimestamp === null) {
543
- this.firstCommitTimestamp = event.timestamp;
544
- }
545
677
  this.commitsSinceLastSync++;
546
678
  this.statusChecksSinceLastAction = 0;
547
679
  break;
@@ -582,6 +714,13 @@ export class GitEfficiencyTracker {
582
714
  this.statusChecksSinceLastAction = 0;
583
715
  break;
584
716
  case 'force_push':
717
+ this.hasUsedBareForcePush = true;
718
+ this.bareForcePushCount++;
719
+ if (this.repoContext.branch !== null &&
720
+ this.repoContext.defaultBranch !== null &&
721
+ this.repoContext.branch === this.repoContext.defaultBranch) {
722
+ this.hasForcePushedToDefaultBranch = true;
723
+ }
585
724
  if (this.lastPushRejectedTimestamp !== null &&
586
725
  event.timestamp - this.lastPushRejectedTimestamp < 300_000) {
587
726
  this.forceAfterReject++;
@@ -613,8 +752,14 @@ export class GitEfficiencyTracker {
613
752
  this.statusChecksSinceLastAction = 0;
614
753
  break;
615
754
  case 'worktree':
616
- this.hasUsedWorktree = true;
617
- this.worktreeCommands++;
755
+ // Both the "worktree ops" count and the usesWorktrees/use_worktrees
756
+ // signal are meant to reflect real worktree usage, not read-only
757
+ // inspection — `list`/`prune`/`lock`/etc. shouldn't count as evidence
758
+ // that worktrees were used to isolate parallel work.
759
+ if (GIT_WORKTREE_ADD_REMOVE_RE.test(command)) {
760
+ this.worktreeCommands++;
761
+ this.hasUsedWorktree = true;
762
+ }
618
763
  break;
619
764
  case 'status':
620
765
  this.statusChecksSinceLastAction++;
@@ -624,18 +769,24 @@ export class GitEfficiencyTracker {
624
769
  break;
625
770
  }
626
771
  }
627
- // A pull immediately followed by a conflict means the branch had already
628
- // diverged enough that even the act of syncing produced a conflict — a
629
- // stronger signal of a stale branch than a conflict from, say, a later
630
- // merge or rebase attempted well after the pull.
772
+ // A pull that diverged enough to conflict is the strongest signal of a
773
+ // stale branch checked directly off the conflicting event's own command
774
+ // (a `git pull` whose own output contains a conflict indicator classifies
775
+ // as merge_conflict/rebase_conflict, never as 'pull', so this can't rely
776
+ // on the event's `type`). A pull immediately followed by a *separate*
777
+ // command that then conflicts is a weaker but still real signal, kept as a
778
+ // fallback for events that don't match the direct case.
631
779
  countStaleBranchPulls() {
632
780
  let staleCount = 0;
633
- for (let i = 0; i < this.events.length - 1; i++) {
634
- if (this.events[i].type === 'pull') {
635
- const next = this.events[i + 1];
636
- if (next.type === 'merge_conflict' || next.type === 'rebase_conflict') {
637
- staleCount++;
638
- }
781
+ for (let i = 0; i < this.events.length; i++) {
782
+ const event = this.events[i];
783
+ if (event.type !== 'merge_conflict' && event.type !== 'rebase_conflict')
784
+ continue;
785
+ if (GIT_PULL_RE.test(event.command ?? '')) {
786
+ staleCount++;
787
+ }
788
+ else if (i > 0 && this.events[i - 1].type === 'pull') {
789
+ staleCount++;
639
790
  }
640
791
  }
641
792
  return staleCount;
@@ -778,15 +929,35 @@ export class GitEfficiencyTracker {
778
929
  detail: 'Conflicts detected without worktree usage. When running multiple AI sessions in parallel (or switching between tasks), use `git worktree add` to give each task its own working directory. This completely eliminates cross-session conflicts.',
779
930
  });
780
931
  }
781
- else {
932
+ else if (stats.commitCount < 3) {
933
+ // Mirrors frequent_sync's "not enough commits yet" gate above — too
934
+ // little activity to tell whether this session even involves the kind
935
+ // of parallel/multi-task work worktrees would protect against.
782
936
  practices.push({
783
937
  id: 'use_worktrees',
784
938
  label: 'Use worktrees for parallel work',
785
939
  status: 'unknown',
786
- detail: 'No worktree usage detected. Consider worktrees if you run parallel sessions.',
940
+ detail: 'Not enough activity yet to tell whether this session needs worktrees.',
941
+ });
942
+ }
943
+ else {
944
+ // Fully known, not a violation: no conflicts occurred and no worktree
945
+ // was used. Distinct from the 'unknown' branch above — this session
946
+ // had enough activity to judge, and simply didn't need worktree
947
+ // isolation, which is a fine outcome, not "we don't know."
948
+ practices.push({
949
+ id: 'use_worktrees',
950
+ label: 'Use worktrees for parallel work',
951
+ status: 'n/a',
952
+ detail: "No conflicts and no worktree usage detected this session — parallel-session isolation wasn't needed here.",
787
953
  });
788
954
  }
789
- // 5. Use --force-with-lease instead of --force
955
+ // 5. Use --force-with-lease instead of --force. Gated on
956
+ // hasUsedBareForcePush rather than forcePushes/usesForceWithLease alone —
957
+ // those two count safe and unsafe force-pushes together, so checking
958
+ // usesForceWithLease alone would let one safe `--force-with-lease` mask a
959
+ // dangerous bare `--force` in the same session with a fully-passing
960
+ // status.
790
961
  if (stats.forcePushes === 0) {
791
962
  practices.push({
792
963
  id: 'force_with_lease',
@@ -795,15 +966,15 @@ export class GitEfficiencyTracker {
795
966
  detail: 'No force pushes yet.',
796
967
  });
797
968
  }
798
- else if (risk.usesForceWithLease) {
969
+ else if (this.hasUsedBareForcePush && risk.usesForceWithLease) {
799
970
  practices.push({
800
971
  id: 'force_with_lease',
801
972
  label: 'Use --force-with-lease',
802
- status: 'pass',
803
- detail: "Goodusing --force-with-lease which refuses to overwrite remote commits you haven't seen.",
973
+ status: 'warn',
974
+ detail: 'Mixed usage this session 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.',
804
975
  });
805
976
  }
806
- else {
977
+ else if (this.hasUsedBareForcePush) {
807
978
  practices.push({
808
979
  id: 'force_with_lease',
809
980
  label: 'Use --force-with-lease',
@@ -811,24 +982,15 @@ export class GitEfficiencyTracker {
811
982
  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.',
812
983
  });
813
984
  }
814
- // 6. Don't force-push after a rejection without investigating
815
- if (risk.forceAfterReject > 0) {
816
- practices.push({
817
- id: 'no_force_after_reject',
818
- label: "Don't force-push after rejection",
819
- status: 'fail',
820
- detail: `Push was rejected ${risk.pushRejections} time(s) and then force-pushed ${risk.forceAfterReject} time(s). When a push is rejected, pull + rebase first to incorporate upstream changes. Force pushing after a rejection overwrites others' work.`,
821
- });
822
- }
823
- else if (risk.pushRejections > 0) {
985
+ else {
824
986
  practices.push({
825
- id: 'no_force_after_reject',
826
- label: "Don't force-push after rejection",
987
+ id: 'force_with_lease',
988
+ label: 'Use --force-with-lease',
827
989
  status: 'pass',
828
- detail: 'Push was rejected but correctly handled without force pushing.',
990
+ detail: "Good using --force-with-lease which refuses to overwrite remote commits you haven't seen.",
829
991
  });
830
992
  }
831
- // 7. Keep PRs small (proxy: many commits without pushing)
993
+ // 6. Keep PRs small (proxy: many commits without pushing)
832
994
  if (risk.commitsSinceLastSync > 15) {
833
995
  practices.push({
834
996
  id: 'small_increments',
@@ -845,7 +1007,7 @@ export class GitEfficiencyTracker {
845
1007
  detail: 'Good — committing and syncing in small batches.',
846
1008
  });
847
1009
  }
848
- // 8. Avoid editing hot files
1010
+ // 7. Avoid editing hot files
849
1011
  if (risk.hotFiles.length > 0) {
850
1012
  practices.push({
851
1013
  id: 'avoid_hot_files',
@@ -854,7 +1016,7 @@ export class GitEfficiencyTracker {
854
1016
  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.`,
855
1017
  });
856
1018
  }
857
- // 9. Build/test before pushing
1019
+ // 8. Build/test before pushing
858
1020
  if (this.buildBeforePush === null && this.lastPushTimestamp === null) {
859
1021
  practices.push({
860
1022
  id: 'verify_before_push',
@@ -882,7 +1044,10 @@ export class GitEfficiencyTracker {
882
1044
  return practices;
883
1045
  }
884
1046
  computePreventionScore(practices) {
885
- const scorable = practices.filter((p) => p.status !== 'unknown');
1047
+ // 'n/a' (fully known, not applicable) is excluded the same way 'unknown'
1048
+ // (genuinely insufficient data) is — neither should count for or against
1049
+ // the score. See BestPractice['status']'s docstring for the distinction.
1050
+ const scorable = practices.filter((p) => p.status !== 'unknown' && p.status !== 'n/a');
886
1051
  if (scorable.length < 2)
887
1052
  return null;
888
1053
  let points = 0;
@@ -899,22 +1064,14 @@ export class GitEfficiencyTracker {
899
1064
  generateSuggestions(stats) {
900
1065
  const suggestions = [];
901
1066
  // --- Proactive prevention suggestions (fire BEFORE conflicts happen) ---
902
- if (stats.riskIndicators.syncedBeforeEditing === false) {
903
- suggestions.push({
904
- severity: 'warning',
905
- category: 'no_initial_sync',
906
- message: 'Started editing without syncing first. Run `git fetch && git rebase origin/main` (or your target branch) at the start of every session. This single habit prevents the majority of AI-assisted coding conflicts.',
907
- evidence: 'First file edit occurred before any git pull/fetch',
908
- });
909
- }
910
- if (stats.riskIndicators.commitsSinceLastSync > 8) {
911
- suggestions.push({
912
- severity: 'warning',
913
- category: 'drift_risk',
914
- message: `${stats.riskIndicators.commitsSinceLastSync} commits without syncing. You're accumulating drift that will compound into painful conflicts. Run \`git fetch && git rebase origin/main\` now — smaller, frequent rebases are far easier than one large one later.`,
915
- evidence: `${stats.riskIndicators.commitsSinceLastSync} commits since last pull/fetch/rebase`,
916
- });
917
- }
1067
+ //
1068
+ // syncedBeforeEditing, commitsSinceLastSync (drift), and hotFiles are
1069
+ // ongoing-state conditions already surfaced as their own Best Practice
1070
+ // checks (sync_before_edit, frequent_sync, avoid_hot_files) — duplicating
1071
+ // them here as one-off suggestions rendered the same fact twice, with a
1072
+ // conflicting severity in the hot-files case. Best Practices is their
1073
+ // canonical home; only force-after-reject (a one-time, reactive event
1074
+ // with actionable remediation) is kept as a suggestion.
918
1075
  if (stats.riskIndicators.forceAfterReject > 0) {
919
1076
  suggestions.push({
920
1077
  severity: 'critical',
@@ -923,14 +1080,6 @@ export class GitEfficiencyTracker {
923
1080
  evidence: `${stats.riskIndicators.forceAfterReject} force push(es) within 5 min of a rejection`,
924
1081
  });
925
1082
  }
926
- if (stats.riskIndicators.hotFiles.length > 0) {
927
- suggestions.push({
928
- severity: 'info',
929
- category: 'hot_files',
930
- message: `You're editing files that previously conflicted (${stats.riskIndicators.hotFiles.slice(0, 3).join(', ')}). These likely have active upstream work. Consider: (1) rebasing immediately to get latest state, (2) coordinating with whoever else is touching these files, or (3) deferring changes until upstream settles.`,
931
- evidence: `${stats.riskIndicators.hotFiles.length} previously-conflicted file(s) re-edited`,
932
- });
933
- }
934
1083
  // --- Reactive suggestions (fire after problems occur) ---
935
1084
  if (stats.mergeConflicts + stats.rebaseConflicts >= 3) {
936
1085
  suggestions.push({
@@ -956,20 +1105,40 @@ export class GitEfficiencyTracker {
956
1105
  evidence: `${stats.abortedOperations} aborted operations`,
957
1106
  });
958
1107
  }
959
- if (stats.forcePushes >= 2) {
1108
+ // Severity is gated on hasUsedBareForcePush/bareForcePushCount — the same
1109
+ // shared signal the force_with_lease best-practice check uses — rather
1110
+ // than the raw forcePushes count, which sums bare AND lease-protected
1111
+ // pushes together. Gating (and scaling) on the bare-only count keeps this
1112
+ // suggestion's ranking consistent with the best-practice check: adding a
1113
+ // safe --force-with-lease push on top of an existing bare push must never
1114
+ // raise severity, since only the bare push is actually risky.
1115
+ //
1116
+ // A bare push to the shared default branch is escalated to 'critical'
1117
+ // outright, regardless of count — it can clobber other collaborators'
1118
+ // work, unlike an identical push to a personal feature branch (which
1119
+ // still scales by count, as above).
1120
+ if (this.hasUsedBareForcePush) {
960
1121
  suggestions.push({
961
- severity: 'critical',
1122
+ severity: this.hasForcePushedToDefaultBranch
1123
+ ? 'critical'
1124
+ : this.bareForcePushCount >= 2
1125
+ ? 'critical'
1126
+ : 'warning',
962
1127
  category: 'force_push',
963
- message: 'Multiple force pushes this session. Always use --force-with-lease as a safety net. 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.',
964
- evidence: `${stats.forcePushes} force pushes this session`,
1128
+ message: this.hasForcePushedToDefaultBranch
1129
+ ? `Bare --force push used on the shared default branch (${this.repoContext.defaultBranch ?? '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.`
1130
+ : '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.',
1131
+ evidence: this.hasForcePushedToDefaultBranch
1132
+ ? `${this.bareForcePushCount} bare --force push(es) this session, including at least one on the default branch`
1133
+ : `${this.bareForcePushCount} bare --force push(es) this session`,
965
1134
  });
966
1135
  }
967
- else if (stats.forcePushes === 1) {
1136
+ else if (stats.riskIndicators.usesForceWithLease && stats.forcePushes >= 2) {
968
1137
  suggestions.push({
969
1138
  severity: 'info',
970
1139
  category: 'force_push',
971
- message: "Force push used. Prefer --force-with-lease for safer force pushes it refuses to overwrite commits you haven't seen locally.",
972
- evidence: '1 force push',
1140
+ message: 'Multiple force pushes this session, 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.',
1141
+ evidence: `${stats.forcePushes} lease-protected force pushes this session`,
973
1142
  });
974
1143
  }
975
1144
  if (stats.resetHards >= 2) {
@@ -1123,15 +1292,16 @@ export class GitEfficiencyTracker {
1123
1292
  };
1124
1293
  }
1125
1294
  computeConflictStrategy() {
1126
- const manualMergeCount = this.conflictRecords.filter((c) => c.resolution === 'resolved').length -
1295
+ const manualMergeCount = Math.max(0, this.conflictRecords.filter((c) => c.resolution === 'resolved').length -
1127
1296
  this.oursCount -
1128
- this.theirsCount;
1297
+ this.theirsCount -
1298
+ this.cherryPickCount);
1129
1299
  return {
1130
1300
  oursCount: this.oursCount,
1131
1301
  theirsCount: this.theirsCount,
1132
- manualMergeCount: Math.max(0, manualMergeCount),
1302
+ manualMergeCount,
1133
1303
  cherryPickCount: this.cherryPickCount,
1134
- totalResolutions: this.oursCount + this.theirsCount + Math.max(0, manualMergeCount),
1304
+ totalResolutions: this.oursCount + this.theirsCount + this.cherryPickCount + manualMergeCount,
1135
1305
  };
1136
1306
  }
1137
1307
  }