* 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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user