1234567module cli89import json210import os11import time12import src.fmt13import src.history14import src.storage1516pub fn cmd_doctor(raw []string) int {17 args := parse('doctor', raw, Options{18 values: ['backup']19 flags: ['json', 'yes', 'fast']20 }) or { return usage_error(err.msg()) }21 if args.positional.len > 0 {22 return usage_error('doctor takes no positional arguments')23 }24 store := open_store() or {25 report := DoctorJson{26 health: 'unhealthy'27 root: storage.find_root(os.getwd()) or { os.getwd() }28 problems: [err.msg()]29 checks: [DoctorCheck{'database open', 'fail', err.msg(), open_remedy(err.msg())}]30 scope: 'database could not be opened; further checks could not run'31 }32 if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report, true) }33 return 134 }35 defer { store.close() }36 yes, fast := args.flag('yes'), args.flag('fast')37 38 backup_path := args.value_or_none('backup') or { '' }39 mut backup_error := ''40 if backup_path != '' {41 store.backup(backup_path) or { backup_error = err.msg() }42 }43 mut report := diagnose(store, fast)44 repairs := plan_repairs(store, report)45 report.repairs = repairs.map(it.action)46 mut repaired := []string{}47 mut repair_errors := []string{}48 mut attempted := false49 50 if repairs.len > 0 && backup_error == '' && (yes || (interactive() && !args.json())) {51 if !yes {52 print_doctor(report, false)53 println('')54 }55 for repair in repairs {56 if !yes {57 println(' repair: ${repair.action}')58 }59 if !(confirm(yes) or { false }) {60 continue61 }62 attempted = true63 repair.run() or {64 repair_errors << '${repair.action}: ${err.msg()}'65 if err is PartialRepair {66 repaired << err.done67 }68 continue69 }70 repaired << repair.action71 }72 73 if attempted {74 report = diagnose(store, fast)75 report.repairs = plan_repairs(store, report).map(it.action)76 }77 }78 report.repaired = repaired79 report.repair_errors = repair_errors80 if yes {81 report.peek_folders_removed = sweep_peek_folders()82 }83 if backup_error != '' {84 report.check('backup', [backup_error])85 } else {86 report.backup = backup_path87 }88 report.health = if report.problems.len > 0 {89 'unhealthy'90 } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }91 if args.json() {92 emit_json(json2.encode(report))93 } else if repairs.len == 0 || yes || attempted || !interactive() {94 print_doctor(report, true)95 }96 return if report.problems.len > 0 { 1 } else { 0 }97}9899100fn diagnose(store &storage.Store, fast bool) DoctorJson {101 mut report := DoctorJson{102 root: store.root103 database: os.join_path(store.root, storage.file_name)104 schema_version: storage.schema_version105 checked_at: time.now().unix()106 koh_version: version107 }108 working('checking database, objects and all stored history')109 store.db.exec('BEGIN') or {110 report.check('read transaction', [err.msg()])111 return report112 }113 report.check('SQLite integrity', store.integrity_check() or { [err.msg()] })114 report.object_check('object hashes and sizes', store.check_objects() or {115 [storage.ObjectProblem{'', err.msg()}]116 })117 report.object_check('save object references', store.check_save_files() or {118 [storage.ObjectProblem{'', err.msg()}]119 })120 doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) }121 store.end() or { report.check('read transaction', [err.msg()]) }122 working('reading working files without cached hashes')123 doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) }124 doctor_build(store.root, mut report)125 report.size_bytes = os.file_size(report.database)126 report.wal_bytes = os.file_size('${report.database}-wal')127 doctor_remote_config(mut report)128 if fast {129 report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked', ''}130 } else {131 working('auditing remote history and releases (read-only)')132 doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }133 }134 report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'135 return report136}137138fn (mut report DoctorJson) check(name string, problems []string) {139 failed := problems.len > 0140 report.checks << DoctorCheck{name, if failed { 'fail' } else { 'pass' }, if failed {141 problems.join('; ')142 } else {143 'verified'144 }, if failed { remedy_for(name) } else { '' }}145 for problem in problems {146 if problem !in report.problems { report.problems << problem }147 }148}149150151fn (mut report DoctorJson) object_check(name string, problems []storage.ObjectProblem) {152 for problem in problems {153 if problem.hash != '' && problem.hash !in report.damaged_objects {154 report.damaged_objects << problem.hash155 }156 }157 report.check(name, problems.map(it.detail))158}159160161162fn remedy_for(check string) string {163 return match check {164 'SQLite integrity' {165 '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'166 }167 'object hashes and sizes', 'save object references' {168 'koh doctor restores verified copies from working files or the remote; with neither, that content is lost'169 }170 'history links and cycles' {171 'koh doctor fetches missing saves from the remote; a cycle needs a backup or the remote copy'172 }173 'saved paths and events' {174 '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'175 }176 'restore state' {177 'run koh revert; it keeps the current files in a checkpoint first'178 }179 'working tree safety' {180 'rename files whose names Koh cannot save, and move links that block a restore'181 }182 'ignore rules' {183 'make the ignore file readable'184 }185 'build configuration' {186 'fix koh.build; koh man build describes it'187 }188 'machine remote', 'machine remote configuration' {189 'run koh remote to set the remote and its credentials again'190 }191 'remote audit' {192 'check the network and the remote settings (koh remote), then run koh doctor again'193 }194 'remote history, events and objects' {195 'koh doctor uploads what this repo holds; anything else must be offered from a machine that has it'196 }197 'remote releases' {198 'publish that version again with koh offer --builds from the save it names'199 }200 'remote stability' {201 'another machine changed the remote during the audit; run koh doctor again'202 }203 'backup' {204 'choose a backup path that does not exist yet'205 }206 else {207 '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'208 }209 }210}211212fn open_remedy(message string) string {213 return if message.contains('newer koh') {214 'run koh update'215 } else if message.contains('another koh command') || message.contains('locked') {216 'wait for the other koh command to finish, then run koh doctor again'217 } else if message.contains('koh init') {218 'run koh init here, or koh steal <repo> to fetch one'219 } else {220 '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'221 }222}223224225fn print_doctor(report DoctorJson, offer bool) {226 root := report.root.replace_once(os.home_dir(), '~')227 if report.problems.len > 0 {228 warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')229 } else if report.warnings.len > 0 {230 ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}')231 } else {232 ok('${root} is healthy')233 }234 println('')235 if report.metadata_available {236 doctor_metadata(report)237 } else {238 println(' ${fmt.dim('repository metadata unavailable; see failed checks')}')239 }240 println('')241 passed := report.checks.filter(it.status == 'pass').map(it.name)242 if passed.len > 0 {243 ok('${fmt.plural(passed.len, 'check')} passed')244 for line in wrap_items(passed, 76) {245 println(' ${fmt.dim(line)}')246 }247 }248 for check in report.checks.filter(it.status != 'pass') {249 mark := match check.status {250 'fail' { fmt.red(fmt.glyph_fail) }251 'skipped' { fmt.dim('–') }252 else { fmt.amber('!') }253 }254 println(' ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')255 if check.remedy != '' {256 println(' fix: ${check.remedy}')257 }258 }259 for warning in report.warnings {260 warn(warning)261 }262 if report.peek_folders_removed > 0 {263 ok('removed ${report.peek_folders_removed} old peek folders')264 }265 if report.backup != '' { ok('backed up to ${report.backup}') }266 for action in report.repaired {267 ok('repaired: ${action}')268 }269 for failure in report.repair_errors {270 warn('repair failed: ${failure}')271 }272 if offer && report.repairs.len > 0 {273 println('')274 warn('koh doctor can repair ${fmt.plural(report.repairs.len, 'finding')}:')275 for action in report.repairs {276 println(' ${action}')277 }278 note('run koh doctor again to choose, or koh doctor --yes to run them all')279 }280 println('')281 note(report.scope)282}283284fn doctor_metadata(report DoctorJson) {285 if report.head.id != '' {286 doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}')287 doctor_row('', fmt.dim([report.head.agent, report.head.session,288 fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · ')))289 } else {290 doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id })291 }292 mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints']293 if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' }294 if report.events > 0 { saves << fmt.plural(report.events, 'event') }295 if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' }296 doctor_row('history', saves.join(' · '))297 doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')298 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'))299 changes := report.changes300 counts := ['${changes.added.len} added', '${changes.modified.len} modified',301 '${changes.removed.len} removed'].filter(!it.starts_with('0 '))302 mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') }303 if report.cache_cleared {304 tree += fmt.dim(' · ${report.cached_files} cached hashes re-read')305 }306 doctor_row('tree', tree)307 if report.remote_target != '' {308 offer := if report.last_offered_head != '' {309 fmt.dim(' · last offer [${report.last_offered_head}]')310 } else {311 ''312 }313 doctor_row('remote', report.remote_target + offer)314 }315 if report.source != '' { doctor_row('source', '${report.source} (read-only)') }316 if report.project_version != '' {317 doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}')318 }319}320321fn doctor_row(label string, value string) {322 println(' ${fmt.dim('${label:-9}')}${value}')323}324325fn wrap_items(items []string, width int) []string {326 mut lines := []string{}327 mut line := ''328 for item in items {329 if line != '' && fmt.visible_width(line) + 3 + item.len > width {330 lines << line331 line = item332 } else {333 line = if line == '' { item } else { '${line} · ${item}' }334 }335 }336 if line != '' {337 lines << line338 }339 return lines340}341342fn shell_quote(text string) string {343 return "'${text.replace("'", "'\\''")}'"344}345346const peek_age = 86400347348fn sweep_peek_folders() int {349 mut removed := 0350 for name in os.ls(os.temp_dir()) or { return 0 } {351 path := os.join_path(os.temp_dir(), name)352 info := os.lstat(path) or { continue }353 if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)354 || info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {355 continue356 }357 os.rmdir_all(path) or { continue }358 removed++359 }360 return removed361}362363// A recent peek may still be open in an editor.364fn peek_is_old(modified i64, now i64) bool {365 return modified < now - peek_age366}367368struct DoctorCheck {369 name string370 status string371 detail string372 remedy string373}374375struct DoctorJson {376mut:377 health string378 scope string379 metadata_available bool380 ignore_files map[string]int381 checks []DoctorCheck382 warnings []string383 problems []string384 root string385 database string386 schema_version int387 checked_at i64388 koh_version string389 head_id string390 head history.Save391 head_files int392 objects int393 saves int394 checkpoints int395 line_saves int396 off_line_saves int397 events int398 flagged_saves int399 head_flagged bool400 file_entries i64401 size_bytes u64402 wal_bytes u64403 object_bytes i64404 packed_bytes i64405 unreferenced_objects i64406 restore_unfinished bool407 pending_strips []string408 changes Changes409 ignored_saved_files []string410 build_platforms []string411 project_version string412 source string413 remote_repo string414 remote_target string415 last_offered_head string416 remote_head string417 remote_objects int418 remote_saves int419 remote_releases int420 cached_files int421 cache_cleared bool422 peek_folders_removed int423 backup string424 repairs []string425 repaired []string426 repair_errors []string427 damaged_objects []string428 missing_saves []string429 strip_residue bool430 remote_bad_objects []string431 remote_missing_saves []string432}