//! `koh doctor` — integrity check and cleanup. //! //! One source of truth means there is nothing to rebuild; what is left to check //! is that the file is sound and that every object a save names is really //! there. Doctor also tidies the things that are safe to lose: the file cache //! and, with --yes, old peek folders. module cli import json2 import os import time import src.fmt import src.history import src.storage pub fn cmd_doctor(raw []string) int { args := parse('doctor', raw, Options{ values: ['backup'] flags: ['json', 'yes', 'offline'] }) or { return usage_error(err.msg()) } if args.positional.len > 0 { return usage_error('doctor takes no positional arguments') } store := open_store() or { report := DoctorJson{ health: 'unhealthy' root: storage.find_root(os.getwd()) or { os.getwd() } problems: [err.msg()] checks: [DoctorCheck{'database open', 'fail', err.msg()}] scope: 'database could not be opened; further checks could not run' } if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) } return 1 } defer { store.close() } mut report := DoctorJson{ root: store.root database: os.join_path(store.root, storage.file_name) schema_version: storage.schema_version checked_at: time.now().unix() koh_version: version } working('checking database, objects and all stored history') store.db.exec('BEGIN') or { return refuse(args.json(), err.msg()) } report.check('SQLite integrity', store.integrity_check() or { [err.msg()] }) report.check('object hashes and sizes', store.check_objects() or { [err.msg()] }) report.check('save object references', store.check_save_files() or { [err.msg()] }) doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) } store.end() or { report.check('read transaction', [err.msg()]) } working('reading working files without cached hashes') doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) } doctor_build(store.root, mut report) if args.flag('yes') { report.peek_folders_removed = sweep_peek_folders() } if path := args.value_or_none('backup') { mut backed_up := true store.backup(path) or { report.check('backup', [err.msg()]) backed_up = false } if backed_up { report.backup = path } } report.size_bytes = os.file_size(report.database) report.wal_bytes = os.file_size('${report.database}-wal') doctor_remote_config(mut report) if !args.flag('offline') { working('auditing remote history and releases (read-only)') doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) } } else { report.checks << DoctorCheck{'remote audit', 'skipped', '--offline requested; remote content not checked'} } report.scope = 'repository data and configuration; application tests, backup recovery and remote write permissions are not verified' report.health = if report.problems.len > 0 { 'unhealthy' } else if report.warnings.len > 0 { 'warning' } else { 'healthy' } if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) } return if report.problems.len > 0 { 1 } else { 0 } } fn (mut report DoctorJson) check(name string, problems []string) { report.checks << DoctorCheck{name, if problems.len == 0 { 'pass' } else { 'fail' }, if problems.len == 0 { 'verified' } else { problems.join('; ') }} for problem in problems { if problem !in report.problems { report.problems << problem } } } fn print_doctor(report DoctorJson) { if report.problems.len == 0 { ok('${report.root} is sound · local health: ${report.health}') } else { warn('${report.root} has ${fmt.plural(report.problems.len, 'problem')}') } note('database: ${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}') if report.metadata_available { if report.head.id != '' { note('head [${report.head.id}] ${report.head.message.all_before('\n')} · ${fmt.stamp(report.head.created_at)}') note('${report.head_files} files at head · agent: ${report.head.agent} · session: ${report.head.session}') } else { note('head: ${if report.saves == 0 { 'no saves yet' } else { report.head_id }}') } note('${report.saves} stored saves · ${report.line_saves} on line · ${report.off_line_saves} off line · ${report.checkpoints} checkpoints') note('${report.objects} objects · ${fmt.human_bytes(report.object_bytes)} content · ${fmt.human_bytes(report.packed_bytes)} compressed') note('${report.events} events · ${report.flagged_saves} flagged saves · ${report.file_entries} file entries') } else { note('repository metadata unavailable; see failed checks') } for check in report.checks { println(' ${check.status}: ${check.name} · ${check.detail}') } for warning in report.warnings { warn(warning) } if report.remote_target != '' { note('remote: ${report.remote_target}') } if report.source != '' { note('read-only source: ${report.source}') } if report.last_offered_head != '' { note('last successful offer: [${report.last_offered_head}] (stored value)') } if report.project_version != '' { note('project ${report.project_version} · ${report.build_platforms.join(', ')}') } if report.cache_cleared { note('cleared ${report.cached_files} cached hashes; working files read again') } if report.peek_folders_removed > 0 { note('removed ${report.peek_folders_removed} old peek folders') } if report.backup != '' { ok('backed up to ${report.backup}') } if report.problems.len > 0 { note('keep .koh intact before recovering history; use a known-good backup or another verified copy') } note('scope: ${report.scope}') } fn shell_quote(text string) string { return "'${text.replace("'", "'\\''")}'" } // peek_age is how old a peek folder must be before doctor clears it. const peek_age = 86400 // sweep_peek_folders clears the scratch trees `koh peek` leaves in the system // temp directory, once they are a day old. fn sweep_peek_folders() int { mut removed := 0 for name in os.ls(os.temp_dir()) or { return 0 } { path := os.join_path(os.temp_dir(), name) info := os.lstat(path) or { continue } if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path) || info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) { continue } os.rmdir_all(path) or { continue } removed++ } return removed } // peek_is_old is true once a peek is more than a day old. A younger one may // still be open in somebody's editor. fn peek_is_old(modified i64, now i64) bool { return modified < now - peek_age } struct DoctorCheck { name string status string detail string } struct DoctorJson { mut: health string scope string metadata_available bool ignore_files map[string]int checks []DoctorCheck warnings []string problems []string root string database string schema_version int checked_at i64 koh_version string head_id string head history.Save head_files int objects int saves int checkpoints int line_saves int off_line_saves int events int flagged_saves int head_flagged bool file_entries i64 size_bytes u64 wal_bytes u64 object_bytes i64 packed_bytes i64 unreferenced_objects i64 restore_unfinished bool pending_strips []string changes Changes ignored_saved_files []string build_platforms []string project_version string source string remote_repo string remote_target string last_offered_head string remote_head string remote_objects int remote_saves int remote_releases int cached_files int cache_cleared bool peek_folders_removed int backup string }