buddy-workbench 0.1.101 → 0.1.102
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/config.js +1 -0
- package/server/lib/free-port.js +9 -2
- package/server/repositories/branch-health-history.js +41 -0
- package/server/routes/branch-health.js +362 -0
- package/server/services/process-manager.js +4 -3
- package/server.js +2 -0
- package/ui/dist/assets/{index-CcA9xhmJ.css → index-CrhFWqfC.css} +1 -1
- package/ui/dist/assets/index-DdhjO_OH.js +596 -0
- package/ui/dist/assets/{xmindAdapter-Ba9Ggpu1.js → xmindAdapter-B-43eXQE.js} +1 -1
- package/ui/dist/index.html +2 -2
- package/ui/dist/sw.js +2 -2
- package/ui/dist/assets/index-DVlA4D4z.js +0 -596
package/package.json
CHANGED
package/server/config.js
CHANGED
|
@@ -35,6 +35,7 @@ export const paths = {
|
|
|
35
35
|
errors: join(userDataDir, 'errors.json'),
|
|
36
36
|
postman: join(userDataDir, 'postman.json'),
|
|
37
37
|
branchSync: join(userDataDir, 'branch-sync.json'),
|
|
38
|
+
branchHealthHistory: join(userDataDir, 'branch-health-history.json'),
|
|
38
39
|
packageUpgrade: join(userDataDir, 'package-upgrade.json'),
|
|
39
40
|
presentations: join(userDataDir, 'presentations.json'),
|
|
40
41
|
mindMaps: join(userDataDir, 'mind-maps'),
|
package/server/lib/free-port.js
CHANGED
|
@@ -5,8 +5,15 @@ const execFileAsync = promisify(execFile);
|
|
|
5
5
|
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
6
6
|
|
|
7
7
|
async function portPids(port) {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execFileAsync('lsof', ['-ti', `:${port}`], { timeout: 1500 });
|
|
10
|
+
return [...new Set(stdout.trim().split(/\s+/).filter(Boolean))];
|
|
11
|
+
} catch (error) {
|
|
12
|
+
// Port cleanup is best-effort. Some managed environments block lsof or
|
|
13
|
+
// do not expose the process list; the server can still attempt to bind.
|
|
14
|
+
if (['ETIMEDOUT', 'EPERM', 'EACCES'].includes(error.code) || error.killed) return [];
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
export async function freePort(port) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { paths } from '../config.js';
|
|
5
|
+
|
|
6
|
+
export function listBranchHealthHistory() {
|
|
7
|
+
if (!existsSync(paths.branchHealthHistory)) return [];
|
|
8
|
+
try {
|
|
9
|
+
const records = JSON.parse(readFileSync(paths.branchHealthHistory, 'utf8'));
|
|
10
|
+
return Array.isArray(records) ? records : [];
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveBranchHealthHistory(records) {
|
|
17
|
+
mkdirSync(dirname(paths.branchHealthHistory), { recursive: true });
|
|
18
|
+
writeFileSync(paths.branchHealthHistory, JSON.stringify(records, null, 2));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function addBranchHealthHistory({ repository, branch, targets, status }) {
|
|
22
|
+
const successfulTargets = Array.isArray(targets) ? targets.filter(Boolean) : [];
|
|
23
|
+
if (!repository?.name || !branch || successfulTargets.length === 0) return null;
|
|
24
|
+
|
|
25
|
+
const record = {
|
|
26
|
+
id: crypto.randomUUID(),
|
|
27
|
+
deletedAt: new Date().toISOString(),
|
|
28
|
+
repository: {
|
|
29
|
+
name: String(repository.name),
|
|
30
|
+
path: String(repository.path || ''),
|
|
31
|
+
remoteUrl: repository.remoteUrl || null
|
|
32
|
+
},
|
|
33
|
+
branch: String(branch),
|
|
34
|
+
targets: successfulTargets,
|
|
35
|
+
status: status === 'partial' ? 'partial' : 'success'
|
|
36
|
+
};
|
|
37
|
+
const records = listBranchHealthHistory();
|
|
38
|
+
records.push(record);
|
|
39
|
+
saveBranchHealthHistory(records);
|
|
40
|
+
return record;
|
|
41
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import { addBranchHealthHistory, listBranchHealthHistory } from '../repositories/branch-health-history.js';
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const GIT_TIMEOUT = 30 * 1000;
|
|
11
|
+
|
|
12
|
+
async function git(folder, args, options = {}) {
|
|
13
|
+
try {
|
|
14
|
+
const result = await execFileAsync('git', args, {
|
|
15
|
+
cwd: folder,
|
|
16
|
+
timeout: GIT_TIMEOUT,
|
|
17
|
+
maxBuffer: 4 * 1024 * 1024
|
|
18
|
+
});
|
|
19
|
+
return result.stdout.trim();
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (options.allowFailure) return '';
|
|
22
|
+
throw new Error(error.stderr?.trim() || error.message || 'Git command failed.');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function repositoryName(folder) {
|
|
27
|
+
return basename(folder) || folder;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function workspaceRepositoryCandidates(workspaceDir) {
|
|
31
|
+
const root = resolve(String(workspaceDir || ''));
|
|
32
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
33
|
+
throw new Error(`Workspace directory does not exist: ${root}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const folders = [];
|
|
37
|
+
if (existsSync(join(root, '.git'))) folders.push(root);
|
|
38
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
39
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
40
|
+
const folder = join(root, entry.name);
|
|
41
|
+
if (existsSync(join(folder, '.git'))) folders.push(folder);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return folders.map((folder) => ({
|
|
45
|
+
name: repositoryName(folder),
|
|
46
|
+
path: folder
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseRefList(raw) {
|
|
51
|
+
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();
|
|
55
|
+
if (!ref || !hash) return null;
|
|
56
|
+
const remote = ref.startsWith('refs/remotes/');
|
|
57
|
+
const remoteMatch = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
|
|
58
|
+
const name = remote ? remoteMatch?.[2] : ref.replace(/^refs\/heads\//, '');
|
|
59
|
+
if (!name || name === 'HEAD' || name.endsWith('/HEAD')) return null;
|
|
60
|
+
return { ref, name, hash, remote, remoteName: remoteMatch?.[1] || null };
|
|
61
|
+
}).filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseCommit(raw) {
|
|
65
|
+
const [hash, shortHash, subject, authorName, authorEmail, authoredAt, committedAt, committerName, committerEmail] = raw.split('\x1f');
|
|
66
|
+
if (!hash) return null;
|
|
67
|
+
return {
|
|
68
|
+
hash,
|
|
69
|
+
shortHash,
|
|
70
|
+
subject,
|
|
71
|
+
author: { name: authorName, email: authorEmail },
|
|
72
|
+
committer: { name: committerName, email: committerEmail },
|
|
73
|
+
authoredAt,
|
|
74
|
+
committedAt
|
|
75
|
+
};
|
|
76
|
+
}
|
|
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
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isProtectedBranch(name) {
|
|
88
|
+
return /^(main|master|develop|development|trunk|release(?:\/|$))/i.test(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function dateAgeDays(date) {
|
|
92
|
+
if (!date) return null;
|
|
93
|
+
const timestamp = Date.parse(date);
|
|
94
|
+
if (Number.isNaN(timestamp)) return null;
|
|
95
|
+
return Math.max(0, Math.floor((Date.now() - timestamp) / 86400000));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function healthForBranch({ merged, ahead, ageDays, isCurrent, isProtected, hasRemote }) {
|
|
99
|
+
if (isCurrent || isProtected) return { status: 'blocked', label: 'Blocked', score: 0 };
|
|
100
|
+
if (ahead > 0) return { status: 'keep', label: 'Keep', score: 20 };
|
|
101
|
+
|
|
102
|
+
let score = 0;
|
|
103
|
+
if (merged) score += 50;
|
|
104
|
+
if (ageDays >= 180) score += 30;
|
|
105
|
+
else if (ageDays >= 90) score += 25;
|
|
106
|
+
else if (ageDays >= 30) score += 15;
|
|
107
|
+
if (!hasRemote) score += 5;
|
|
108
|
+
|
|
109
|
+
if (merged && ageDays >= 90) return { status: 'safe', label: 'Safe to delete', score };
|
|
110
|
+
if (score >= 45) return { status: 'review', label: 'Review', score };
|
|
111
|
+
return { status: 'keep', label: 'Keep', score };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function readBranchDetails(folder, branch, refs, targetRef, currentBranch) {
|
|
115
|
+
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
|
+
if (!commit) return null;
|
|
120
|
+
|
|
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)';
|
|
125
|
+
|
|
126
|
+
if (branch.localRef) {
|
|
127
|
+
const reflog = await git(folder, ['reflog', 'show', '--format=%ct%x1f%cI%x1f%gs', branch.localRef], { allowFailure: true });
|
|
128
|
+
const earliestEntry = reflog.split(/\r?\n/).filter(Boolean).at(-1);
|
|
129
|
+
if (earliestEntry) {
|
|
130
|
+
const [timestamp, date] = earliestEntry.split('\x1f');
|
|
131
|
+
const numericTimestamp = Number(timestamp);
|
|
132
|
+
if (Number.isFinite(numericTimestamp)) {
|
|
133
|
+
firstCommit = { ...firstCommit, createdAt: new Date(numericTimestamp * 1000).toISOString() };
|
|
134
|
+
createdAtSource = 'local reflog';
|
|
135
|
+
} else if (date) {
|
|
136
|
+
firstCommit = { ...firstCommit, createdAt: date };
|
|
137
|
+
createdAtSource = 'local reflog';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
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))];
|
|
147
|
+
const protectedContaining = containingNames.filter(isProtectedBranch);
|
|
148
|
+
// A child branch containing main's commit is not evidence that main was
|
|
149
|
+
// merged into that child. Only protected/default targets count as merge
|
|
150
|
+
// evidence for cleanup decisions in this local-only phase.
|
|
151
|
+
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;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ageDays = dateAgeDays(commit.committedAt || commit.authoredAt);
|
|
163
|
+
const isCurrent = !branch.remote && currentBranch === branch.name;
|
|
164
|
+
const isProtected = isProtectedBranch(branch.name);
|
|
165
|
+
const health = healthForBranch({ merged, ahead, ageDays, isCurrent, isProtected, hasRemote: Boolean(branch.remoteRef) });
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
id: branch.name,
|
|
169
|
+
name: branch.name,
|
|
170
|
+
source: branch.localRef && branch.remoteRef ? 'local + remote' : branch.localRef ? 'local' : 'remote',
|
|
171
|
+
hasLocal: Boolean(branch.localRef),
|
|
172
|
+
hasRemote: Boolean(branch.remoteRef),
|
|
173
|
+
current: isCurrent,
|
|
174
|
+
protected: isProtected,
|
|
175
|
+
createdAt: firstCommit?.createdAt || null,
|
|
176
|
+
createdAtSource,
|
|
177
|
+
creator: firstCommit?.creator || { name: '', email: '' },
|
|
178
|
+
lastCommit: commit,
|
|
179
|
+
ageDays,
|
|
180
|
+
commitCount: branchCount,
|
|
181
|
+
ahead,
|
|
182
|
+
behind,
|
|
183
|
+
merged,
|
|
184
|
+
mergedInto,
|
|
185
|
+
mergedIntoAll: protectedContaining,
|
|
186
|
+
mergeSource: merged ? 'local ancestry (inferred)' : 'not found in local refs',
|
|
187
|
+
openPullRequest: null,
|
|
188
|
+
health,
|
|
189
|
+
reasons: [
|
|
190
|
+
merged ? `Merged into ${mergedInto}` : 'No containing target branch found',
|
|
191
|
+
ageDays >= 90 ? `${ageDays} days since last commit` : null,
|
|
192
|
+
ahead > 0 ? `${ahead} commits not in the default branch` : null,
|
|
193
|
+
isCurrent ? 'Currently checked out' : null,
|
|
194
|
+
isProtected ? 'Matches a protected branch pattern' : null
|
|
195
|
+
].filter(Boolean),
|
|
196
|
+
refs: { local: branch.localRef || null, remote: branch.remoteRef || null }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function scanRepository(folder) {
|
|
201
|
+
const root = resolve(folder);
|
|
202
|
+
if (!existsSync(join(root, '.git'))) throw new Error(`Not a Git repository: ${root}`);
|
|
203
|
+
|
|
204
|
+
const [currentBranch, remoteUrl, refsRaw] = await Promise.all([
|
|
205
|
+
git(root, ['branch', '--show-current'], { allowFailure: true }),
|
|
206
|
+
git(root, ['remote', 'get-url', 'origin'], { allowFailure: true }),
|
|
207
|
+
git(root, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes'])
|
|
208
|
+
]);
|
|
209
|
+
const refs = parseRefList(refsRaw);
|
|
210
|
+
|
|
211
|
+
// Remote refs are normalized to their branch name so local and origin/main
|
|
212
|
+
// are presented as one row when both are available.
|
|
213
|
+
const normalized = new Map();
|
|
214
|
+
refs.forEach((item) => {
|
|
215
|
+
const name = item.name;
|
|
216
|
+
const branch = normalized.get(name) || { name, localRef: null, remoteRef: null };
|
|
217
|
+
if (item.remote && item.remoteName === 'origin') branch.remoteRef = item.ref;
|
|
218
|
+
if (!item.remote) branch.localRef = item.ref;
|
|
219
|
+
normalized.set(name, branch);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const preferredTargets = ['main', 'master', 'develop', 'development', 'trunk'];
|
|
223
|
+
const targetName = preferredTargets.find((name) => normalized.has(name)) || [...normalized.keys()].find(isProtectedBranch) || null;
|
|
224
|
+
const targetBranch = targetName ? normalized.get(targetName) : null;
|
|
225
|
+
const targetRef = targetBranch?.localRef || targetBranch?.remoteRef || null;
|
|
226
|
+
const details = [];
|
|
227
|
+
for (const branch of normalized.values()) {
|
|
228
|
+
const detail = await readBranchDetails(root, branch, refs, targetRef, currentBranch);
|
|
229
|
+
if (detail) details.push(detail);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
details.sort((a, b) => b.health.score - a.health.score || (b.ageDays || 0) - (a.ageDays || 0) || a.name.localeCompare(b.name));
|
|
233
|
+
return {
|
|
234
|
+
repository: { name: repositoryName(root), path: root, remoteUrl: remoteUrl || null, currentBranch: currentBranch || null },
|
|
235
|
+
defaultBranch: targetName,
|
|
236
|
+
branches: details,
|
|
237
|
+
scannedAt: new Date().toISOString(),
|
|
238
|
+
analysis: {
|
|
239
|
+
source: 'local Git',
|
|
240
|
+
mergeEvidence: 'Local ref ancestry; remote PR history is not queried in this phase.',
|
|
241
|
+
creationEvidence: 'Local reflog when available, otherwise first commit date.'
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
router.get('/repositories', (req, res) => {
|
|
247
|
+
try {
|
|
248
|
+
const workspaceDir = String(req.query.workspaceDir || '').trim();
|
|
249
|
+
if (!workspaceDir) return res.status(400).json({ error: 'Workspace directory is required.', repositories: [] });
|
|
250
|
+
res.json({ workspaceDir: resolve(workspaceDir), repositories: workspaceRepositoryCandidates(workspaceDir) });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
res.status(400).json({ error: error.message, repositories: [] });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
router.get('/history', (_req, res) => {
|
|
257
|
+
res.json({ history: listBranchHealthHistory().slice().reverse() });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function deletionChecks(branch) {
|
|
261
|
+
const blockers = [];
|
|
262
|
+
if (!branch.hasLocal && !branch.hasRemote) blockers.push('Branch no longer exists in local or origin refs.');
|
|
263
|
+
if (branch.current) blockers.push('The current branch cannot be deleted.');
|
|
264
|
+
if (branch.protected) blockers.push('Protected branch patterns cannot be deleted.');
|
|
265
|
+
if (!branch.merged) blockers.push('The branch head is not contained in another local ref.');
|
|
266
|
+
if (branch.ahead > 0) blockers.push(`${branch.ahead} commit(s) are not in the default branch.`);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
hasLocal: branch.hasLocal,
|
|
270
|
+
hasRemote: branch.hasRemote,
|
|
271
|
+
current: branch.current,
|
|
272
|
+
protected: branch.protected,
|
|
273
|
+
merged: branch.merged,
|
|
274
|
+
ahead: branch.ahead,
|
|
275
|
+
localDeletionAllowed: branch.hasLocal && blockers.length === 0,
|
|
276
|
+
remoteDeletionAllowed: branch.hasRemote && blockers.length === 0,
|
|
277
|
+
blockers
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
router.post('/preflight', async (req, res) => {
|
|
282
|
+
try {
|
|
283
|
+
const folder = String(req.body?.folder || '').trim();
|
|
284
|
+
const branchName = String(req.body?.branch || '').trim();
|
|
285
|
+
if (!folder || !branchName) return res.status(400).json({ error: 'Repository folder and branch are required.' });
|
|
286
|
+
const result = await scanRepository(folder);
|
|
287
|
+
const branch = result.branches.find((item) => item.name === branchName);
|
|
288
|
+
if (!branch) return res.status(404).json({ error: `Branch not found: ${branchName}` });
|
|
289
|
+
const checks = deletionChecks(branch);
|
|
290
|
+
res.json({ branch, checks, canDelete: checks.localDeletionAllowed || checks.remoteDeletionAllowed });
|
|
291
|
+
} catch (error) {
|
|
292
|
+
res.status(400).json({ error: error.message || 'Unable to preflight branch deletion.' });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
router.post('/delete', async (req, res) => {
|
|
297
|
+
try {
|
|
298
|
+
const folder = String(req.body?.folder || '').trim();
|
|
299
|
+
const branchName = String(req.body?.branch || '').trim();
|
|
300
|
+
const confirmName = String(req.body?.confirmName || '').trim();
|
|
301
|
+
const deleteLocal = req.body?.deleteLocal === true;
|
|
302
|
+
const deleteRemote = req.body?.deleteRemote === true;
|
|
303
|
+
if (!folder || !branchName) return res.status(400).json({ error: 'Repository folder and branch are required.' });
|
|
304
|
+
if (!deleteLocal && !deleteRemote) return res.status(400).json({ error: 'Select at least one deletion target.' });
|
|
305
|
+
if (confirmName !== branchName) return res.status(400).json({ error: 'Type the exact branch name to confirm deletion.' });
|
|
306
|
+
|
|
307
|
+
// Re-scan immediately before mutating refs so a stale page cannot delete a
|
|
308
|
+
// branch after it has changed ownership, merge state, or checkout state.
|
|
309
|
+
const before = await scanRepository(folder);
|
|
310
|
+
const branch = before.branches.find((item) => item.name === branchName);
|
|
311
|
+
if (!branch) return res.status(404).json({ error: `Branch not found: ${branchName}` });
|
|
312
|
+
const checks = deletionChecks(branch);
|
|
313
|
+
if (checks.blockers.length) return res.status(409).json({ error: checks.blockers.join(' '), checks, branch });
|
|
314
|
+
if (deleteLocal && !checks.localDeletionAllowed) return res.status(409).json({ error: 'Local branch deletion is not allowed after the latest preflight.', checks, branch });
|
|
315
|
+
if (deleteRemote && !checks.remoteDeletionAllowed) return res.status(409).json({ error: 'Remote branch deletion is not allowed after the latest preflight.', checks, branch });
|
|
316
|
+
|
|
317
|
+
const results = [];
|
|
318
|
+
if (deleteLocal) {
|
|
319
|
+
try {
|
|
320
|
+
await git(resolve(folder), ['branch', '-d', '--', branchName]);
|
|
321
|
+
results.push({ target: 'local', success: true });
|
|
322
|
+
} catch (error) {
|
|
323
|
+
results.push({ target: 'local', success: false, error: error.message });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (deleteRemote) {
|
|
327
|
+
try {
|
|
328
|
+
if (!branch.hasRemote) throw new Error('Origin branch does not exist.');
|
|
329
|
+
await git(resolve(folder), ['push', 'origin', '--delete', branchName]);
|
|
330
|
+
results.push({ target: 'remote', success: true });
|
|
331
|
+
} catch (error) {
|
|
332
|
+
results.push({ target: 'remote', success: false, error: error.message });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const success = results.length > 0 && results.every((item) => item.success);
|
|
337
|
+
const successfulTargets = results.filter((item) => item.success).map((item) => item.target);
|
|
338
|
+
const historyRecord = addBranchHealthHistory({
|
|
339
|
+
repository: before.repository,
|
|
340
|
+
branch: branchName,
|
|
341
|
+
targets: successfulTargets,
|
|
342
|
+
status: success ? 'success' : 'partial'
|
|
343
|
+
});
|
|
344
|
+
const after = await scanRepository(folder);
|
|
345
|
+
res.status(success ? 200 : 207).json({ success, branch: branchName, results, historyRecord, ...after });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
res.status(400).json({ error: error.message || 'Unable to delete branch.' });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
router.post('/scan', async (req, res) => {
|
|
352
|
+
try {
|
|
353
|
+
const folder = String(req.body?.folder || '').trim();
|
|
354
|
+
if (!folder) return res.status(400).json({ error: 'Repository folder is required.' });
|
|
355
|
+
res.json(await scanRepository(folder));
|
|
356
|
+
} catch (error) {
|
|
357
|
+
res.status(400).json({ error: error.message || 'Unable to scan repository branches.' });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
export { deletionChecks, scanRepository };
|
|
362
|
+
export default router;
|
|
@@ -14,6 +14,7 @@ const launchHistory = [];
|
|
|
14
14
|
const savedPortHistory = new Map(listPortHistory().map((record) => [record.port, record]));
|
|
15
15
|
const supervisorPath = fileURLToPath(new URL('./script-supervisor.js', import.meta.url));
|
|
16
16
|
const execFileAsync = promisify(execFile);
|
|
17
|
+
const PROCESS_INSPECTION_TIMEOUT = 2500;
|
|
17
18
|
const keyFor = (launcherId, scriptId) => `${launcherId}:${scriptId}`;
|
|
18
19
|
export function countErrorBlocks(text, isStderr = false) {
|
|
19
20
|
if (!text) return 0;
|
|
@@ -130,7 +131,7 @@ async function listeningProcesses() {
|
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
try {
|
|
133
|
-
const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'], { maxBuffer: 1024 * 1024 });
|
|
134
|
+
const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'], { maxBuffer: 1024 * 1024, timeout: PROCESS_INSPECTION_TIMEOUT });
|
|
134
135
|
const rows = [];
|
|
135
136
|
let current = {};
|
|
136
137
|
for (const line of stdout.split('\n')) {
|
|
@@ -145,7 +146,7 @@ async function listeningProcesses() {
|
|
|
145
146
|
}
|
|
146
147
|
return rows.filter((item) => item.pid && item.port);
|
|
147
148
|
} catch (error) {
|
|
148
|
-
if (error.code === 1) return [];
|
|
149
|
+
if (error.code === 1 || ['ETIMEDOUT', 'EPERM', 'EACCES'].includes(error.code) || error.killed) return [];
|
|
149
150
|
throw new Error('Unable to inspect listening ports. Ensure lsof is available.');
|
|
150
151
|
}
|
|
151
152
|
}
|
|
@@ -155,7 +156,7 @@ async function processGroupFor(pid) {
|
|
|
155
156
|
// on some installations that resolves through a visible shell window.
|
|
156
157
|
if (process.platform === 'win32') return null;
|
|
157
158
|
try {
|
|
158
|
-
const { stdout } = await execFileAsync('ps', ['-o', 'pgid=', '-p', String(pid)]);
|
|
159
|
+
const { stdout } = await execFileAsync('ps', ['-o', 'pgid=', '-p', String(pid)], { timeout: PROCESS_INSPECTION_TIMEOUT });
|
|
159
160
|
return Number(stdout.trim());
|
|
160
161
|
} catch { return null; }
|
|
161
162
|
}
|
package/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import postmanRoutes from './server/routes/postman.js';
|
|
|
23
23
|
import bookmarkSyncRoutes from './server/routes/bookmark-sync.js';
|
|
24
24
|
import fileOrganizerRoutes from './server/routes/file-organizer.js';
|
|
25
25
|
import branchSyncRoutes from './server/routes/branch-sync.js';
|
|
26
|
+
import branchHealthRoutes from './server/routes/branch-health.js';
|
|
26
27
|
import updateRoutes from './server/routes/updates.js';
|
|
27
28
|
import dataBackupRoutes from './server/routes/data-backup.js';
|
|
28
29
|
import presentationsRoutes from './server/routes/presentations.js';
|
|
@@ -101,6 +102,7 @@ app.use('/api/postman', postmanRoutes);
|
|
|
101
102
|
app.use('/api/bookmark-sync', bookmarkSyncRoutes);
|
|
102
103
|
app.use('/api/file-organizer', fileOrganizerRoutes);
|
|
103
104
|
app.use('/api/branch-sync', branchSyncRoutes);
|
|
105
|
+
app.use('/api/branch-health', branchHealthRoutes);
|
|
104
106
|
app.use('/api/updates', updateRoutes);
|
|
105
107
|
app.use('/api/data-backup', dataBackupRoutes);
|
|
106
108
|
app.use('/api/presentations', presentationsRoutes);
|