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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 separatorIndex = line.indexOf(' ');
53
- const ref = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
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 parseFirstCommit(raw) {
79
- const [timestamp, date, name, email] = raw.split('\x1f');
80
- const numericTimestamp = Number(timestamp);
81
- return {
82
- createdAt: Number.isFinite(numericTimestamp) ? new Date(numericTimestamp * 1000).toISOString() : date || null,
83
- creator: { name: name || '', email: email || '' }
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 = parseFirstCommit(await git(folder, [
122
- 'log', '--reverse', '--format=%ct%x1f%aI%x1f%an%x1f%ae', '-1', ref
123
- ], { allowFailure: true }));
124
- let createdAtSource = 'first commit (inferred)';
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 branchCount = Number(await git(folder, ['rev-list', '--count', ref], { allowFailure: true })) || 0;
143
- const containingRefs = parseRefList(await git(folder, [
144
- 'for-each-ref', '--contains', commit.hash, '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes'
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
- const counts = (await git(folder, ['rev-list', '--left-right', '--count', `${targetRef}...${ref}`], { allowFailure: true })).split(/\s+/).map(Number);
158
- behind = Number.isFinite(counts[0]) ? counts[0] : 0;
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, refsRaw] = await Promise.all([
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(refsRaw);
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 detail = await readBranchDetails(root, branch, refs, targetRef, currentBranch);
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 when available, otherwise first commit date.'
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 '';