dc0dd30a22dc
migrate shows what it found and what it will do, asks first (--yes skips), counts saves as it imports and checks them, and reports each object once (3.2.12)
- time
- 2026-09-28 18:51 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- clear feedback
- parent
- f0d2d40f26a8
↓ koh-dc0dd30a.tar.gzatlas at this save
files changed7 files
VERSION+1 −1shell/koh3.plugin.zsh+1 −1src/cli/cmd_migrate.v+19 −3src/migrate/migrate.v+58 −19src/migrate/migrate_test.v+9 −5tests/cli_test.v+10 −5v.mod+1 −1shell/koh3.plugin.zsh+1 −1
@@ -165,7 +165,7 @@165165 steal) _arguments '--list' '--mine' '--theirs' '--json' ;;166166 strip) _arguments '--save[one save]:save:_koh3_ids' '*:path:_files' ;;167167 doctor) _arguments '--backup[copy .koh to]:path:_files' ;;168- migrate) _arguments '--lane[Koh 2 lane]:lane:(dev main)' ;;168+ migrate) _arguments '--lane[Koh 2 lane]:lane:(dev main)' '--yes[skip the question]' '--json' ;;169169 man) _describe 'command' commands ;;170170 esac171171 }src/cli/cmd_migrate.v+19 −3
@@ -8,11 +8,12 @@88 import src.machine99 import src.migrate1010 import src.snapshot11+import time1112 1213 pub fn cmd_migrate(raw []string) int {1314 args := parse('migrate', raw, Options{1415 values: ['lane']15- flags: ['json']16+ flags: ['json', 'yes']1617 }) or { return usage_error(err.msg()) }1718 mut lane := args.value('lane')1819 if lane == '' {@@ -21,8 +22,23 @@2122 if lane !in ['dev', 'main'] {2223 return usage_error('--lane takes dev or main, not "${lane}"')2324 }24- root := os.getwd()25- report := migrate.run(root, lane) or { return refuse(args.json(), err.msg()) }25+ plan := migrate.prepare(os.getwd(), lane) or { return refuse(args.json(), err.msg()) }26+ if !args.json() {27+ note('Koh 2 repo · lane ${plan.lane} · ${fmt.plural(plan.saves, 'save')} (${fmt.plural(plan.checkpoints, 'checkpoint')}) · ${fmt.plural(plan.objects, 'object')} · last save ${time.unix(plan.last_saved).ymmdd()}')28+ note('this will:')29+ note(' · copy the history into one .koh file and check every save')30+ if plan.renames_build {31+ note(' · rename kepr.build to koh.build, as its own save')32+ }33+ note(' · keep the Koh 2 data as ${migrate.archive_dir}/ — move it back to undo')34+ }35+ approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }36+ if !approved {37+ return refuse(args.json(), 'not migrating, then — nothing changed')38+ }39+ report := migrate.run(plan, fn (stage string, done int, total int) {40+ working('${stage} ${done} / ${total} saves')41+ }) or { return refuse(args.json(), err.msg()) }2642 if args.json() {2743 emit_json(json2.encode(MigrateJson{2844 repo: report.rootsrc/migrate/migrate.v+58 −19
@@ -70,9 +70,27 @@7070 unsafe_paths []string7171 }7272 73-// run converts the repo at `root`. `lane` picks which line to import: `dev`74-// unless told otherwise, because that is where the work is.75-pub fn run(root string, lane string) !Report {73+// Plan is what migrate found and will do, shown before anything is written.74+pub struct Plan {75+ root string76+ line []Manifest77+ unsafe_paths []string78+pub:79+ lane string80+ saves int81+ checkpoints int82+ objects int83+ last_saved i6484+ renames_build bool85+}86+87+// Progress hears how far a stage ("importing", "checking") has come.88+pub type Progress = fn (stage string, done int, total int)89+90+// prepare reads the repo at `root` and checks it can be converted, writing91+// nothing. `lane` picks which line to import: `dev` unless told otherwise,92+// because that is where the work is.93+pub fn prepare(root string, lane string) !Plan {7694 if !os.is_dir(os.join_path(root, koh2_dir, 'snapshots')) {7795 return error('no Koh 2 repo here — migrate expects a .koh/ folder with snapshots/')7896 }@@ -95,12 +113,34 @@95113 unsafe_paths := drop_unsafe_paths(mut line)96114 check_clean_tree(root, line[line.len - 1])!97115 check_build_files(root)!116+ mut hashes := map[string]bool{}117+ for manifest in line {118+ for file in manifest.files {119+ hashes[file.hash] = true120+ }121+ }122+ return Plan{123+ root: root124+ line: line125+ unsafe_paths: unsafe_paths126+ lane: chosen127+ saves: line.len128+ checkpoints: line.filter(it.is_checkpoint).len129+ objects: hashes.len130+ last_saved: head.timestamp131+ renames_build: os.is_file(os.join_path(root, kepr_build_file))132+ }133+}98134 135+// run converts the repo `prepare` read, telling `progress` as it goes.136+pub fn run(plan Plan, progress Progress) !Report {137+ root := plan.root138+ line := plan.line99139 mut report := Report{100140 root: root101- lane: chosen102- head: head.id103- unsafe_paths: unsafe_paths141+ lane: plan.lane142+ head: line[line.len - 1].id143+ unsafe_paths: plan.unsafe_paths104144 }105145 // Import into a draft file first: nothing is touched until it verifies.106146 draft_path := os.join_path(root, draft_file)@@ -112,7 +152,8 @@112152 // work in its WAL until the last connection goes away, and moving the file113153 // alone would leave that work behind.114154 mut failure := ''115- if _ := import_and_verify(draft, root, line, mut report) {155+ if _ := import_and_verify(draft, root, line, mut report, progress) {156+ report.objects = int(draft.object_count() or { 0 })116157 } else {117158 failure = err.msg()118159 }@@ -123,10 +164,8 @@123164 swap(root, mut report)!124165 rename_build_file(root, mut report)!125166 report.saves = line.len167+ report.checkpoints = plan.checkpoints126168 for manifest in line {127- if manifest.is_checkpoint {128- report.checkpoints++129- }130169 if manifest.flagged {131170 report.flags++132171 }@@ -324,19 +363,20 @@324363 return names325364 }326365 327-fn import_and_verify(store &storage.Store, root string, line []Manifest, mut report Report) ! {366+fn import_and_verify(store &storage.Store, root string, line []Manifest, mut report Report, progress Progress) ! {328367 store.begin()!329- if _ := import_line(store, root, line, mut report) {368+ if _ := import_line(store, root, line, mut report, progress) {330369 store.end()!331370 } else {332371 store.rollback()333372 return err334373 }335- verify(store, line)!374+ verify(store, line, progress)!336375 }337376 338-fn import_line(store &storage.Store, root string, line []Manifest, mut report Report) !bool {339- for manifest in line {377+fn import_line(store &storage.Store, root string, line []Manifest, mut report Report, progress Progress) !bool {378+ for i, manifest in line {379+ progress('importing', i + 1, line.len)340380 mut files := []snapshot.FileEntry{}341381 for file in manifest.files {342382 hash := bare_hash(file.hash)@@ -346,11 +386,9 @@346386 if stored != hash {347387 return error('the inlined ${file.path} in save ${manifest.id} does not hash to ${hash}')348388 }349- report.objects++350389 } else if file.object {351390 blob := read_object(root, hash)!352391 store.put_object_blob(hash, blob)!353- report.objects++354392 } else {355393 return error('${file.path} in save ${manifest.id} is neither an object nor inlined')356394 }@@ -397,12 +435,13 @@397435 }398436 }399437 400-fn verify(store &storage.Store, line []Manifest) ! {438+fn verify(store &storage.Store, line []Manifest, progress Progress) ! {401439 imported := history.save_count(store)!402440 if imported != line.len {403441 return error('imported ${imported} saves, expected ${line.len}')404442 }405- for manifest in line {443+ for n, manifest in line {444+ progress('checking', n + 1, line.len)406445 files := history.files_of(store, manifest.id)!407446 if files.len != manifest.files.len {408447 return error('save ${manifest.id} has ${files.len} files, its manifest has ${manifest.files.len}')src/migrate/migrate_test.v+9 −5
@@ -22,6 +22,8 @@2222 }2323 2424 // koh2_binary finds Koh 2: `koh2` since the cutover, `koh` before it, and never Koh 3.25+fn quiet(stage string, done int, total int) {}26+2527 fn koh2_binary() ?string {2628 for name in ['koh2', 'koh'] {2729 path := os.find_abs_path_of_executable(name) or { continue }@@ -116,7 +118,7 @@116118 manifests[record.id] = files117119 }118120 119- report := run(fixture.dir, default_lane)!121+ report := run(prepare(fixture.dir, default_lane)!, quiet)!120122 // The fixture lives on main, because that is the lane koh2 starts on.121123 assert report.lane == 'main'122124 assert report.saves == 3@@ -133,6 +135,8 @@133135 defer {134136 store.close()135137 }138+ // Objects are counted once each, not once per save that lists them.139+ assert report.objects == int(store.object_count()!)136140 // Every save kept its ID, its files and their hashes.137141 assert history.save_count(store)! == 3138142 for id, expected in manifests {@@ -187,7 +191,7 @@187191 os.write_file(os.join_path(fixture.dir, 'kepr.build'), build_file)!188192 sh(fixture.dir, '${koh2} save "add the build file"')!189193 190- report := run(fixture.dir, default_lane)!194+ report := run(prepare(fixture.dir, default_lane)!, quiet)!191195 assert report.build_renamed192196 assert !os.exists(os.join_path(fixture.dir, 'kepr.build'))193197 assert os.read_file(os.join_path(fixture.dir, 'koh.build'))! == build_file@@ -213,7 +217,7 @@213217 os.write_file(os.join_path(fixture.dir, 'kepr.build'), 'command = true\n')!214218 os.write_file(os.join_path(fixture.dir, 'koh.build'), 'command = true\n')!215219 sh(fixture.dir, '${koh2} save "both build files"')!216- if _ := run(fixture.dir, default_lane) {220+ if _ := prepare(fixture.dir, default_lane) {217221 assert false, 'both build files must be refused'218222 } else {219223 assert err.msg().contains('both kepr.build and koh.build')@@ -229,7 +233,7 @@229233 defer {230234 os.rmdir_all(fixture.dir) or {}231235 }232- report := run(fixture.dir, default_lane)!236+ report := run(prepare(fixture.dir, default_lane)!, quiet)!233237 assert !report.build_renamed234238 assert !os.exists(os.join_path(fixture.dir, 'koh.build'))235239 assert os.is_dir(os.join_path(fixture.dir, '.koh-v2'))@@ -258,7 +262,7 @@258262 os.rmdir_all(fixture.dir) or {}259263 }260264 os.write_file(os.join_path(fixture.dir, 'big.txt'), 'unsaved change\n')!261- if _ := run(fixture.dir, default_lane) {265+ if _ := prepare(fixture.dir, default_lane) {262266 assert false, 'a dirty tree must be refused'263267 } else {264268 assert err.msg().contains('save with koh2 first')tests/cli_test.v+10 −5
@@ -1419,8 +1419,13 @@14191419 original := save_ids(dir)14201420 assert original.len == 314211421 1422- report := run_home(dir, home, 'migrate')1422+ // Off a terminal it asks nothing and changes nothing without --yes.1423+ unasked := run_home(dir, home, 'migrate')1424+ assert unasked.code == 1 && unasked.err.contains('needs --yes'), unasked.err1425+ assert os.is_dir(os.join_path(dir, '.koh'))1426+ report := run_home(dir, home, 'migrate', '--yes')14231427 assert report.code == 01428+ assert report.out.contains('this will:')14241429 assert report.out.contains('migrated 3 saves')14251430 // The swap: `.koh` is a file, the old data is archived, nothing is left in the draft.14261431 assert os.is_file(os.join_path(dir, '.koh'))@@ -1438,7 +1443,7 @@14381443 assert run_home(dir, home, 'status').code == 014391444 14401445 // Migrating again has nothing to convert.1441- assert run_home(dir, home, 'migrate').code == 11446+ assert run_home(dir, home, 'migrate', '--yes').code == 114421447 }14431448 14441449 fn test_migrate_refuses_a_tree_that_does_not_match_the_last_save() {@@ -1455,7 +1460,7 @@14551460 run_koh2(dir, '${q(koh2)} save "only save"')14561461 write(dir, 'code.txt', 'unsaved change\n')14571462 1458- refused := run_home(dir, home, 'migrate')1463+ refused := run_home(dir, home, 'migrate', '--yes')14591464 assert refused.code == 114601465 assert refused.err.contains('save with koh2 first')14611466 // Nothing moved: still Koh 2, no archive, no draft file.@@ -1494,13 +1499,13 @@14941499 os.write_file(ignore_path, rules.join('\n') + '\nnative/*.dSYM\n')!14951500 // A saved file the walk skips still has to match what was saved.14961501 write(dir, 'native/t.dSYM/Info.plist', 'changed\n')1497- changed := run_home(dir, home, 'migrate')1502+ changed := run_home(dir, home, 'migrate', '--yes')14981503 assert changed.code == 114991504 assert changed.err.contains('native/t.dSYM/Info.plist does not match')15001505 write(dir, 'native/t.dSYM/Info.plist', 'plist\n')15011506 assert os.execute('cd ${q(dir)} && ${q(koh2)} status --json').output.contains('"dirty":false')15021507 1503- migrated := run_home(dir, home, 'migrate')1508+ migrated := run_home(dir, home, 'migrate', '--yes')15041509 assert migrated.code == 0, migrated.err15051510 // They stay where they were, for the user to keep or delete.15061511 assert os.read_file(os.join_path(dir, '.kohconfig'))! == 'stable_days = 14\n'v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.2.11'3+ version: '3.2.12'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }