15b6b8d6c6d5

let migrate accept a clean Koh 2 tree holding files Koh 2 never saved or saved under a later folder rule, so real repos migrate (3.0.44)

time
2026-09-24 15:21 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
koh 3 reliability campaign
parent
ba25a6819e6f
5 files VERSION+1 −1src/migrate/migrate.v+35 −4src/migrate/migrate_test.v+15 −0tests/cli_test.v+42 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.431+3.0.44

view file

src/migrate/migrate.v+35 −4
@@ -251,18 +251,49 @@251251 		actual[entry.path] = entry.hash252252 	}253253 	for path, hash in expected {254-		if path !in actual {255-			return error('${path} is missing from the working tree — save with koh2 first')254+		// Koh 2 kept saving files under a folder pattern that Koh 3 reads as255+		// covering the folder, so a saved path is checked on disk even when256+		// the walk skipped it.257+		have := actual[path] or {258+			disk_hash(root, path) or {259+				return error('${path} is missing from the working tree — save with koh2 first')260+			}256261 		}257-		if actual[path] != hash {262+		if have != hash {258263 			return error('${path} does not match the last save — save with koh2 first')259264 		}260265 	}261266 	for path in actual.keys() {262-		if path !in expected {267+		if path !in expected && !koh2_never_saved(path) {263268 			return error('${path} is not in the last save — save with koh2 first')264269 		}265270 	}271+}272+273+// disk_hash hashes a regular file inside the repo. Koh 2 never followed links,274+// nor does Koh 3, so a link on the way to it means the saved file is missing.275+fn disk_hash(root string, path string) ?string {276+	// Only a plain path inside the repo; a damaged manifest cannot point out.277+	if path.starts_with('/') || path.split('/').any(it in ['', '.', '..']) {278+		return none279+	}280+	full := os.join_path(root, path)281+	if !os.is_file(full) || os.is_link(full) {282+		return none283+	}284+	snapshot.check_links(root, [snapshot.FileEntry{285+		path: path286+	}]) or { return none }287+	return storage.hash_hex(os.read_bytes(full) or { return none })288+}289+290+// koh2_never_saved mirrors the hard ignores in Koh 2's snapshot.zig that Koh 3291+// does not share, so a clean Koh 2 tree reads as clean. Koh 2 prunes a folder292+// with such a name too, so every segment counts. The files stay where they are.293+fn koh2_never_saved(path string) bool {294+	parts := path.split('/')295+	return parts[0] == '.kohconfig' || parts[0].starts_with('.kohignore.')296+		|| parts.any(it.ends_with('.bundle'))266297 }267298 268299 fn import_and_verify(store &storage.Store, root string, line []Manifest, mut report Report) ! {

view file

src/migrate/migrate_test.v+15 −0
@@ -187,3 +187,18 @@187187 	assert !os.exists(os.join_path(fixture.dir, '.koh-v2'))188188 	assert !os.exists(os.join_path(fixture.dir, staging_file))189189 }190+191+// A damaged manifest naming a path outside the repo must never be read there.192+fn test_a_saved_path_is_only_read_inside_the_repo() {193+	root := os.join_path(os.temp_dir(), 'koh3_migrate_paths_${os.getpid()}', 'repo')194+	os.mkdir_all(root)!195+	defer {196+		os.rmdir_all(os.dir(root)) or {}197+	}198+	os.write_file(os.join_path(os.dir(root), 'outside.txt'), 'secret\n')!199+	os.write_file(os.join_path(root, 'inside.txt'), 'fine\n')!200+	assert disk_hash(root, 'inside.txt') != none201+	for path in ['../outside.txt', '/etc/hosts', './inside.txt', 'a//inside.txt'] {202+		assert disk_hash(root, path) == none, path203+	}204+}

view file

tests/cli_test.v+42 −0
@@ -896,3 +896,45 @@896896 	assert !os.exists(os.join_path(dir, '.koh-v2'))897897 	assert !os.exists(os.join_path(dir, '.koh-migrate'))898898 }899+900+// Koh 2 never saved a root `.kohconfig`, root `.kohignore.<remote>` rules or901+// `*.bundle` files, so a clean Koh 2 repo may hold them. Most real repos have902+// a `.kohconfig`; migrate must not call them unsaved work.903+fn test_migrate_passes_over_the_files_koh2_never_saved() {904+	koh2 := koh2_binary()905+	if koh2 == '' {906+		return907+	}908+	dir := temp_repo('migrate-koh2-ignores')909+	defer { os.rmdir_all(dir) or {} }910+	home := temp_repo('migrate-koh2-ignores-home')911+	defer { os.rmdir_all(home) or {} }912+	run_koh2(dir, '${q(koh2)} init')913+	write(dir, 'code.txt', 'x'.repeat(700))914+	run_koh2(dir, '${q(koh2)} save "only save"')915+	write(dir, '.kohconfig', 'stable_days = 14\n')916+	write(dir, '.kohignore.public', 'private/\n')917+	write(dir, 'lib/app.bundle', 'bundle\n')918+	write(dir, 'ios/Settings.bundle/Root.plist', 'settings\n')919+	// Koh 2 keeps saving a file under a folder pattern added later, where920+	// Koh 3 reads the same rule as covering the folder: the saved file is on921+	// disk and matches, so the tree is clean.922+	write(dir, 'native/t.dSYM/Info.plist', 'plist\n')923+	run_koh2(dir, '${q(koh2)} save --agent test --session test "add the dsym"')924+	// Koh 2 skips *.bundle even when its rules no longer list it.925+	ignore_path := os.join_path(dir, '.koh', 'ignore')926+	rules := os.read_file(ignore_path)!.split_into_lines().filter(it != '*.bundle')927+	os.write_file(ignore_path, rules.join('\n') + '\nnative/*.dSYM\n')!928+	// A saved file the walk skips still has to match what was saved.929+	write(dir, 'native/t.dSYM/Info.plist', 'changed\n')930+	changed := run_home(dir, home, 'migrate')931+	assert changed.code == 1932+	assert changed.err.contains('native/t.dSYM/Info.plist does not match')933+	write(dir, 'native/t.dSYM/Info.plist', 'plist\n')934+	assert os.execute('cd ${q(dir)} && ${q(koh2)} status --json').output.contains('"dirty":false')935+936+	migrated := run_home(dir, home, 'migrate')937+	assert migrated.code == 0, migrated.err938+	// They stay where they were, for the user to keep or delete.939+	assert os.read_file(os.join_path(dir, '.kohconfig'))! == 'stable_days = 14\n'940+}

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.43'3+	version: '3.0.44'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file