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>
This commit is contained in:
Rezolv
2026-06-21 23:21:39 -04:00
committed by GitHub
parent 3857912ff6
commit 9acd0208cd
4 changed files with 149 additions and 16 deletions

View File

@@ -492,14 +492,6 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
throw new Error('Working tree is dirty. Commit or stash changes before migrating.');
}
// Capture HEAD sha for rollback
let headSha: string;
try {
headSha = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', windowsHide: true }).trim();
} catch (err) {
throw new Error(`git rev-parse HEAD failed: ${(err as Error).message}`);
}
const pDir = planningDir(cwd);
const phasesDir = path.join(pDir, 'phases');
const roadmapPath = path.join(pDir, 'ROADMAP.md');
@@ -508,6 +500,23 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
const renamedDirs: string[] = [];
const editedFiles: string[] = [];
// Surgical, git-independent rollback state (#1542). A `git reset --hard` +
// `git clean` rollback restores NOTHING for a gitignored `.planning/`
// (commit_docs:false — the default) and is a whole-repo operation besides.
// Instead, record the exact renames performed and snapshot each file before
// rewriting it, then undo precisely those on failure — correct whether
// `.planning/` is git-tracked or ignored.
const performedRenames: Array<{ oldPath: string; newPath: string }> = [];
const fileBackups = new Map<string, { existed: boolean; content: string }>();
const snapshotFile = (filePath: string): void => {
if (fileBackups.has(filePath)) return;
try {
fileBackups.set(filePath, { existed: true, content: fs.readFileSync(filePath, 'utf8') });
} catch {
fileBackups.set(filePath, { existed: false, content: '' });
}
};
try {
// 1. Rename phase directories
for (const phaseEntry of plan.phases) {
@@ -515,6 +524,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
const newPath = path.join(phasesDir, phaseEntry.newDir);
if (fs.existsSync(oldPath)) {
fs.renameSync(oldPath, newPath);
performedRenames.push({ oldPath, newPath });
renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
}
}
@@ -532,6 +542,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
}
}
snapshotFile(roadmapPath);
fs.writeFileSync(roadmapPath, lines.join('\n'), 'utf8');
editedFiles.push('ROADMAP.md');
}
@@ -561,6 +572,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
}
if (changed) {
snapshotFile(filePath);
fs.writeFileSync(filePath, content, 'utf8');
editedFiles.push(fileName);
}
@@ -573,18 +585,28 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
} catch { /* config may not exist yet */ }
configData['phase_id_convention'] = 'milestone-prefixed';
snapshotFile(configPath);
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
editedFiles.push('config.json');
} catch (err) {
// Rollback via git reset --hard + git clean
try {
execSync(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe', windowsHide: true });
execSync('git clean -fd .planning/phases/', { cwd, stdio: 'pipe', windowsHide: true });
} catch {
// Swallow rollback errors — surface original error
// Surgical rollback: reverse the renames (newest first) and restore every
// file we snapshotted (deleting files that did not previously exist). This
// actually restores `.planning/` regardless of git tracking — so the
// "rolled back" claim is truthful — and never touches anything else.
for (let i = performedRenames.length - 1; i >= 0; i--) {
const { oldPath, newPath } = performedRenames[i];
try {
if (fs.existsSync(newPath)) fs.renameSync(newPath, oldPath);
} catch { /* best-effort */ }
}
throw new Error(`Migration failed (rolled back to ${headSha}): ${(err as Error).message}`);
for (const [filePath, backup] of fileBackups) {
try {
if (backup.existed) fs.writeFileSync(filePath, backup.content, 'utf8');
else if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch { /* best-effort */ }
}
throw new Error(`Migration failed and rolled back: ${(err as Error).message}`);
}
return { applied: true, renamedDirs, editedFiles };