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
↓ koh-15b6b8d6.tar.gzatlas at this save
files changed5 files
VERSION+1 −1src/migrate/migrate.v+35 −4src/migrate/migrate_test.v+15 −0tests/cli_test.v+42 −0v.mod+1 −1src/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) ! {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+}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+}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 }