//! `koh migrate` — convert one Koh 2 repo into a Koh 3 repo, in place. //! //! Koh 2 kept one JSON manifest per save plus an object store; Koh 3 keeps the //! same objects in one SQLite file. The conversion reads the manifests as the //! source of truth, imports everything into a fresh `.koh`, verifies it against //! those manifests, and only then swaps it into place. The Koh 2 data is not //! deleted — it is moved aside as `.koh-v2/`, so `trash .koh` and moving it back //! undoes the migration entirely. module migrate import encoding.base64 import json2 import os import src.build import src.events import src.fmt import src.history import src.snapshot import src.storage pub const koh2_dir = '.koh' pub const archive_dir = '.koh-v2' // Koh 2's build file, renamed to `koh.build` (build.config_name) by the // migration, which is the name Koh 3 reads. const kepr_build_file = 'kepr.build' pub const draft_file = '.koh-migrate' pub const default_lane = 'dev' // One file in a Koh 2 manifest. Small files were inlined as base64 instead of // being written to the object store, and those entries carry `inline` with no // `object` key at all — so `inline` decides, and `object` is only the // alternative. struct ManifestFile { path string hash string size i64 object bool inline ?string } struct Manifest { id string lane string message string timestamp i64 parent_id ?string size_bytes i64 agent ?string session ?string is_checkpoint bool flagged bool flag_reason ?string mut: files []ManifestFile } pub struct Report { pub mut: root string lane string saves int checkpoints int flags int objects int inlined int head string ignore_carried bool log_md_was_tracked bool build_renamed bool unsafe_paths []string } // Plan is what migrate found and will do, shown before anything is written. pub struct Plan { root string line []Manifest unsafe_paths []string found map[string][]u8 pub: lost []Lost lane string saves int checkpoints int objects int last_saved i64 renames_build bool } // Lost is one file version Koh 2's saves list but its store no longer holds. // Those saves come over without it, flagged, unless `--with` hands it back. pub struct Lost { pub mut: path string hash string size i64 saves []string first i64 last i64 } // Progress hears how far a stage ("importing", "checking") has come. pub type Progress = fn (stage string, done int, total int) // prepare reads the repo at `root` and checks it can be converted, writing // nothing. `lane` picks which line to import: `dev` unless told otherwise, // because that is where the work is. pub fn prepare(root string, lane string, with []string) !Plan { if !os.is_dir(os.join_path(root, koh2_dir, 'snapshots')) { return error('no Koh 2 repo here — migrate expects a .koh/ folder with snapshots/') } manifests := read_manifests(root)! if manifests.len == 0 { return error('the Koh 2 repo has no manifests to import') } // `dev` holds everything when it was used at all. A repo that never left // `main` — which is what koh2 gave a new repo — imports from main instead // of failing on an empty lane. mut chosen := lane head := lane_head(root, manifests, chosen) or { if chosen != default_lane { return err } chosen = other_lane(manifests) or { return err } lane_head(root, manifests, chosen)! } mut line := ancestry(manifests, head)! unsafe_paths := drop_unsafe_paths(mut line) found := versions_by_hash(with)! lost := drop_lost_objects(root, mut line, found) check_clean_tree(root, line[line.len - 1])! check_build_files(root)! mut hashes := map[string]bool{} for manifest in line { for file in manifest.files { hashes[file.hash] = true } } return Plan{ root: root line: line unsafe_paths: unsafe_paths found: found lost: lost lane: chosen saves: line.len checkpoints: line.filter(it.is_checkpoint).len objects: hashes.len last_saved: head.timestamp renames_build: os.is_file(os.join_path(root, kepr_build_file)) } } // run converts the repo `prepare` read, telling `progress` as it goes. pub fn run(plan Plan, progress Progress) !Report { root := plan.root line := plan.line mut report := Report{ root: root lane: plan.lane head: line[line.len - 1].id unsafe_paths: plan.unsafe_paths } // Import into a draft file first: nothing is touched until it verifies. draft_path := os.join_path(root, draft_file) if os.exists(draft_path) { return error('${draft_path} already exists — a previous migrate did not finish; move it away and try again') } mut draft := storage.create_at(draft_path, root)! // The draft must be closed before it is moved: SQLite keeps finished // work in its WAL until the last connection goes away, and moving the file // alone would leave that work behind. mut failure := '' if _ := import_and_verify(draft, plan, mut report, progress) { report.objects = int(draft.object_count() or { 0 }) } else { failure = err.msg() } draft.close() if failure != '' { // The draft is migrate's own; leaving it would refuse the next attempt. for suffix in ['', '-wal', '-shm'] { os.rm(draft_path + suffix) or {} } return error(failure) } swap(root, mut report)! rename_build_file(root, mut report)! report.saves = line.len report.checkpoints = plan.checkpoints for manifest in line { if manifest.flagged { report.flags++ } for file in manifest.files { if file.inline != none { report.inlined++ } } } return report } fn read_manifests(root string) ![]Manifest { dir := os.join_path(root, koh2_dir, 'snapshots') mut manifests := []Manifest{} for name in os.ls(dir)! { if !name.ends_with('.json') { continue } body := os.read_file(os.join_path(dir, name))! manifests << json2.decode[Manifest](body)! } return manifests } // lane_head finds the newest save on a lane: the state table when it is there, // otherwise the manifest nothing else claims as a parent. fn lane_head(root string, manifests []Manifest, lane string) !Manifest { if recorded := state_head(root, lane) { for manifest in manifests { if manifest.id == recorded { return manifest } } } mut claimed := map[string]bool{} for manifest in manifests { if parent := manifest.parent_id { claimed[parent] = true } } mut candidates := []Manifest{} for manifest in manifests { if manifest.lane == lane && manifest.id !in claimed { candidates << manifest } } if candidates.len == 0 { return error('no saves on lane "${lane}"') } candidates.sort(a.timestamp < b.timestamp) return candidates[candidates.len - 1] } // other_lane names the lane that does have saves, when the preferred one is // empty. fn other_lane(manifests []Manifest) ?string { mut lanes := map[string]bool{} for manifest in manifests { lanes[manifest.lane] = true } for lane in ['main', 'dev'] { if lane in lanes { return lane } } for lane in lanes.keys() { return lane } return none } fn state_head(root string, lane string) ?string { db := os.join_path(root, koh2_dir, 'koh.db') if !os.is_file(db) { return none } store := storage.connect(db, 1000) or { return none } defer { store.close() } mut stmt := store.prepare('SELECT value FROM state WHERE key = ?') or { return none } defer { stmt.finalize() } stmt.bind_text(1, 'head_${lane}') or { return none } if stmt.step() or { return none } { value := stmt.text(0) if value != '' { return value } } return none } // ancestry walks back from the lane head to the first save, oldest first, and // refuses a manifest whose parent is missing: a gap would silently lose work. fn ancestry(manifests []Manifest, head Manifest) ![]Manifest { mut by_id := map[string]Manifest{} for manifest in manifests { by_id[manifest.id] = manifest } mut line := []Manifest{} mut cursor := head for { line << cursor parent := cursor.parent_id or { break } if parent == '' { break } next := by_id[parent] or { return error('save ${cursor.id} names parent ${parent}, which is not in ${koh2_dir}/snapshots') } cursor = next } line.reverse_in_place() return line } // check_clean_tree refuses unless the working tree matches the lane head, which // is the same rule the document states: save with koh2 first. fn check_clean_tree(root string, head Manifest) ! { ignor := snapshot.parse(os.read_file(os.join_path(root, koh2_dir, 'ignore')) or { os.read_file(os.join_path(root, snapshot.ignore_file)) or { '' } }) entries := snapshot.walk_raw(root, ignor)! mut expected := map[string]string{} for file in head.files { expected[file.path] = bare_hash(file.hash) } mut actual := map[string]string{} for entry in entries { actual[entry.path] = entry.hash } for path, hash in expected { // Koh 2 kept saving files under a folder pattern that Koh 3 reads as // covering the folder, so a saved path is checked on disk even when // the walk skipped it. have := actual[path] or { disk_hash(root, path) or { return error('${path} is missing from the working tree — save with koh2 first') } } if have != hash { return error('${path} does not match the last save — save with koh2 first') } } for path in actual.keys() { if path !in expected && !koh2_never_saved(path) { return error('${path} is not in the last save — save with koh2 first') } } } // disk_hash hashes a regular file inside the repo. Koh 2 never followed links, // nor does Koh 3, so a link on the way to it means the saved file is missing. fn disk_hash(root string, path string) ?string { // Only a legal path inside the repo; a damaged manifest cannot point out. if !snapshot.legal_path(path) { return none } full := os.join_path(root, path) if !os.is_file(full) || os.is_link(full) { return none } snapshot.check_links(root, [snapshot.FileEntry{ path: path }]) or { return none } return storage.hash_hex(os.read_bytes(full) or { return none }) } // koh2_never_saved mirrors the hard ignores in Koh 2's snapshot.zig that Koh 3 // does not share, so a clean Koh 2 tree reads as clean. Koh 2 prunes a folder // with such a name too, so every segment counts. The files stay where they are. fn koh2_never_saved(path string) bool { parts := path.split('/') return parts[0] == '.kohconfig' || parts[0].starts_with('.kohignore.') || parts.any(it.ends_with('.bundle')) } // drop_unsafe_paths removes, by the shared path rule, every file Koh 3 would // refuse to restore, and names each one once for the report. fn drop_unsafe_paths(mut line []Manifest) []string { mut dropped := map[string]bool{} for mut manifest in line { for file in manifest.files { if !snapshot.legal_path(file.path) { dropped[snapshot.escape_path(file.path)] = true } } manifest.files = manifest.files.filter(snapshot.legal_path(it.path)) } mut names := dropped.keys() names.sort() return names } // versions_by_hash reads the files `--with` names, so a version Koh 2 lost can // be recognised by its contents wherever it was found. fn versions_by_hash(paths []string) !map[string][]u8 { mut found := map[string][]u8{} for path in paths { data := os.read_bytes(path) or { return error('cannot read ${path}: ${err.msg()}') } found[storage.hash_hex(data)] = data } return found } // drop_lost_objects takes out of the line every file whose object Koh 2 no // longer holds and nobody handed back, and says which, where and when. fn drop_lost_objects(root string, mut line []Manifest, found map[string][]u8) []Lost { mut lost := map[string]Lost{} for mut manifest in line { mut kept := []ManifestFile{} for file in manifest.files { hash := bare_hash(file.hash) if file.inline != none || !file.object || hash in found || os.is_file(os.join_path(root, koh2_dir, 'objects', hash[..2], hash[2..])) { kept << file continue } key := '${file.path}\n${hash}' mut seen := lost[key] or { Lost{ path: file.path hash: hash size: file.size first: manifest.timestamp } } seen.saves << manifest.id seen.last = manifest.timestamp lost[key] = seen } manifest.files = kept } mut all := lost.values() all.sort(a.path < b.path) return all } fn import_and_verify(store &storage.Store, plan Plan, mut report Report, progress Progress) ! { store.begin()! if _ := import_line(store, plan, mut report, progress) { store.end()! } else { store.rollback() return err } verify(store, plan.line, progress)! } fn import_line(store &storage.Store, plan Plan, mut report Report, progress Progress) !bool { line := plan.line mut lost_at := map[string][]string{} for lost in plan.lost { for id in lost.saves { lost_at[id] << lost.path } } for i, manifest in line { progress('importing', i + 1, line.len) mut files := []snapshot.FileEntry{} for file in manifest.files { hash := bare_hash(file.hash) if encoded := file.inline { data := base64_decode(encoded)! stored := store.put_object(data)! if stored != hash { return error('the inlined ${file.path} in save ${manifest.id} does not hash to ${hash}') } } else if data := plan.found[hash] { store.put_object(data)! } else if file.object { blob := read_object(plan.root, hash)! store.put_object_blob(hash, blob)! } else { return error('${file.path} in save ${manifest.id} is neither an object nor inlined') } files << snapshot.FileEntry{ path: file.path hash: hash } } save := history.Save{ id: manifest.id parent: manifest.parent_id or { '' } message: manifest.message created_at: manifest.timestamp machine: 'koh2' is_checkpoint: manifest.is_checkpoint size_bytes: manifest.size_bytes agent: manifest.agent or { '' } session: manifest.session or { '' } } history.import_save(store, save, files)! if paths := lost_at[manifest.id] { // Said on the save itself, so loading it later explains the gap. reason := 'migrate: Koh 2 had lost ${paths.join(', ')} at this save' events.flag(store, manifest.id, if manifest.flagged { '${manifest.flag_reason or { '' }}; ${reason}' } else { reason })! } else if manifest.flagged { events.flag(store, manifest.id, manifest.flag_reason or { '' })! } report.log_md_was_tracked = report.log_md_was_tracked || has_path(files, 'LOG.md') } // The newest save on the imported line becomes head. history.set_head(store, line[line.len - 1].id)! return true } fn has_path(files []snapshot.FileEntry, path string) bool { for file in files { if file.path == path { return true } } return false } fn read_object(root string, hash string) ![]u8 { path := os.join_path(root, koh2_dir, 'objects', hash[..2], hash[2..]) return os.read_bytes(path) or { return error('object ${hash} is missing from ${koh2_dir}/objects') } } fn verify(store &storage.Store, line []Manifest, progress Progress) ! { imported := history.save_count(store)! if imported != line.len { return error('imported ${fmt.plural(int(imported), 'save')}, expected ${line.len}') } for n, manifest in line { progress('checking', n + 1, line.len) files := history.files_of(store, manifest.id)! if files.len != manifest.files.len { return error('save ${manifest.id} has ${files.len} files, its manifest has ${manifest.files.len}') } // By path, not by position: Koh 2 did not always list a manifest's files in order. mut stored := map[string]string{} for file in files { stored[file.path] = file.hash } for file in manifest.files { hash := bare_hash(file.hash) if stored[file.path] or { '' } != hash { return error('save ${manifest.id}: ${file.path} does not match its manifest') } store.get_object(hash) or { return error('save ${manifest.id} needs object ${hash}: ${err.msg()}') } } } } // swap puts the imported database in place and moves the Koh 2 data aside. fn swap(root string, mut report Report) ! { old := os.join_path(root, koh2_dir) archive := os.join_path(root, archive_dir) if os.exists(archive) { return error('${archive} already exists — move it away first') } os.mv(old, archive, os.MvParams{})! os.mv(os.join_path(root, draft_file), os.join_path(root, storage.file_name), os.MvParams{})! // Koh 2 kept its ignore rules under .koh/; carry them to the root, which is // where Koh 3 reads them from. ignore_source := os.join_path(archive, 'ignore') ignore_target := os.join_path(root, snapshot.ignore_file) if os.is_file(ignore_source) && !os.exists(ignore_target) { body := os.read_file(ignore_source)! os.write_file(ignore_target, body)! report.ignore_carried = true } } // check_build_files runs in the check phase, before the swap: a repo holding // both build files is refused while nothing has moved, never left half done. fn check_build_files(root string) ! { if os.is_file(os.join_path(root, kepr_build_file)) && os.exists(os.join_path(root, build.config_name)) { return error('both ${kepr_build_file} and ${build.config_name} exist — keep the one you want and migrate again') } } // rename_build_file finishes the move to `koh.build`, and records it as a save: // the migration must not leave the working tree dirty with a rename the next // save would otherwise pick up as unreviewed work. fn rename_build_file(root string, mut report Report) ! { old := os.join_path(root, kepr_build_file) if !os.is_file(old) { return } fresh := os.join_path(root, build.config_name) os.mv(old, fresh, os.MvParams{})! mut store := storage.open(root)! defer { store.close() } history.save_worktree(store, root, snapshot.load(root), history.SaveInput{ message: 'migrate: rename ${kepr_build_file} to ${build.config_name}' })! report.build_renamed = true } // bare_hash drops the "blake3:" prefix Koh 2 wrote in its manifests. fn bare_hash(value string) string { if at := value.index(':') { return value[at + 1..] } return value } // base64_decode reads the inlined form Koh 2 used for files under 512 bytes. fn base64_decode(encoded string) ![]u8 { return base64.decode(encoded) }