Files
msd-core/tests/roadmap-upgrade.test.cjs
Rezolv 9acd0208cd fix(#1542): roadmap upgrade rollback restores .planning regardless of git tracking (#1543)
* fix(core): roadmap upgrade rollback must restore .planning regardless of git tracking (#1542)

applyMigration rolled back a failed migration with git reset --hard + git clean
-fd .planning/phases/. For a commit_docs:false project (.planning gitignored —
the default) that restores NOTHING (reset ignores untracked, clean without -x
skips ignored), yet it threw 'Migration failed (rolled back to <sha>)' — a false
claim leaving .planning half-migrated. git reset --hard is also a whole-repo op.

Replace it with a surgical, git-independent rollback: record the exact renames
performed and snapshot each file before rewriting it, then on failure reverse the
renames and restore the snapshots (deleting files that did not previously exist).
Correct whether .planning is tracked or ignored; touches only what it changed.

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz

* chore(changeset): Fixed fragment for #1543 (roadmap upgrade surgical rollback)

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz

* test(core): update bug-685 execSync count floor after surgical rollback (#1542)

The #1542 surgical, git-independent rollback removed the rev-parse/reset/clean
git execSync calls from roadmap-upgrade.cts, leaving only the git status
precondition. bug-685 asserted calls.length >= 4; lower the floor to >= 1 — the
durable guard (every remaining git execSync sets windowsHide:true) is unchanged.

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-21 23:21:39 -04:00

104 lines
4.5 KiB
JavaScript

'use strict';
const { test, describe, mock } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { computeMigrationPlan, applyMigration } = require('../gsd-core/bin/lib/roadmap-upgrade.cjs');
/**
* Build a git project whose `.planning/` is GITIGNORED (commit_docs:false) —
* the condition under which the old `git reset --hard` + `git clean -fd`
* rollback restored nothing yet still reported "rolled back". #1542.
*/
function makeGitignoredPlanningProject() {
const dir = createTempDir('m3-rollback-');
fs.writeFileSync(path.join(dir, '.gitignore'), '.planning/\n');
fs.writeFileSync(path.join(dir, 'README.md'), '# tracked\n');
const git = (c) => execSync(c, { cwd: dir, stdio: 'pipe' });
git('git init');
git('git config user.email t@t.t');
git('git config user.name t');
git('git config commit.gpgsign false');
git('git add -A');
git('git commit -m initial');
// .planning created AFTER the commit → untracked + gitignored.
const planning = path.join(dir, '.planning');
fs.mkdirSync(path.join(planning, 'phases', '01-foo'), { recursive: true });
fs.mkdirSync(path.join(planning, 'phases', '02-bar'), { recursive: true });
fs.writeFileSync(path.join(planning, 'phases', '01-foo', 'PLAN.md'), 'foo plan\n');
fs.writeFileSync(path.join(planning, 'phases', '02-bar', 'PLAN.md'), 'bar plan\n');
fs.writeFileSync(
path.join(planning, 'ROADMAP.md'),
['## v1.0: First Milestone', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'),
);
return dir;
}
function snapshotPlanning(dir) {
const planning = path.join(dir, '.planning');
return {
phases: fs.readdirSync(path.join(planning, 'phases')).sort(),
roadmap: fs.readFileSync(path.join(planning, 'ROADMAP.md'), 'utf8'),
hasConfig: fs.existsSync(path.join(planning, 'config.json')),
};
}
describe('roadmap upgrade rollback (#1542)', () => {
test('a mid-migration failure restores .planning even when it is gitignored', (t) => {
const dir = makeGitignoredPlanningProject();
t.after(() => cleanup(dir));
const plan = computeMigrationPlan(dir);
assert.equal(plan.alreadyMigrated, false);
assert.ok(plan.phases.length >= 1, 'fixture must produce phase renames');
assert.ok(plan.roadmapEdits.length >= 1, 'fixture must produce roadmap edits');
const before = snapshotPlanning(dir);
assert.equal(before.hasConfig, false, 'precondition: no config.json yet');
// Inject a failure on the LAST mutation step (the config.json write) so the
// phase renames AND the ROADMAP rewrite have already happened when rollback
// fires — exactly the half-migrated state the old git rollback could not undo.
const realWrite = fs.writeFileSync;
const writeMock = mock.method(fs, 'writeFileSync', function (target, data, opts) {
if (String(target).endsWith('config.json')) {
const err = new Error('EIO: simulated write failure');
err.code = 'EIO';
throw err;
}
return realWrite.call(fs, target, data, opts);
});
t.after(() => writeMock.mock.restore());
assert.throws(() => applyMigration(dir, plan, { dryRun: false }), /Migration failed/);
// The rollback must have actually restored the workspace — not just claimed to.
const after = snapshotPlanning(dir);
assert.deepEqual(after.phases, before.phases, 'phase dirs must be restored to their original names');
assert.equal(after.roadmap, before.roadmap, 'ROADMAP.md must be restored to its original content');
assert.equal(after.hasConfig, false, 'config.json created during migration must be removed on rollback');
});
test('a successful migration still applies (renames + roadmap rewrite + config), no rollback', (t) => {
const dir = makeGitignoredPlanningProject();
t.after(() => cleanup(dir));
const plan = computeMigrationPlan(dir);
const before = snapshotPlanning(dir);
const result = applyMigration(dir, plan, { dryRun: false });
assert.equal(result.applied, true);
const after = snapshotPlanning(dir);
assert.notDeepEqual(after.phases, before.phases, 'phase dirs renamed on success');
assert.equal(after.hasConfig, true, 'config.json written on success');
const config = JSON.parse(fs.readFileSync(path.join(dir, '.planning', 'config.json'), 'utf8'));
assert.equal(config.phase_id_convention, 'milestone-prefixed');
});
});