12345678910module migrate1112import encoding.base6413import json214import os15import src.events16import src.history17import src.snapshot18import src.storage1920pub const koh2_dir = '.koh'21pub const archive_dir = '.koh-v2'22pub const staging_file = '.koh-migrate'23pub const default_lane = 'dev'242526272829struct ManifestFile {30 path string31 hash string32 size i6433 object bool34 inline ?string35}3637struct Manifest {38 id string39 lane string40 message string41 timestamp i6442 parent_id ?string43 size_bytes i6444 agent ?string45 session ?string46 is_checkpoint bool47 flagged bool48 flag_reason ?string49 files []ManifestFile50}5152pub struct Report {53pub mut:54 root string55 lane string56 saves int57 checkpoints int58 flags int59 objects int60 inlined int61 head string62 ignore_carried bool63 log_md_was_tracked bool64}65666768pub fn run(root string, lane string) !Report {69 if !os.is_dir(os.join_path(root, koh2_dir, 'snapshots')) {70 return error('no Koh 2 repo here — migrate expects a .koh/ folder with snapshots/')71 }72 manifests := read_manifests(root)!73 if manifests.len == 0 {74 return error('the Koh 2 repo has no manifests to import')75 }76 77 78 79 mut chosen := lane80 head := lane_head(root, manifests, chosen) or {81 if chosen != default_lane {82 return err83 }84 chosen = other_lane(manifests) or { return err }85 lane_head(root, manifests, chosen)!86 }87 line := ancestry(manifests, head)!88 check_clean_tree(root, head)!8990 mut report := Report{91 root: root92 lane: chosen93 head: head.id94 }95 96 staging := os.join_path(root, staging_file)97 if os.exists(staging) {98 return error('${staging} already exists — a previous migrate did not finish; move it away and try again')99 }100 mut staged := storage.create_at(staging, root)!101 102 103 104 mut failure := ''105 if _ := import_and_verify(staged, root, line, mut report) {106 } else {107 failure = err.msg()108 }109 staged.close()110 if failure != '' {111 return error(failure)112 }113 swap(root, mut report)!114 report.saves = line.len115 for manifest in line {116 if manifest.is_checkpoint {117 report.checkpoints++118 }119 if manifest.flagged {120 report.flags++121 }122 for file in manifest.files {123 if file.inline != none {124 report.inlined++125 }126 }127 }128 return report129}130131fn read_manifests(root string) ![]Manifest {132 dir := os.join_path(root, koh2_dir, 'snapshots')133 mut manifests := []Manifest{}134 for name in os.ls(dir)! {135 if !name.ends_with('.json') {136 continue137 }138 body := os.read_file(os.join_path(dir, name))!139 manifests << json2.decode[Manifest](body)!140 }141 return manifests142}143144145146fn lane_head(root string, manifests []Manifest, lane string) !Manifest {147 if recorded := state_head(root, lane) {148 for manifest in manifests {149 if manifest.id == recorded {150 return manifest151 }152 }153 }154 mut claimed := map[string]bool{}155 for manifest in manifests {156 if parent := manifest.parent_id {157 claimed[parent] = true158 }159 }160 mut candidates := []Manifest{}161 for manifest in manifests {162 if manifest.lane == lane && manifest.id !in claimed {163 candidates << manifest164 }165 }166 if candidates.len == 0 {167 return error('no saves on lane "${lane}"')168 }169 candidates.sort(a.timestamp < b.timestamp)170 return candidates[candidates.len - 1]171}172173174175fn other_lane(manifests []Manifest) ?string {176 mut lanes := map[string]bool{}177 for manifest in manifests {178 lanes[manifest.lane] = true179 }180 for lane in ['main', 'dev'] {181 if lane in lanes {182 return lane183 }184 }185 for lane in lanes.keys() {186 return lane187 }188 return none189}190191fn state_head(root string, lane string) ?string {192 db := os.join_path(root, koh2_dir, 'koh.db')193 if !os.is_file(db) {194 return none195 }196 store := storage.connect(db, 1000) or { return none }197 defer {198 store.close()199 }200 mut stmt := store.prepare('SELECT value FROM state WHERE key = ?') or { return none }201 defer {202 stmt.finalize()203 }204 stmt.bind_text(1, 'head_${lane}') or { return none }205 if stmt.step() or { return none } {206 value := stmt.text(0)207 if value != '' {208 return value209 }210 }211 return none212}213214215216fn ancestry(manifests []Manifest, head Manifest) ![]Manifest {217 mut by_id := map[string]Manifest{}218 for manifest in manifests {219 by_id[manifest.id] = manifest220 }221 mut line := []Manifest{}222 mut cursor := head223 for {224 line << cursor225 parent := cursor.parent_id or { break }226 if parent == '' {227 break228 }229 next := by_id[parent] or {230 return error('save ${cursor.id} names parent ${parent}, which is not in ${koh2_dir}/snapshots')231 }232 cursor = next233 }234 line.reverse_in_place()235 return line236}237238239240fn check_clean_tree(root string, head Manifest) ! {241 ignor := snapshot.parse(os.read_file(os.join_path(root, koh2_dir, 'ignore')) or {242 os.read_file(os.join_path(root, snapshot.ignore_file)) or { '' }243 })244 entries := snapshot.walk_raw(root, ignor)!245 mut expected := map[string]string{}246 for file in head.files {247 expected[file.path] = bare_hash(file.hash)248 }249 mut actual := map[string]string{}250 for entry in entries {251 actual[entry.path] = entry.hash252 }253 for path, hash in expected {254 255 256 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 }261 }262 if have != hash {263 return error('${path} does not match the last save — save with koh2 first')264 }265 }266 for path in actual.keys() {267 if path !in expected && !koh2_never_saved(path) {268 return error('${path} is not in the last save — save with koh2 first')269 }270 }271}272273274275fn disk_hash(root string, path string) ?string {276 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}289290291292293fn 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'))297}298299fn import_and_verify(store &storage.Store, root string, line []Manifest, mut report Report) ! {300 store.begin()!301 if _ := import_line(store, root, line, mut report) {302 store.commit()!303 } else {304 store.rollback()305 return err306 }307 verify(store, line)!308}309310fn import_line(store &storage.Store, root string, line []Manifest, mut report Report) !bool {311 for manifest in line {312 mut files := []snapshot.FileEntry{}313 for file in manifest.files {314 hash := bare_hash(file.hash)315 if encoded := file.inline {316 data := base64_decode(encoded)!317 stored := store.put_object(data)!318 if stored != hash {319 return error('the inlined ${file.path} in save ${manifest.id} does not hash to ${hash}')320 }321 report.objects++322 } else if file.object {323 blob := read_object(root, hash)!324 store.put_object_blob(hash, blob)!325 report.objects++326 } else {327 return error('${file.path} in save ${manifest.id} is neither an object nor inlined')328 }329 files << snapshot.FileEntry{330 path: file.path331 hash: hash332 }333 }334 save := history.Save{335 id: manifest.id336 parent: manifest.parent_id or { '' }337 message: manifest.message338 created_at: manifest.timestamp339 machine: 'koh2'340 is_checkpoint: manifest.is_checkpoint341 size_bytes: manifest.size_bytes342 agent: manifest.agent or { '' }343 session: manifest.session or { '' }344 }345 history.import_save(store, save, files)!346 if manifest.flagged {347 events.flag(store, manifest.id, manifest.flag_reason or { '' })!348 }349 report.log_md_was_tracked = report.log_md_was_tracked || has_path(files, 'LOG.md')350 }351 352 history.set_head(store, line[line.len - 1].id)!353 return true354}355356fn has_path(files []snapshot.FileEntry, path string) bool {357 for file in files {358 if file.path == path {359 return true360 }361 }362 return false363}364365fn read_object(root string, hash string) ![]u8 {366 path := os.join_path(root, koh2_dir, 'objects', hash[..2], hash[2..])367 return os.read_bytes(path) or {368 return error('object ${hash} is missing from ${koh2_dir}/objects')369 }370}371372fn verify(store &storage.Store, line []Manifest) ! {373 imported := history.save_count(store)!374 if imported != line.len {375 return error('imported ${imported} saves, expected ${line.len}')376 }377 for manifest in line {378 files := history.files_of(store, manifest.id)!379 if files.len != manifest.files.len {380 return error('save ${manifest.id} has ${files.len} files, its manifest has ${manifest.files.len}')381 }382 for i, file in manifest.files {383 hash := bare_hash(file.hash)384 if files[i].path != file.path || files[i].hash != hash {385 return error('save ${manifest.id}: ${files[i].path} does not match its manifest')386 }387 store.get_object(hash) or {388 return error('save ${manifest.id} needs object ${hash}: ${err.msg()}')389 }390 }391 }392}393394395fn swap(root string, mut report Report) ! {396 old := os.join_path(root, koh2_dir)397 archive := os.join_path(root, archive_dir)398 if os.exists(archive) {399 return error('${archive} already exists — move it away first')400 }401 os.mv(old, archive, os.MvParams{})!402 os.mv(os.join_path(root, staging_file), os.join_path(root, storage.file_name), os.MvParams{})!403 404 405 ignore_source := os.join_path(archive, 'ignore')406 ignore_target := os.join_path(root, snapshot.ignore_file)407 if os.is_file(ignore_source) && !os.exists(ignore_target) {408 body := os.read_file(ignore_source)!409 os.write_file(ignore_target, body)!410 report.ignore_carried = true411 }412}413414415fn bare_hash(value string) string {416 if at := value.index(':') {417 return value[at + 1..]418 }419 return value420}421422423fn base64_decode(encoded string) ![]u8 {424 return base64.decode(encoded)425}