@newrelic/preflight 1.14.35 → 1.14.37
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 -3
- package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
- package/dist/dashboard/routes/api-handler.js +170 -39
- package/dist/dashboard/routes/api-handler.js.map +1 -1
- package/dist/dashboard/workflow-store.d.ts +20 -0
- package/dist/dashboard/workflow-store.d.ts.map +1 -1
- package/dist/dashboard/workflow-store.js +8 -0
- package/dist/dashboard/workflow-store.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +126 -42
- package/dist/index.js.map +1 -1
- package/dist/metrics/cost-tracker.d.ts +17 -0
- package/dist/metrics/cost-tracker.d.ts.map +1 -1
- package/dist/metrics/cost-tracker.js +19 -0
- package/dist/metrics/cost-tracker.js.map +1 -1
- package/dist/metrics/decision-tracker.d.ts +18 -1
- package/dist/metrics/decision-tracker.d.ts.map +1 -1
- package/dist/metrics/decision-tracker.js +28 -11
- package/dist/metrics/decision-tracker.js.map +1 -1
- package/dist/metrics/git-efficiency-tracker.d.ts +18 -5
- package/dist/metrics/git-efficiency-tracker.d.ts.map +1 -1
- package/dist/metrics/git-efficiency-tracker.js +335 -137
- package/dist/metrics/git-efficiency-tracker.js.map +1 -1
- package/dist/metrics/turn-cost-attributor.d.ts +37 -7
- package/dist/metrics/turn-cost-attributor.d.ts.map +1 -1
- package/dist/metrics/turn-cost-attributor.js +140 -45
- package/dist/metrics/turn-cost-attributor.js.map +1 -1
- package/dist/security/audit-trail.d.ts +32 -1
- package/dist/security/audit-trail.d.ts.map +1 -1
- package/dist/security/audit-trail.js +142 -4
- package/dist/security/audit-trail.js.map +1 -1
- package/dist/storage/local-store.d.ts +29 -0
- package/dist/storage/local-store.d.ts.map +1 -1
- package/dist/storage/local-store.js +93 -0
- package/dist/storage/local-store.js.map +1 -1
- package/dist/web/assets/{index-DI6sTfcj.js → index-ndsRsiKh.js} +3 -3
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -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,27 @@ 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
|
+
};
|
|
72
110
|
// ---------------------------------------------------------------------------
|
|
73
111
|
// Tracker
|
|
74
112
|
// ---------------------------------------------------------------------------
|
|
75
113
|
export class GitEfficiencyTracker {
|
|
76
114
|
events = [];
|
|
77
115
|
conflictRecords = [];
|
|
78
|
-
|
|
79
|
-
pendingConflictCommand = '';
|
|
80
|
-
pendingConflictFiles = [];
|
|
116
|
+
pendingConflicts = [];
|
|
81
117
|
lastSyncTimestamp = null;
|
|
82
118
|
pullsSinceLastConflict = 0;
|
|
83
119
|
consecutiveFailedPushes = 0;
|
|
@@ -93,9 +129,33 @@ export class GitEfficiencyTracker {
|
|
|
93
129
|
editedFiles = new Set();
|
|
94
130
|
hasUsedWorktree = false;
|
|
95
131
|
hasUsedForceWithLease = false;
|
|
132
|
+
// Tracks whether ANY bare (non-lease) force-push occurred this session,
|
|
133
|
+
// independent of whether --force-with-lease was ever also used. This is
|
|
134
|
+
// the single shared signal both the force_with_lease best-practice check
|
|
135
|
+
// and the force_push suggestion's severity gate on, so the two checks
|
|
136
|
+
// agree on what counts as "was this session's force-push usage safe."
|
|
137
|
+
hasUsedBareForcePush = false;
|
|
138
|
+
// Count of bare (non-lease) force-pushes specifically — kept separate from
|
|
139
|
+
// stats.forcePushes (which sums bare AND lease-protected pushes) so
|
|
140
|
+
// severity scales with how much *unsafe* force-pushing happened, not with
|
|
141
|
+
// total force-push volume. A safe --force-with-lease push must never be
|
|
142
|
+
// able to escalate severity that a bare push alone already set.
|
|
143
|
+
bareForcePushCount = 0;
|
|
144
|
+
// Snapshot of whether any bare force-push landed while repoContext.branch
|
|
145
|
+
// matched repoContext.defaultBranch — a bare push to the shared default
|
|
146
|
+
// branch can clobber other collaborators' work, unlike an identical push
|
|
147
|
+
// to a personal feature branch only one person is using, so the two must
|
|
148
|
+
// not scale to the same severity.
|
|
149
|
+
hasForcePushedToDefaultBranch = false;
|
|
96
150
|
totalToolCalls = 0;
|
|
97
151
|
sessionStartTimestamp = null;
|
|
98
152
|
commitTimestamps = [];
|
|
153
|
+
// Latest commit timestamp covered by a `hydrateGitLog()` call. A live
|
|
154
|
+
// `git commit` event with a timestamp at or before this is one `git log`
|
|
155
|
+
// already saw at hydration time — recordToolCall() must skip it rather
|
|
156
|
+
// than double-count a commit that's about to arrive (or already has)
|
|
157
|
+
// through the normal hook-observed path too. See recordToolCall().
|
|
158
|
+
hydratedThroughMs = 0;
|
|
99
159
|
worktreeCommands = 0;
|
|
100
160
|
oursCount = 0;
|
|
101
161
|
theirsCount = 0;
|
|
@@ -107,7 +167,6 @@ export class GitEfficiencyTracker {
|
|
|
107
167
|
commitsBehindMain = null;
|
|
108
168
|
quickConflictResolutions = 0;
|
|
109
169
|
prEvents = [];
|
|
110
|
-
firstCommitTimestamp = null;
|
|
111
170
|
repoContext = {
|
|
112
171
|
repoName: null,
|
|
113
172
|
branch: null,
|
|
@@ -136,14 +195,31 @@ export class GitEfficiencyTracker {
|
|
|
136
195
|
const command = record.command;
|
|
137
196
|
if (!command)
|
|
138
197
|
return;
|
|
139
|
-
// Track GitHub CLI PR commands
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
198
|
+
// Track GitHub CLI PR commands. Split on shell separators first so a
|
|
199
|
+
// `gh` invocation chained after a `git` command (e.g. `git push && gh pr
|
|
200
|
+
// create --fill`) is still detected — checking the git-prefix guard
|
|
201
|
+
// against the whole compound string would skip it even though only the
|
|
202
|
+
// first segment is a `git` command. Each segment still skips the case
|
|
203
|
+
// where "gh" is just text inside a git argument, e.g. `git commit -m "gh
|
|
204
|
+
// pr create note"`.
|
|
205
|
+
for (const segment of command.split(/&&|;|\|/)) {
|
|
206
|
+
const trimmedSegment = segment.trim();
|
|
207
|
+
if (GH_COMMAND_RE.test(trimmedSegment) && !trimmedSegment.startsWith('git ')) {
|
|
208
|
+
this.processGhCommand(trimmedSegment, record.timestamp);
|
|
209
|
+
}
|
|
143
210
|
}
|
|
144
211
|
if (!GIT_COMMAND_RE.test(command))
|
|
145
212
|
return;
|
|
146
213
|
const event = this.classifyGitCommand(command, record);
|
|
214
|
+
// A hook-observed commit whose timestamp `hydrateGitLog()` already saw
|
|
215
|
+
// (via `git log`) is the same commit, not a new one — hydrateGitLog()
|
|
216
|
+
// can't dedupe this itself since a live event's `command` is the raw
|
|
217
|
+
// shell string, not `git commit (<hash>)`, so it never matches its own
|
|
218
|
+
// hash-based check. Without this, a day-boundary hydration followed by
|
|
219
|
+
// this same commit's hook event arriving from the same drain batch
|
|
220
|
+
// would count it twice.
|
|
221
|
+
if (event.type === 'commit' && event.timestamp <= this.hydratedThroughMs)
|
|
222
|
+
return;
|
|
147
223
|
this.events.push(event);
|
|
148
224
|
this.processEvent(event, command, record);
|
|
149
225
|
}
|
|
@@ -181,14 +257,29 @@ export class GitEfficiencyTracker {
|
|
|
181
257
|
const merged = this.prEvents.filter((e) => e.action === 'merge').length;
|
|
182
258
|
const checksViewed = this.prEvents.filter((e) => e.action === 'checks').length;
|
|
183
259
|
const prsUpdated = this.prEvents.filter((e) => e.action === 'edit' || e.action === 'ready').length;
|
|
184
|
-
// Time from
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
260
|
+
// Time from each PR's most recent preceding commit to its `gh pr
|
|
261
|
+
// create`, averaged across every PR opened this session — not a single
|
|
262
|
+
// delta anchored to whichever commit happened to be the very first one
|
|
263
|
+
// this tracker ever saw, which would go stale after the first PR and
|
|
264
|
+
// ignore every PR opened later in the same session.
|
|
265
|
+
const sortedCommitTimestamps = [...this.commitTimestamps].sort((a, b) => a - b);
|
|
266
|
+
const timesToCreate = [];
|
|
267
|
+
for (const prEvent of this.prEvents) {
|
|
268
|
+
if (prEvent.action !== 'create')
|
|
269
|
+
continue;
|
|
270
|
+
let precedingCommitTimestamp = null;
|
|
271
|
+
for (const commitTimestamp of sortedCommitTimestamps) {
|
|
272
|
+
if (commitTimestamp > prEvent.timestamp)
|
|
273
|
+
break;
|
|
274
|
+
precedingCommitTimestamp = commitTimestamp;
|
|
275
|
+
}
|
|
276
|
+
if (precedingCommitTimestamp !== null) {
|
|
277
|
+
timesToCreate.push(Math.max(0, prEvent.timestamp - precedingCommitTimestamp));
|
|
190
278
|
}
|
|
191
279
|
}
|
|
280
|
+
const avgTimeToCreateMs = timesToCreate.length > 0
|
|
281
|
+
? timesToCreate.reduce((a, b) => a + b, 0) / timesToCreate.length
|
|
282
|
+
: null;
|
|
192
283
|
return {
|
|
193
284
|
created,
|
|
194
285
|
merged,
|
|
@@ -209,17 +300,57 @@ export class GitEfficiencyTracker {
|
|
|
209
300
|
success: true,
|
|
210
301
|
durationMs: null,
|
|
211
302
|
};
|
|
212
|
-
// Only add if we don't already have this commit tracked
|
|
213
|
-
|
|
303
|
+
// Only add if we don't already have this commit tracked. Against an
|
|
304
|
+
// existing hydrated event (one carrying its own real hash), match by
|
|
305
|
+
// exact hash equality — precise, and avoids collapsing two genuinely
|
|
306
|
+
// distinct commits that just happen to land within the proximity
|
|
307
|
+
// window. Against an existing hook-observed event, fall back to
|
|
308
|
+
// timestamp proximity: a prior session's hook-observed `commit` event,
|
|
309
|
+
// replayed via replayTimeline() before this method ever runs, has no
|
|
310
|
+
// hash in its command text at all, so a hash match would never catch
|
|
311
|
+
// it and every restart would double-count that commit.
|
|
312
|
+
const isDuplicate = this.events.some((e) => {
|
|
313
|
+
if (e.type !== 'commit')
|
|
314
|
+
return false;
|
|
315
|
+
const existingHash = e.command ? HYDRATED_COMMIT_HASH_RE.exec(e.command)?.[1] : undefined;
|
|
316
|
+
if (existingHash !== undefined) {
|
|
317
|
+
return existingHash === commit.hash;
|
|
318
|
+
}
|
|
319
|
+
return Math.abs(e.timestamp - commit.timestamp) < COMMIT_DEDUP_WINDOW_MS;
|
|
320
|
+
});
|
|
214
321
|
if (!isDuplicate) {
|
|
215
322
|
this.events.push(event);
|
|
216
323
|
this.commitTimestamps.push(commit.timestamp);
|
|
217
324
|
// Don't increment commitsSinceLastSync for historical commits — this counter
|
|
218
325
|
// tracks real-time session activity, not replayed history.
|
|
219
326
|
}
|
|
327
|
+
// Advance the watermark even for a commit that was already tracked —
|
|
328
|
+
// either way, `git log` has now vouched for everything up to here, so
|
|
329
|
+
// recordToolCall() must not add it again when the hook-observed event
|
|
330
|
+
// for it (still queued in the same drain batch) gets processed.
|
|
331
|
+
if (commit.timestamp > this.hydratedThroughMs) {
|
|
332
|
+
this.hydratedThroughMs = commit.timestamp;
|
|
333
|
+
}
|
|
220
334
|
}
|
|
221
335
|
}
|
|
222
|
-
|
|
336
|
+
/**
|
|
337
|
+
* @param repoName The replayed session's own repo (from `SessionSummary.repoName`).
|
|
338
|
+
* When both this and the tracker's own `repoContext.repoName` (set via
|
|
339
|
+
* `hydrateRepoContext()`) are known and they don't match, the whole
|
|
340
|
+
* timeline is skipped — otherwise a session worked on in a
|
|
341
|
+
* different repo earlier today would have its commits/conflicts/force-
|
|
342
|
+
* pushes counted against whichever repo this process's header currently
|
|
343
|
+
* names. When either side is unknown (null/undefined — e.g. no git
|
|
344
|
+
* remote configured, or `repoContext` not hydrated yet), the timeline is
|
|
345
|
+
* replayed unfiltered rather than risk dropping legitimate same-repo
|
|
346
|
+
* history.
|
|
347
|
+
*/
|
|
348
|
+
replayTimeline(entries, repoName) {
|
|
349
|
+
if (repoName != null &&
|
|
350
|
+
this.repoContext.repoName != null &&
|
|
351
|
+
repoName !== this.repoContext.repoName) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
223
354
|
for (const entry of entries) {
|
|
224
355
|
const syntheticRecord = {
|
|
225
356
|
id: `replay-${entry.timestamp}`,
|
|
@@ -250,8 +381,27 @@ export class GitEfficiencyTracker {
|
|
|
250
381
|
const pushCount = this.events.filter((e) => e.type === 'push' || e.type === 'force_push' || e.type === 'force_push_lease').length;
|
|
251
382
|
const commitCount = this.events.filter((e) => e.type === 'commit').length;
|
|
252
383
|
const branchOperations = this.events.filter((e) => e.type === 'branch').length;
|
|
253
|
-
|
|
254
|
-
|
|
384
|
+
// A conflict that's currently open (mid-merge, not yet aborted or
|
|
385
|
+
// resolved) is counted in the mergeConflicts/rebaseConflicts KPI above,
|
|
386
|
+
// but this.conflictRecords only gains an entry once it's aborted or
|
|
387
|
+
// resolved — so it would otherwise never enter the resolution-rate
|
|
388
|
+
// denominator, letting a session with one resolved and one still-open
|
|
389
|
+
// conflict show a "perfect" 100% rate. Synthesizing (not persisting) a
|
|
390
|
+
// 'pending' record per still-queued conflict keeps the denominator
|
|
391
|
+
// honest without ever double-counting once that conflict does resolve —
|
|
392
|
+
// at that point it leaves the queue and gets a real entry instead.
|
|
393
|
+
const allConflictRecords = [
|
|
394
|
+
...this.conflictRecords,
|
|
395
|
+
...this.pendingConflicts.map((p) => ({
|
|
396
|
+
timestamp: p.timestamp,
|
|
397
|
+
resolution: 'pending',
|
|
398
|
+
resolutionTimeMs: null,
|
|
399
|
+
command: p.command,
|
|
400
|
+
files: p.files,
|
|
401
|
+
})),
|
|
402
|
+
];
|
|
403
|
+
const resolved = allConflictRecords.filter((c) => c.resolution === 'resolved');
|
|
404
|
+
const conflictResolutionRate = allConflictRecords.length > 0 ? resolved.length / allConflictRecords.length : null;
|
|
255
405
|
const resolutionTimes = resolved
|
|
256
406
|
.filter((c) => c.resolutionTimeMs !== null)
|
|
257
407
|
.map((c) => c.resolutionTimeMs);
|
|
@@ -309,10 +459,21 @@ export class GitEfficiencyTracker {
|
|
|
309
459
|
conflictResolutionRate,
|
|
310
460
|
avgConflictResolutionMs,
|
|
311
461
|
staleBranchPulls,
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
462
|
+
// this.events/this.conflictRecords are append-only in processing
|
|
463
|
+
// order, not timestamp order — parallel tool calls within a session,
|
|
464
|
+
// or multi-session buffer draining, can push an earlier-timestamped
|
|
465
|
+
// event after a later one. Sort by timestamp ascending (oldest-first)
|
|
466
|
+
// before exposing, so gitCommandTimeline's consumer
|
|
467
|
+
// (GitEfficiency.tsx's `[...timeline].reverse().slice(0, 30)`) picks
|
|
468
|
+
// the true newest 30, not whichever 30 happened to be pushed last.
|
|
469
|
+
gitCommandTimeline: [...this.events].sort((a, b) => a.timestamp - b.timestamp).slice(-50),
|
|
470
|
+
conflictHistory: [...allConflictRecords].sort((a, b) => a.timestamp - b.timestamp),
|
|
471
|
+
// Both arrays render in whatever order the checks above happen to
|
|
472
|
+
// .push() in — fixed source-code order, not severity order — so a
|
|
473
|
+
// critical-severity item could render below a milder one. Sort by
|
|
474
|
+
// severity (most severe first) before exposing.
|
|
475
|
+
suggestions: [...suggestions].sort((a, b) => SUGGESTION_SEVERITY_RANK[a.severity] - SUGGESTION_SEVERITY_RANK[b.severity]),
|
|
476
|
+
bestPractices: [...bestPractices].sort((a, b) => BEST_PRACTICE_STATUS_RANK[a.status] - BEST_PRACTICE_STATUS_RANK[b.status]),
|
|
316
477
|
preventionScore,
|
|
317
478
|
efficiencyScore,
|
|
318
479
|
riskIndicators,
|
|
@@ -325,9 +486,7 @@ export class GitEfficiencyTracker {
|
|
|
325
486
|
reset(_sessionId) {
|
|
326
487
|
this.events = [];
|
|
327
488
|
this.conflictRecords = [];
|
|
328
|
-
this.
|
|
329
|
-
this.pendingConflictCommand = '';
|
|
330
|
-
this.pendingConflictFiles = [];
|
|
489
|
+
this.pendingConflicts = [];
|
|
331
490
|
this.lastSyncTimestamp = null;
|
|
332
491
|
this.pullsSinceLastConflict = 0;
|
|
333
492
|
this.consecutiveFailedPushes = 0;
|
|
@@ -343,9 +502,13 @@ export class GitEfficiencyTracker {
|
|
|
343
502
|
this.editedFiles.clear();
|
|
344
503
|
this.hasUsedWorktree = false;
|
|
345
504
|
this.hasUsedForceWithLease = false;
|
|
505
|
+
this.hasUsedBareForcePush = false;
|
|
506
|
+
this.bareForcePushCount = 0;
|
|
507
|
+
this.hasForcePushedToDefaultBranch = false;
|
|
346
508
|
this.totalToolCalls = 0;
|
|
347
509
|
this.sessionStartTimestamp = null;
|
|
348
510
|
this.commitTimestamps = [];
|
|
511
|
+
this.hydratedThroughMs = 0;
|
|
349
512
|
this.worktreeCommands = 0;
|
|
350
513
|
this.oursCount = 0;
|
|
351
514
|
this.theirsCount = 0;
|
|
@@ -357,7 +520,6 @@ export class GitEfficiencyTracker {
|
|
|
357
520
|
this.commitsBehindMain = null;
|
|
358
521
|
this.quickConflictResolutions = 0;
|
|
359
522
|
this.prEvents = [];
|
|
360
|
-
this.firstCommitTimestamp = null;
|
|
361
523
|
this.repoContext = { repoName: null, branch: null, remoteName: null, defaultBranch: null };
|
|
362
524
|
}
|
|
363
525
|
// -------------------------------------------------------------------------
|
|
@@ -425,18 +587,26 @@ export class GitEfficiencyTracker {
|
|
|
425
587
|
return { ...base, type: 'other_git' };
|
|
426
588
|
}
|
|
427
589
|
processEvent(event, command, record) {
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
590
|
+
// Attribute ours/theirs/cherry-pick resolution strategy to the oldest
|
|
591
|
+
// still-open conflict, not to every matching command — a multi-file
|
|
592
|
+
// conflict resolved with one `--ours` per file must count once toward
|
|
593
|
+
// that conflict, not once per file, and a strategy command run with no
|
|
594
|
+
// conflict pending isn't resolving anything. `--abort` also matches the
|
|
595
|
+
// bare cherry-pick pattern, so it's excluded explicitly — aborting isn't
|
|
596
|
+
// a resolution strategy.
|
|
597
|
+
const oldestPending = this.pendingConflicts[0];
|
|
598
|
+
if (oldestPending) {
|
|
599
|
+
if (GIT_CHECKOUT_OURS_RE.test(command))
|
|
600
|
+
oldestPending.usedOurs = true;
|
|
601
|
+
if (GIT_CHECKOUT_THEIRS_RE.test(command))
|
|
602
|
+
oldestPending.usedTheirs = true;
|
|
603
|
+
if (GIT_CHERRY_PICK_RE.test(command) && !CHERRY_PICK_ABORT_RE.test(command)) {
|
|
604
|
+
oldestPending.usedCherryPick = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
435
607
|
switch (event.type) {
|
|
436
608
|
case 'merge_conflict':
|
|
437
609
|
case 'rebase_conflict': {
|
|
438
|
-
this.pendingConflictTimestamp = event.timestamp;
|
|
439
|
-
this.pendingConflictCommand = command;
|
|
440
610
|
const output = record.error ?? '';
|
|
441
611
|
const files = [];
|
|
442
612
|
let match;
|
|
@@ -445,56 +615,64 @@ export class GitEfficiencyTracker {
|
|
|
445
615
|
files.push(match[1].trim());
|
|
446
616
|
this.conflictedFiles.add(match[1].trim());
|
|
447
617
|
}
|
|
448
|
-
this.
|
|
618
|
+
this.pendingConflicts.push({
|
|
619
|
+
timestamp: event.timestamp,
|
|
620
|
+
command,
|
|
621
|
+
files,
|
|
622
|
+
usedOurs: false,
|
|
623
|
+
usedTheirs: false,
|
|
624
|
+
usedCherryPick: false,
|
|
625
|
+
});
|
|
449
626
|
this.pullsSinceLastConflict = 0;
|
|
450
627
|
break;
|
|
451
628
|
}
|
|
452
629
|
case 'merge_abort':
|
|
453
630
|
case 'rebase_abort':
|
|
454
|
-
case 'cherry_pick_abort':
|
|
455
|
-
|
|
631
|
+
case 'cherry_pick_abort': {
|
|
632
|
+
const pending = this.pendingConflicts.shift();
|
|
633
|
+
if (pending) {
|
|
456
634
|
this.conflictRecords.push({
|
|
457
|
-
timestamp:
|
|
635
|
+
timestamp: pending.timestamp,
|
|
458
636
|
resolution: 'aborted',
|
|
459
|
-
resolutionTimeMs: event.timestamp -
|
|
460
|
-
command:
|
|
461
|
-
files:
|
|
637
|
+
resolutionTimeMs: event.timestamp - pending.timestamp,
|
|
638
|
+
command: pending.command,
|
|
639
|
+
files: pending.files,
|
|
462
640
|
});
|
|
463
|
-
this.pendingConflictTimestamp = null;
|
|
464
|
-
this.pendingConflictCommand = '';
|
|
465
|
-
this.pendingConflictFiles = [];
|
|
466
641
|
}
|
|
467
642
|
break;
|
|
643
|
+
}
|
|
468
644
|
case 'commit': {
|
|
469
645
|
// git commit --amend fixes a prior commit, not a merge conflict.
|
|
470
|
-
//
|
|
471
|
-
//
|
|
646
|
+
// Drop the oldest pending conflict on amend (without recording a
|
|
647
|
+
// resolution) so a later, unrelated commit doesn't retroactively
|
|
648
|
+
// "resolve" it.
|
|
472
649
|
if (command.includes('--amend')) {
|
|
473
|
-
this.
|
|
474
|
-
this.pendingConflictCommand = '';
|
|
475
|
-
this.pendingConflictFiles = [];
|
|
650
|
+
this.pendingConflicts.shift();
|
|
476
651
|
}
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
timestamp
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
652
|
+
else {
|
|
653
|
+
const pending = this.pendingConflicts.shift();
|
|
654
|
+
if (pending) {
|
|
655
|
+
const resolutionMs = event.timestamp - pending.timestamp;
|
|
656
|
+
this.conflictRecords.push({
|
|
657
|
+
timestamp: pending.timestamp,
|
|
658
|
+
resolution: 'resolved',
|
|
659
|
+
resolutionTimeMs: resolutionMs,
|
|
660
|
+
command: pending.command,
|
|
661
|
+
files: pending.files,
|
|
662
|
+
});
|
|
663
|
+
if (pending.usedOurs)
|
|
664
|
+
this.oursCount++;
|
|
665
|
+
if (pending.usedTheirs)
|
|
666
|
+
this.theirsCount++;
|
|
667
|
+
if (pending.usedCherryPick)
|
|
668
|
+
this.cherryPickCount++;
|
|
669
|
+
// Under 30s resolution with multiple conflicted files is suspiciously fast
|
|
670
|
+
if (resolutionMs < 30_000 && pending.files.length > 1) {
|
|
671
|
+
this.quickConflictResolutions++;
|
|
672
|
+
}
|
|
489
673
|
}
|
|
490
|
-
this.pendingConflictTimestamp = null;
|
|
491
|
-
this.pendingConflictCommand = '';
|
|
492
|
-
this.pendingConflictFiles = [];
|
|
493
674
|
}
|
|
494
675
|
this.commitTimestamps.push(event.timestamp);
|
|
495
|
-
if (this.firstCommitTimestamp === null) {
|
|
496
|
-
this.firstCommitTimestamp = event.timestamp;
|
|
497
|
-
}
|
|
498
676
|
this.commitsSinceLastSync++;
|
|
499
677
|
this.statusChecksSinceLastAction = 0;
|
|
500
678
|
break;
|
|
@@ -535,6 +713,13 @@ export class GitEfficiencyTracker {
|
|
|
535
713
|
this.statusChecksSinceLastAction = 0;
|
|
536
714
|
break;
|
|
537
715
|
case 'force_push':
|
|
716
|
+
this.hasUsedBareForcePush = true;
|
|
717
|
+
this.bareForcePushCount++;
|
|
718
|
+
if (this.repoContext.branch !== null &&
|
|
719
|
+
this.repoContext.defaultBranch !== null &&
|
|
720
|
+
this.repoContext.branch === this.repoContext.defaultBranch) {
|
|
721
|
+
this.hasForcePushedToDefaultBranch = true;
|
|
722
|
+
}
|
|
538
723
|
if (this.lastPushRejectedTimestamp !== null &&
|
|
539
724
|
event.timestamp - this.lastPushRejectedTimestamp < 300_000) {
|
|
540
725
|
this.forceAfterReject++;
|
|
@@ -566,8 +751,14 @@ export class GitEfficiencyTracker {
|
|
|
566
751
|
this.statusChecksSinceLastAction = 0;
|
|
567
752
|
break;
|
|
568
753
|
case 'worktree':
|
|
569
|
-
|
|
570
|
-
|
|
754
|
+
// Both the "worktree ops" count and the usesWorktrees/use_worktrees
|
|
755
|
+
// signal are meant to reflect real worktree usage, not read-only
|
|
756
|
+
// inspection — `list`/`prune`/`lock`/etc. shouldn't count as evidence
|
|
757
|
+
// that worktrees were used to isolate parallel work.
|
|
758
|
+
if (GIT_WORKTREE_ADD_REMOVE_RE.test(command)) {
|
|
759
|
+
this.worktreeCommands++;
|
|
760
|
+
this.hasUsedWorktree = true;
|
|
761
|
+
}
|
|
571
762
|
break;
|
|
572
763
|
case 'status':
|
|
573
764
|
this.statusChecksSinceLastAction++;
|
|
@@ -577,18 +768,24 @@ export class GitEfficiencyTracker {
|
|
|
577
768
|
break;
|
|
578
769
|
}
|
|
579
770
|
}
|
|
580
|
-
// A pull
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
771
|
+
// A pull that diverged enough to conflict is the strongest signal of a
|
|
772
|
+
// stale branch — checked directly off the conflicting event's own command
|
|
773
|
+
// (a `git pull` whose own output contains a conflict indicator classifies
|
|
774
|
+
// as merge_conflict/rebase_conflict, never as 'pull', so this can't rely
|
|
775
|
+
// on the event's `type`). A pull immediately followed by a *separate*
|
|
776
|
+
// command that then conflicts is a weaker but still real signal, kept as a
|
|
777
|
+
// fallback for events that don't match the direct case.
|
|
584
778
|
countStaleBranchPulls() {
|
|
585
779
|
let staleCount = 0;
|
|
586
|
-
for (let i = 0; i < this.events.length
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
780
|
+
for (let i = 0; i < this.events.length; i++) {
|
|
781
|
+
const event = this.events[i];
|
|
782
|
+
if (event.type !== 'merge_conflict' && event.type !== 'rebase_conflict')
|
|
783
|
+
continue;
|
|
784
|
+
if (GIT_PULL_RE.test(event.command ?? '')) {
|
|
785
|
+
staleCount++;
|
|
786
|
+
}
|
|
787
|
+
else if (i > 0 && this.events[i - 1].type === 'pull') {
|
|
788
|
+
staleCount++;
|
|
592
789
|
}
|
|
593
790
|
}
|
|
594
791
|
return staleCount;
|
|
@@ -739,7 +936,12 @@ export class GitEfficiencyTracker {
|
|
|
739
936
|
detail: 'No worktree usage detected. Consider worktrees if you run parallel sessions.',
|
|
740
937
|
});
|
|
741
938
|
}
|
|
742
|
-
// 5. Use --force-with-lease instead of --force
|
|
939
|
+
// 5. Use --force-with-lease instead of --force. Gated on
|
|
940
|
+
// hasUsedBareForcePush rather than forcePushes/usesForceWithLease alone —
|
|
941
|
+
// those two count safe and unsafe force-pushes together, so checking
|
|
942
|
+
// usesForceWithLease alone would let one safe `--force-with-lease` mask a
|
|
943
|
+
// dangerous bare `--force` in the same session with a fully-passing
|
|
944
|
+
// status.
|
|
743
945
|
if (stats.forcePushes === 0) {
|
|
744
946
|
practices.push({
|
|
745
947
|
id: 'force_with_lease',
|
|
@@ -748,15 +950,15 @@ export class GitEfficiencyTracker {
|
|
|
748
950
|
detail: 'No force pushes yet.',
|
|
749
951
|
});
|
|
750
952
|
}
|
|
751
|
-
else if (risk.usesForceWithLease) {
|
|
953
|
+
else if (this.hasUsedBareForcePush && risk.usesForceWithLease) {
|
|
752
954
|
practices.push({
|
|
753
955
|
id: 'force_with_lease',
|
|
754
956
|
label: 'Use --force-with-lease',
|
|
755
|
-
status: '
|
|
756
|
-
detail:
|
|
957
|
+
status: 'warn',
|
|
958
|
+
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.',
|
|
757
959
|
});
|
|
758
960
|
}
|
|
759
|
-
else {
|
|
961
|
+
else if (this.hasUsedBareForcePush) {
|
|
760
962
|
practices.push({
|
|
761
963
|
id: 'force_with_lease',
|
|
762
964
|
label: 'Use --force-with-lease',
|
|
@@ -764,24 +966,15 @@ export class GitEfficiencyTracker {
|
|
|
764
966
|
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.',
|
|
765
967
|
});
|
|
766
968
|
}
|
|
767
|
-
|
|
768
|
-
if (risk.forceAfterReject > 0) {
|
|
769
|
-
practices.push({
|
|
770
|
-
id: 'no_force_after_reject',
|
|
771
|
-
label: "Don't force-push after rejection",
|
|
772
|
-
status: 'fail',
|
|
773
|
-
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.`,
|
|
774
|
-
});
|
|
775
|
-
}
|
|
776
|
-
else if (risk.pushRejections > 0) {
|
|
969
|
+
else {
|
|
777
970
|
practices.push({
|
|
778
|
-
id: '
|
|
779
|
-
label:
|
|
971
|
+
id: 'force_with_lease',
|
|
972
|
+
label: 'Use --force-with-lease',
|
|
780
973
|
status: 'pass',
|
|
781
|
-
detail:
|
|
974
|
+
detail: "Good — using --force-with-lease which refuses to overwrite remote commits you haven't seen.",
|
|
782
975
|
});
|
|
783
976
|
}
|
|
784
|
-
//
|
|
977
|
+
// 6. Keep PRs small (proxy: many commits without pushing)
|
|
785
978
|
if (risk.commitsSinceLastSync > 15) {
|
|
786
979
|
practices.push({
|
|
787
980
|
id: 'small_increments',
|
|
@@ -798,7 +991,7 @@ export class GitEfficiencyTracker {
|
|
|
798
991
|
detail: 'Good — committing and syncing in small batches.',
|
|
799
992
|
});
|
|
800
993
|
}
|
|
801
|
-
//
|
|
994
|
+
// 7. Avoid editing hot files
|
|
802
995
|
if (risk.hotFiles.length > 0) {
|
|
803
996
|
practices.push({
|
|
804
997
|
id: 'avoid_hot_files',
|
|
@@ -807,7 +1000,7 @@ export class GitEfficiencyTracker {
|
|
|
807
1000
|
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.`,
|
|
808
1001
|
});
|
|
809
1002
|
}
|
|
810
|
-
//
|
|
1003
|
+
// 8. Build/test before pushing
|
|
811
1004
|
if (this.buildBeforePush === null && this.lastPushTimestamp === null) {
|
|
812
1005
|
practices.push({
|
|
813
1006
|
id: 'verify_before_push',
|
|
@@ -852,22 +1045,14 @@ export class GitEfficiencyTracker {
|
|
|
852
1045
|
generateSuggestions(stats) {
|
|
853
1046
|
const suggestions = [];
|
|
854
1047
|
// --- Proactive prevention suggestions (fire BEFORE conflicts happen) ---
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
if (stats.riskIndicators.commitsSinceLastSync > 8) {
|
|
864
|
-
suggestions.push({
|
|
865
|
-
severity: 'warning',
|
|
866
|
-
category: 'drift_risk',
|
|
867
|
-
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.`,
|
|
868
|
-
evidence: `${stats.riskIndicators.commitsSinceLastSync} commits since last pull/fetch/rebase`,
|
|
869
|
-
});
|
|
870
|
-
}
|
|
1048
|
+
//
|
|
1049
|
+
// syncedBeforeEditing, commitsSinceLastSync (drift), and hotFiles are
|
|
1050
|
+
// ongoing-state conditions already surfaced as their own Best Practice
|
|
1051
|
+
// checks (sync_before_edit, frequent_sync, avoid_hot_files) — duplicating
|
|
1052
|
+
// them here as one-off suggestions rendered the same fact twice, with a
|
|
1053
|
+
// conflicting severity in the hot-files case. Best Practices is their
|
|
1054
|
+
// canonical home; only force-after-reject (a one-time, reactive event
|
|
1055
|
+
// with actionable remediation) is kept as a suggestion.
|
|
871
1056
|
if (stats.riskIndicators.forceAfterReject > 0) {
|
|
872
1057
|
suggestions.push({
|
|
873
1058
|
severity: 'critical',
|
|
@@ -876,14 +1061,6 @@ export class GitEfficiencyTracker {
|
|
|
876
1061
|
evidence: `${stats.riskIndicators.forceAfterReject} force push(es) within 5 min of a rejection`,
|
|
877
1062
|
});
|
|
878
1063
|
}
|
|
879
|
-
if (stats.riskIndicators.hotFiles.length > 0) {
|
|
880
|
-
suggestions.push({
|
|
881
|
-
severity: 'info',
|
|
882
|
-
category: 'hot_files',
|
|
883
|
-
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.`,
|
|
884
|
-
evidence: `${stats.riskIndicators.hotFiles.length} previously-conflicted file(s) re-edited`,
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
1064
|
// --- Reactive suggestions (fire after problems occur) ---
|
|
888
1065
|
if (stats.mergeConflicts + stats.rebaseConflicts >= 3) {
|
|
889
1066
|
suggestions.push({
|
|
@@ -909,20 +1086,40 @@ export class GitEfficiencyTracker {
|
|
|
909
1086
|
evidence: `${stats.abortedOperations} aborted operations`,
|
|
910
1087
|
});
|
|
911
1088
|
}
|
|
912
|
-
|
|
1089
|
+
// Severity is gated on hasUsedBareForcePush/bareForcePushCount — the same
|
|
1090
|
+
// shared signal the force_with_lease best-practice check uses — rather
|
|
1091
|
+
// than the raw forcePushes count, which sums bare AND lease-protected
|
|
1092
|
+
// pushes together. Gating (and scaling) on the bare-only count keeps this
|
|
1093
|
+
// suggestion's ranking consistent with the best-practice check: adding a
|
|
1094
|
+
// safe --force-with-lease push on top of an existing bare push must never
|
|
1095
|
+
// raise severity, since only the bare push is actually risky.
|
|
1096
|
+
//
|
|
1097
|
+
// A bare push to the shared default branch is escalated to 'critical'
|
|
1098
|
+
// outright, regardless of count — it can clobber other collaborators'
|
|
1099
|
+
// work, unlike an identical push to a personal feature branch (which
|
|
1100
|
+
// still scales by count, as above).
|
|
1101
|
+
if (this.hasUsedBareForcePush) {
|
|
913
1102
|
suggestions.push({
|
|
914
|
-
severity:
|
|
1103
|
+
severity: this.hasForcePushedToDefaultBranch
|
|
1104
|
+
? 'critical'
|
|
1105
|
+
: this.bareForcePushCount >= 2
|
|
1106
|
+
? 'critical'
|
|
1107
|
+
: 'warning',
|
|
915
1108
|
category: 'force_push',
|
|
916
|
-
message:
|
|
917
|
-
|
|
1109
|
+
message: this.hasForcePushedToDefaultBranch
|
|
1110
|
+
? `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.`
|
|
1111
|
+
: '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.',
|
|
1112
|
+
evidence: this.hasForcePushedToDefaultBranch
|
|
1113
|
+
? `${this.bareForcePushCount} bare --force push(es) this session, including at least one on the default branch`
|
|
1114
|
+
: `${this.bareForcePushCount} bare --force push(es) this session`,
|
|
918
1115
|
});
|
|
919
1116
|
}
|
|
920
|
-
else if (stats.forcePushes
|
|
1117
|
+
else if (stats.riskIndicators.usesForceWithLease && stats.forcePushes >= 2) {
|
|
921
1118
|
suggestions.push({
|
|
922
1119
|
severity: 'info',
|
|
923
1120
|
category: 'force_push',
|
|
924
|
-
message:
|
|
925
|
-
evidence:
|
|
1121
|
+
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.',
|
|
1122
|
+
evidence: `${stats.forcePushes} lease-protected force pushes this session`,
|
|
926
1123
|
});
|
|
927
1124
|
}
|
|
928
1125
|
if (stats.resetHards >= 2) {
|
|
@@ -1076,15 +1273,16 @@ export class GitEfficiencyTracker {
|
|
|
1076
1273
|
};
|
|
1077
1274
|
}
|
|
1078
1275
|
computeConflictStrategy() {
|
|
1079
|
-
const manualMergeCount = this.conflictRecords.filter((c) => c.resolution === 'resolved').length -
|
|
1276
|
+
const manualMergeCount = Math.max(0, this.conflictRecords.filter((c) => c.resolution === 'resolved').length -
|
|
1080
1277
|
this.oursCount -
|
|
1081
|
-
this.theirsCount
|
|
1278
|
+
this.theirsCount -
|
|
1279
|
+
this.cherryPickCount);
|
|
1082
1280
|
return {
|
|
1083
1281
|
oursCount: this.oursCount,
|
|
1084
1282
|
theirsCount: this.theirsCount,
|
|
1085
|
-
manualMergeCount
|
|
1283
|
+
manualMergeCount,
|
|
1086
1284
|
cherryPickCount: this.cherryPickCount,
|
|
1087
|
-
totalResolutions: this.oursCount + this.theirsCount +
|
|
1285
|
+
totalResolutions: this.oursCount + this.theirsCount + this.cherryPickCount + manualMergeCount,
|
|
1088
1286
|
};
|
|
1089
1287
|
}
|
|
1090
1288
|
}
|