migrate.v

11.1 KB · raw

1//! `koh migrate` — convert one Koh 2 repo into a Koh 3 repo, in place.2//!3//! Koh 2 kept one JSON manifest per save plus an object store; Koh 3 keeps the4//! same objects in one SQLite file. The conversion reads the manifests as the5//! source of truth, imports everything into a fresh `.koh`, verifies it against6//! those manifests, and only then swaps it into place. The Koh 2 data is not7//! deleted — it is moved aside as `.koh-v2/`, so `trash .koh` and moving it back8//! undoes the migration entirely.910module 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'2425// One file in a Koh 2 manifest. Small files were inlined as base64 instead of26// being written to the object store, and those entries carry `inline` with no27// `object` key at all — so `inline` decides, and `object` is only the28// alternative.29struct 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}6566// run converts the repo at `root`. `lane` picks which line to import: `dev`67// unless told otherwise, because that is where the work is.68pub 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	// `dev` holds everything when it was used at all. A repo that never left77	// `main` — which is what koh2 gave a new repo — imports from main instead78	// of failing on an empty lane.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	// Import into a staging file first: nothing is touched until it verifies.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	// The staged file must be closed before it is moved: SQLite keeps committed102	// work in its WAL until the last connection goes away, and moving the file103	// alone would leave that work behind.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}143144// lane_head finds the newest save on a lane: the state table when it is there,145// otherwise the manifest nothing else claims as a parent.146fn 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}172173// other_lane names the lane that does have saves, when the preferred one is174// empty.175fn 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}213214// ancestry walks back from the lane head to the first save, oldest first, and215// refuses a manifest whose parent is missing: a gap would silently lose work.216fn 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}237238// check_clean_tree refuses unless the working tree matches the lane head, which239// is the same rule the document states: save with koh2 first.240fn 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	// The newest save on the imported line becomes head.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}362363// swap puts the imported database in place and moves the Koh 2 data aside.364fn 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	// Koh 2 kept its ignore rules under .koh/; carry them to the root, which is373	// where Koh 3 reads them from.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}382383// bare_hash drops the "blake3:" prefix Koh 2 wrote in its manifests.384fn bare_hash(value string) string {385	if at := value.index(':') {386		return value[at + 1..]387	}388	return value389}390391// base64_decode reads the inlined form Koh 2 used for files under 512 bytes.392fn base64_decode(encoded string) ![]u8 {393	return base64.decode(encoded)394}