[phase-1] cleanup: journaled legacy migration
Summary
Promotes the legacy single-binary Nu install → versioned-layout transition to a journaled transaction (Prepared → Renamed → Active) tracked at state/migration-journal.json. Reconciliation lives at two layers: a self-healing reconcile(root)? at the top of every migrate_legacy_install_with_detector call, plus an explicit numan doctor --fix (Auto-tier) catch-up for users who never called numan use.
This branch is the second half of the split that landed alongside #67 — the numan-use feature PR stays small, the journaled migration lands as its own phase-1 cleanup PR.
Changes
New: src/nu/migrate_legacy.rs
Extracted from nu::version_manager:
pub type LegacyVersionDetector— injectable version-detection seam for tests.pub type LegacyPostCreateHook— fired aftercreate_dir_all(<version>/)and before the rename, simulating the original cross-device-rename bug.pub fn detect_legacy_version(binary)— production detector (VERSION metadata file preferred, fall back tonu --version).pub fn migrate_legacy_install(root)andpub fn migrate_legacy_install_with_detector(root, detect, post_create)— journaled migration with self-healing reconcile at entry.fn parse_nu_version_from_output(output)(private helper).- 11 tests spanning: skip-when-no-legacy, skip-when-any-versioned-dir-present-but-cleanup-the-empty-sibling, post-create-hook recovery, partial-failure proceed, journal-cleared-on-success,
Created-only journal reconcile, version-metadata-file detection, etc.
New: src/state/migration_journal.rs
The journal file format, lifecycle stages, and the reconcile(root) self-heal implementation.
SCHEMA_VERSION,PendingMigration { schema_version, version, stage },MigrationStage::{ Prepared, Renamed, Active }.PendingMigration::save/delete/loadagainststate/migration-journal.json(write-after-mkdir-stub viaatomic::write_json_atomic).reconcile(root)— for eachPrepared-only entry: remove the orphan<root>/tools/nushell/<version>/subdir the failedcreate_dir_allleft behind, thendeletethe journal. File-system truth takes precedence over journal stage.
Updated: src/cmd/doctor.rs
- New finding
journal.migration_pending(Severity::Warn, RepairTier::Auto, fix hintnuman use). - New repair path during
apply_repairs: when the finding is present ANDPendingMigration::load(...).is_some(), callsmigration_journal::reconcile(root)and records the result asjournal.migration_repaired. - Two new unit tests:
doctor_reports_migration_journal_findinganddoctor_fix_reconciles_migration_journal.
Updated: src/util/hints.rs
pub const CMD_USE: &str = "numan use";— fix-hint for the migration journal finding.
Updated: src/state/mod.rs
pub mod migration_journal;
Updated: src/nu/version_manager.rs
write_active_markerispub(crate)(consumed bymigrate_legacy::migrate_legacy_install_with_detector). All other migration-related code and tests moved out.
Updated: src/cmd/use_cmd.rs
- Once this branch ships,
numan useshould callcrate::nu::migrate_legacy::migrate_legacy_install(root)(andwrite_active_markerstays as the public hook).
Testing
cargo test --lib— 468 passed (26 added: 24 migrate_legacy_* + 2 doctor migration-finding).cargo clippy -- -D warningsclean.cargo fmt --checkclean.
Architecture Notes
The migration is a journaled transaction with two explicit stages and three transitions:
Preparedwritten beforecreate_dir_all(<version>/).Renamedwritten after the legacy binary has been moved into<version>/<bin>.Activereached afterwrite_active_version(root, &version)succeeds.- On reaching
Active, the journal is deleted. - If a process is killed mid-flight, the next
numan useinvocation ornuman doctor --fixreconciles by removing the orphan empty<version>/directory the failedcreate_dir_allleft behind, then clearing the journal — never auto-deleting a populated install.
This is consistent with Numan's wider journal pattern (journal.rs, autoload_journal.rs, lifecycle_journal.rs, plugin_deactivate_journal.rs): on-disk state is the source of truth, and journals exist to detect interrupted transitions, not to bypass the filesystem.
Future Work
- Extend
reconcileto pick up aRenamed-only entry by completing thewrite_active_versionstep when the on-tree binary exists (i.e., a fully renamed but unactivated install). - Add CI acceptance matrix entries that toggle the journal during fixture migration to exercise every
Prepared-only,Renamed-only, andActive-reached state. - Wire the
write_active_markermigration call into a follow-up PR that re-adds the migrate line incmd/use_cmd.rs(post-merge gating by feature flag, then unconditionally after one release cycle).