@yemi33/minions 0.1.1770 → 0.1.1771
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/CHANGELOG.md +5 -0
- package/engine/cleanup.js +111 -1
- package/engine/copilot-models.json +1 -1
- package/engine/lifecycle.js +10 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/engine/cleanup.js
CHANGED
|
@@ -48,6 +48,105 @@ function worktreeMatchesBranch(dirLower, branch, actualBranch = '') {
|
|
|
48
48
|
return worktreeBranchMatches(actualBranch, branch) || worktreeDirMatchesBranch(dirLower, branch);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function normalizeLocalBranchName(branch) {
|
|
52
|
+
return String(branch || '').trim().replace(/^refs\/heads\//i, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isSafeLocalBranchName(branch) {
|
|
56
|
+
if (!branch || branch !== sanitizeBranch(branch)) return false;
|
|
57
|
+
if (branch.startsWith('-') || branch.includes('..') || branch.includes('@{')) return false;
|
|
58
|
+
if (branch.endsWith('/') || branch.endsWith('.lock')) return false;
|
|
59
|
+
return branch.split('/').every(part => part && part !== '.' && part !== '..' && !part.endsWith('.lock'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isProtectedLocalBranch(branch, project = {}) {
|
|
63
|
+
const protectedBranches = new Set(['main', 'master', 'trunk', 'develop', 'development', 'head']);
|
|
64
|
+
const configuredMain = normalizeLocalBranchName(project.mainBranch);
|
|
65
|
+
if (configuredMain) protectedBranches.add(configuredMain.toLowerCase());
|
|
66
|
+
return protectedBranches.has(branch.toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function localBranchWorktreeInUse(root, branch) {
|
|
70
|
+
try {
|
|
71
|
+
const out = String(shared.execSilent('git worktree list --porcelain', {
|
|
72
|
+
cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
|
|
73
|
+
}) || '');
|
|
74
|
+
return out.split(/\r?\n/).some(line => line.trim() === `branch refs/heads/${branch}`);
|
|
75
|
+
} catch {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cleanupMergedPrLocalBranch(root, project, pr) {
|
|
81
|
+
const branch = normalizeLocalBranchName(pr?.branch);
|
|
82
|
+
const result = { deleted: false, forced: false, skipped: null };
|
|
83
|
+
if (pr?.status !== shared.PR_STATUS.MERGED) { result.skipped = 'not-merged'; return result; }
|
|
84
|
+
if (!root || !branch) { result.skipped = 'missing-branch'; return result; }
|
|
85
|
+
if (!isSafeLocalBranchName(branch)) { result.skipped = 'unsafe-branch-name'; return result; }
|
|
86
|
+
if (isProtectedLocalBranch(branch, project)) { result.skipped = 'protected-branch'; return result; }
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const current = String(shared.execSilent('git branch --show-current', {
|
|
90
|
+
cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
|
|
91
|
+
}) || '').trim();
|
|
92
|
+
if (current === branch) { result.skipped = 'current-branch'; return result; }
|
|
93
|
+
} catch {
|
|
94
|
+
result.skipped = 'current-branch-unknown';
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (localBranchWorktreeInUse(root, branch)) { result.skipped = 'branch-in-worktree'; return result; }
|
|
99
|
+
|
|
100
|
+
let localHead = '';
|
|
101
|
+
try {
|
|
102
|
+
localHead = String(shared.execSilent(`git rev-parse --verify "refs/heads/${branch}"`, {
|
|
103
|
+
cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
|
|
104
|
+
}) || '').trim();
|
|
105
|
+
} catch {
|
|
106
|
+
result.skipped = 'missing-local-branch';
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
shared.execSilent(`git branch -d -- "${branch}"`, {
|
|
112
|
+
cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 15000, windowsHide: true,
|
|
113
|
+
});
|
|
114
|
+
log('info', `Post-merge cleanup: deleted local branch ${branch}`);
|
|
115
|
+
return { deleted: true, forced: false, skipped: null };
|
|
116
|
+
} catch (deleteErr) {
|
|
117
|
+
const localHeadLower = localHead.toLowerCase();
|
|
118
|
+
// Only use -D when the local tip still matches the merged PR head (or its remote-tracking ref);
|
|
119
|
+
// otherwise a reused local branch could lose unrelated work after the PR merged.
|
|
120
|
+
const proofHeads = [pr.headSha, pr._adoSourceCommit, pr.sourceCommit]
|
|
121
|
+
.map(v => String(v || '').trim().toLowerCase())
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
let safeToForce = proofHeads.includes(localHeadLower);
|
|
124
|
+
if (!safeToForce) {
|
|
125
|
+
try {
|
|
126
|
+
const remoteHead = String(shared.execSilent(`git rev-parse --verify "refs/remotes/origin/${branch}"`, {
|
|
127
|
+
cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
|
|
128
|
+
}) || '').trim().toLowerCase();
|
|
129
|
+
safeToForce = !!remoteHead && remoteHead === localHeadLower;
|
|
130
|
+
} catch { /* no matching remote-tracking branch */ }
|
|
131
|
+
}
|
|
132
|
+
if (!safeToForce) {
|
|
133
|
+
result.skipped = 'unproven-force-delete';
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
shared.execSilent(`git branch -D -- "${branch}"`, {
|
|
138
|
+
cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 15000, windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
log('info', `Post-merge cleanup: force-deleted local branch ${branch} after merged PR confirmation`);
|
|
141
|
+
return { deleted: true, forced: true, skipped: null };
|
|
142
|
+
} catch (forceErr) {
|
|
143
|
+
log('warn', `Post-merge cleanup: failed to delete local branch ${branch}: ${forceErr.message || deleteErr.message}`);
|
|
144
|
+
result.skipped = 'delete-failed';
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
51
150
|
/**
|
|
52
151
|
* Sweep leaked test-fixture meetings from a `meetings/` directory.
|
|
53
152
|
*
|
|
@@ -342,9 +441,11 @@ async function runCleanup(config, verbose = false) {
|
|
|
342
441
|
// Check if this worktree's branch is merged/abandoned
|
|
343
442
|
// Prefer actual git branch metadata; compact Windows dirs intentionally omit branch names.
|
|
344
443
|
const dirLower = dir.toLowerCase();
|
|
444
|
+
let matchedMergedBranch = '';
|
|
345
445
|
for (const branch of mergedBranches) {
|
|
346
446
|
if (worktreeMatchesBranch(dirLower, branch, actualBranch)) {
|
|
347
447
|
shouldClean = true;
|
|
448
|
+
matchedMergedBranch = branch;
|
|
348
449
|
break;
|
|
349
450
|
}
|
|
350
451
|
}
|
|
@@ -392,7 +493,7 @@ async function runCleanup(config, verbose = false) {
|
|
|
392
493
|
} catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
|
|
393
494
|
}
|
|
394
495
|
|
|
395
|
-
wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch });
|
|
496
|
+
wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch, matchedMergedBranch });
|
|
396
497
|
}
|
|
397
498
|
|
|
398
499
|
// Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
|
|
@@ -412,10 +513,14 @@ async function runCleanup(config, verbose = false) {
|
|
|
412
513
|
// the initial status check and the actual deletion (Bug #15: TOCTOU race)
|
|
413
514
|
const freshPrs = safeJson(projectPrPath(project)) || [];
|
|
414
515
|
const freshMergedBranches = new Set();
|
|
516
|
+
const freshMergedPrByBranch = new Map();
|
|
415
517
|
for (const pr of freshPrs) {
|
|
416
518
|
if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED || pr.status === shared.PLAN_STATUS.COMPLETED) {
|
|
417
519
|
if (pr.branch) freshMergedBranches.add(pr.branch);
|
|
418
520
|
}
|
|
521
|
+
if (pr.status === shared.PR_STATUS.MERGED && pr.branch) {
|
|
522
|
+
freshMergedPrByBranch.set(sanitizeBranch(normalizeLocalBranchName(pr.branch)).toLowerCase(), pr);
|
|
523
|
+
}
|
|
419
524
|
}
|
|
420
525
|
|
|
421
526
|
for (const entry of wtEntries) {
|
|
@@ -446,6 +551,10 @@ async function runCleanup(config, verbose = false) {
|
|
|
446
551
|
_killProcessInWorktree(entry.dir, activeProcesses, activeDispatchIds);
|
|
447
552
|
if (shared.removeWorktree(entry.wtPath, root, worktreeRoot)) {
|
|
448
553
|
cleaned.worktrees++;
|
|
554
|
+
const mergedPr = entry.matchedMergedBranch
|
|
555
|
+
? freshMergedPrByBranch.get(sanitizeBranch(normalizeLocalBranchName(entry.matchedMergedBranch)).toLowerCase())
|
|
556
|
+
: null;
|
|
557
|
+
if (mergedPr) cleanupMergedPrLocalBranch(root, project, mergedPr);
|
|
449
558
|
if (verbose) console.log(` Removed worktree: ${entry.wtPath}`);
|
|
450
559
|
} else {
|
|
451
560
|
if (verbose) console.log(` Failed to remove worktree ${entry.wtPath}`);
|
|
@@ -935,4 +1044,5 @@ module.exports = {
|
|
|
935
1044
|
worktreeDirMatchesBranch, // exported for testing
|
|
936
1045
|
worktreeMatchesBranch, // exported for testing
|
|
937
1046
|
getWorktreeBranch, // exported for lifecycle cleanup
|
|
1047
|
+
cleanupMergedPrLocalBranch, // exported for lifecycle cleanup and testing
|
|
938
1048
|
};
|
package/engine/lifecycle.js
CHANGED
|
@@ -7,14 +7,14 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const shared = require('./shared');
|
|
10
|
-
const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems,
|
|
10
|
+
const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
|
|
11
11
|
log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
|
|
12
12
|
ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
|
|
13
13
|
const { trackEngineUsage } = require('./llm');
|
|
14
14
|
const { resolveRuntime } = require('./runtimes');
|
|
15
15
|
const queries = require('./queries');
|
|
16
16
|
const { isBranchActive } = require('./cooldown');
|
|
17
|
-
const { worktreeMatchesBranch, getWorktreeBranch } = require('./cleanup');
|
|
17
|
+
const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
|
|
18
18
|
const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
|
|
19
19
|
MINIONS_DIR, ENGINE_DIR, PLANS_DIR, PRD_DIR, INBOX_DIR, AGENTS_DIR } = queries;
|
|
20
20
|
|
|
@@ -1838,6 +1838,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
1838
1838
|
if (pr.branch && project) {
|
|
1839
1839
|
const root = path.resolve(project.localPath);
|
|
1840
1840
|
const wtRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
|
|
1841
|
+
let removedBranchWorktree = false;
|
|
1841
1842
|
// Find worktrees matching this branch; compact Windows dirs require branch metadata.
|
|
1842
1843
|
try {
|
|
1843
1844
|
const dirs = require('fs').readdirSync(wtRoot);
|
|
@@ -1847,11 +1848,16 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
1847
1848
|
if (worktreeMatchesBranch(dirLower, pr.branch, getWorktreeBranch(wtPath)) || dir === pr.branch || dir === `bt-${prNum}`) {
|
|
1848
1849
|
try {
|
|
1849
1850
|
if (!require('fs').statSync(wtPath).isDirectory()) continue;
|
|
1850
|
-
|
|
1851
|
-
|
|
1851
|
+
if (shared.removeWorktree(wtPath, root, wtRoot)) {
|
|
1852
|
+
removedBranchWorktree = true;
|
|
1853
|
+
log('info', `Post-merge cleanup: removed worktree ${dir}`);
|
|
1854
|
+
}
|
|
1852
1855
|
} catch (err) { log('warn', `Failed to remove worktree ${dir}: ${err.message}`); }
|
|
1853
1856
|
}
|
|
1854
1857
|
}
|
|
1858
|
+
if (removedBranchWorktree && newStatus === PR_STATUS.MERGED) {
|
|
1859
|
+
cleanupMergedPrLocalBranch(root, project, pr);
|
|
1860
|
+
}
|
|
1855
1861
|
} catch (err) { log('warn', `Post-merge worktree cleanup: ${err.message}`); }
|
|
1856
1862
|
}
|
|
1857
1863
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1771",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|