//! Repository checks, remote verification, repairs and explicit maintenance. //! //! Checks only read. Cache resets are automatic; repairs run only with //! consent; --yes also permits old-peek cleanup and --backup writes a //! consistent database copy. module cli import json2 import os import time import src.exchange 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', 'fast'] }) 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(), open_remedy(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, true) } return 1 } defer { store.close() } yes, fast := args.flag('yes'), args.flag('fast') // Before any repair, so the copy holds the repo as Doctor found it. backup_path := args.value_or_none('backup') or { '' } mut backup_error := '' if backup_path != '' { store.backup(backup_path) or { backup_error = err.msg() } } mut report := diagnose(store, fast) repairs := plan_repairs(store, report) report.repairs = repairs.map(it.action) mut repaired := []string{} mut repair_errors := []string{} mut attempted := false // A requested backup that failed leaves nothing to fall back on. if repairs.len > 0 && backup_error == '' && (yes || (interactive() && !args.json())) { if !yes { print_doctor(report, false) println('') } for repair in repairs { if !yes { println(' repair: ${repair.action}') } if !(confirm(yes) or { false }) { continue } attempted = true repair.run() or { repair_errors << '${repair.action}: ${err.msg()}' if err is exchange.Partway { repaired << err.done } continue } repaired << repair.action } // A repair that failed may still have written part of its work. if attempted { report = diagnose(store, fast) report.repairs = plan_repairs(store, report).map(it.action) } } report.repaired = repaired report.repair_errors = repair_errors if yes { report.peek_folders_removed = sweep_peek_folders() } if backup_error != '' { report.check('backup', [backup_error]) } else { report.backup = backup_path } 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 if repairs.len == 0 || yes || attempted || backup_error != '' || !interactive() { print_doctor(report, true) } return if report.problems.len > 0 { 1 } else { 0 } } // diagnose runs every check; it changes nothing but the disposable hash cache. fn diagnose(store &storage.Store, fast bool) DoctorJson { 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 { report.check('read transaction', [err.msg()]) return report } report.check('SQLite integrity', store.integrity_check() or { [err.msg()] }) report.object_check('object hashes and sizes', store.check_objects() or { [storage.ObjectProblem{'', err.msg()}] }) report.object_check('save object references', store.check_save_files() or { [storage.ObjectProblem{'', 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) report.size_bytes = os.file_size(report.database) report.wal_bytes = os.file_size('${report.database}-wal') doctor_remote_config(mut report) if fast { report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked', ''} } else { working('auditing remote history and releases (read-only)') doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) } } report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access' return report } fn (mut report DoctorJson) check(name string, problems []string) { failed := problems.len > 0 report.checks << DoctorCheck{name, if failed { 'fail' } else { 'pass' }, if failed { problems.join('; ') } else { 'verified' }, if failed { remedy_for(name) } else { '' }} for problem in problems { if problem !in report.problems { report.problems << problem } } } // An object problem with no hash is a check that could not run. fn (mut report DoctorJson) object_check(name string, problems []storage.ObjectProblem) { for problem in problems { if problem.hash != '' && problem.hash !in report.damaged_objects { report.damaged_objects << problem.hash } } report.check(name, problems.map(it.detail)) } // What to do when a check fails. A problem that needs its own command, such as // a strip of one unsafe path, names it in its text. fn remedy_for(check string) string { return match check { 'SQLite integrity' { 'koh doctor can rebuild the indexes; if damage remains, restore a copy made with koh doctor --backup, or rebuild from the remote with koh steal in an empty folder' } 'object hashes and sizes', 'save object references' { 'koh doctor restores verified copies from working files or the remote; with neither, that content is lost' } 'history links and cycles' { 'koh doctor fetches missing saves from the remote; a cycle needs a backup or the remote copy' } 'saved paths and events' { 'run the koh strip command shown for an unsafe path, or strip one side of a file/folder collision; koh doctor re-applies recorded strips; a bad event or unfinished strip note needs a backup or the remote copy' } 'restore state' { 'run koh revert; it keeps the current files in a checkpoint first' } 'working tree safety' { 'rename files whose names Koh cannot save, and move links that block a restore' } 'ignore rules' { 'make the ignore file readable' } 'build configuration' { 'fix koh.build; koh man build describes it' } 'machine remote', 'machine remote configuration' { 'run koh remote to set the remote and its credentials again' } 'remote audit' { 'check the network and the remote settings (koh remote), then run koh doctor again' } 'remote history, events and objects' { 'koh doctor uploads what this repo holds; anything else must be offered from a machine that has it' } 'remote releases' { 'publish that version again with koh offer --builds from the save it names' } 'remote stability' { 'another machine changed the remote during the audit; run koh doctor again' } 'backup' { 'choose a backup path that does not exist yet' } else { 'run koh doctor again; if it repeats, restore a copy made with koh doctor --backup, or rebuild from the remote with koh steal in an empty folder' } } } fn open_remedy(message string) string { return if message.contains('newer koh') { 'run koh update' } else if message.contains('another koh command') || message.contains('locked') { 'wait for the other koh command to finish, then run koh doctor again' } else if message.contains('koh init') { 'run koh init here, or koh steal to fetch one' } else { 'keep .koh as it is; restore a copy made with koh doctor --backup, or rebuild from the remote with koh steal in an empty folder' } } // offer lists the repairs left to run; it is false just before Doctor asks about them. fn print_doctor(report DoctorJson, offer bool) { root := report.root.replace_once(os.home_dir(), '~') if report.problems.len > 0 { warn('${root} has ${fmt.plural(report.problems.len, 'problem')}') } else if report.warnings.len > 0 { ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}') } else { ok('${root} is healthy') } println('') if report.metadata_available { doctor_metadata(report) } else { println(' ${fmt.dim('repository metadata unavailable; see failed checks')}') } println('') passed := report.checks.filter(it.status == 'pass').map(it.name) if passed.len > 0 { ok('${fmt.plural(passed.len, 'check')} passed') for line in wrap_items(passed, 76) { println(' ${fmt.dim(line)}') } } for check in report.checks.filter(it.status != 'pass') { mark := match check.status { 'fail' { fmt.red(fmt.glyph_fail) } 'skipped' { fmt.dim('–') } else { fmt.amber('!') } } println(' ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}') if check.remedy != '' { println(' fix: ${check.remedy}') } } for warning in report.warnings { warn(warning) } if report.peek_folders_removed > 0 { ok('removed ${report.peek_folders_removed} old peek folders') } if report.backup != '' { ok('backed up to ${report.backup}') } for action in report.repaired { ok('repaired: ${action}') } for failure in report.repair_errors { warn('repair failed: ${failure}') } if offer && report.repairs.len > 0 { println('') warn('koh doctor can repair ${fmt.plural(report.repairs.len, 'finding')}:') for action in report.repairs { println(' ${action}') } note('run koh doctor again to choose, or koh doctor --yes to run them all') } println('') note(report.scope) } fn doctor_metadata(report DoctorJson) { if report.head.id != '' { doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}') doctor_row('', fmt.dim([report.head.agent, report.head.session, fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · '))) } else { doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id }) } mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints'] if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' } if report.events > 0 { saves << fmt.plural(report.events, 'event') } if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' } doctor_row('history', saves.join(' · ')) doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}') doctor_row('', fmt.dim('${report.objects} objects · ${fmt.human_bytes(report.object_bytes)} → ${fmt.human_bytes(report.packed_bytes)} compressed · ${report.file_entries} file entries')) changes := report.changes counts := ['${changes.added.len} added', '${changes.modified.len} modified', '${changes.removed.len} removed'].filter(!it.starts_with('0 ')) mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') } if report.cache_cleared { tree += fmt.dim(' · ${report.cached_files} cached hashes re-read') } doctor_row('tree', tree) if report.remote_target != '' { offer := if report.last_offered_head != '' { fmt.dim(' · last offer [${report.last_offered_head}]') } else { '' } doctor_row('remote', report.remote_target + offer) } if report.source != '' { doctor_row('source', '${report.source} (read-only)') } if report.project_version != '' { doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}') } } fn doctor_row(label string, value string) { println(' ${fmt.dim('${label:-9}')}${value}') } fn wrap_items(items []string, width int) []string { mut lines := []string{} mut line := '' for item in items { if line != '' && fmt.visible_width(line) + 3 + item.len > width { lines << line line = item } else { line = if line == '' { item } else { '${line} · ${item}' } } } if line != '' { lines << line } return lines } fn shell_quote(text string) string { return "'${text.replace("'", "'\\''")}'" } const peek_age = 86400 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 } // A recent peek may still be open in an editor. fn peek_is_old(modified i64, now i64) bool { return modified < now - peek_age } struct DoctorCheck { name string status string detail string remedy 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 repairs []string repaired []string repair_errors []string damaged_objects []string missing_saves []string strip_residue bool strip_residue_paths []string remote_strip_residue []string remote_bad_objects []string remote_missing_saves []string }