12345678910module migrate1112import encoding.base6413import json14import 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 << json.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 if path !in actual {255 return error('${path} is missing from the working tree — save with koh2 first')256 }257 if actual[path] != hash {258 return error('${path} does not match the last save — save with koh2 first')259 }260 }261 for path in actual.keys() {262 if path !in expected {263 return error('${path} is not in the last save — save with koh2 first')264 }265 }266}267268fn import_and_verify(store &storage.Store, root string, line []Manifest, mut report Report) ! {269 store.begin()!270 if _ := import_line(store, root, line, mut report) {271 store.commit()!272 } else {273 store.rollback()274 return err275 }276 verify(store, line)!277}278279fn import_line(store &storage.Store, root string, line []Manifest, mut report Report) !bool {280 for manifest in line {281 mut files := []snapshot.FileEntry{}282 for file in manifest.files {283 hash := bare_hash(file.hash)284 if encoded := file.inline {285 data := base64_decode(encoded)!286 stored := store.put_object(data)!287 if stored != hash {288 return error('the inlined ${file.path} in save ${manifest.id} does not hash to ${hash}')289 }290 report.objects++291 } else if file.object {292 blob := read_object(root, hash)!293 store.put_object_blob(hash, blob)!294 report.objects++295 } else {296 return error('${file.path} in save ${manifest.id} is neither an object nor inlined')297 }298 files << snapshot.FileEntry{299 path: file.path300 hash: hash301 }302 }303 save := history.Save{304 id: manifest.id305 parent: manifest.parent_id or { '' }306 message: manifest.message307 created_at: manifest.timestamp308 machine: 'koh2'309 is_checkpoint: manifest.is_checkpoint310 size_bytes: manifest.size_bytes311 agent: manifest.agent or { '' }312 session: manifest.session or { '' }313 }314 history.import_save(store, save, files)!315 if manifest.flagged {316 events.flag(store, manifest.id, manifest.flag_reason or { '' })!317 }318 report.log_md_was_tracked = report.log_md_was_tracked || has_path(files, 'LOG.md')319 }320 321 history.set_head(store, line[line.len - 1].id)!322 return true323}324325fn has_path(files []snapshot.FileEntry, path string) bool {326 for file in files {327 if file.path == path {328 return true329 }330 }331 return false332}333334fn read_object(root string, hash string) ![]u8 {335 path := os.join_path(root, koh2_dir, 'objects', hash[..2], hash[2..])336 return os.read_bytes(path) or {337 return error('object ${hash} is missing from ${koh2_dir}/objects')338 }339}340341fn verify(store &storage.Store, line []Manifest) ! {342 imported := history.save_count(store)!343 if imported != line.len {344 return error('imported ${imported} saves, expected ${line.len}')345 }346 for manifest in line {347 files := history.files_of(store, manifest.id)!348 if files.len != manifest.files.len {349 return error('save ${manifest.id} has ${files.len} files, its manifest has ${manifest.files.len}')350 }351 for i, file in manifest.files {352 hash := bare_hash(file.hash)353 if files[i].path != file.path || files[i].hash != hash {354 return error('save ${manifest.id}: ${files[i].path} does not match its manifest')355 }356 store.get_object(hash) or {357 return error('save ${manifest.id} needs object ${hash}: ${err.msg()}')358 }359 }360 }361}362363364fn swap(root string, mut report Report) ! {365 old := os.join_path(root, koh2_dir)366 archive := os.join_path(root, archive_dir)367 if os.exists(archive) {368 return error('${archive} already exists — move it away first')369 }370 os.mv(old, archive, os.MvParams{})!371 os.mv(os.join_path(root, staging_file), os.join_path(root, storage.file_name), os.MvParams{})!372 373 374 ignore_source := os.join_path(archive, 'ignore')375 ignore_target := os.join_path(root, snapshot.ignore_file)376 if os.is_file(ignore_source) && !os.exists(ignore_target) {377 body := os.read_file(ignore_source)!378 os.write_file(ignore_target, body)!379 report.ignore_carried = true380 }381}382383384fn bare_hash(value string) string {385 if at := value.index(':') {386 return value[at + 1..]387 }388 return value389}390391392fn base64_decode(encoded string) ![]u8 {393 return base64.decode(encoded)394}