@limina-labs/momentum 0.33.0 → 0.34.0
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/adapters/codex/adapter.js +7 -0
- package/adapters/opencode/adapter.js +2 -0
- package/bin/antigravity.js +1 -0
- package/bin/lanes.js +29 -6
- package/bin/momentum.js +1 -0
- package/bin/state-commands.js +109 -1
- package/core/commands/complete-phase.md +58 -21
- package/core/commands/lanes.md +18 -1
- package/core/commands/start-phase.md +17 -1
- package/core/commands/start-project.md +3 -1
- package/core/config.js +1 -1
- package/core/lanes/lib/cleanup.js +349 -0
- package/core/lanes/lib/land.js +34 -1
- package/core/lanes/lib/reconcile.js +129 -0
- package/package.json +1 -1
|
@@ -167,6 +167,10 @@ function transformCommandsIntoSkills(targetDir, recordManaged) {
|
|
|
167
167
|
|
|
168
168
|
let count = 0;
|
|
169
169
|
for (const file of fs.readdirSync(commandsDir)) {
|
|
170
|
+
// Skip macOS AppleDouble sidecars FIRST: `._complete-phase.md` ends with
|
|
171
|
+
// `.md`, so an endsWith check alone would mint a `._complete-phase/SKILL.md`
|
|
172
|
+
// junk skill dir (Phase 27 G4 — the source of the ._* skill-dir litter).
|
|
173
|
+
if (file.startsWith('._') || file === '.DS_Store') continue;
|
|
170
174
|
if (!file.endsWith('.md')) continue;
|
|
171
175
|
const name = file.replace(/\.md$/, '');
|
|
172
176
|
const body = fs.readFileSync(path.join(commandsDir, file), 'utf8');
|
|
@@ -223,3 +227,6 @@ function renderSkillMarkdown(name, description, recipeBody) {
|
|
|
223
227
|
recipeBody.endsWith('\n') ? recipeBody : recipeBody + '\n',
|
|
224
228
|
].join('\n');
|
|
225
229
|
}
|
|
230
|
+
|
|
231
|
+
// Phase 27 G4 — exported for the AppleDouble-filter regression test.
|
|
232
|
+
module.exports.transformCommandsIntoSkills = transformCommandsIntoSkills;
|
|
@@ -173,6 +173,8 @@ function ensureCommandFrontmatter(targetDir) {
|
|
|
173
173
|
}
|
|
174
174
|
let count = 0;
|
|
175
175
|
for (const file of fs.readdirSync(commandsDir)) {
|
|
176
|
+
// Skip AppleDouble sidecars first — `._x.md` ends with `.md` (Phase 27 G4).
|
|
177
|
+
if (file.startsWith('._') || file === '.DS_Store') continue;
|
|
176
178
|
if (!file.endsWith('.md')) continue;
|
|
177
179
|
const filePath = path.join(commandsDir, file);
|
|
178
180
|
const body = fs.readFileSync(filePath, 'utf8');
|
package/bin/antigravity.js
CHANGED
|
@@ -29,6 +29,7 @@ const PLUGIN_NAME = 'momentum';
|
|
|
29
29
|
function copyDirRecursive(src, dest, dryRun, log) {
|
|
30
30
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
31
31
|
for (const entry of entries) {
|
|
32
|
+
if (entry.name.startsWith('._') || entry.name === '.DS_Store') continue; // Phase 27 G4 — no AppleDouble litter
|
|
32
33
|
const s = path.join(src, entry.name);
|
|
33
34
|
const d = path.join(dest, entry.name);
|
|
34
35
|
if (entry.isDirectory()) {
|
package/bin/lanes.js
CHANGED
|
@@ -18,6 +18,7 @@ const { spawnSync } = require('child_process');
|
|
|
18
18
|
|
|
19
19
|
const MOMENTUM_ROOT = path.resolve(__dirname, '..');
|
|
20
20
|
const state = require(path.join(MOMENTUM_ROOT, 'core', 'lanes', 'lib', 'state'));
|
|
21
|
+
const cleanup = require(path.join(MOMENTUM_ROOT, 'core', 'lanes', 'lib', 'cleanup'));
|
|
21
22
|
|
|
22
23
|
// ─── small helpers ───────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -39,7 +40,7 @@ function parseFlags(argv) {
|
|
|
39
40
|
for (let i = 0; i < argv.length; i++) {
|
|
40
41
|
const a = argv[i];
|
|
41
42
|
if (a === '--no-worktree' || a === '--rm-worktree' || a === '--execute' ||
|
|
42
|
-
a === '--force' || a === '--json' || a === '--ack-all') {
|
|
43
|
+
a === '--force' || a === '--json' || a === '--ack-all' || a === '--no-remote') {
|
|
43
44
|
flags[a.slice(2)] = true;
|
|
44
45
|
} else if (a.startsWith('--')) {
|
|
45
46
|
flags[a.slice(2)] = argv[++i];
|
|
@@ -197,7 +198,7 @@ function cmdDone(cwd, argv) {
|
|
|
197
198
|
function cmdClose(cwd, argv) {
|
|
198
199
|
const { flags, positional } = parseFlags(argv);
|
|
199
200
|
const id = positional[0];
|
|
200
|
-
if (!id) return fail('usage: momentum lanes close <lane-id> [--rm-worktree]');
|
|
201
|
+
if (!id) return fail('usage: momentum lanes close <lane-id> [--rm-worktree] [--force] [--no-remote]');
|
|
201
202
|
const anchor = requireAnchor(cwd);
|
|
202
203
|
if (!anchor) return;
|
|
203
204
|
let lane;
|
|
@@ -206,10 +207,21 @@ function cmdClose(cwd, argv) {
|
|
|
206
207
|
} catch (err) {
|
|
207
208
|
return fail(err.message);
|
|
208
209
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
// ENH-063: close does FULL cleanup — local + remote branch (default-branch-
|
|
211
|
+
// safe) + lane state, plus the worktree with --rm-worktree. Best-effort: an
|
|
212
|
+
// unmerged branch is refused (never force-deleted) but that does not fail close.
|
|
213
|
+
const res = cleanup.cleanupTarget({
|
|
214
|
+
cwd,
|
|
215
|
+
branch: lane.branch,
|
|
216
|
+
worktree: flags['rm-worktree'] ? lane.worktree : null,
|
|
217
|
+
laneId: id,
|
|
218
|
+
deleteRemote: !flags['no-remote'],
|
|
219
|
+
force: Boolean(flags.force),
|
|
220
|
+
});
|
|
221
|
+
const glyph = { removed: '✓', deleted: '✓', cleared: '✓', skipped: 'ℹ', blocked: '⚠' };
|
|
222
|
+
for (const a of res.actions) console.log(` ${glyph[a.status] || '·'} ${a.step}: ${a.detail}`);
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
console.log(`⚠ some cleanup refused (${res.blocked.join(', ')}) — e.g. an unmerged branch; rerun with --force only if intended`);
|
|
213
225
|
}
|
|
214
226
|
console.log(`✓ lane '${id}' closed`);
|
|
215
227
|
}
|
|
@@ -243,6 +255,13 @@ Usage:
|
|
|
243
255
|
momentum lanes land <id> [--into <ref>] [--execute] [--force] [--mark-landed]
|
|
244
256
|
--mark-landed bookkeeping only: record an out-of-band merge that
|
|
245
257
|
already happened (done + merged lane → landed)
|
|
258
|
+
momentum lanes cleanup <branch> [--worktree P] [--lane ID] [flags]
|
|
259
|
+
Remove a spent branch's worktree + branch + lane state, default-branch-safe
|
|
260
|
+
--no-remote skip origin delete --force allow -D / dirty worktree
|
|
261
|
+
--dry-run report only --keep-state leave lane dir untouched
|
|
262
|
+
momentum lanes reconcile [--execute] [--into <ref>] [--json]
|
|
263
|
+
Sweep landed/merged lanes whose terminal merge happened out-of-band
|
|
264
|
+
(human/forge) and clean them once verified contained in <ref>
|
|
246
265
|
|
|
247
266
|
State lives at <git-common-dir>/momentum/lanes — shared by all worktrees,
|
|
248
267
|
untracked, no daemon. Substrate: plain git worktrees by default; treehouse
|
|
@@ -287,6 +306,10 @@ function dispatch(argv, cwd) {
|
|
|
287
306
|
return delegate('signals', 'cmdInbox', rest);
|
|
288
307
|
case 'land':
|
|
289
308
|
return delegate('land', 'cmdLand', rest);
|
|
309
|
+
case 'cleanup':
|
|
310
|
+
return delegate('cleanup', 'cmdCleanup', rest);
|
|
311
|
+
case 'reconcile':
|
|
312
|
+
return delegate('reconcile', 'cmdReconcile', rest);
|
|
290
313
|
case '--help':
|
|
291
314
|
case '-h':
|
|
292
315
|
case 'help':
|
package/bin/momentum.js
CHANGED
|
@@ -1620,6 +1620,7 @@ Ecosystem use — entry/exit commands (run from any repo):
|
|
|
1620
1620
|
existing ecosystem
|
|
1621
1621
|
momentum leave Detach THIS repo from its ecosystem
|
|
1622
1622
|
momentum doctor Diagnose state + list available transitions
|
|
1623
|
+
momentum doctor --clean [--execute] Sweep stray artifacts (AppleDouble/.bak/orphan worktrees)
|
|
1623
1624
|
momentum antigravity plugin-pack Pack momentum skills as a native Antigravity plugin ([--global])
|
|
1624
1625
|
|
|
1625
1626
|
Ecosystem use — operator toolkit (advanced):
|
package/bin/state-commands.js
CHANGED
|
@@ -129,8 +129,103 @@ function cmdLeave(args) {
|
|
|
129
129
|
console.log(`✓ Left "${reg.ecosystemName}". Now standalone.`);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// ── Phase 27 G4 — stray-artifact sweep (`momentum doctor --clean`) ──────────
|
|
133
|
+
|
|
134
|
+
const BAK_ROOTS = ['.claude', '.agents', '.opencode', '.codex', '.githooks', 'scripts'];
|
|
135
|
+
|
|
136
|
+
/** Bounded walk collecting AppleDouble sidecars anywhere (always junk). */
|
|
137
|
+
function walkAppleDouble(dir, acc, depth) {
|
|
138
|
+
if (depth > 8) return;
|
|
139
|
+
let entries;
|
|
140
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
141
|
+
for (const e of entries) {
|
|
142
|
+
if (e.name === '.git' || e.name === 'node_modules') continue;
|
|
143
|
+
const abs = path.join(dir, e.name);
|
|
144
|
+
if (e.name.startsWith('._') || e.name === '.DS_Store') { acc.appleDouble.push(abs); continue; }
|
|
145
|
+
if (e.isDirectory()) walkAppleDouble(abs, acc, depth + 1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `.bak` files under a momentum-managed root only (never user backups). */
|
|
150
|
+
function collectBaks(dir, acc, depth) {
|
|
151
|
+
if (depth > 8) return;
|
|
152
|
+
let entries;
|
|
153
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
154
|
+
for (const e of entries) {
|
|
155
|
+
const abs = path.join(dir, e.name);
|
|
156
|
+
if (e.isDirectory()) collectBaks(abs, acc, depth + 1);
|
|
157
|
+
else if (e.name.endsWith('.bak')) acc.baks.push(abs);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Find stray artifacts: AppleDouble, momentum `.bak` litter, orphan worktrees. */
|
|
162
|
+
function findStrayArtifacts(root) {
|
|
163
|
+
const { spawnSync } = require('child_process');
|
|
164
|
+
const acc = { appleDouble: [], baks: [], prunableWorktrees: [], orphanWorktreeDirs: [] };
|
|
165
|
+
walkAppleDouble(root, acc, 0);
|
|
166
|
+
for (const sub of BAK_ROOTS) collectBaks(path.join(root, sub), acc, 0);
|
|
167
|
+
for (const f of ['CLAUDE.md.bak', 'AGENTS.md.bak']) {
|
|
168
|
+
const p = path.join(root, f);
|
|
169
|
+
if (fs.existsSync(p)) acc.baks.push(p);
|
|
170
|
+
}
|
|
171
|
+
// registered worktrees whose directory is gone → prunable
|
|
172
|
+
const registered = new Set();
|
|
173
|
+
const wl = spawnSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, encoding: 'utf8' });
|
|
174
|
+
if (wl.status === 0) {
|
|
175
|
+
for (const line of wl.stdout.split('\n')) {
|
|
176
|
+
if (line.startsWith('worktree ')) {
|
|
177
|
+
const p = line.slice('worktree '.length);
|
|
178
|
+
registered.add(realpathOf(p));
|
|
179
|
+
if (p !== root && !fs.existsSync(p)) acc.prunableWorktrees.push(p);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// .claude/worktrees/* leftover dirs not registered as git worktrees
|
|
184
|
+
const cw = path.join(root, '.claude', 'worktrees');
|
|
185
|
+
try {
|
|
186
|
+
for (const name of fs.readdirSync(cw)) {
|
|
187
|
+
const abs = path.join(cw, name);
|
|
188
|
+
if (fs.statSync(abs).isDirectory() && !registered.has(realpathOf(abs))) acc.orphanWorktreeDirs.push(abs);
|
|
189
|
+
}
|
|
190
|
+
} catch { /* no .claude/worktrees */ }
|
|
191
|
+
return acc;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sweepStrayArtifacts(root, execute) {
|
|
195
|
+
const { spawnSync } = require('child_process');
|
|
196
|
+
const stray = findStrayArtifacts(root);
|
|
197
|
+
const total = stray.appleDouble.length + stray.baks.length
|
|
198
|
+
+ stray.prunableWorktrees.length + stray.orphanWorktreeDirs.length;
|
|
199
|
+
const rel = (p) => path.relative(root, p) || p;
|
|
200
|
+
|
|
201
|
+
console.log(execute ? 'Cleaning stray artifacts:' : 'Stray artifacts (dry-run — pass --execute to remove):');
|
|
202
|
+
if (total === 0) { console.log(' ✓ none — tree is clean'); return 0; }
|
|
203
|
+
|
|
204
|
+
for (const p of stray.appleDouble) {
|
|
205
|
+
if (execute) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
206
|
+
console.log(` ${execute ? '✓ removed' : '·'} AppleDouble: ${rel(p)}`);
|
|
207
|
+
}
|
|
208
|
+
for (const p of stray.baks) {
|
|
209
|
+
if (execute) { try { fs.rmSync(p, { force: true }); } catch { /* ignore */ } }
|
|
210
|
+
console.log(` ${execute ? '✓ removed' : '·'} .bak: ${rel(p)}`);
|
|
211
|
+
}
|
|
212
|
+
for (const p of stray.orphanWorktreeDirs) {
|
|
213
|
+
if (execute) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
214
|
+
console.log(` ${execute ? '✓ removed' : '·'} orphan worktree dir: ${rel(p)}`);
|
|
215
|
+
}
|
|
216
|
+
if (stray.prunableWorktrees.length) {
|
|
217
|
+
if (execute) spawnSync('git', ['worktree', 'prune'], { cwd: root });
|
|
218
|
+
for (const p of stray.prunableWorktrees) console.log(` ${execute ? '✓ pruned' : '·'} stale worktree ref: ${rel(p)}`);
|
|
219
|
+
}
|
|
220
|
+
console.log(execute ? `✓ cleaned ${total} stray artifact(s)` : `${total} stray artifact(s) — rerun with --execute to remove`);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
132
224
|
function cmdDoctor(args) {
|
|
133
|
-
|
|
225
|
+
// Phase 27 G4 — `momentum doctor --clean [--execute]` sweeps stray artifacts.
|
|
226
|
+
if (args && args.includes('--clean')) {
|
|
227
|
+
return sweepStrayArtifacts(realpathOf(process.cwd()), args.includes('--execute'));
|
|
228
|
+
}
|
|
134
229
|
const cwd = realpathOf(process.cwd());
|
|
135
230
|
lib._clearRootCache();
|
|
136
231
|
const state = stateLib.detectState(cwd);
|
|
@@ -176,6 +271,17 @@ function cmdDoctor(args) {
|
|
|
176
271
|
}
|
|
177
272
|
} catch (_err) { /* advisory is best-effort — never block doctor */ }
|
|
178
273
|
|
|
274
|
+
// Phase 27 G4 — stray-artifact advisory (best-effort, non-blocking).
|
|
275
|
+
try {
|
|
276
|
+
const stray = findStrayArtifacts(cwd);
|
|
277
|
+
const n = stray.appleDouble.length + stray.baks.length
|
|
278
|
+
+ stray.prunableWorktrees.length + stray.orphanWorktreeDirs.length;
|
|
279
|
+
if (n > 0) {
|
|
280
|
+
console.log(`Advisory: ${n} stray artifact(s) (AppleDouble / .bak / orphan worktrees) — run \`momentum doctor --clean\` to review, \`--clean --execute\` to remove.`);
|
|
281
|
+
console.log('');
|
|
282
|
+
}
|
|
283
|
+
} catch { /* best-effort */ }
|
|
284
|
+
|
|
179
285
|
const transitions = stateLib.availableTransitions(state, reg || {});
|
|
180
286
|
if (transitions.length === 0) {
|
|
181
287
|
console.log('No suggested next steps for this state.');
|
|
@@ -215,4 +321,6 @@ module.exports = {
|
|
|
215
321
|
cmdLeave,
|
|
216
322
|
cmdDoctor,
|
|
217
323
|
formatStateLabel,
|
|
324
|
+
findStrayArtifacts,
|
|
325
|
+
sweepStrayArtifacts,
|
|
218
326
|
};
|
|
@@ -53,22 +53,34 @@ Verify, finalize, and release a completed phase.
|
|
|
53
53
|
- Format as fenced code blocks with the command as the heading
|
|
54
54
|
- This is the durable record that Rule 12 was honored — it must exist before the release section runs
|
|
55
55
|
|
|
56
|
-
7. Update all tracking
|
|
56
|
+
7. Update all tracking (this happens BEFORE the release — see the Release
|
|
57
|
+
gate below; the release must never precede tracking):
|
|
57
58
|
- `specs/phases/README.md` → mark `Complete`
|
|
58
|
-
- `specs/status.md` →
|
|
59
|
-
(
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
- `specs/status.md` → (a) header **`Latest Release`** = this version + name;
|
|
60
|
+
(b) add the phase's row to the **Completed Phases** table; (c) remove this
|
|
61
|
+
lane's row from the Active Phase table (leave other lanes' rows untouched —
|
|
62
|
+
Rule 15); (d) update Current Phase
|
|
63
|
+
- `specs/planning/roadmap.md` → update phase status → Complete (+ version)
|
|
62
64
|
- `specs/changelog/YYYY-MM.md` → add release entry
|
|
63
65
|
|
|
64
66
|
### Release
|
|
65
67
|
|
|
66
|
-
> **Gate (Rule 12):** Do NOT enter Release if `retrospective.md` lacks a `## Verification Evidence` section. If evidence is missing, return to Step 3 and capture it.
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
> **Gate A (Rule 12):** Do NOT enter Release if `retrospective.md` lacks a `## Verification Evidence` section. If evidence is missing, return to Step 3 and capture it.
|
|
69
|
+
>
|
|
70
|
+
> **Gate B (tracking-before-release):** Do NOT merge, tag, or release until ALL
|
|
71
|
+
> tracking that reflects this release (Step 7 — `status.md` `Latest Release` +
|
|
72
|
+
> Completed Phases row, `specs/phases/README.md`, `roadmap.md`, the changelog)
|
|
73
|
+
> is written AND committed on the phase branch (Step 8). The release must never
|
|
74
|
+
> precede tracking — otherwise `status.md` says "in progress" while the tag is
|
|
75
|
+
> already public. Confirm the working tree is clean of tracking edits (Step 8
|
|
76
|
+
> committed them) before Step 9's approval and Step 10's merge/tag.
|
|
77
|
+
|
|
78
|
+
8. Commit all remaining changes (retrospective + ALL Step-7 tracking) and push.
|
|
79
|
+
This commit MUST land before the merge/tag — it is Gate B:
|
|
69
80
|
```bash
|
|
70
81
|
git add -A
|
|
71
82
|
git commit -m "docs: complete Phase N - {phase name}"
|
|
83
|
+
git status --porcelain # expect empty — tracking is committed (Gate B satisfied)
|
|
72
84
|
git push origin phase-N-shortname
|
|
73
85
|
```
|
|
74
86
|
|
|
@@ -118,11 +130,26 @@ Verify, finalize, and release a completed phase.
|
|
|
118
130
|
git tag -a vX.Y.Z -m "Phase N: {phase name}"
|
|
119
131
|
git push origin vX.Y.Z
|
|
120
132
|
```
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
**`end_state` variants** (config; the sequence above is the
|
|
134
|
+
`merge-after-yes` default):
|
|
135
|
+
- `feature-branch-only` — SKIP the merge/tag; the branch is pushed, the
|
|
136
|
+
human owns merge + release.
|
|
137
|
+
- `staging-promotion` — merge to `branch_flow[0]` only, then STOP; the human
|
|
138
|
+
promotes to `main` and tags.
|
|
139
|
+
- `open-pr` — do NOT merge; push the branch and open a PR for review
|
|
140
|
+
(config-gated, agent-run — `git_forge=github`: `gh pr create`; GitLab:
|
|
141
|
+
`glab mr create`), then STOP. Merge happens on the forge on human approval.
|
|
142
|
+
|
|
143
|
+
**Human handshake → verify → clean** (every non-`merge-after-yes`
|
|
144
|
+
end_state). Step-13 auto-cleanup only fires when the AGENT did the terminal
|
|
145
|
+
merge. When a human/forge does it, NEVER clean on trust: ask the user to
|
|
146
|
+
confirm once they have merged/promoted, then verify + clean in one step —
|
|
147
|
+
```bash
|
|
148
|
+
momentum lanes reconcile # is the branch a verified ancestor of the terminal branch yet?
|
|
149
|
+
momentum lanes reconcile --execute # only once it shows 'landed' — cleans worktree + branch + state
|
|
150
|
+
```
|
|
151
|
+
`reconcile` refuses to clean a lane whose branch is not yet contained in the
|
|
152
|
+
terminal branch — a "yes, I merged it" is never taken on trust (Rule 12).
|
|
126
153
|
|
|
127
154
|
12. Report summary to user:
|
|
128
155
|
- What was delivered
|
|
@@ -130,19 +157,29 @@ Verify, finalize, and release a completed phase.
|
|
|
130
157
|
status from `## Project Extensions` if any ran
|
|
131
158
|
- What's next
|
|
132
159
|
|
|
133
|
-
13.
|
|
134
|
-
are confirmed; do NOT skip
|
|
160
|
+
13. Clean up the spent phase branch (ENH-042 / BUG-026 — now that merge → main
|
|
161
|
+
+ release are confirmed; do NOT skip, or stale branches/worktrees/state
|
|
162
|
+
accumulate). One command removes ALL of it, default-branch-safe:
|
|
135
163
|
```bash
|
|
136
|
-
|
|
137
|
-
|
|
164
|
+
# worktree (if the phase ran in a lane) + local branch + remote branch
|
|
165
|
+
# (skipped when it is the forge default — BUG-025 guard) + lane state.
|
|
166
|
+
momentum lanes cleanup phase-N-shortname
|
|
167
|
+
# --lane <id> if this phase ran as a registered `momentum lanes` lane
|
|
168
|
+
# --force only to override an unmerged-branch refusal AFTER you have
|
|
169
|
+
# verified the branch actually landed
|
|
138
170
|
```
|
|
171
|
+
If cleanup reports `blocked` on the remote branch because it is the forge
|
|
172
|
+
DEFAULT, STOP — that is the BUG-025 hijack. Reassign the default to the
|
|
173
|
+
terminal branch first (e.g. `git_forge=github`: `gh repo edit
|
|
174
|
+
--default-branch main`), then rerun. `-d` (not `-D`) refuses an unmerged
|
|
175
|
+
branch by design — investigate rather than force.
|
|
139
176
|
|
|
140
177
|
14. Branch-hygiene self-audit (ENH-042) — confirm no released phase left a
|
|
141
|
-
dangling branch:
|
|
178
|
+
dangling branch or worktree:
|
|
142
179
|
```bash
|
|
143
|
-
# Any origin branch for an already-released phase should be gone.
|
|
144
180
|
git branch -r | grep -E 'origin/(phase-|chore/|audit/)' || echo "✓ clean — no stale branches"
|
|
181
|
+
git worktree list # no leftover lane worktrees for landed work
|
|
145
182
|
```
|
|
146
|
-
For each match, verify it is fully merged into `main`
|
|
147
|
-
(`git branch -r --merged main`) and
|
|
183
|
+
For each stale match, verify it is fully merged into `main`
|
|
184
|
+
(`git branch -r --merged main`) and clean it per step 13. Leave any
|
|
148
185
|
*unmerged* branch alone and surface it to the user.
|
package/core/commands/lanes.md
CHANGED
|
@@ -75,7 +75,7 @@ landing) and ack what you've acted on.
|
|
|
75
75
|
momentum lanes done <id> # enter the landing queue
|
|
76
76
|
momentum lanes land <id> # validate: turn, freshness, graded gate
|
|
77
77
|
momentum lanes land <id> --execute# merge --no-ff into the CURRENT branch
|
|
78
|
-
momentum lanes close <id> [--rm-worktree]
|
|
78
|
+
momentum lanes close <id> [--rm-worktree] # retire: full cleanup (branch + state)
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
Landing follows the Rule 6 **Landing Order**: one lane at a time, run the
|
|
@@ -87,6 +87,23 @@ with a non-empty `## Verification Evidence` section (Rule 12).
|
|
|
87
87
|
`land` never pushes — pushes to protected branches keep their own
|
|
88
88
|
approval gate.
|
|
89
89
|
|
|
90
|
+
### Cleanup — a landed lane is spent (BUG-026)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
momentum lanes reconcile # report lanes now merged out-of-band (human/forge)
|
|
94
|
+
momentum lanes reconcile --execute # clean them: worktree + branch + state
|
|
95
|
+
momentum lanes cleanup <branch> [--worktree P] [--dry-run] # one branch, manually
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
When the AGENT lands on the terminal branch (`branch_flow` last entry),
|
|
99
|
+
`land --execute` **auto-cleans** the worktree + branch + lane state
|
|
100
|
+
(default-branch-safe; `--keep` opts out). When a HUMAN or the FORGE does
|
|
101
|
+
the terminal merge (`end_state` `staging-promotion` / `feature-branch-only`
|
|
102
|
+
/ `open-pr`), cleanup is deferred: run `momentum lanes reconcile` — it
|
|
103
|
+
cleans only lanes whose branch is a **verified ancestor** of the terminal
|
|
104
|
+
branch (a "yes, I merged it" is never trusted, Rule 12). Cleanup never
|
|
105
|
+
deletes a branch that is the forge default (the BUG-025 guard).
|
|
106
|
+
|
|
90
107
|
## Tracking
|
|
91
108
|
|
|
92
109
|
Opening/closing lanes is not itself phase history; the WORK inside a lane
|
|
@@ -87,7 +87,19 @@ After setup, **executes the plan end-to-end autonomously** — no per-group appr
|
|
|
87
87
|
|
|
88
88
|
9. Create git branch and initial commit:
|
|
89
89
|
```bash
|
|
90
|
-
git checkout main
|
|
90
|
+
git checkout main
|
|
91
|
+
# Resilient when 'main' is unborn on the remote (fresh repo — BUG-025):
|
|
92
|
+
git ls-remote --heads origin main | grep -q . && git pull origin main || true
|
|
93
|
+
# BUG-025 safety net: the terminal branch must ALREADY be the remote default
|
|
94
|
+
# before ANY phase branch is pushed (founding normally established it). If it
|
|
95
|
+
# is still not on origin, establish it FIRST so phase-N can't hijack default:
|
|
96
|
+
if git remote get-url origin >/dev/null 2>&1 && ! git ls-remote --heads origin main | grep -q .; then
|
|
97
|
+
touch .momentum/merge-approved && git push -u origin main && git remote set-head origin main 2>/dev/null || true
|
|
98
|
+
fi
|
|
99
|
+
# Optional forge repair (only when git_forge=github AND gh is on PATH;
|
|
100
|
+
# non-fatal; idempotent). Fixes a repo whose default was ALREADY hijacked.
|
|
101
|
+
# Agent-run recipe step — momentum ships no forge code, stays forge-neutral:
|
|
102
|
+
command -v gh >/dev/null 2>&1 && gh repo edit --default-branch main 2>/dev/null || true
|
|
91
103
|
git checkout -b phase-N-shortname
|
|
92
104
|
git add specs/
|
|
93
105
|
git commit -m "docs: start Phase N - {phase name}"
|
|
@@ -135,6 +147,10 @@ defaults below) and ask the user the gate question matching the project's
|
|
|
135
147
|
- **`feature-branch-only`**: "All groups complete and verified. The feature
|
|
136
148
|
branch `<phase-branch>` is pushed. Merge, tag, and release are yours to
|
|
137
149
|
do. Review the branch?"
|
|
150
|
+
- **`open-pr`**: "All groups complete and verified. Ready to push
|
|
151
|
+
`<phase-branch>` and open a PR for review? Merge + release happen when a
|
|
152
|
+
human approves and merges the PR on the forge; then `momentum lanes
|
|
153
|
+
reconcile --execute` cleans up the branch + worktree + state."
|
|
138
154
|
|
|
139
155
|
Then append: "Project-specific release/publish/deploy steps (e.g.
|
|
140
156
|
`npm publish`, forge release creation) run from `## Project Extensions` in
|
|
@@ -96,7 +96,9 @@ If you're still exploring the idea, run `/brainstorm-idea` first.
|
|
|
96
96
|
```
|
|
97
97
|
(On a brand-new repo this may be the first content commit on the default
|
|
98
98
|
branch — the bootstrap exception to Rule 6. On a repo with existing code,
|
|
99
|
-
normal branch discipline applies.
|
|
99
|
+
normal branch discipline applies. Founding pushes nothing; `/start-phase`
|
|
100
|
+
establishes the terminal branch as the remote default before it pushes the
|
|
101
|
+
first phase branch — BUG-025.)
|
|
100
102
|
|
|
101
103
|
8. Report to user:
|
|
102
104
|
- Summary of what was founded
|
package/core/config.js
CHANGED
|
@@ -52,7 +52,7 @@ const ENUMS = {
|
|
|
52
52
|
publish_target: ['npm', 'pypi', 'crates-io', 'nuget', 'none', 'custom'],
|
|
53
53
|
git_forge: ['github', 'gitlab', 'bitbucket', 'gitea', 'forgejo', 'bare-ssh'],
|
|
54
54
|
release_flow: ['tag-and-publish', 'tag-and-deploy', 'tag-only', 'custom'],
|
|
55
|
-
end_state: ['merge-after-yes', 'staging-promotion', 'feature-branch-only'],
|
|
55
|
+
end_state: ['merge-after-yes', 'staging-promotion', 'feature-branch-only', 'open-pr'],
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
// Fields the CLI can infer from manifests + git remote. Used by drift detection.
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared, forge-neutral cleanup action for a "spent" branch/lane
|
|
5
|
+
* (Phase 27 G0 — BUG-026 / ENH-063; see specs/phases/phase-27-lifecycle-cleanup).
|
|
6
|
+
*
|
|
7
|
+
* A phase/lane branch is "spent" once its work has landed on the terminal
|
|
8
|
+
* integration branch. `cleanupTarget()` removes what it leaves behind, in a
|
|
9
|
+
* fixed, idempotent, DEFAULT-BRANCH-SAFE order:
|
|
10
|
+
*
|
|
11
|
+
* 1. worktree git worktree remove (never the caller's own worktree)
|
|
12
|
+
* 2. local branch git branch -d (-D only with force; unmerged → blocked)
|
|
13
|
+
* 3. remote branch git push origin --delete (SKIPPED when it is the forge default)
|
|
14
|
+
* 4. lane state clear inbox/signals; leave a `cleaned` tombstone by default
|
|
15
|
+
*
|
|
16
|
+
* Pure git + node builtins; ships NO forge code — the optional `gh` default-branch
|
|
17
|
+
* assertion lives in the recipes, config-gated (momentum stays forge-neutral).
|
|
18
|
+
* Idempotent: anything already gone is reported `skipped`, never an error. The
|
|
19
|
+
* remote-default guard is load-bearing: it is the reason a hijacked default
|
|
20
|
+
* branch (BUG-025) can never be deleted out from under the repo.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { spawnSync } = require('child_process');
|
|
26
|
+
|
|
27
|
+
const state = require('./state');
|
|
28
|
+
|
|
29
|
+
function git(cwd, ...args) {
|
|
30
|
+
return spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
31
|
+
}
|
|
32
|
+
function ok(res) { return res && res.status === 0; }
|
|
33
|
+
function out(res) { return ((res && res.stdout) || '').trim(); }
|
|
34
|
+
function err(res) { return ((res && res.stderr) || '').trim(); }
|
|
35
|
+
|
|
36
|
+
/** Real path (resolves symlinks like macOS /var → /private/var), best-effort. */
|
|
37
|
+
function realpath(p) {
|
|
38
|
+
try { return fs.realpathSync(p); } catch { return path.resolve(p); }
|
|
39
|
+
}
|
|
40
|
+
function samePath(a, b) {
|
|
41
|
+
return Boolean(a) && Boolean(b) && realpath(a) === realpath(b);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── read-only git predicates ────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function hasRemote(cwd, remote = 'origin') {
|
|
47
|
+
return ok(git(cwd, 'remote', 'get-url', remote));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function localBranchExists(cwd, branch) {
|
|
51
|
+
return ok(git(cwd, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function remoteBranchExists(cwd, branch, remote = 'origin') {
|
|
55
|
+
const res = git(cwd, 'ls-remote', '--heads', remote, branch);
|
|
56
|
+
return ok(res) && out(res) !== '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when `ref` is contained in `container` (already merged / landed). */
|
|
60
|
+
function isMerged(cwd, ref, container) {
|
|
61
|
+
return ok(git(cwd, 'merge-base', '--is-ancestor', ref, container));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The remote's default branch short name (e.g. "main"), or null when unknown.
|
|
66
|
+
* Prefers the locally-tracked symbolic ref; falls back to asking the remote
|
|
67
|
+
* (network) unless { network:false }.
|
|
68
|
+
*/
|
|
69
|
+
function remoteDefaultBranch(cwd, { remote = 'origin', network = true } = {}) {
|
|
70
|
+
const local = git(cwd, 'symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`);
|
|
71
|
+
if (ok(local) && out(local)) {
|
|
72
|
+
return out(local).replace(new RegExp(`^${remote}/`), '');
|
|
73
|
+
}
|
|
74
|
+
if (network) {
|
|
75
|
+
const ls = git(cwd, 'ls-remote', '--symref', remote, 'HEAD');
|
|
76
|
+
if (ok(ls)) {
|
|
77
|
+
const m = out(ls).match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
|
|
78
|
+
if (m) return m[1];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Registered worktrees: [{ path, branch }]. */
|
|
85
|
+
function worktreeList(cwd) {
|
|
86
|
+
const res = git(cwd, 'worktree', 'list', '--porcelain');
|
|
87
|
+
if (!ok(res)) return [];
|
|
88
|
+
const trees = [];
|
|
89
|
+
let cur = null;
|
|
90
|
+
for (const line of out(res).split('\n')) {
|
|
91
|
+
if (line.startsWith('worktree ')) { cur = { path: line.slice('worktree '.length), branch: null }; trees.push(cur); }
|
|
92
|
+
else if (line.startsWith('branch ') && cur) cur.branch = line.slice('branch '.length).replace('refs/heads/', '');
|
|
93
|
+
}
|
|
94
|
+
return trees;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── the shared cleanup action ───────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove the artifacts a spent branch/lane leaves behind.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} opts
|
|
103
|
+
* cwd where git runs (must NOT be inside `worktree`)
|
|
104
|
+
* branch branch to delete (local + remote)
|
|
105
|
+
* worktree worktree path to remove (optional)
|
|
106
|
+
* laneId lane-state id to clear (optional)
|
|
107
|
+
* anchor lane-state anchor (optional; derived from cwd)
|
|
108
|
+
* deleteRemote delete origin/<branch> when safe (default true)
|
|
109
|
+
* force allow `git branch -D` / `worktree remove --force` (default false)
|
|
110
|
+
* dryRun report intended actions without performing them
|
|
111
|
+
* keepState keep the full lane dir untouched (default false → tombstone)
|
|
112
|
+
* remote remote name (default 'origin')
|
|
113
|
+
* @returns {{ ok:boolean, actions:Array<{step,status,detail}>, blocked:string[] }}
|
|
114
|
+
*/
|
|
115
|
+
function cleanupTarget(opts = {}) {
|
|
116
|
+
const cwd = opts.cwd || process.cwd();
|
|
117
|
+
const {
|
|
118
|
+
branch, worktree, laneId,
|
|
119
|
+
deleteRemote = true, force = false, dryRun = false, keepState = false,
|
|
120
|
+
remote = 'origin',
|
|
121
|
+
} = opts;
|
|
122
|
+
const anchor = opts.anchor || state.resolveAnchor(cwd);
|
|
123
|
+
const actions = [];
|
|
124
|
+
const blocked = [];
|
|
125
|
+
const record = (step, status, detail) => actions.push({ step, status, detail });
|
|
126
|
+
|
|
127
|
+
const selfRoot = out(git(cwd, 'rev-parse', '--show-toplevel'));
|
|
128
|
+
|
|
129
|
+
// 1. worktree — before the branch, since a checked-out branch blocks `-d`
|
|
130
|
+
if (worktree) {
|
|
131
|
+
const registered = worktreeList(cwd).find((w) => samePath(w.path, worktree));
|
|
132
|
+
if (!registered && !fs.existsSync(worktree)) {
|
|
133
|
+
record('worktree', 'skipped', `${worktree} — not present`);
|
|
134
|
+
} else if (samePath(worktree, selfRoot)) {
|
|
135
|
+
record('worktree', 'blocked', `refusing to remove the current worktree (${worktree}) — run cleanup from the integration checkout`);
|
|
136
|
+
blocked.push('worktree');
|
|
137
|
+
} else if (dryRun) {
|
|
138
|
+
record('worktree', 'would', `git worktree remove ${worktree}${force ? ' --force' : ''}`);
|
|
139
|
+
} else {
|
|
140
|
+
const args = ['worktree', 'remove', worktree];
|
|
141
|
+
if (force) args.push('--force');
|
|
142
|
+
const res = git(cwd, ...args);
|
|
143
|
+
if (ok(res)) {
|
|
144
|
+
record('worktree', 'removed', worktree);
|
|
145
|
+
} else {
|
|
146
|
+
record('worktree', 'blocked', `git worktree remove failed (${err(res) || 'dirty or locked'}) — rerun with --force`);
|
|
147
|
+
blocked.push('worktree');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
git(cwd, 'worktree', 'prune');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. local branch — skip if its worktree is still present
|
|
154
|
+
if (branch && localBranchExists(cwd, branch)) {
|
|
155
|
+
if (blocked.includes('worktree')) {
|
|
156
|
+
record('local-branch', 'skipped', `'${branch}' — worktree still present`);
|
|
157
|
+
} else if (dryRun) {
|
|
158
|
+
record('local-branch', 'would', `git branch -${force ? 'D' : 'd'} ${branch}`);
|
|
159
|
+
} else {
|
|
160
|
+
const res = git(cwd, 'branch', force ? '-D' : '-d', branch);
|
|
161
|
+
if (ok(res)) {
|
|
162
|
+
record('local-branch', 'deleted', branch);
|
|
163
|
+
} else {
|
|
164
|
+
record('local-branch', 'blocked', `'${branch}' not fully merged (git branch -d refused) — verify it landed, then rerun with --force`);
|
|
165
|
+
blocked.push('local-branch');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} else if (branch) {
|
|
169
|
+
record('local-branch', 'skipped', `'${branch}' — no local branch`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 3. remote branch — NEVER the forge default (BUG-025 guard)
|
|
173
|
+
if (branch && deleteRemote) {
|
|
174
|
+
if (!hasRemote(cwd, remote)) {
|
|
175
|
+
record('remote-branch', 'skipped', `no '${remote}' remote`);
|
|
176
|
+
} else if (!remoteBranchExists(cwd, branch, remote)) {
|
|
177
|
+
record('remote-branch', 'skipped', `${remote}/${branch} — not on the remote`);
|
|
178
|
+
} else {
|
|
179
|
+
const def = remoteDefaultBranch(cwd, { remote });
|
|
180
|
+
if (def && def === branch) {
|
|
181
|
+
record('remote-branch', 'blocked', `${remote}/${branch} is the forge DEFAULT branch — refusing to delete (BUG-025 guard); reassign the default first`);
|
|
182
|
+
blocked.push('remote-branch');
|
|
183
|
+
} else if (dryRun) {
|
|
184
|
+
record('remote-branch', 'would', `git push ${remote} --delete ${branch}`);
|
|
185
|
+
} else {
|
|
186
|
+
const res = git(cwd, 'push', remote, '--delete', branch);
|
|
187
|
+
if (ok(res)) {
|
|
188
|
+
record('remote-branch', 'deleted', `${remote}/${branch}`);
|
|
189
|
+
} else {
|
|
190
|
+
record('remote-branch', 'blocked', `git push --delete failed (${err(res)})`);
|
|
191
|
+
blocked.push('remote-branch');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 4. lane state — clear heavy state, leave a `cleaned` tombstone by default
|
|
198
|
+
if (laneId && anchor) {
|
|
199
|
+
const dir = state.laneDir(anchor, laneId);
|
|
200
|
+
const manifest = state.readManifest(anchor, laneId);
|
|
201
|
+
if (!manifest && !fs.existsSync(dir)) {
|
|
202
|
+
record('lane-state', 'skipped', `no lane '${laneId}'`);
|
|
203
|
+
} else if (keepState) {
|
|
204
|
+
record('lane-state', 'skipped', `'${laneId}' — keepState`);
|
|
205
|
+
} else if (dryRun) {
|
|
206
|
+
record('lane-state', 'would', `clear '${laneId}' inbox + tombstone the manifest`);
|
|
207
|
+
} else {
|
|
208
|
+
try { fs.rmSync(state.inboxDir(anchor, laneId), { recursive: true, force: true }); } catch { /* absent */ }
|
|
209
|
+
if (manifest) {
|
|
210
|
+
try {
|
|
211
|
+
state.updateLane(anchor, laneId, { worktree: null, cleaned: true, cleanedAt: new Date().toISOString() });
|
|
212
|
+
} catch { /* ENOLANE race — nothing to tombstone */ }
|
|
213
|
+
}
|
|
214
|
+
record('lane-state', 'cleared', `'${laneId}' inbox removed; manifest tombstoned (cleaned)`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { ok: blocked.length === 0, actions, blocked };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── default-branch establishment (BUG-025) ──────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Ensure the terminal integration branch (e.g. main) is the branch a fresh
|
|
225
|
+
* forge repo adopts as its default — by making sure it is on the remote FIRST,
|
|
226
|
+
* before any phase branch is ever pushed. Pure git; the forge-side default
|
|
227
|
+
* assertion (`gh repo edit --default-branch`) is a recipe step, config-gated.
|
|
228
|
+
*
|
|
229
|
+
* Read-only by default (returns a plan the recipe acts on). With { push:true }
|
|
230
|
+
* it performs the founding bootstrap push when the remote lacks the branch.
|
|
231
|
+
*
|
|
232
|
+
* @returns {{ status:string, detail:string, pushed:boolean, defaultBranch?:string }}
|
|
233
|
+
* status ∈ no-remote | no-local-branch | needs-push | push-failed | ok | default-mismatch
|
|
234
|
+
*/
|
|
235
|
+
function ensureTerminalBranchIsRemoteDefault(cwd, terminalBranch, opts = {}) {
|
|
236
|
+
const { push = false, remote = 'origin', setLocalHead = true } = opts;
|
|
237
|
+
if (!hasRemote(cwd, remote)) {
|
|
238
|
+
return { status: 'no-remote', detail: `no '${remote}' remote — nothing to establish`, pushed: false };
|
|
239
|
+
}
|
|
240
|
+
if (!localBranchExists(cwd, terminalBranch)) {
|
|
241
|
+
return { status: 'no-local-branch', detail: `local '${terminalBranch}' does not exist yet`, pushed: false };
|
|
242
|
+
}
|
|
243
|
+
let pushed = false;
|
|
244
|
+
if (!remoteBranchExists(cwd, terminalBranch, remote)) {
|
|
245
|
+
if (!push) {
|
|
246
|
+
return {
|
|
247
|
+
status: 'needs-push',
|
|
248
|
+
detail: `'${terminalBranch}' is not on ${remote} — push it FIRST (before any phase branch) so the forge adopts it as default`,
|
|
249
|
+
pushed: false,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const res = git(cwd, 'push', '-u', remote, terminalBranch);
|
|
253
|
+
if (!ok(res)) return { status: 'push-failed', detail: err(res), pushed: false };
|
|
254
|
+
pushed = true;
|
|
255
|
+
}
|
|
256
|
+
if (setLocalHead) git(cwd, 'remote', 'set-head', remote, terminalBranch);
|
|
257
|
+
const def = remoteDefaultBranch(cwd, { remote });
|
|
258
|
+
const isDefault = def === terminalBranch;
|
|
259
|
+
return {
|
|
260
|
+
status: isDefault || pushed ? 'ok' : 'default-mismatch',
|
|
261
|
+
detail: isDefault
|
|
262
|
+
? `${remote} default is '${terminalBranch}'`
|
|
263
|
+
: `${remote} default is '${def || 'unknown'}' — run the forge assertion (e.g. gh repo edit --default-branch ${terminalBranch}) to repair`,
|
|
264
|
+
pushed,
|
|
265
|
+
defaultBranch: def,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── CLI: `momentum lanes cleanup` ───────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function parseCleanupArgs(argv) {
|
|
272
|
+
const flags = {};
|
|
273
|
+
const positional = [];
|
|
274
|
+
for (let i = 0; i < argv.length; i++) {
|
|
275
|
+
const a = argv[i];
|
|
276
|
+
if (a === '--force' || a === '--dry-run' || a === '--no-remote' || a === '--keep-state' || a === '--json') {
|
|
277
|
+
flags[a.slice(2)] = true;
|
|
278
|
+
} else if (a.startsWith('--')) {
|
|
279
|
+
flags[a.slice(2)] = argv[++i];
|
|
280
|
+
} else {
|
|
281
|
+
positional.push(a);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { flags, positional };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `momentum lanes cleanup <branch> [--worktree P] [--lane ID] [--no-remote]
|
|
289
|
+
* [--force] [--dry-run] [--keep-state] [--json]`
|
|
290
|
+
*
|
|
291
|
+
* A thin surface over cleanupTarget() for the recipes (complete-phase) and
|
|
292
|
+
* for manual use. When --lane is given but --branch/--worktree are omitted,
|
|
293
|
+
* they are read from the lane manifest.
|
|
294
|
+
*/
|
|
295
|
+
function cmdCleanup(cwd, argv) {
|
|
296
|
+
const { flags, positional } = parseCleanupArgs(argv);
|
|
297
|
+
let branch = positional[0];
|
|
298
|
+
const laneId = flags.lane || null;
|
|
299
|
+
let worktree = flags.worktree || null;
|
|
300
|
+
|
|
301
|
+
if (laneId) {
|
|
302
|
+
const anchor = state.resolveAnchor(cwd);
|
|
303
|
+
const lane = anchor && state.readManifest(anchor, laneId);
|
|
304
|
+
if (lane) {
|
|
305
|
+
branch = branch || lane.branch;
|
|
306
|
+
worktree = worktree || lane.worktree || null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!branch && !worktree && !laneId) {
|
|
310
|
+
console.error('✗ usage: momentum lanes cleanup <branch> [--worktree P] [--lane ID] [--no-remote] [--force] [--dry-run] [--keep-state]');
|
|
311
|
+
return 1;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const result = cleanupTarget({
|
|
315
|
+
cwd, branch, worktree, laneId,
|
|
316
|
+
deleteRemote: !flags['no-remote'],
|
|
317
|
+
force: Boolean(flags.force),
|
|
318
|
+
dryRun: Boolean(flags['dry-run']),
|
|
319
|
+
keepState: Boolean(flags['keep-state']),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (flags.json) {
|
|
323
|
+
console.log(JSON.stringify(result, null, 2));
|
|
324
|
+
return result.ok ? 0 : 1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const glyph = { removed: '✓', deleted: '✓', cleared: '✓', skipped: 'ℹ', would: '·', blocked: '✗' };
|
|
328
|
+
for (const a of result.actions) {
|
|
329
|
+
console.log(` ${glyph[a.status] || '·'} ${a.step}: ${a.detail}`);
|
|
330
|
+
}
|
|
331
|
+
if (result.ok) {
|
|
332
|
+
console.log(flags['dry-run'] ? '✓ dry-run — nothing changed' : '✓ cleanup complete');
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
console.error(`✗ cleanup incomplete — blocked: ${result.blocked.join(', ')}`);
|
|
336
|
+
return 1;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
cleanupTarget,
|
|
341
|
+
ensureTerminalBranchIsRemoteDefault,
|
|
342
|
+
remoteDefaultBranch,
|
|
343
|
+
remoteBranchExists,
|
|
344
|
+
localBranchExists,
|
|
345
|
+
isMerged,
|
|
346
|
+
hasRemote,
|
|
347
|
+
worktreeList,
|
|
348
|
+
cmdCleanup,
|
|
349
|
+
};
|
package/core/lanes/lib/land.js
CHANGED
|
@@ -41,17 +41,29 @@ const path = require('path');
|
|
|
41
41
|
const { spawnSync } = require('child_process');
|
|
42
42
|
|
|
43
43
|
const state = require('./state');
|
|
44
|
+
const cleanup = require('./cleanup');
|
|
45
|
+
const config = require('../../config');
|
|
44
46
|
|
|
45
47
|
function git(cwd, ...args) {
|
|
46
48
|
return spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/** Terminal integration branch = last branch_flow entry (config), default 'main'. */
|
|
52
|
+
function terminalBranch(repoRoot) {
|
|
53
|
+
try {
|
|
54
|
+
const cfg = config.readConfig(path.join(repoRoot, 'specs'));
|
|
55
|
+
const bf = cfg && cfg.branch_flow;
|
|
56
|
+
if (Array.isArray(bf) && bf.length) return bf[bf.length - 1];
|
|
57
|
+
} catch { /* config absent/unreadable — fall through */ }
|
|
58
|
+
return 'main';
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
function parseFlags(argv) {
|
|
50
62
|
const flags = {};
|
|
51
63
|
const positional = [];
|
|
52
64
|
for (let i = 0; i < argv.length; i++) {
|
|
53
65
|
const a = argv[i];
|
|
54
|
-
if (a === '--execute' || a === '--force' || a === '--json' || a === '--mark-landed') flags[a.slice(2)] = true;
|
|
66
|
+
if (a === '--execute' || a === '--force' || a === '--json' || a === '--mark-landed' || a === '--keep') flags[a.slice(2)] = true;
|
|
55
67
|
else if (a.startsWith('--')) flags[a.slice(2)] = argv[++i];
|
|
56
68
|
else positional.push(a);
|
|
57
69
|
}
|
|
@@ -294,6 +306,27 @@ function cmdLand(cwd, argv) {
|
|
|
294
306
|
if (others.length) {
|
|
295
307
|
console.log(`ℹ advisory rebase signal sent to ${others.length} open lane(s): ${others.map((l) => l.id).join(', ')}`);
|
|
296
308
|
}
|
|
309
|
+
|
|
310
|
+
// Phase 27 (BUG-026): a lane that landed on the TERMINAL integration branch is
|
|
311
|
+
// spent — clean its worktree + branch + lane state now (default-branch-safe),
|
|
312
|
+
// unless --keep. Landing into an intermediate branch (e.g. staging under
|
|
313
|
+
// staging-promotion) is NOT spent — cleanup defers to `lanes reconcile`.
|
|
314
|
+
const terminal = terminalBranch(repoRoot);
|
|
315
|
+
if (flags.keep) {
|
|
316
|
+
console.log('ℹ --keep: worktree, branch, and lane state left in place');
|
|
317
|
+
} else if (into === terminal) {
|
|
318
|
+
const res = cleanup.cleanupTarget({
|
|
319
|
+
cwd, branch: lane.branch, worktree: lane.worktree, laneId: id, deleteRemote: true,
|
|
320
|
+
});
|
|
321
|
+
const glyph = { removed: '✓', deleted: '✓', cleared: '✓', skipped: 'ℹ', blocked: '⚠' };
|
|
322
|
+
for (const a of res.actions) console.log(` ${glyph[a.status] || '·'} cleanup ${a.step}: ${a.detail}`);
|
|
323
|
+
if (!res.ok) {
|
|
324
|
+
console.log(`⚠ cleanup incomplete (${res.blocked.join(', ')}) — resolve, then: momentum lanes cleanup ${lane.branch}`);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
console.log(`ℹ landed on '${into}' (not the terminal branch '${terminal}') — cleanup deferred to \`momentum lanes reconcile\` once it reaches '${terminal}'`);
|
|
328
|
+
}
|
|
329
|
+
|
|
297
330
|
console.log('ℹ run the suite on the updated branch before the next landing (Rule 6 Landing Order)');
|
|
298
331
|
return 0;
|
|
299
332
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `momentum lanes reconcile` — sweep lanes whose terminal merge happened
|
|
5
|
+
* OUT-OF-BAND (Phase 27 G3, BUG-026). For the `end_state`s where a human or the
|
|
6
|
+
* forge performs the terminal merge (`staging-promotion` after promotion,
|
|
7
|
+
* `feature-branch-only`, `open-pr`), the agent never auto-cleans at land time;
|
|
8
|
+
* this command detects lanes now contained in the terminal branch upstream and
|
|
9
|
+
* runs the same default-branch-safe `cleanupTarget()` on them.
|
|
10
|
+
*
|
|
11
|
+
* Validate-first: without --execute it only reports which lanes are landed
|
|
12
|
+
* (cleanable) vs still pending. `git fetch --prune` runs first (unless
|
|
13
|
+
* --no-fetch) so containment is judged against the freshest `origin/<terminal>`.
|
|
14
|
+
*
|
|
15
|
+
* Verify-before-clean (Rule 12): a lane is cleaned ONLY when its branch is a
|
|
16
|
+
* verified ancestor of the terminal branch — a human's "yes, I merged it" is
|
|
17
|
+
* never taken on trust.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { spawnSync } = require('child_process');
|
|
22
|
+
|
|
23
|
+
const state = require('./state');
|
|
24
|
+
const cleanup = require('./cleanup');
|
|
25
|
+
const config = require('../../config');
|
|
26
|
+
|
|
27
|
+
function git(cwd, ...args) {
|
|
28
|
+
return spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
29
|
+
}
|
|
30
|
+
function ok(res) { return res && res.status === 0; }
|
|
31
|
+
|
|
32
|
+
function terminalBranch(repoRoot) {
|
|
33
|
+
try {
|
|
34
|
+
const cfg = config.readConfig(path.join(repoRoot, 'specs'));
|
|
35
|
+
const bf = cfg && cfg.branch_flow;
|
|
36
|
+
if (Array.isArray(bf) && bf.length) return bf[bf.length - 1];
|
|
37
|
+
} catch { /* config absent — default below */ }
|
|
38
|
+
return 'main';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The best ref to test containment against: origin/<terminal> if present, else local. */
|
|
42
|
+
function containerRef(cwd, terminal) {
|
|
43
|
+
if (ok(git(cwd, 'rev-parse', '--verify', '--quiet', `refs/remotes/origin/${terminal}`))) {
|
|
44
|
+
return `origin/${terminal}`;
|
|
45
|
+
}
|
|
46
|
+
return terminal;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseFlags(argv) {
|
|
50
|
+
const flags = {};
|
|
51
|
+
const positional = [];
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const a = argv[i];
|
|
54
|
+
if (a === '--execute' || a === '--json' || a === '--no-fetch' || a === '--force') flags[a.slice(2)] = true;
|
|
55
|
+
else if (a.startsWith('--')) flags[a.slice(2)] = argv[++i];
|
|
56
|
+
else positional.push(a);
|
|
57
|
+
}
|
|
58
|
+
return { flags, positional };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify a lane: landed (branch contained in the terminal ref, or already
|
|
63
|
+
* gone) vs pending (still not merged). Returns { lane, verdict, reason }.
|
|
64
|
+
*/
|
|
65
|
+
function classify(cwd, lane, container) {
|
|
66
|
+
const local = cleanup.localBranchExists(cwd, lane.branch);
|
|
67
|
+
const remote = cleanup.remoteBranchExists(cwd, lane.branch);
|
|
68
|
+
if (!local && !remote) {
|
|
69
|
+
// Branch already deleted everywhere — only residual worktree/state remains.
|
|
70
|
+
return { lane, verdict: 'landed', reason: `branch '${lane.branch}' already gone — residual state only` };
|
|
71
|
+
}
|
|
72
|
+
const ref = local ? lane.branch : `origin/${lane.branch}`;
|
|
73
|
+
if (cleanup.isMerged(cwd, ref, container)) {
|
|
74
|
+
return { lane, verdict: 'landed', reason: `'${lane.branch}' is contained in '${container}'` };
|
|
75
|
+
}
|
|
76
|
+
return { lane, verdict: 'pending', reason: `'${lane.branch}' not yet contained in '${container}'` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cmdReconcile(cwd, argv) {
|
|
80
|
+
const { flags } = parseFlags(argv);
|
|
81
|
+
const anchor = state.resolveAnchor(cwd);
|
|
82
|
+
if (!anchor) { console.error('✗ not inside a git repository'); return 1; }
|
|
83
|
+
const repoRoot = state.worktreeRoot(cwd);
|
|
84
|
+
const terminal = flags.into || terminalBranch(repoRoot);
|
|
85
|
+
|
|
86
|
+
if (!flags['no-fetch']) git(cwd, 'fetch', '--prune'); // best-effort; offline is fine
|
|
87
|
+
const container = containerRef(cwd, terminal);
|
|
88
|
+
|
|
89
|
+
// Sweep every non-cleaned, non-closed lane.
|
|
90
|
+
const lanes = state.listLanes(anchor).filter((l) => !l.cleaned && l.status !== 'closed');
|
|
91
|
+
const classified = lanes.map((l) => classify(cwd, l, container));
|
|
92
|
+
const landed = classified.filter((c) => c.verdict === 'landed');
|
|
93
|
+
const pending = classified.filter((c) => c.verdict === 'pending');
|
|
94
|
+
|
|
95
|
+
if (flags.json && !flags.execute) {
|
|
96
|
+
console.log(JSON.stringify({ terminal, container, landed, pending }, null, 2));
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(`reconcile against '${container}' (terminal branch '${terminal}'):`);
|
|
101
|
+
if (!classified.length) console.log(' ℹ no open/landed lanes to reconcile');
|
|
102
|
+
for (const c of pending) console.log(` · pending: ${c.lane.id} — ${c.reason}`);
|
|
103
|
+
for (const c of landed) console.log(` ✓ landed: ${c.lane.id} — ${c.reason}`);
|
|
104
|
+
|
|
105
|
+
if (!flags.execute) {
|
|
106
|
+
if (landed.length) {
|
|
107
|
+
console.log(`✓ ${landed.length} lane(s) cleanable — run with --execute to clean worktree + branch + state`);
|
|
108
|
+
} else {
|
|
109
|
+
console.log('✓ nothing to clean');
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let failures = 0;
|
|
115
|
+
for (const { lane } of landed) {
|
|
116
|
+
console.log(`cleaning '${lane.id}'…`);
|
|
117
|
+
const res = cleanup.cleanupTarget({
|
|
118
|
+
cwd, branch: lane.branch, worktree: lane.worktree, laneId: lane.id,
|
|
119
|
+
deleteRemote: true, force: Boolean(flags.force),
|
|
120
|
+
});
|
|
121
|
+
const glyph = { removed: '✓', deleted: '✓', cleared: '✓', skipped: 'ℹ', blocked: '⚠' };
|
|
122
|
+
for (const a of res.actions) console.log(` ${glyph[a.status] || '·'} ${a.step}: ${a.detail}`);
|
|
123
|
+
if (!res.ok) { failures++; console.log(` ⚠ '${lane.id}' cleanup incomplete: ${res.blocked.join(', ')}`); }
|
|
124
|
+
}
|
|
125
|
+
console.log(`✓ reconcile complete — cleaned ${landed.length - failures}/${landed.length} landed lane(s); ${pending.length} still pending`);
|
|
126
|
+
return failures ? 1 : 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { cmdReconcile, classify, terminalBranch };
|
package/package.json
CHANGED