@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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1771 (2026-05-07)
4
+
5
+ ### Features
6
+ - clean merged pr local branches (#2165)
7
+
3
8
  ## 0.1.1770 (2026-05-07)
4
9
 
5
10
  ### Fixes
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
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-07T15:55:28.458Z"
4
+ "cachedAt": "2026-05-07T16:58:12.762Z"
5
5
  }
@@ -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, execSilent, execAsync, projectPrPath, getPrLinks,
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
- execSilent(`git worktree remove "${wtPath}" --force`, { cwd: root, stdio: 'pipe', timeout: 15000 });
1851
- log('info', `Post-merge cleanup: removed worktree ${dir}`);
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.1770",
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"