1234567module cli89import json210import os11import time12import src.exchange13import src.fmt14import src.history15import src.storage1617pub fn cmd_doctor(raw []string) int {18 args := parse('doctor', raw, Options{19 values: ['backup']20 flags: ['json', 'yes', 'fast']21 }) or { return usage_error(err.msg()) }22 if args.positional.len > 0 {23 return usage_error('doctor takes no positional arguments')24 }25 store := open_store() or {26 report := DoctorJson{27 health: 'unhealthy'28 root: storage.find_root(os.getwd()) or { os.getwd() }29 problems: [err.msg()]30 checks: [DoctorCheck{'database open', 'fail', err.msg(), open_remedy(err.msg())}]31 scope: 'database could not be opened; further checks could not run'32 }33 if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report, true) }34 return 135 }36 defer { store.close() }37 yes, fast := args.flag('yes'), args.flag('fast')38 39 backup_path := args.value_or_none('backup') or { '' }40 mut backup_error := ''41 if backup_path != '' {42 store.backup(backup_path) or { backup_error = err.msg() }43 }44 mut report := diagnose(store, fast)45 repairs := plan_repairs(store, report)46 report.repairs = repairs.map(it.action)47 mut repaired := []string{}48 mut repair_errors := []string{}49 mut attempted := false50 51 if repairs.len > 0 && backup_error == '' && (yes || (interactive() && !args.json())) {52 if !yes {53 print_doctor(report, false)54 println('')55 }56 for repair in repairs {57 if !yes {58 println(' repair: ${repair.action}')59 }60 if !(confirm(yes) or { false }) {61 continue62 }63 attempted = true64 repair.run() or {65 repair_errors << '${repair.action}: ${err.msg()}'66 if err is exchange.Partway {67 repaired << err.done68 }69 continue70 }71 repaired << repair.action72 }73 74 if attempted {75 report = diagnose(store, fast)76 report.repairs = plan_repairs(store, report).map(it.action)77 }78 }79 report.repaired = repaired80 report.repair_errors = repair_errors81 if yes {82 report.peek_folders_removed = sweep_peek_folders()83 }84 if backup_error != '' {85 report.check('backup', [backup_error])86 } else {87 report.backup = backup_path88 }89 report.health = if report.problems.len > 0 {90 'unhealthy'91 } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }92 if args.json() {93 emit_json(json2.encode(report))94 } else if repairs.len == 0 || yes || attempted || backup_error != '' || !interactive() {95 print_doctor(report, true)96 }97 return if report.problems.len > 0 { 1 } else { 0 }98}99100101fn diagnose(store &storage.Store, fast bool) DoctorJson {102 mut report := DoctorJson{103 root: store.root104 database: os.join_path(store.root, storage.file_name)105 schema_version: storage.schema_version106 checked_at: time.now().unix()107 koh_version: version108 }109 working('checking database, objects and all stored history')110 store.db.exec('BEGIN') or {111 report.check('read transaction', [err.msg()])112 return report113 }114 report.check('SQLite integrity', store.integrity_check() or { [err.msg()] })115 report.object_check('object hashes and sizes', store.check_objects() or {116 [storage.ObjectProblem{'', err.msg()}]117 })118 report.object_check('save object references', store.check_save_files() or {119 [storage.ObjectProblem{'', err.msg()}]120 })121 doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) }122 store.end() or { report.check('read transaction', [err.msg()]) }123 working('reading working files without cached hashes')124 doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) }125 doctor_build(store.root, mut report)126 report.size_bytes = os.file_size(report.database)127 report.wal_bytes = os.file_size('${report.database}-wal')128 doctor_remote_config(mut report)129 if fast {130 report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked', ''}131 } else {132 working('auditing remote history and releases (read-only)')133 doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }134 }135 report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'136 return report137}138139fn (mut report DoctorJson) check(name string, problems []string) {140 failed := problems.len > 0141 report.checks << DoctorCheck{name, if failed { 'fail' } else { 'pass' }, if failed {142 problems.join('; ')143 } else {144 'verified'145 }, if failed { remedy_for(name) } else { '' }}146 for problem in problems {147 if problem !in report.problems { report.problems << problem }148 }149}150151152fn (mut report DoctorJson) object_check(name string, problems []storage.ObjectProblem) {153 for problem in problems {154 if problem.hash != '' && problem.hash !in report.damaged_objects {155 report.damaged_objects << problem.hash156 }157 }158 report.check(name, problems.map(it.detail))159}160161162163fn remedy_for(check string) string {164 return match check {165 'SQLite integrity' {166 '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 <repo> in an empty folder'167 }168 'object hashes and sizes', 'save object references' {169 'koh doctor restores verified copies from working files or the remote; with neither, that content is lost'170 }171 'history links and cycles' {172 'koh doctor fetches missing saves from the remote; a cycle needs a backup or the remote copy'173 }174 'saved paths and events' {175 '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'176 }177 'restore state' {178 'run koh revert; it keeps the current files in a checkpoint first'179 }180 'working tree safety' {181 'rename files whose names Koh cannot save, and move links that block a restore'182 }183 'ignore rules' {184 'make the ignore file readable'185 }186 'build configuration' {187 'fix koh.build; koh man build describes it'188 }189 'machine remote', 'machine remote configuration' {190 'run koh remote to set the remote and its credentials again'191 }192 'remote audit' {193 'check the network and the remote settings (koh remote), then run koh doctor again'194 }195 'remote history, events and objects' {196 'koh doctor uploads what this repo holds; anything else must be offered from a machine that has it'197 }198 'remote releases' {199 'publish that version again with koh offer --builds from the save it names'200 }201 'remote stability' {202 'another machine changed the remote during the audit; run koh doctor again'203 }204 'backup' {205 'choose a backup path that does not exist yet'206 }207 else {208 'run koh doctor again; if it repeats, restore a copy made with koh doctor --backup, or rebuild from the remote with koh steal <repo> in an empty folder'209 }210 }211}212213fn open_remedy(message string) string {214 return if message.contains('newer koh') {215 'run koh update'216 } else if message.contains('another koh command') || message.contains('locked') {217 'wait for the other koh command to finish, then run koh doctor again'218 } else if message.contains('koh init') {219 'run koh init here, or koh steal <repo> to fetch one'220 } else {221 'keep .koh as it is; restore a copy made with koh doctor --backup, or rebuild from the remote with koh steal <repo> in an empty folder'222 }223}224225226fn print_doctor(report DoctorJson, offer bool) {227 root := report.root.replace_once(os.home_dir(), '~')228 if report.problems.len > 0 {229 warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')230 } else if report.warnings.len > 0 {231 ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}')232 } else {233 ok('${root} is healthy')234 }235 println('')236 if report.metadata_available {237 doctor_metadata(report)238 } else {239 println(' ${fmt.dim('repository metadata unavailable; see failed checks')}')240 }241 println('')242 passed := report.checks.filter(it.status == 'pass').map(it.name)243 if passed.len > 0 {244 ok('${fmt.plural(passed.len, 'check')} passed')245 for line in wrap_items(passed, 76) {246 println(' ${fmt.dim(line)}')247 }248 }249 for check in report.checks.filter(it.status != 'pass') {250 mark := match check.status {251 'fail' { fmt.red(fmt.glyph_fail) }252 'skipped' { fmt.dim('–') }253 else { fmt.amber('!') }254 }255 println(' ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')256 if check.remedy != '' {257 println(' fix: ${check.remedy}')258 }259 }260 for warning in report.warnings {261 warn(warning)262 }263 if report.peek_folders_removed > 0 {264 ok('removed ${report.peek_folders_removed} old peek folders')265 }266 if report.backup != '' { ok('backed up to ${report.backup}') }267 for action in report.repaired {268 ok('repaired: ${action}')269 }270 for failure in report.repair_errors {271 warn('repair failed: ${failure}')272 }273 if offer && report.repairs.len > 0 {274 println('')275 warn('koh doctor can repair ${fmt.plural(report.repairs.len, 'finding')}:')276 for action in report.repairs {277 println(' ${action}')278 }279 note('run koh doctor again to choose, or koh doctor --yes to run them all')280 }281 println('')282 note(report.scope)283}284285fn doctor_metadata(report DoctorJson) {286 if report.head.id != '' {287 doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}')288 doctor_row('', fmt.dim([report.head.agent, report.head.session,289 fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · ')))290 } else {291 doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id })292 }293 mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints']294 if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' }295 if report.events > 0 { saves << fmt.plural(report.events, 'event') }296 if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' }297 doctor_row('history', saves.join(' · '))298 doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')299 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'))300 changes := report.changes301 counts := ['${changes.added.len} added', '${changes.modified.len} modified',302 '${changes.removed.len} removed'].filter(!it.starts_with('0 '))303 mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') }304 if report.cache_cleared {305 tree += fmt.dim(' · ${report.cached_files} cached hashes re-read')306 }307 doctor_row('tree', tree)308 if report.remote_target != '' {309 offer := if report.last_offered_head != '' {310 fmt.dim(' · last offer [${report.last_offered_head}]')311 } else {312 ''313 }314 doctor_row('remote', report.remote_target + offer)315 }316 if report.source != '' { doctor_row('source', '${report.source} (read-only)') }317 if report.project_version != '' {318 doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}')319 }320}321322fn doctor_row(label string, value string) {323 println(' ${fmt.dim('${label:-9}')}${value}')324}325326fn wrap_items(items []string, width int) []string {327 mut lines := []string{}328 mut line := ''329 for item in items {330 if line != '' && fmt.visible_width(line) + 3 + item.len > width {331 lines << line332 line = item333 } else {334 line = if line == '' { item } else { '${line} · ${item}' }335 }336 }337 if line != '' {338 lines << line339 }340 return lines341}342343fn shell_quote(text string) string {344 return "'${text.replace("'", "'\\''")}'"345}346347const peek_age = 86400348349fn sweep_peek_folders() int {350 mut removed := 0351 for name in os.ls(os.temp_dir()) or { return 0 } {352 path := os.join_path(os.temp_dir(), name)353 info := os.lstat(path) or { continue }354 if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)355 || info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {356 continue357 }358 os.rmdir_all(path) or { continue }359 removed++360 }361 return removed362}363364// A recent peek may still be open in an editor.365fn peek_is_old(modified i64, now i64) bool {366 return modified < now - peek_age367}368369struct DoctorCheck {370 name string371 status string372 detail string373 remedy string374}375376struct DoctorJson {377mut:378 health string379 scope string380 metadata_available bool381 ignore_files map[string]int382 checks []DoctorCheck383 warnings []string384 problems []string385 root string386 database string387 schema_version int388 checked_at i64389 koh_version string390 head_id string391 head history.Save392 head_files int393 objects int394 saves int395 checkpoints int396 line_saves int397 off_line_saves int398 events int399 flagged_saves int400 head_flagged bool401 file_entries i64402 size_bytes u64403 wal_bytes u64404 object_bytes i64405 packed_bytes i64406 unreferenced_objects i64407 restore_unfinished bool408 pending_strips []string409 changes Changes410 ignored_saved_files []string411 build_platforms []string412 project_version string413 source string414 remote_repo string415 remote_target string416 last_offered_head string417 remote_head string418 remote_objects int419 remote_saves int420 remote_releases int421 cached_files int422 cache_cleared bool423 peek_folders_removed int424 backup string425 repairs []string426 repaired []string427 repair_errors []string428 damaged_objects []string429 missing_saves []string430 strip_residue bool431 strip_residue_paths []string432 remote_strip_residue []string433 remote_bad_objects []string434 remote_missing_saves []string435}