buddy-workbench 0.2.10 → 0.2.12
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/package.json +1 -1
- package/server/routes/branch-health.js +117 -41
- package/server/routes/overview.js +6 -0
- package/ui/dist/assets/index-CxerYHRg.css +1 -0
- package/ui/dist/assets/index-DT0lQTey.js +623 -0
- package/ui/dist/assets/{xmindAdapter-BgI7SsNo.js → xmindAdapter-BxzU4Y3W.js} +1 -1
- package/ui/dist/index.html +2 -2
- package/ui/dist/sw.js +2 -2
- package/ui/dist/assets/index-BdK43fGD.css +0 -1
- package/ui/dist/assets/index-RvymXcJp.js +0 -623
package/package.json
CHANGED
|
@@ -8,13 +8,14 @@ import { addBranchHealthHistory, listBranchHealthHistory } from '../repositories
|
|
|
8
8
|
const router = Router();
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const GIT_TIMEOUT = 30 * 1000;
|
|
11
|
+
const COMPLEX_ANALYSIS_MAX_AGE_DAYS = 365;
|
|
11
12
|
|
|
12
13
|
async function git(folder, args, options = {}) {
|
|
13
14
|
try {
|
|
14
15
|
const result = await execFileAsync('git', args, {
|
|
15
16
|
cwd: folder,
|
|
16
17
|
timeout: GIT_TIMEOUT,
|
|
17
|
-
maxBuffer: 4 * 1024 * 1024
|
|
18
|
+
maxBuffer: options.maxBuffer || 4 * 1024 * 1024
|
|
18
19
|
});
|
|
19
20
|
return result.stdout.trim();
|
|
20
21
|
} catch (error) {
|
|
@@ -49,9 +50,8 @@ function workspaceRepositoryCandidates(workspaceDir) {
|
|
|
49
50
|
|
|
50
51
|
function parseRefList(raw) {
|
|
51
52
|
return raw.split(/\r?\n/).filter(Boolean).map((line) => {
|
|
52
|
-
const
|
|
53
|
-
const ref =
|
|
54
|
-
const hash = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1).trim();
|
|
53
|
+
const refFields = line.split('\t', 1)[0].trim().split(/\s+/);
|
|
54
|
+
const [ref, hash] = refFields;
|
|
55
55
|
if (!ref || !hash) return null;
|
|
56
56
|
const remote = ref.startsWith('refs/remotes/');
|
|
57
57
|
const remoteMatch = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
|
|
@@ -75,13 +75,61 @@ function parseCommit(raw) {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
function parseCommitMetadata(raw) {
|
|
79
|
+
const commits = new Map();
|
|
80
|
+
raw.split(/\r?\n/).filter(Boolean).forEach((line) => {
|
|
81
|
+
const separatorIndex = line.indexOf('\t');
|
|
82
|
+
if (separatorIndex < 0) return;
|
|
83
|
+
const metadata = parseCommit(line.slice(separatorIndex + 1));
|
|
84
|
+
if (metadata?.hash) commits.set(metadata.hash, metadata);
|
|
85
|
+
});
|
|
86
|
+
return commits;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseCommitGraph(raw) {
|
|
90
|
+
const parentsByCommit = new Map();
|
|
91
|
+
const childrenByCommit = new Map();
|
|
92
|
+
const commitsByHash = new Map();
|
|
93
|
+
raw.split(/\r?\n/).filter(Boolean).forEach((line) => {
|
|
94
|
+
const fields = line.split('\x1f');
|
|
95
|
+
const commit = fields[0];
|
|
96
|
+
if (!commit) return;
|
|
97
|
+
const parents = (fields[1] || '').split(' ').filter(Boolean);
|
|
98
|
+
parentsByCommit.set(commit, parents);
|
|
99
|
+
const metadata = parseCommit([commit, ...fields.slice(2)].join('\x1f'));
|
|
100
|
+
if (metadata) commitsByHash.set(commit, metadata);
|
|
101
|
+
parents.forEach((parent) => {
|
|
102
|
+
if (!childrenByCommit.has(parent)) childrenByCommit.set(parent, new Set());
|
|
103
|
+
childrenByCommit.get(parent).add(commit);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
return { parentsByCommit, childrenByCommit, commitsByHash };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function reachableCommits(tip, parentsByCommit) {
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const pending = [tip];
|
|
112
|
+
while (pending.length) {
|
|
113
|
+
const commit = pending.pop();
|
|
114
|
+
if (!commit || seen.has(commit)) continue;
|
|
115
|
+
seen.add(commit);
|
|
116
|
+
for (const parent of parentsByCommit.get(commit) || []) pending.push(parent);
|
|
117
|
+
}
|
|
118
|
+
return seen;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isAncestor(ancestor, descendant, childrenByCommit) {
|
|
122
|
+
if (!ancestor || !descendant) return false;
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const pending = [ancestor];
|
|
125
|
+
while (pending.length) {
|
|
126
|
+
const commit = pending.pop();
|
|
127
|
+
if (commit === descendant) return true;
|
|
128
|
+
if (seen.has(commit)) continue;
|
|
129
|
+
seen.add(commit);
|
|
130
|
+
for (const child of childrenByCommit.get(commit) || []) pending.push(child);
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
85
133
|
}
|
|
86
134
|
|
|
87
135
|
function isProtectedBranch(name) {
|
|
@@ -111,19 +159,17 @@ function healthForBranch({ merged, ahead, ageDays, isCurrent, isProtected, hasRe
|
|
|
111
159
|
return { status: 'keep', label: 'Keep', score };
|
|
112
160
|
}
|
|
113
161
|
|
|
114
|
-
async function readBranchDetails(folder, branch, refs, targetRef, currentBranch) {
|
|
162
|
+
async function readBranchDetails(folder, branch, refs, targetRef, currentBranch, commit, creator, branchCount, parentsByCommit, childrenByCommit, branchAncestors, targetAncestors) {
|
|
115
163
|
const ref = branch.localRef || branch.remoteRef;
|
|
116
|
-
const commit = parseCommit(await git(folder, [
|
|
117
|
-
'show', '-s', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%cn%x1f%ce', ref
|
|
118
|
-
], { allowFailure: true }));
|
|
119
164
|
if (!commit) return null;
|
|
120
165
|
|
|
121
|
-
let firstCommit =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
166
|
+
let firstCommit = creator ? { creator } : null;
|
|
167
|
+
let createdAtSource = 'not available from bulk scan';
|
|
168
|
+
const ageDays = dateAgeDays(commit.committedAt || commit.authoredAt);
|
|
169
|
+
const runComplexAnalysis = ageDays === null || ageDays <= COMPLEX_ANALYSIS_MAX_AGE_DAYS;
|
|
170
|
+
if (!runComplexAnalysis) createdAtSource = 'not analyzed (last commit older than 365 days)';
|
|
125
171
|
|
|
126
|
-
if (branch.localRef) {
|
|
172
|
+
if (runComplexAnalysis && branch.localRef) {
|
|
127
173
|
const reflog = await git(folder, ['reflog', 'show', '--format=%ct%x1f%cI%x1f%gs', branch.localRef], { allowFailure: true });
|
|
128
174
|
const earliestEntry = reflog.split(/\r?\n/).filter(Boolean).at(-1);
|
|
129
175
|
if (earliestEntry) {
|
|
@@ -139,27 +185,23 @@ async function readBranchDetails(folder, branch, refs, targetRef, currentBranch)
|
|
|
139
185
|
}
|
|
140
186
|
}
|
|
141
187
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
], { allowFailure: true }));
|
|
146
|
-
const containingNames = [...new Set(containingRefs.map((item) => item.name).filter((name) => name !== branch.name))];
|
|
188
|
+
const containingNames = runComplexAnalysis
|
|
189
|
+
? [...new Set(refs.filter((item) => item.name !== branch.name && isAncestor(commit.hash, item.hash, childrenByCommit)).map((item) => item.name))]
|
|
190
|
+
: [];
|
|
147
191
|
const protectedContaining = containingNames.filter(isProtectedBranch);
|
|
148
192
|
// A child branch containing main's commit is not evidence that main was
|
|
149
193
|
// merged into that child. Only protected/default targets count as merge
|
|
150
194
|
// evidence for cleanup decisions in this local-only phase.
|
|
151
195
|
const mergedInto = protectedContaining[0] || null;
|
|
152
|
-
const merged = Boolean(mergedInto);
|
|
153
|
-
|
|
154
|
-
let ahead = 0;
|
|
155
|
-
let behind = 0;
|
|
156
|
-
if (targetRef && ref !== targetRef) {
|
|
157
|
-
|
|
158
|
-
behind =
|
|
159
|
-
ahead = Number.isFinite(counts[1]) ? counts[1] : 0;
|
|
196
|
+
const merged = runComplexAnalysis ? Boolean(mergedInto) : false;
|
|
197
|
+
|
|
198
|
+
let ahead = runComplexAnalysis ? 0 : null;
|
|
199
|
+
let behind = runComplexAnalysis ? 0 : null;
|
|
200
|
+
if (runComplexAnalysis && targetRef && ref !== targetRef) {
|
|
201
|
+
ahead = [...branchAncestors].filter((hash) => !targetAncestors.has(hash)).length;
|
|
202
|
+
behind = [...targetAncestors].filter((hash) => !branchAncestors.has(hash)).length;
|
|
160
203
|
}
|
|
161
204
|
|
|
162
|
-
const ageDays = dateAgeDays(commit.committedAt || commit.authoredAt);
|
|
163
205
|
const isCurrent = !branch.remote && currentBranch === branch.name;
|
|
164
206
|
const isProtected = isProtectedBranch(branch.name);
|
|
165
207
|
const health = healthForBranch({ merged, ahead, ageDays, isCurrent, isProtected, hasRemote: Boolean(branch.remoteRef) });
|
|
@@ -178,16 +220,17 @@ async function readBranchDetails(folder, branch, refs, targetRef, currentBranch)
|
|
|
178
220
|
lastCommit: commit,
|
|
179
221
|
ageDays,
|
|
180
222
|
commitCount: branchCount,
|
|
223
|
+
analysisSkipped: !runComplexAnalysis,
|
|
181
224
|
ahead,
|
|
182
225
|
behind,
|
|
183
226
|
merged,
|
|
184
227
|
mergedInto,
|
|
185
228
|
mergedIntoAll: protectedContaining,
|
|
186
|
-
mergeSource: merged ? 'local ancestry (inferred)' : 'not found in local refs',
|
|
229
|
+
mergeSource: !runComplexAnalysis ? 'skipped (last commit older than 365 days)' : merged ? 'local ancestry (inferred)' : 'not found in local refs',
|
|
187
230
|
openPullRequest: null,
|
|
188
231
|
health,
|
|
189
232
|
reasons: [
|
|
190
|
-
merged ? `Merged into ${mergedInto}` : 'No containing target branch found',
|
|
233
|
+
!runComplexAnalysis ? 'Complex ancestry analysis skipped for branches inactive over one year' : merged ? `Merged into ${mergedInto}` : 'No containing target branch found',
|
|
191
234
|
ageDays >= 90 ? `${ageDays} days since last commit` : null,
|
|
192
235
|
ahead > 0 ? `${ahead} commits not in the default branch` : null,
|
|
193
236
|
isCurrent ? 'Currently checked out' : null,
|
|
@@ -201,12 +244,22 @@ async function scanRepository(folder) {
|
|
|
201
244
|
const root = resolve(folder);
|
|
202
245
|
if (!existsSync(join(root, '.git'))) throw new Error(`Not a Git repository: ${root}`);
|
|
203
246
|
|
|
204
|
-
const [currentBranch, remoteUrl,
|
|
247
|
+
const [currentBranch, remoteUrl, refsAndMetadataRaw] = await Promise.all([
|
|
205
248
|
git(root, ['branch', '--show-current'], { allowFailure: true }),
|
|
206
249
|
git(root, ['remote', 'get-url', 'origin'], { allowFailure: true }),
|
|
207
|
-
git(root, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes'])
|
|
250
|
+
git(root, ['for-each-ref', '--format=%(refname) %(objectname)%09%(objectname)%1f%(objectname:short)%1f%(subject)%1f%(authorname)%1f%(authoremail:trim)%1f%(authordate:iso-strict)%1f%(committerdate:iso-strict)%1f%(committername)%1f%(committeremail:trim)', 'refs/heads', 'refs/remotes'])
|
|
208
251
|
]);
|
|
209
|
-
const refs = parseRefList(
|
|
252
|
+
const refs = parseRefList(refsAndMetadataRaw);
|
|
253
|
+
const commitMetadata = parseCommitMetadata(refsAndMetadataRaw);
|
|
254
|
+
const graphTips = [...new Set(refs.filter((item) => {
|
|
255
|
+
const commit = commitMetadata.get(item.hash);
|
|
256
|
+
const ageDays = dateAgeDays(commit?.committedAt || commit?.authoredAt);
|
|
257
|
+
return isProtectedBranch(item.name) || ageDays === null || ageDays <= COMPLEX_ANALYSIS_MAX_AGE_DAYS;
|
|
258
|
+
}).map((item) => item.hash))];
|
|
259
|
+
const graphRaw = graphTips.length > 0
|
|
260
|
+
? await git(root, ['log', '--parents', '--format=%H%x1f%P%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%cn%x1f%ce', ...graphTips], { maxBuffer: 128 * 1024 * 1024 })
|
|
261
|
+
: '';
|
|
262
|
+
const { parentsByCommit, childrenByCommit, commitsByHash } = parseCommitGraph(graphRaw);
|
|
210
263
|
|
|
211
264
|
// Remote refs are normalized to their branch name so local and origin/main
|
|
212
265
|
// are presented as one row when both are available.
|
|
@@ -223,9 +276,30 @@ async function scanRepository(folder) {
|
|
|
223
276
|
const targetName = preferredTargets.find((name) => normalized.has(name)) || [...normalized.keys()].find(isProtectedBranch) || null;
|
|
224
277
|
const targetBranch = targetName ? normalized.get(targetName) : null;
|
|
225
278
|
const targetRef = targetBranch?.localRef || targetBranch?.remoteRef || null;
|
|
279
|
+
const targetHash = refs.find((item) => item.ref === targetRef)?.hash;
|
|
280
|
+
const targetAncestors = targetHash ? reachableCommits(targetHash, parentsByCommit) : new Set();
|
|
226
281
|
const details = [];
|
|
227
282
|
for (const branch of normalized.values()) {
|
|
228
|
-
const
|
|
283
|
+
const ref = branch.localRef || branch.remoteRef;
|
|
284
|
+
const tip = refs.find((item) => item.ref === ref)?.hash;
|
|
285
|
+
const commit = commitMetadata.get(tip) || null;
|
|
286
|
+
const ageDays = dateAgeDays(commit?.committedAt || commit?.authoredAt);
|
|
287
|
+
const runComplexAnalysis = ageDays === null || ageDays <= COMPLEX_ANALYSIS_MAX_AGE_DAYS;
|
|
288
|
+
const branchAncestors = runComplexAnalysis ? reachableCommits(tip, parentsByCommit) : null;
|
|
289
|
+
const branchCount = branchAncestors?.size ?? null;
|
|
290
|
+
let creator = null;
|
|
291
|
+
if (branchAncestors) {
|
|
292
|
+
let firstCommitTimestamp = Infinity;
|
|
293
|
+
for (const hash of branchAncestors) {
|
|
294
|
+
const candidate = commitsByHash.get(hash);
|
|
295
|
+
const timestamp = Date.parse(candidate?.authoredAt || '');
|
|
296
|
+
if (candidate && Number.isFinite(timestamp) && timestamp < firstCommitTimestamp) {
|
|
297
|
+
firstCommitTimestamp = timestamp;
|
|
298
|
+
creator = candidate.author;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const detail = await readBranchDetails(root, branch, refs, targetRef, currentBranch, commit, creator, branchCount, parentsByCommit, childrenByCommit, branchAncestors, targetAncestors);
|
|
229
303
|
if (detail) details.push(detail);
|
|
230
304
|
}
|
|
231
305
|
|
|
@@ -237,8 +311,9 @@ async function scanRepository(folder) {
|
|
|
237
311
|
scannedAt: new Date().toISOString(),
|
|
238
312
|
analysis: {
|
|
239
313
|
source: 'local Git',
|
|
314
|
+
complexAnalysisMaxAgeDays: COMPLEX_ANALYSIS_MAX_AGE_DAYS,
|
|
240
315
|
mergeEvidence: 'Local ref ancestry; remote PR history is not queried in this phase.',
|
|
241
|
-
creationEvidence: 'Local reflog
|
|
316
|
+
creationEvidence: 'Local reflog for recently active local branches; older branches skip creation and ancestry analysis.'
|
|
242
317
|
}
|
|
243
318
|
};
|
|
244
319
|
}
|
|
@@ -262,6 +337,7 @@ function deletionChecks(branch) {
|
|
|
262
337
|
if (!branch.hasLocal && !branch.hasRemote) blockers.push('Branch no longer exists in local or origin refs.');
|
|
263
338
|
if (branch.current) blockers.push('The current branch cannot be deleted.');
|
|
264
339
|
if (branch.protected) blockers.push('Protected branch patterns cannot be deleted.');
|
|
340
|
+
if (branch.analysisSkipped) blockers.push('Complex ancestry analysis was skipped for this branch, so deletion cannot be verified.');
|
|
265
341
|
if (!branch.merged) blockers.push('The branch head is not contained in another local ref.');
|
|
266
342
|
if (branch.ahead > 0) blockers.push(`${branch.ahead} commit(s) are not in the default branch.`);
|
|
267
343
|
|
|
@@ -4,8 +4,12 @@ import { Router } from 'express';
|
|
|
4
4
|
import { readSettings } from '../repositories/settings.js';
|
|
5
5
|
import { listTodos } from '../repositories/todos.js';
|
|
6
6
|
import { recordApiError } from '../lib/api-errors.js';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { root } from '../config.js';
|
|
9
|
+
import { join } from 'node:path';
|
|
7
10
|
|
|
8
11
|
const router = Router();
|
|
12
|
+
const serverVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
|
|
9
13
|
const httpClient = axios.create({
|
|
10
14
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
|
11
15
|
validateStatus: () => true,
|
|
@@ -15,6 +19,8 @@ const httpClient = axios.create({
|
|
|
15
19
|
const isSuccess = (response) => response.status >= 200 && response.status < 300;
|
|
16
20
|
const apiMessage = (response, fallback) => response?.data?.errors?.[0]?.message || response?.data?.errorMessages?.[0] || response?.data?.message || response?.statusText || fallback;
|
|
17
21
|
|
|
22
|
+
router.get('/version', (_req, res) => res.json({ version: serverVersion }));
|
|
23
|
+
|
|
18
24
|
function jiraHost(domain) {
|
|
19
25
|
const clean = String(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
|
|
20
26
|
if (!clean) return '';
|