test(execute): pin a multi-session path that never force-deletes (red)
Round 2 of the review of the session-order flow. One invariant closes the class: Phase 2.55, 2.6 and 7.6 remove worktrees only without force and branches only with -d; a refusal keeps the work and is reported. These tests execute the bash blocks of commands/trekexecute.md in throwaway repos with linked worktrees: - Check 4 and Step 5 remove a clean worktree and a merged branch, keep an unmerged branch and a worktree with untracked files, and list them - Step 1 stops on a branch or worktree left by an earlier attempt - Step 3 stops on a dirty main checkout (B1) and tells a refused merge (no MERGE_HEAD) from a real conflict; after a merged wave it records the wave and drops only identical, untracked, archived progress copies - Phase 2.5, Check 4 and Step 0 resolve the main checkout and refuse a linked worktree (B2); Step 0 derives a main-relative plan path - Check 1 does not count .claude/trekplan-sessions/ as a dirty tree - wave.json and merged-waves.json carry the plan sha256 and refuse another plan or version, naming the run and the clear command (B3); Step 6, not Phase 8, clears on every wave merged + Step 4 passed One test is a structural guard over the text (no force flag in those phases); the executed blocks pin the behavior. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
07db6cfd51
commit
b4a0283c94
1 changed files with 356 additions and 36 deletions
|
|
@ -6,11 +6,20 @@
|
|||
// pin the collect side: a running session is never torn down, progress files
|
||||
// outlive their worktrees, merged waves are remembered, and the prose in
|
||||
// commands/trekexecute.md calls these helpers in that order.
|
||||
//
|
||||
// The git side of the multi-session path is pinned by EXECUTING the bash
|
||||
// blocks of commands/trekexecute.md in throwaway repos (with linked worktrees):
|
||||
// nothing is force-deleted, a refusal keeps the work and says so, a dirty main
|
||||
// checkout or a refused merge stops before anything changes, and a linked
|
||||
// worktree is never mistaken for the main checkout. One test is a STRUCTURAL
|
||||
// guard over the text (no force flag anywhere in Phase 2.55/2.6/7.6); the
|
||||
// executed blocks are what pin the behavior.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, chmodSync, cpSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, chmodSync, cpSync, realpathSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -66,6 +75,78 @@ function withRepo(fn) {
|
|||
}
|
||||
}
|
||||
|
||||
// The nth ```bash fence after `anchor` (and before `end`, when given).
|
||||
function bashBlock(anchor, n = 0, end = null) {
|
||||
let at = EXEC.indexOf(anchor);
|
||||
assert.ok(at >= 0, `trekexecute.md has no "${anchor}"`);
|
||||
const limit = end ? EXEC.indexOf(end, at + anchor.length) : EXEC.length;
|
||||
let body = null;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const open = EXEC.indexOf('```bash', at);
|
||||
assert.ok(open >= 0 && open < limit, `no bash block #${i} after "${anchor}"${end ? ` before "${end}"` : ''}`);
|
||||
const start = EXEC.indexOf('\n', open) + 1;
|
||||
const close = EXEC.indexOf('```', start);
|
||||
body = EXEC.slice(start, close);
|
||||
at = close + 3;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function fill(script, values) {
|
||||
let out = script;
|
||||
for (const [k, v] of Object.entries(values)) out = out.split(`{${k}}`).join(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
const BASH = existsSync('/bin/bash') ? '/bin/bash' : 'bash';
|
||||
function runBash(script, cwd, env = {}) {
|
||||
return spawnSync(BASH, ['-c', script], { cwd, encoding: 'utf-8', env: { ...process.env, ...env } });
|
||||
}
|
||||
|
||||
function gitc(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
||||
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function branches(root) {
|
||||
return gitc(root, 'for-each-ref', '--format=%(refname:short)', 'refs/heads/trek/demo/').split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
function hasMergeHead(root) {
|
||||
return spawnSync('git', ['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: root }).status === 0;
|
||||
}
|
||||
|
||||
// withRepo, as a git repo: the fixture plan committed, a local identity, no hooks.
|
||||
function withGitRepo(fn) {
|
||||
return withRepo((dir) => {
|
||||
const root = realpathSync(dir);
|
||||
gitc(root, 'init', '-q');
|
||||
gitc(root, 'config', 'user.email', 'test@example.invalid');
|
||||
gitc(root, 'config', 'user.name', 'test');
|
||||
gitc(root, 'config', 'core.hooksPath', '/dev/null');
|
||||
gitc(root, 'add', '.');
|
||||
gitc(root, 'commit', '-q', '-m', 'init');
|
||||
return fn(root);
|
||||
});
|
||||
}
|
||||
|
||||
// A session worktree + branch the way Step 1 makes them, optionally with a
|
||||
// committed file (unmerged work) or an untracked file (a dirty worktree).
|
||||
function addSession(root, n, { commit = null, untracked = null } = {}) {
|
||||
const wt = join(root, '.claude', 'trekplan-sessions', 'demo', 'worktrees', `session-${n}`);
|
||||
gitc(root, 'worktree', 'add', '-q', '-b', `trek/demo/session-${n}`, wt, 'HEAD');
|
||||
if (commit) {
|
||||
writeFileSync(join(wt, commit), `session ${n}\n`);
|
||||
gitc(wt, 'add', commit);
|
||||
gitc(wt, 'commit', '-q', '-m', `session ${n}`);
|
||||
}
|
||||
if (untracked) writeFileSync(join(wt, untracked), 'notes\n');
|
||||
return wt;
|
||||
}
|
||||
|
||||
const sha256 = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
|
||||
test('parseExecutionStrategy reads sessions, waves, step ranges, dependencies and cwd', () => {
|
||||
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
|
||||
assert.deepEqual(s.waves, [1, 2]);
|
||||
|
|
@ -164,7 +245,7 @@ test('CLI status: pending until each session writes its progress file (at the li
|
|||
|
||||
putProgress(sessionDir, 2, { status: 'completed' });
|
||||
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
|
||||
st = waveStatus({ strategy: s, wave: 1, planPath: PLAN_REL, sessionDir });
|
||||
st = waveStatus({ strategy: s, wave: 1, planPath: PLAN_REL, planSha256: sha256(join(root, PLAN_REL)), sessionDir });
|
||||
assert.equal(st.all_completed, true);
|
||||
});
|
||||
});
|
||||
|
|
@ -286,7 +367,7 @@ test('clear refuses while a wave awaits its sessions; the record refuses another
|
|||
cpSync(FIXTURE, join(root, 'other', 'demo.md'));
|
||||
const o = cli(root, sessionDir, 'next-wave', '--plan', 'other/demo.md');
|
||||
assert.equal(o.status, 1);
|
||||
assert.match(o.stderr, /belongs to \.claude\/plans\/demo\.md/);
|
||||
assert.match(o.stderr, /unfinished multi-session run of \.claude\/plans\/demo\.md/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -311,41 +392,12 @@ test('trekexecute.md Step 3 archives before any worktree goes, records the merge
|
|||
}
|
||||
const step6 = section('### Step 6 — Aggregate results', '### --session N mode');
|
||||
assert.ok(step6.includes('$ORDERS_DIR/wave-*-session-*.progress.json'), 'Step 6 reads the archived copies');
|
||||
assert.ok(step6.includes('lib/util/session-orders.mjs" clear'), 'Step 6 clears the orders dir');
|
||||
assert.match(step6, /`none`[\s\S]*Step 4 passed|Step 4 passed[\s\S]*`none`/,
|
||||
'the clear is keyed on every wave merged AND Step 4 passed, not on the Phase 8 label');
|
||||
|
||||
const p8 = section('## Phase 8 — Final report', '## Phase 9 —');
|
||||
assert.ok(p8.includes('lib/util/session-orders.mjs" clear'), 'Phase 8 states when the orders dir is cleared');
|
||||
});
|
||||
|
||||
test('trekexecute.md: the wave-in-flight guard is anchored at the git toplevel, and Check 4 refuses to clean while it exists', () => {
|
||||
const p25 = section('## Phase 2.5 —', '## Phase 2.55');
|
||||
assert.ok(p25.includes('$(git rev-parse --show-toplevel)/.claude/trekplan-sessions/{slug}/orders/wave.json'),
|
||||
'Phase 2.5 must look for wave.json under the git toplevel');
|
||||
assert.doesNotMatch(p25, /`\.claude\/trekplan-sessions\/\{slug\}\/orders\/wave\.json`/, 'no cwd-relative wave.json check');
|
||||
|
||||
const c4 = section('### Check 4 —', 'After all 4 checks pass');
|
||||
const fence = c4.indexOf('```bash');
|
||||
assert.ok(fence >= 0 && fence < c4.indexOf('git worktree remove'), 'Check 4 opens with its guard block');
|
||||
const body = c4.slice(c4.indexOf('\n', fence) + 1, c4.indexOf('```', fence + 7));
|
||||
assert.match(body, /wave\.json/);
|
||||
|
||||
const repo = mkdtempSync(join(tmpdir(), 'check4-'));
|
||||
try {
|
||||
assert.equal(spawnSync('git', ['init', '-q'], { cwd: repo }).status, 0);
|
||||
const sub = join(repo, 'plugins', 'demo');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
const orders = join(repo, '.claude', 'trekplan-sessions', 'demo', 'orders');
|
||||
mkdirSync(orders, { recursive: true });
|
||||
const script = body.split('{slug}').join('demo');
|
||||
writeFileSync(join(orders, 'wave.json'), '{}\n');
|
||||
const refused = spawnSync('bash', ['-c', script], { cwd: sub, encoding: 'utf-8' });
|
||||
assert.notEqual(refused.status, 0, 'from a subdirectory, a wave in flight must stop Check 4');
|
||||
assert.match(refused.stderr, /wave\.json/);
|
||||
rmSync(join(orders, 'wave.json'));
|
||||
const passed = spawnSync('bash', ['-c', script], { cwd: sub, encoding: 'utf-8' });
|
||||
assert.equal(passed.status, 0, passed.stderr);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
assert.ok(!p8.includes('lib/util/session-orders.mjs" clear'), 'Phase 8 no longer decides the clear');
|
||||
});
|
||||
|
||||
test('trekexecute.md: Phase 7.6 promises no unwired re-run and names a path that still exists; Hard rule 12 carries the awaiting-sessions exception', () => {
|
||||
|
|
@ -355,8 +407,276 @@ test('trekexecute.md: Phase 7.6 promises no unwired re-run and names a path that
|
|||
assert.doesNotMatch(p76, /run this same \/trekexecute command again|On that later run/,
|
||||
'nothing routes a later run to a recovery in progress');
|
||||
|
||||
const rule12 = section('12. **Worktree cleanup is unconditional.**', '13. **');
|
||||
const rule12 = section('12. **Worktree cleanup', '13. **');
|
||||
assert.match(rule12, /never while a wave awaits its sessions/);
|
||||
assert.match(rule12, /never with force/i, 'Hard rule 12 carries the no-force invariant');
|
||||
});
|
||||
|
||||
test('wave.json and the merged-waves record carry the plan sha256; a changed plan is refused, naming the run that holds the orders dir and how to clear it', () => {
|
||||
withRepo((root) => {
|
||||
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
|
||||
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
|
||||
const sha = sha256(join(root, PLAN_REL));
|
||||
assert.equal(run('write', '--wave', '1').status, 0);
|
||||
assert.equal(JSON.parse(readFileSync(join(sessionDir, 'orders', 'wave.json'), 'utf-8')).plan_sha256, sha);
|
||||
assert.ok(readFileSync(join(sessionDir, 'orders', 'wave-1-session-1.md'), 'utf-8')
|
||||
.includes(`- progress file: ${join(sessionDir, PROGRESS_REL[1])}`), 'the order names its progress file on a line of its own');
|
||||
assert.equal(run('record-merged', '--wave', '1').status, 0);
|
||||
assert.equal(JSON.parse(readFileSync(join(sessionDir, 'orders', 'merged-waves.json'), 'utf-8')).plan_sha256, sha);
|
||||
|
||||
writeFileSync(join(root, PLAN_REL), readFileSync(FIXTURE, 'utf-8') + '\n<!-- edited -->\n');
|
||||
const st = run('status', '--wave', '1');
|
||||
assert.equal(st.status, 1, 'a wave written for another version of the plan is not collected');
|
||||
assert.match(st.stderr, /wave\.json/);
|
||||
rmSync(join(sessionDir, 'orders', 'wave.json'));
|
||||
const nx = run('next-wave');
|
||||
assert.equal(nx.status, 1, 'a record written for another version of the plan is refused');
|
||||
assert.ok(nx.stderr.includes(join(sessionDir, 'orders')), 'the message names the orders dir');
|
||||
assert.match(nx.stderr, /unfinished multi-session run of \.claude\/plans\/demo\.md/);
|
||||
assert.match(nx.stderr, /clear --session-dir/);
|
||||
});
|
||||
});
|
||||
|
||||
// STRUCTURAL GUARD over the text, not a behavior test: the invariant is that
|
||||
// the multi-session path never force-deletes. The executed-block tests below
|
||||
// pin the behavior; this one only catches a force flag creeping back in.
|
||||
test('structural guard: Phase 2.55, 2.6 and 7.6 carry no force-delete of a worktree, a branch or a directory', () => {
|
||||
const text = [section('## Phase 2.55 —', '## Phase 3 '), section('## Phase 7.6 —', '## Phase 8 —')]
|
||||
.join('\n').replace(/\\\n\s*/g, ' ');
|
||||
const FORBIDDEN = [
|
||||
/worktree\s+remove\b.*(--force|\s-f\b)/,
|
||||
/\bbranch\b.*(\s-[a-zA-Z]*D\b|\s--force\b|\s-f\b)/,
|
||||
/\brm\s+-[a-zA-Z]*r/,
|
||||
/\bgit\s+clean\b/,
|
||||
/\breset\s+--hard\b/,
|
||||
];
|
||||
assert.deepEqual(text.split('\n').filter(l => FORBIDDEN.some(re => re.test(l))), []);
|
||||
});
|
||||
|
||||
test('Phase 2.5 finds a wave in flight from any subdirectory of the main checkout and refuses a linked worktree', () => {
|
||||
withGitRepo((root) => {
|
||||
const block = fill(bashBlock('## Phase 2.5 —', 0, '## Phase 2.55'), { slug: 'demo' });
|
||||
assert.doesNotMatch(block, /--show-toplevel/, 'a linked worktree has its own toplevel');
|
||||
const sub = join(root, 'plugins', 'demo');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
let r = runBash(block, sub);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout.trim(), 'NO_WAVE');
|
||||
const orders = join(root, '.claude', 'trekplan-sessions', 'demo', 'orders');
|
||||
mkdirSync(orders, { recursive: true });
|
||||
writeFileSync(join(orders, 'wave.json'), '{}\n');
|
||||
r = runBash(block, sub);
|
||||
assert.equal(r.stdout.trim(), 'WAVE_IN_FLIGHT');
|
||||
const wt = addSession(root, 1);
|
||||
r = runBash(block, wt);
|
||||
assert.equal(r.status, 1, 'run from a session worktree');
|
||||
assert.match(r.stderr, /main checkout/);
|
||||
assert.ok(r.stderr.includes(root), 'the message names the main checkout');
|
||||
});
|
||||
});
|
||||
|
||||
test('Phase 2.55 Check 1 does not count Voyage\'s own session state as uncommitted work', () => {
|
||||
const c1 = section('### Check 1 —', '### Check 2 —');
|
||||
assert.ok(c1.includes("git status --porcelain -- ':(top,exclude).claude/trekplan-sessions/'"));
|
||||
withGitRepo((root) => {
|
||||
addSession(root, 1);
|
||||
mkdirSync(join(root, '.claude', 'trekplan-sessions', 'demo', 'orders'), { recursive: true });
|
||||
writeFileSync(join(root, '.claude', 'trekplan-sessions', 'demo', 'orders', 'merged-waves.json'), '{}\n');
|
||||
const sub = join(root, 'plugins');
|
||||
mkdirSync(sub);
|
||||
const r = spawnSync('git', ['status', '--porcelain', '--', ':(top,exclude).claude/trekplan-sessions/'], { cwd: sub, encoding: 'utf-8' });
|
||||
assert.equal(r.stdout, '', 'orders and session worktrees are not a dirty tree');
|
||||
writeFileSync(join(root, PLAN_REL), 'edited\n');
|
||||
const d = spawnSync('git', ['status', '--porcelain', '--', ':(top,exclude).claude/trekplan-sessions/'], { cwd: sub, encoding: 'utf-8' });
|
||||
assert.match(d.stdout, /demo\.md/, 'a real change anywhere in the repo still is');
|
||||
});
|
||||
});
|
||||
|
||||
test('Check 4 refuses from a linked worktree and while a wave is in flight, and removes only what holds no work', () => {
|
||||
withGitRepo((root) => {
|
||||
const block = fill(bashBlock('### Check 4 —'), { slug: 'demo' });
|
||||
const s1 = addSession(root, 1, { commit: 's1.txt' }); // clean worktree, unmerged commit
|
||||
const s2 = addSession(root, 2, { untracked: 'notes.txt' }); // untracked file, branch at HEAD
|
||||
gitc(root, 'branch', 'trek/demo/session-3'); // merged, no worktree
|
||||
const orders = join(root, '.claude', 'trekplan-sessions', 'demo', 'orders');
|
||||
mkdirSync(orders, { recursive: true });
|
||||
writeFileSync(join(orders, 'wave.json'), '{}\n');
|
||||
const sub = join(root, 'plugins', 'demo');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
|
||||
let r = runBash(block, s2);
|
||||
assert.equal(r.status, 1, 'run from a session worktree');
|
||||
assert.match(r.stderr, /main checkout/);
|
||||
r = runBash(block, sub);
|
||||
assert.equal(r.status, 1, 'from a subdirectory, a wave in flight stops Check 4');
|
||||
assert.match(r.stderr, /wave\.json/);
|
||||
assert.ok(existsSync(s1) && existsSync(s2), 'nothing removed');
|
||||
assert.equal(branches(root).length, 3);
|
||||
|
||||
rmSync(join(orders, 'wave.json'));
|
||||
r = runBash(block, sub);
|
||||
assert.equal(r.status, 1, 'kept work stops the pre-flight');
|
||||
assert.equal(existsSync(s1), false, 'a clean worktree goes; its branch keeps the commit');
|
||||
assert.ok(existsSync(join(s2, 'notes.txt')), 'a worktree with untracked files is kept');
|
||||
assert.deepEqual(branches(root), ['trek/demo/session-1', 'trek/demo/session-2'],
|
||||
'the unmerged branch and the one checked out in a kept worktree stay; the merged one goes');
|
||||
const out = r.stdout + r.stderr;
|
||||
assert.match(out, /KEPT worktree .*session-2/);
|
||||
assert.match(out, /KEPT branch trek\/demo\/session-1/);
|
||||
});
|
||||
});
|
||||
|
||||
test('Step 5 removes only what holds no work, lists every kept worktree and branch, and drops the wave marker', () => {
|
||||
withGitRepo((root) => {
|
||||
const sd = join(root, '.claude', 'trekplan-sessions', 'demo');
|
||||
const s1 = addSession(root, 1, { commit: 's1.txt' });
|
||||
const s2 = addSession(root, 2, { untracked: 'notes.txt' });
|
||||
gitc(root, 'branch', 'trek/demo/session-3');
|
||||
mkdirSync(join(sd, 'orders'), { recursive: true });
|
||||
writeFileSync(join(sd, 'orders', 'wave.json'), '{}\n');
|
||||
const block = fill(bashBlock('### Step 5 — Cleanup'), { slug: 'demo' });
|
||||
const r = runBash(block, root, { REPO_ROOT: root, WORKTREE_DIR: join(sd, 'worktrees'), ORDERS_DIR: join(sd, 'orders') });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(existsSync(s1), false);
|
||||
assert.ok(existsSync(join(s2, 'notes.txt')));
|
||||
assert.deepEqual(branches(root), ['trek/demo/session-1', 'trek/demo/session-2']);
|
||||
assert.match(r.stdout, /KEPT worktree .*session-2/);
|
||||
assert.match(r.stdout, /KEPT branch trek\/demo\/session-1/);
|
||||
assert.equal(existsSync(join(sd, 'orders', 'wave.json')), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('Step 0 resolves the main checkout and a main-relative plan path from a subdirectory, and refuses a linked worktree', () => {
|
||||
withGitRepo((root) => {
|
||||
const env = { CLAUDE_PLUGIN_ROOT: ROOT };
|
||||
const show = '\nprintf "%s\\n" "$REPO_ROOT" "$PLAN_REL"\n';
|
||||
const sub = join(root, 'plugins', 'demo');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
const r = runBash(fill(bashBlock('### Step 0 — Resolve paths'), { slug: 'demo', 'plan-path': '../../.claude/plans/demo.md' }) + show, sub, env);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.deepEqual(r.stdout.trim().split('\n').slice(-2), [root, PLAN_REL]);
|
||||
const wt = addSession(root, 1);
|
||||
const l = runBash(fill(bashBlock('### Step 0 — Resolve paths'), { slug: 'demo', 'plan-path': PLAN_REL }) + show, wt, env);
|
||||
assert.equal(l.status, 1, 'run from a session worktree');
|
||||
assert.match(l.stderr, /main checkout/);
|
||||
});
|
||||
});
|
||||
|
||||
test('Step 1 stops on a session branch or worktree left by an earlier attempt, creating and deleting nothing', () => {
|
||||
withGitRepo((root) => {
|
||||
const wd = join(root, '.claude', 'trekplan-sessions', 'demo', 'worktrees');
|
||||
const block = fill(bashBlock('**2a. Create worktrees'), { slug: 'demo', 'wave W session numbers': '1 2' });
|
||||
const env = { WORKTREE_DIR: wd };
|
||||
gitc(root, 'branch', 'trek/demo/session-2');
|
||||
const tip = gitc(root, 'rev-parse', 'trek/demo/session-2');
|
||||
let r = runBash(block, root, env);
|
||||
assert.equal(r.status, 1);
|
||||
assert.match(r.stderr, /trek\/demo\/session-2/);
|
||||
assert.equal(gitc(root, 'rev-parse', 'trek/demo/session-2'), tip, 'the existing branch is untouched');
|
||||
assert.equal(existsSync(join(wd, 'session-1')), false, 'nothing created');
|
||||
|
||||
gitc(root, 'branch', '-d', 'trek/demo/session-2');
|
||||
mkdirSync(join(wd, 'session-1'), { recursive: true });
|
||||
r = runBash(block, root, env);
|
||||
assert.equal(r.status, 1);
|
||||
assert.match(r.stderr, /session-1/);
|
||||
rmSync(join(wd, 'session-1'), { recursive: true });
|
||||
r = runBash(block, root, env);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
});
|
||||
});
|
||||
|
||||
test('Step 3 merges only into a clean main checkout and tells a refused merge from a conflict', () => {
|
||||
withGitRepo((root) => {
|
||||
const env = { REPO_ROOT: root };
|
||||
const clean = bashBlock('**Merge session branches back');
|
||||
const merge = (n) => fill(bashBlock('**Merge session branches back', 1), { slug: 'demo', N: String(n), 'session-title': `Session ${n}` });
|
||||
addSession(root, 1, { commit: 's1.txt' });
|
||||
addSession(root, 2, { commit: 's2.txt' });
|
||||
const head = () => gitc(root, 'rev-parse', 'HEAD');
|
||||
const h0 = head();
|
||||
|
||||
writeFileSync(join(root, PLAN_REL), 'edited\n');
|
||||
let r = runBash(clean, root, env);
|
||||
assert.equal(r.status, 1, 'a dirty main checkout stops the merge');
|
||||
assert.match(r.stderr, /commit or stash your changes in/i);
|
||||
assert.ok(r.stderr.includes(root));
|
||||
gitc(root, 'checkout', '--', PLAN_REL);
|
||||
assert.equal(runBash(clean, root, env).status, 0);
|
||||
|
||||
writeFileSync(join(root, 's1.txt'), 'mine\n'); // untracked, in the merge's way
|
||||
r = runBash(merge(1), root, env);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stdout, /MERGE_REFUSED/);
|
||||
assert.equal(head(), h0, 'nothing merged');
|
||||
assert.equal(hasMergeHead(root), false);
|
||||
assert.equal(readFileSync(join(root, 's1.txt'), 'utf-8'), 'mine\n', 'the user file is untouched');
|
||||
rmSync(join(root, 's1.txt'));
|
||||
|
||||
writeFileSync(join(root, 's1.txt'), 'main\n');
|
||||
gitc(root, 'add', 's1.txt');
|
||||
gitc(root, 'commit', '-q', '-m', 'main has its own s1');
|
||||
const h1 = head();
|
||||
r = runBash(merge(1), root, env);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stdout, /MERGE_CONFLICT/);
|
||||
assert.equal(head(), h1);
|
||||
assert.equal(hasMergeHead(root), false, 'a real conflict is aborted');
|
||||
assert.deepEqual(branches(root), ['trek/demo/session-1', 'trek/demo/session-2']);
|
||||
|
||||
r = runBash(merge(2), root, env);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /MERGED/);
|
||||
assert.equal(spawnSync('git', ['merge-base', '--is-ancestor', 'trek/demo/session-2', 'HEAD'], { cwd: root }).status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Step 3 after a merged wave: records it, drops only archived untracked progress copies, removes worktrees and merged branches without force', () => {
|
||||
withGitRepo((root) => {
|
||||
const sd = join(root, '.claude', 'trekplan-sessions', 'demo');
|
||||
const s1 = addSession(root, 1, { commit: 's1.txt' });
|
||||
const s2 = addSession(root, 2, { commit: 's2.txt' });
|
||||
assert.equal(cli(root, sd, 'write', '--plan', PLAN_REL, '--wave', '1').status, 0);
|
||||
putProgress(sd, 1, { status: 'completed' });
|
||||
putProgress(sd, 2, { status: 'completed' });
|
||||
assert.equal(cli(root, sd, 'archive', '--plan', PLAN_REL, '--wave', '1').status, 0);
|
||||
gitc(root, 'merge', '--no-ff', '-q', '-m', 'merge 1', 'trek/demo/session-1');
|
||||
gitc(root, 'merge', '--no-ff', '-q', '-m', 'merge 2', 'trek/demo/session-2');
|
||||
// Session 2's worktree copy changed after the archive: it is not the record, so it stays.
|
||||
putProgress(sd, 2, { status: 'completed', note: 'written after the archive' });
|
||||
|
||||
const env = { REPO_ROOT: root, VOYAGE_ROOT: ROOT, SESSION_DIR: sd, ORDERS_DIR: join(sd, 'orders'), WORKTREE_DIR: join(sd, 'worktrees'), PLAN_REL };
|
||||
const record = runBash(fill(bashBlock('**Record the merged wave'), { W: '1' }), root, env);
|
||||
assert.equal(record.status, 0, record.stderr);
|
||||
const r = runBash(fill(bashBlock('**Record the merged wave', 1), { W: '1', slug: 'demo', 'wave W session numbers': '1 2' }), root, env);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
|
||||
assert.equal(existsSync(s1), false, 'session 1: identical archived copy dropped, clean worktree removed');
|
||||
assert.ok(existsSync(join(sd, 'orders', 'wave-1-session-1.progress.json')), 'the archived copy stays');
|
||||
assert.ok(existsSync(join(sd, PROGRESS_REL[2])), 'a progress file that differs from its archive is never deleted');
|
||||
assert.match(r.stdout, /KEPT worktree .*session-2/);
|
||||
assert.deepEqual(branches(root), ['trek/demo/session-2'], 'merged branch 1 deleted; branch 2 stays with its kept worktree');
|
||||
assert.equal(existsSync(join(sd, 'orders', 'wave.json')), false);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(sd, 'orders', 'merged-waves.json'), 'utf-8')).waves, [1]);
|
||||
});
|
||||
});
|
||||
|
||||
test('every session-orders.mjs call runs from the main checkout with the main-relative plan path', () => {
|
||||
const calls = EXEC.replace(/\\\n\s*/g, ' ').split('\n').filter(l => l.includes('lib/util/session-orders.mjs"'));
|
||||
assert.ok(calls.length >= 7, `expected the write/status/archive/record-merged/next-wave/clear calls, saw ${calls.length}`);
|
||||
for (const l of calls) {
|
||||
assert.match(l, /\(cd "\$REPO_ROOT" && node "\$VOYAGE_ROOT\/lib\/util\/session-orders\.mjs"/, l);
|
||||
if (!/session-orders\.mjs" clear/.test(l)) assert.match(l, /--plan "\$PLAN_REL"/, l);
|
||||
}
|
||||
});
|
||||
|
||||
test('trekexecute.md: the stop paths name their way out', () => {
|
||||
const step0 = section('### Step 0 — Resolve paths', '### Step 1 —');
|
||||
assert.match(step0, /`next-wave` exits 1[\s\S]*unfinished multi-session run[\s\S]*session-orders\.mjs" clear/,
|
||||
'Step 0 tells the user which run holds the orders dir and how to clear it');
|
||||
const step3 = section('### Step 3 — Collect wave W', '### Step 4 —');
|
||||
assert.match(step3, /\*\*Way out of a stuck wave\*\*/);
|
||||
assert.match(step3, /MERGE_REFUSED[\s\S]*keep `wave\.json`/i, 'a refused merge stops and keeps the wave in flight');
|
||||
});
|
||||
|
||||
test('CLI refuses a plan without an Execution Strategy, loudly', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue