@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.
- package/dist/dashboard/routes/api-handler.d.ts +3 -0
- package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
- package/dist/dashboard/routes/api-handler.js +4 -0
- package/dist/dashboard/routes/api-handler.js.map +1 -1
- package/dist/dashboard/workflow-store.d.ts +7 -1
- package/dist/dashboard/workflow-store.d.ts.map +1 -1
- package/dist/dashboard/workflow-store.js +3 -1
- package/dist/dashboard/workflow-store.js.map +1 -1
- package/dist/hooks/workflow-watcher.d.ts.map +1 -1
- package/dist/hooks/workflow-watcher.js +4 -1
- package/dist/hooks/workflow-watcher.js.map +1 -1
- package/dist/index.js +28 -10
- package/dist/index.js.map +1 -1
- package/dist/metrics/git-efficiency-tracker.d.ts +13 -5
- package/dist/metrics/git-efficiency-tracker.d.ts.map +1 -1
- package/dist/metrics/git-efficiency-tracker.js +308 -138
- package/dist/metrics/git-efficiency-tracker.js.map +1 -1
- package/dist/transport/nr-ingest.d.ts +7 -1
- package/dist/transport/nr-ingest.d.ts.map +1 -1
- package/dist/transport/nr-ingest.js +6 -2
- package/dist/transport/nr-ingest.js.map +1 -1
- package/dist/web/assets/index-D4cXYK49.css +2 -0
- package/dist/web/assets/{index-DaSOJbc6.js → index-rfKWywgK.js} +3 -3
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Dz8FmNEb.css +0 -2
|
@@ -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
|
-
|
|
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
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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: [...
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
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
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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.
|
|
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
|
-
|
|
632
|
+
case 'cherry_pick_abort': {
|
|
633
|
+
const pending = this.pendingConflicts.shift();
|
|
634
|
+
if (pending) {
|
|
503
635
|
this.conflictRecords.push({
|
|
504
|
-
timestamp:
|
|
636
|
+
timestamp: pending.timestamp,
|
|
505
637
|
resolution: 'aborted',
|
|
506
|
-
resolutionTimeMs: event.timestamp -
|
|
507
|
-
command:
|
|
508
|
-
files:
|
|
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
|
-
//
|
|
518
|
-
//
|
|
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.
|
|
521
|
-
this.pendingConflictCommand = '';
|
|
522
|
-
this.pendingConflictFiles = [];
|
|
651
|
+
this.pendingConflicts.shift();
|
|
523
652
|
}
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
timestamp
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
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
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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: '
|
|
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: '
|
|
803
|
-
detail:
|
|
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
|
-
|
|
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: '
|
|
826
|
-
label:
|
|
987
|
+
id: 'force_with_lease',
|
|
988
|
+
label: 'Use --force-with-lease',
|
|
827
989
|
status: 'pass',
|
|
828
|
-
detail:
|
|
990
|
+
detail: "Good — using --force-with-lease which refuses to overwrite remote commits you haven't seen.",
|
|
829
991
|
});
|
|
830
992
|
}
|
|
831
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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:
|
|
1122
|
+
severity: this.hasForcePushedToDefaultBranch
|
|
1123
|
+
? 'critical'
|
|
1124
|
+
: this.bareForcePushCount >= 2
|
|
1125
|
+
? 'critical'
|
|
1126
|
+
: 'warning',
|
|
962
1127
|
category: 'force_push',
|
|
963
|
-
message:
|
|
964
|
-
|
|
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
|
|
1136
|
+
else if (stats.riskIndicators.usesForceWithLease && stats.forcePushes >= 2) {
|
|
968
1137
|
suggestions.push({
|
|
969
1138
|
severity: 'info',
|
|
970
1139
|
category: 'force_push',
|
|
971
|
-
message:
|
|
972
|
-
evidence:
|
|
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
|
|
1302
|
+
manualMergeCount,
|
|
1133
1303
|
cherryPickCount: this.cherryPickCount,
|
|
1134
|
-
totalResolutions: this.oursCount + this.theirsCount +
|
|
1304
|
+
totalResolutions: this.oursCount + this.theirsCount + this.cherryPickCount + manualMergeCount,
|
|
1135
1305
|
};
|
|
1136
1306
|
}
|
|
1137
1307
|
}
|