12345678910module migrate1112import encoding.base6413import json214import os15import src.build16import src.events17import src.history18import src.snapshot19import src.storage2021pub const koh2_dir = '.koh'22pub const archive_dir = '.koh-v2'232425const kepr_build_file = 'kepr.build'26pub const draft_file = '.koh-migrate'27pub const default_lane = 'dev'282930313233struct ManifestFile {34 path string35 hash string36 size i6437 object bool38 inline ?string39}4041struct Manifest {42 id string43 lane string44 message string45 timestamp i6446 parent_id ?string47 size_bytes i6448 agent ?string49 session ?string50 is_checkpoint bool51 flagged bool52 flag_reason ?string53mut:54 files []ManifestFile55}5657pub struct Report {58pub mut:59 root string60 lane string61 saves int62 checkpoints int63 flags int64 objects int65 inlined int66 head string67 ignore_carried bool68 log_md_was_tracked bool69 build_renamed bool70 unsafe_paths []string71}727374pub struct Plan {75 root string76 line []Manifest77 unsafe_paths []string78pub:79 lane string80 saves int81 checkpoints int82 objects int83 last_saved i6484 renames_build bool85}868788pub type Progress = fn (stage string, done int, total int)8990919293pub fn prepare(root string, lane string) !Plan {94 if !os.is_dir(os.join_path(root, koh2_dir, 'snapshots')) {95 return error('no Koh 2 repo here — migrate expects a .koh/ folder with snapshots/')96 }97 manifests := read_manifests(root)!98 if manifests.len == 0 {99 return error('the Koh 2 repo has no manifests to import')100 }101 102 103 104 mut chosen := lane105 head := lane_head(root, manifests, chosen) or {106 if chosen != default_lane {107 return err108 }109 chosen = other_lane(manifests) or { return err }110 lane_head(root, manifests, chosen)!111 }112 mut line := ancestry(manifests, head)!113 unsafe_paths := drop_unsafe_paths(mut line)114 check_clean_tree(root, line[line.len - 1])!115 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}134135136pub fn run(plan Plan, progress Progress) !Report {137 root := plan.root138 line := plan.line139 mut report := Report{140 root: root141 lane: plan.lane142 head: line[line.len - 1].id143 unsafe_paths: plan.unsafe_paths144 }145 146 draft_path := os.join_path(root, draft_file)147 if os.exists(draft_path) {148 return error('${draft_path} already exists — a previous migrate did not finish; move it away and try again')149 }150 mut draft := storage.create_at(draft_path, root)!151 152 153 154 mut failure := ''155 if _ := import_and_verify(draft, root, line, mut report, progress) {156 report.objects = int(draft.object_count() or { 0 })157 } else {158 failure = err.msg()159 }160 draft.close()161 if failure != '' {162 return error(failure)163 }164 swap(root, mut report)!165 rename_build_file(root, mut report)!166 report.saves = line.len167 report.checkpoints = plan.checkpoints168 for manifest in line {169 if manifest.flagged {170 report.flags++171 }172 for file in manifest.files {173 if file.inline != none {174 report.inlined++175 }176 }177 }178 return report179}180181fn read_manifests(root string) ![]Manifest {182 dir := os.join_path(root, koh2_dir, 'snapshots')183 mut manifests := []Manifest{}184 for name in os.ls(dir)! {185 if !name.ends_with('.json') {186 continue187 }188 body := os.read_file(os.join_path(dir, name))!189 manifests << json2.decode[Manifest](body)!190 }191 return manifests192}193194195196fn lane_head(root string, manifests []Manifest, lane string) !Manifest {197 if recorded := state_head(root, lane) {198 for manifest in manifests {199 if manifest.id == recorded {200 return manifest201 }202 }203 }204 mut claimed := map[string]bool{}205 for manifest in manifests {206 if parent := manifest.parent_id {207 claimed[parent] = true208 }209 }210 mut candidates := []Manifest{}211 for manifest in manifests {212 if manifest.lane == lane && manifest.id !in claimed {213 candidates << manifest214 }215 }216 if candidates.len == 0 {217 return error('no saves on lane "${lane}"')218 }219 candidates.sort(a.timestamp < b.timestamp)220 return candidates[candidates.len - 1]221}222223224225fn other_lane(manifests []Manifest) ?string {226 mut lanes := map[string]bool{}227 for manifest in manifests {228 lanes[manifest.lane] = true229 }230 for lane in ['main', 'dev'] {231 if lane in lanes {232 return lane233 }234 }235 for lane in lanes.keys() {236 return lane237 }238 return none239}240241fn state_head(root string, lane string) ?string {242 db := os.join_path(root, koh2_dir, 'koh.db')243 if !os.is_file(db) {244 return none245 }246 store := storage.connect(db, 1000) or { return none }247 defer {248 store.close()249 }250 mut stmt := store.prepare('SELECT value FROM state WHERE key = ?') or { return none }251 defer {252 stmt.finalize()253 }254 stmt.bind_text(1, 'head_${lane}') or { return none }255 if stmt.step() or { return none } {256 value := stmt.text(0)257 if value != '' {258 return value259 }260 }261 return none262}263264265266fn ancestry(manifests []Manifest, head Manifest) ![]Manifest {267 mut by_id := map[string]Manifest{}268 for manifest in manifests {269 by_id[manifest.id] = manifest270 }271 mut line := []Manifest{}272 mut cursor := head273 for {274 line << cursor275 parent := cursor.parent_id or { break }276 if parent == '' {277 break278 }279 next := by_id[parent] or {280 return error('save ${cursor.id} names parent ${parent}, which is not in ${koh2_dir}/snapshots')281 }282 cursor = next283 }284 line.reverse_in_place()285 return line286}287288289290fn check_clean_tree(root string, head Manifest) ! {291 ignor := snapshot.parse(os.read_file(os.join_path(root, koh2_dir, 'ignore')) or {292 os.read_file(os.join_path(root, snapshot.ignore_file)) or { '' }293 })294 entries := snapshot.walk_raw(root, ignor)!295 mut expected := map[string]string{}296 for file in head.files {297 expected[file.path] = bare_hash(file.hash)298 }299 mut actual := map[string]string{}300 for entry in entries {301 actual[entry.path] = entry.hash302 }303 for path, hash in expected {304 305 306 307 have := actual[path] or {308 disk_hash(root, path) or {309 return error('${path} is missing from the working tree — save with koh2 first')310 }311 }312 if have != hash {313 return error('${path} does not match the last save — save with koh2 first')314 }315 }316 for path in actual.keys() {317 if path !in expected && !koh2_never_saved(path) {318 return error('${path} is not in the last save — save with koh2 first')319 }320 }321}322323324325fn disk_hash(root string, path string) ?string {326 327 if !snapshot.legal_path(path) {328 return none329 }330 full := os.join_path(root, path)331 if !os.is_file(full) || os.is_link(full) {332 return none333 }334 snapshot.check_links(root, [snapshot.FileEntry{335 path: path336 }]) or { return none }337 return storage.hash_hex(os.read_bytes(full) or { return none })338}339340341342343fn koh2_never_saved(path string) bool {344 parts := path.split('/')345 return parts[0] == '.kohconfig' || parts[0].starts_with('.kohignore.')346 || parts.any(it.ends_with('.bundle'))347}348349350351fn drop_unsafe_paths(mut line []Manifest) []string {352 mut dropped := map[string]bool{}353 for mut manifest in line {354 for file in manifest.files {355 if !snapshot.legal_path(file.path) {356 dropped[snapshot.escape_path(file.path)] = true357 }358 }359 manifest.files = manifest.files.filter(snapshot.legal_path(it.path))360 }361 mut names := dropped.keys()362 names.sort()363 return names364}365366fn import_and_verify(store &storage.Store, root string, line []Manifest, mut report Report, progress Progress) ! {367 store.begin()!368 if _ := import_line(store, root, line, mut report, progress) {369 store.end()!370 } else {371 store.rollback()372 return err373 }374 verify(store, line, progress)!375}376377fn 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)380 mut files := []snapshot.FileEntry{}381 for file in manifest.files {382 hash := bare_hash(file.hash)383 if encoded := file.inline {384 data := base64_decode(encoded)!385 stored := store.put_object(data)!386 if stored != hash {387 return error('the inlined ${file.path} in save ${manifest.id} does not hash to ${hash}')388 }389 } else if file.object {390 blob := read_object(root, hash)!391 store.put_object_blob(hash, blob)!392 } else {393 return error('${file.path} in save ${manifest.id} is neither an object nor inlined')394 }395 files << snapshot.FileEntry{396 path: file.path397 hash: hash398 }399 }400 save := history.Save{401 id: manifest.id402 parent: manifest.parent_id or { '' }403 message: manifest.message404 created_at: manifest.timestamp405 machine: 'koh2'406 is_checkpoint: manifest.is_checkpoint407 size_bytes: manifest.size_bytes408 agent: manifest.agent or { '' }409 session: manifest.session or { '' }410 }411 history.import_save(store, save, files)!412 if manifest.flagged {413 events.flag(store, manifest.id, manifest.flag_reason or { '' })!414 }415 report.log_md_was_tracked = report.log_md_was_tracked || has_path(files, 'LOG.md')416 }417 418 history.set_head(store, line[line.len - 1].id)!419 return true420}421422fn has_path(files []snapshot.FileEntry, path string) bool {423 for file in files {424 if file.path == path {425 return true426 }427 }428 return false429}430431fn read_object(root string, hash string) ![]u8 {432 path := os.join_path(root, koh2_dir, 'objects', hash[..2], hash[2..])433 return os.read_bytes(path) or {434 return error('object ${hash} is missing from ${koh2_dir}/objects')435 }436}437438fn verify(store &storage.Store, line []Manifest, progress Progress) ! {439 imported := history.save_count(store)!440 if imported != line.len {441 return error('imported ${imported} saves, expected ${line.len}')442 }443 for n, manifest in line {444 progress('checking', n + 1, line.len)445 files := history.files_of(store, manifest.id)!446 if files.len != manifest.files.len {447 return error('save ${manifest.id} has ${files.len} files, its manifest has ${manifest.files.len}')448 }449 for i, file in manifest.files {450 hash := bare_hash(file.hash)451 if files[i].path != file.path || files[i].hash != hash {452 return error('save ${manifest.id}: ${files[i].path} does not match its manifest')453 }454 store.get_object(hash) or {455 return error('save ${manifest.id} needs object ${hash}: ${err.msg()}')456 }457 }458 }459}460461462fn swap(root string, mut report Report) ! {463 old := os.join_path(root, koh2_dir)464 archive := os.join_path(root, archive_dir)465 if os.exists(archive) {466 return error('${archive} already exists — move it away first')467 }468 os.mv(old, archive, os.MvParams{})!469 os.mv(os.join_path(root, draft_file), os.join_path(root, storage.file_name), os.MvParams{})!470 471 472 ignore_source := os.join_path(archive, 'ignore')473 ignore_target := os.join_path(root, snapshot.ignore_file)474 if os.is_file(ignore_source) && !os.exists(ignore_target) {475 body := os.read_file(ignore_source)!476 os.write_file(ignore_target, body)!477 report.ignore_carried = true478 }479}480481482483fn check_build_files(root string) ! {484 if os.is_file(os.join_path(root, kepr_build_file))485 && os.exists(os.join_path(root, build.config_name)) {486 return error('both ${kepr_build_file} and ${build.config_name} exist — keep the one you want and migrate again')487 }488}489490491492493fn rename_build_file(root string, mut report Report) ! {494 old := os.join_path(root, kepr_build_file)495 if !os.is_file(old) {496 return497 }498 fresh := os.join_path(root, build.config_name)499 os.mv(old, fresh, os.MvParams{})!500 mut store := storage.open(root)!501 defer {502 store.close()503 }504 history.save_worktree(store, root, snapshot.load(root), history.SaveInput{505 message: 'migrate: rename ${kepr_build_file} to ${build.config_name}'506 })!507 report.build_renamed = true508}509510511fn bare_hash(value string) string {512 if at := value.index(':') {513 return value[at + 1..]514 }515 return value516}517518519fn base64_decode(encoded string) ![]u8 {520 return base64.decode(encoded)521}