12345678module migrate910import json211import os12import src.events13import src.history14import src.snapshot15import src.storage1617struct Fixture {18 dir string19 first string20 second string21 message string22}232425fn koh2_binary() ?string {26 for name in ['koh2', 'koh'] {27 path := os.find_abs_path_of_executable(name) or { continue }28 if os.execute('${os.quoted_path(path)} --version').output.starts_with('koh v2') {29 return path30 }31 }32 return none33}3435fn sh(dir string, command string) !string {36 result := os.execute('cd ${dir} && ${command}')37 if result.exit_code != 0 {38 return error('${command} failed: ${result.output}')39 }40 return result.output41}4243fn make_fixture(koh2 string) !Fixture {44 dir := os.join_path(os.temp_dir(), 'koh3_migrate_${os.getpid()}')45 os.rmdir_all(dir) or {}46 os.mkdir_all(dir, os.MkdirParams{})!47 sh(dir, '${koh2} init')!48 49 os.write_file(os.join_path(dir, 'big.txt'), 'x'.repeat(700))!50 os.write_file(os.join_path(dir, 'small.txt'), 'tiny\n')!51 sh(dir, '${koh2} save "first save"')!52 sh(dir, '${koh2} checkpoint "before something"')!53 os.write_file(os.join_path(dir, 'big.txt'), 'y'.repeat(700))!54 sh(dir, '${koh2} save "second save"')!5556 57 58 59 mut first := ''60 for name in os.ls(os.join_path(dir, '.koh', 'snapshots'))! {61 if !name.ends_with('.json') {62 continue63 }64 record :=65 json2.decode[Manifest](os.read_file(os.join_path(dir, '.koh', 'snapshots', name))!)!66 if record.message == 'first save' {67 first = record.id68 }69 }70 assert first != ''71 return Fixture{72 dir: dir73 first: first74 second: koh2_head(dir)75 message: 'first save'76 }77}787980fn koh2_head(dir string) string {81 db := storage.connect(os.join_path(dir, '.koh', 'koh.db'), 1000) or { return '' }82 defer {83 db.close()84 }85 mut stmt := db.prepare('SELECT value FROM state WHERE key = ?') or { return '' }86 defer {87 stmt.finalize()88 }89 stmt.bind_text(1, 'head_main') or { return '' }90 if stmt.step() or { return '' } {91 return stmt.text(0)92 }93 return ''94}9596fn test_migrating_a_koh2_repo_keeps_every_save_exactly() {97 koh2 := koh2_binary() or { return }98 fixture := make_fixture(koh2)!99 defer {100 os.rmdir_all(fixture.dir) or {}101 }102 103 sh(fixture.dir, '${koh2} flag ${fixture.first} --reason "sketchy"')!104105 106 mut manifests := map[string][]string{}107 for name in os.ls(os.join_path(fixture.dir, '.koh', 'snapshots'))! {108 if !name.ends_with('.json') {109 continue110 }111 record := json2.decode[Manifest](os.read_file(os.join_path(fixture.dir, '.koh', 'snapshots', name))!)!112 mut files := []string{}113 for file in record.files {114 files << '${file.path} ${bare_hash(file.hash)}'115 }116 manifests[record.id] = files117 }118119 report := run(fixture.dir, default_lane)!120 121 assert report.lane == 'main'122 assert report.saves == 3123 assert report.checkpoints == 1124 assert report.flags == 1125 assert report.objects >= 3126127 128 assert os.is_file(os.join_path(fixture.dir, '.koh'))129 assert os.is_dir(os.join_path(fixture.dir, '.koh-v2'))130 assert os.is_file(os.join_path(fixture.dir, '.kohignore'))131132 store := storage.open(fixture.dir)!133 defer {134 store.close()135 }136 137 assert history.save_count(store)! == 3138 for id, expected in manifests {139 save := history.by_id(store, id) or {140 assert false, 'save ${id} did not survive the migration'141 return142 }143 assert save.machine == 'koh2'144 mut actual := []string{}145 for file in history.files_of(store, id)! {146 actual << '${file.path} ${file.hash}'147 148 if _ := store.get_object(file.hash) {149 } else {150 assert false, 'object ${file.hash} is missing or corrupt'151 }152 }153 assert actual == expected154 }155 156 middle := history.by_id(store, fixture.first) or { panic('the first save did not survive') }157 assert (history.head(store) or { panic('no head after migrating') }).id == fixture.second158 assert middle.message == 'first save'159160 161 162 assert events.flag_state(store, fixture.first)!.flagged163 assert events.flag_state(store, fixture.first)!.reason == 'sketchy'164 entries := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))!165 mut changed := []string{}166 head_files := snapshot.index_files(history.files_of(store, fixture.second)!)167 for entry in entries {168 if entry.path !in head_files {169 changed << entry.path170 } else if head_files[entry.path].hash != entry.hash {171 changed << entry.path172 }173 }174 175 assert changed == ['.kohignore']176}177178179180fn test_migration_renames_the_koh_2_build_file() {181 koh2 := koh2_binary() or { return }182 fixture := make_fixture(koh2)!183 defer {184 os.rmdir_all(fixture.dir) or {}185 }186 build_file := 'command = true\noutput = out/demo\nplatforms = macos-aarch64\n'187 os.write_file(os.join_path(fixture.dir, 'kepr.build'), build_file)!188 sh(fixture.dir, '${koh2} save "add the build file"')!189190 report := run(fixture.dir, default_lane)!191 assert report.build_renamed192 assert !os.exists(os.join_path(fixture.dir, 'kepr.build'))193 assert os.read_file(os.join_path(fixture.dir, 'koh.build'))! == build_file194195 store := storage.open(fixture.dir)!196 defer {197 store.close()198 }199 head := history.head(store) or { panic('no head after migrating') }200 assert head.message == 'migrate: rename kepr.build to koh.build'201 files := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))!202 assert snapshot.same_tree(history.files_of(store, head.id)!, files)203}204205206207fn test_migration_refuses_two_build_files_without_moving_anything() {208 koh2 := koh2_binary() or { return }209 fixture := make_fixture(koh2)!210 defer {211 os.rmdir_all(fixture.dir) or {}212 }213 os.write_file(os.join_path(fixture.dir, 'kepr.build'), 'command = true\n')!214 os.write_file(os.join_path(fixture.dir, 'koh.build'), 'command = true\n')!215 sh(fixture.dir, '${koh2} save "both build files"')!216 if _ := run(fixture.dir, default_lane) {217 assert false, 'both build files must be refused'218 } else {219 assert err.msg().contains('both kepr.build and koh.build')220 }221 assert os.is_dir(os.join_path(fixture.dir, '.koh'))222 assert !os.exists(os.join_path(fixture.dir, '.koh-v2'))223}224225226fn test_migration_without_a_build_file_is_unchanged() {227 koh2 := koh2_binary() or { return }228 fixture := make_fixture(koh2)!229 defer {230 os.rmdir_all(fixture.dir) or {}231 }232 report := run(fixture.dir, default_lane)!233 assert !report.build_renamed234 assert !os.exists(os.join_path(fixture.dir, 'koh.build'))235 assert os.is_dir(os.join_path(fixture.dir, '.koh-v2'))236 store := storage.open(fixture.dir)!237 defer {238 store.close()239 }240 head := history.head(store) or { panic('no head after migrating') }241 242 243 244 saved := history.files_of(store, head.id)!245 mut unsaved := []string{}246 for file in snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))! {247 if !saved.any(it.path == file.path) {248 unsaved << file.path249 }250 }251 assert unsaved == [snapshot.ignore_file]252}253254fn test_migrate_refuses_a_tree_that_does_not_match_the_last_save() {255 koh2 := koh2_binary() or { return }256 fixture := make_fixture(koh2)!257 defer {258 os.rmdir_all(fixture.dir) or {}259 }260 os.write_file(os.join_path(fixture.dir, 'big.txt'), 'unsaved change\n')!261 if _ := run(fixture.dir, default_lane) {262 assert false, 'a dirty tree must be refused'263 } else {264 assert err.msg().contains('save with koh2 first')265 }266 267 assert os.is_dir(os.join_path(fixture.dir, '.koh'))268 assert !os.exists(os.join_path(fixture.dir, '.koh-v2'))269 assert !os.exists(os.join_path(fixture.dir, draft_file))270}271272273fn test_a_saved_path_is_only_read_inside_the_repo() {274 root := os.join_path(os.temp_dir(), 'koh3_migrate_paths_${os.getpid()}', 'repo')275 os.mkdir_all(root)!276 defer {277 os.rmdir_all(os.dir(root)) or {}278 }279 os.write_file(os.join_path(os.dir(root), 'outside.txt'), 'secret\n')!280 os.write_file(os.join_path(root, 'inside.txt'), 'fine\n')!281 assert disk_hash(root, 'inside.txt') != none282 for path in ['../outside.txt', '/etc/hosts', './inside.txt', 'a//inside.txt'] {283 assert disk_hash(root, path) == none, path284 }285}286287fn test_disk_hash_uses_the_shared_path_rule() {288 root := os.join_path(os.temp_dir(), 'koh3_migrate_rule_${os.getpid()}')289 os.mkdir_all(root)!290 defer {291 os.rmdir_all(root) or {}292 }293 for name in ['back\\slash', 'Icon\r'] {294 295 os.write_file('${root}/${name}', 'odd\n')!296 assert disk_hash(root, name) == none, name297 }298}299300fn test_unsafe_manifest_paths_are_dropped_and_named_once() {301 mut line := [302 Manifest{303 id: 'a'304 files: [ManifestFile{305 path: 'ok.txt'306 }, ManifestFile{307 path: 'Icon\r'308 }]309 },310 Manifest{311 id: 'b'312 files: [ManifestFile{313 path: 'Icon\r'314 }, ManifestFile{315 path: '../x'316 }]317 },318 ]319 assert drop_unsafe_paths(mut line) == ['../x', 'Icon\\x0d']320 assert line[0].files.map(it.path) == ['ok.txt']321 assert line[1].files.len == 0322}