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
7 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.2.111+3.2.12

view file

shell/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 }

view file

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.root

view file

src/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}')

view file

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')

view file

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'

view file

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 }

view file