[phase-1] cleanup: journaled legacy migration

Merged
TO
3mo ago
+3379-382

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 after create_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 to nu --version).
  • pub fn migrate_legacy_install(root) and pub 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 / load against state/migration-journal.json (write-after-mkdir-stub via atomic::write_json_atomic).
  • reconcile(root) — for each Prepared-only entry: remove the orphan <root>/tools/nushell/<version>/ subdir the failed create_dir_all left behind, then delete the journal. File-system truth takes precedence over journal stage.

Updated: src/cmd/doctor.rs

  • New finding journal.migration_pending (Severity::Warn, RepairTier::Auto, fix hint numan use).
  • New repair path during apply_repairs: when the finding is present AND PendingMigration::load(...).is_some(), calls migration_journal::reconcile(root) and records the result as journal.migration_repaired.
  • Two new unit tests: doctor_reports_migration_journal_finding and doctor_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_marker is pub(crate) (consumed by migrate_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 use should call crate::nu::migrate_legacy::migrate_legacy_install(root) (and write_active_marker stays as the public hook).

Testing

  • cargo test --lib — 468 passed (26 added: 24 migrate_legacy_* + 2 doctor migration-finding).
  • cargo clippy -- -D warnings clean.
  • cargo fmt --check clean.

Architecture Notes

The migration is a journaled transaction with two explicit stages and three transitions:

  • Prepared written before create_dir_all(<version>/).
  • Renamed written after the legacy binary has been moved into <version>/<bin>.
  • Active reached after write_active_version(root, &version) succeeds.
  • On reaching Active, the journal is deleted.
  • If a process is killed mid-flight, the next numan use invocation or numan doctor --fix reconciles by removing the orphan empty <version>/ directory the failed create_dir_all left 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 reconcile to pick up a Renamed-only entry by completing the write_active_version step 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, and Active-reached state.
  • Wire the write_active_marker migration call into a follow-up PR that re-adds the migrate line in cmd/use_cmd.rs (post-merge gating by feature flag, then unconditionally after one release cycle).