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 if repairs.len > 0 && (yes || (interactive() && !args.json())) {49 if !yes {50 print_doctor(report, false)51 println('')52 }53 for repair in repairs {54 if !yes {55 println(' repair: ${repair.action}')56 }57 if !(confirm(yes) or { false }) {58 continue59 }60 repair.run() or {61 repair_errors << '${repair.action}: ${err.msg()}'62 continue63 }64 repaired << repair.action65 }66 if repaired.len > 0 {67 report = diagnose(store, fast)68 report.repairs = plan_repairs(store, report).map(it.action)69 }70 }71 report.repaired = repaired72 report.repair_errors = repair_errors73 if yes {74 report.peek_folders_removed = sweep_peek_folders()75 }76 if backup_error != '' {77 report.check('backup', [backup_error])78 } else {79 report.backup = backup_path80 }81 report.health = if report.problems.len > 0 {82 'unhealthy'83 } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }84 if args.json() {85 emit_json(json2.encode(report))86 } else if repairs.len == 0 || yes || repaired.len > 0 || !interactive() {87 print_doctor(report, true)88 }89 return if report.problems.len > 0 { 1 } else { 0 }90}919293fn diagnose(store &storage.Store, fast bool) DoctorJson {94 mut report := DoctorJson{95 root: store.root96 database: os.join_path(store.root, storage.file_name)97 schema_version: storage.schema_version98 checked_at: time.now().unix()99 koh_version: version100 }101 working('checking database, objects and all stored history')102 store.db.exec('BEGIN') or {103 report.check('read transaction', [err.msg()])104 return report105 }106 report.check('SQLite integrity', store.integrity_check() or { [err.msg()] })107 report.object_check('object hashes and sizes', store.check_objects() or {108 [storage.ObjectProblem{'', err.msg()}]109 })110 report.object_check('save object references', store.check_save_files() or {111 [storage.ObjectProblem{'', err.msg()}]112 })113 doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) }114 store.end() or { report.check('read transaction', [err.msg()]) }115 working('reading working files without cached hashes')116 doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) }117 doctor_build(store.root, mut report)118 report.size_bytes = os.file_size(report.database)119 report.wal_bytes = os.file_size('${report.database}-wal')120 doctor_remote_config(mut report)121 if fast {122 report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked', ''}123 } else {124 working('auditing remote history and releases (read-only)')125 doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }126 }127 report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'128 return report129}130131fn (mut report DoctorJson) check(name string, problems []string) {132 failed := problems.len > 0133 report.checks << DoctorCheck{name, if failed { 'fail' } else { 'pass' }, if failed {134 problems.join('; ')135 } else {136 'verified'137 }, if failed { remedy_for(name) } else { '' }}138 for problem in problems {139 if problem !in report.problems { report.problems << problem }140 }141}142143144fn (mut report DoctorJson) object_check(name string, problems []storage.ObjectProblem) {145 for problem in problems {146 if problem.hash != '' && problem.hash !in report.damaged_objects {147 report.damaged_objects << problem.hash148 }149 }150 report.check(name, problems.map(it.detail))151}152153154155fn remedy_for(check string) string {156 return match check {157 'SQLite integrity' {158 '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'159 }160 'object hashes and sizes', 'save object references' {161 'koh doctor restores verified copies from working files or the remote; with neither, that content is lost'162 }163 'history links and cycles' {164 'koh doctor fetches missing saves from the remote; a cycle needs a backup or the remote copy'165 }166 'saved paths and events' {167 '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 needs a backup or the remote copy'168 }169 'restore state' {170 'run koh revert; it keeps the current files in a checkpoint first'171 }172 'working tree safety' {173 'rename files whose names Koh cannot save, and move links that block a restore'174 }175 'ignore rules' {176 'make the ignore file readable'177 }178 'build configuration' {179 'fix koh.build; koh man build describes it'180 }181 'machine remote', 'machine remote configuration' {182 'run koh remote to set the remote and its credentials again'183 }184 'remote audit' {185 'check the network and the remote settings (koh remote), then run koh doctor again'186 }187 'remote history, events and objects' {188 'koh doctor uploads what this repo holds; anything else must be offered from a machine that has it'189 }190 'remote releases' {191 'publish that version again with koh offer --builds from the save it names'192 }193 'remote stability' {194 'another machine changed the remote during the audit; run koh doctor again'195 }196 'backup' {197 'choose a backup path that does not exist yet'198 }199 else {200 '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'201 }202 }203}204205fn open_remedy(message string) string {206 return if message.contains('newer koh') {207 'run koh update'208 } else if message.contains('another koh command') || message.contains('locked') {209 'wait for the other koh command to finish, then run koh doctor again'210 } else if message.contains('koh init') {211 'run koh init here, or koh steal <repo> to fetch one'212 } else {213 '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'214 }215}216217218fn print_doctor(report DoctorJson, offer bool) {219 root := report.root.replace_once(os.home_dir(), '~')220 if report.problems.len > 0 {221 warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')222 } else if report.warnings.len > 0 {223 ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}')224 } else {225 ok('${root} is healthy')226 }227 println('')228 if report.metadata_available {229 doctor_metadata(report)230 } else {231 println(' ${fmt.dim('repository metadata unavailable; see failed checks')}')232 }233 println('')234 passed := report.checks.filter(it.status == 'pass').map(it.name)235 if passed.len > 0 {236 ok('${fmt.plural(passed.len, 'check')} passed')237 for line in wrap_items(passed, 76) {238 println(' ${fmt.dim(line)}')239 }240 }241 for check in report.checks.filter(it.status != 'pass') {242 mark := match check.status {243 'fail' { fmt.red(fmt.glyph_fail) }244 'skipped' { fmt.dim('–') }245 else { fmt.amber('!') }246 }247 println(' ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')248 if check.remedy != '' {249 println(' fix: ${check.remedy}')250 }251 }252 for warning in report.warnings {253 warn(warning)254 }255 if report.peek_folders_removed > 0 {256 ok('removed ${report.peek_folders_removed} old peek folders')257 }258 if report.backup != '' { ok('backed up to ${report.backup}') }259 for action in report.repaired {260 ok('repaired: ${action}')261 }262 for failure in report.repair_errors {263 warn('repair failed: ${failure}')264 }265 if offer && report.repairs.len > 0 {266 println('')267 warn('koh doctor can repair ${fmt.plural(report.repairs.len, 'finding')}:')268 for action in report.repairs {269 println(' ${action}')270 }271 note('run koh doctor again to choose, or koh doctor --yes to run them all')272 }273 println('')274 note(report.scope)275}276277fn doctor_metadata(report DoctorJson) {278 if report.head.id != '' {279 doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}')280 doctor_row('', fmt.dim([report.head.agent, report.head.session,281 fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · ')))282 } else {283 doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id })284 }285 mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints']286 if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' }287 if report.events > 0 { saves << fmt.plural(report.events, 'event') }288 if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' }289 doctor_row('history', saves.join(' · '))290 doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')291 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'))292 changes := report.changes293 counts := ['${changes.added.len} added', '${changes.modified.len} modified',294 '${changes.removed.len} removed'].filter(!it.starts_with('0 '))295 mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') }296 if report.cache_cleared {297 tree += fmt.dim(' · ${report.cached_files} cached hashes re-read')298 }299 doctor_row('tree', tree)300 if report.remote_target != '' {301 offer := if report.last_offered_head != '' {302 fmt.dim(' · last offer [${report.last_offered_head}]')303 } else {304 ''305 }306 doctor_row('remote', report.remote_target + offer)307 }308 if report.source != '' { doctor_row('source', '${report.source} (read-only)') }309 if report.project_version != '' {310 doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}')311 }312}313314fn doctor_row(label string, value string) {315 println(' ${fmt.dim('${label:-9}')}${value}')316}317318fn wrap_items(items []string, width int) []string {319 mut lines := []string{}320 mut line := ''321 for item in items {322 if line != '' && fmt.visible_width(line) + 3 + item.len > width {323 lines << line324 line = item325 } else {326 line = if line == '' { item } else { '${line} · ${item}' }327 }328 }329 if line != '' {330 lines << line331 }332 return lines333}334335fn shell_quote(text string) string {336 return "'${text.replace("'", "'\\''")}'"337}338339const peek_age = 86400340341fn sweep_peek_folders() int {342 mut removed := 0343 for name in os.ls(os.temp_dir()) or { return 0 } {344 path := os.join_path(os.temp_dir(), name)345 info := os.lstat(path) or { continue }346 if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)347 || info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {348 continue349 }350 os.rmdir_all(path) or { continue }351 removed++352 }353 return removed354}355356// A recent peek may still be open in an editor.357fn peek_is_old(modified i64, now i64) bool {358 return modified < now - peek_age359}360361struct DoctorCheck {362 name string363 status string364 detail string365 remedy string366}367368struct DoctorJson {369mut:370 health string371 scope string372 metadata_available bool373 ignore_files map[string]int374 checks []DoctorCheck375 warnings []string376 problems []string377 root string378 database string379 schema_version int380 checked_at i64381 koh_version string382 head_id string383 head history.Save384 head_files int385 objects int386 saves int387 checkpoints int388 line_saves int389 off_line_saves int390 events int391 flagged_saves int392 head_flagged bool393 file_entries i64394 size_bytes u64395 wal_bytes u64396 object_bytes i64397 packed_bytes i64398 unreferenced_objects i64399 restore_unfinished bool400 pending_strips []string401 changes Changes402 ignored_saved_files []string403 build_platforms []string404 project_version string405 source string406 remote_repo string407 remote_target string408 last_offered_head string409 remote_head string410 remote_objects int411 remote_saves int412 remote_releases int413 cached_files int414 cache_cleared bool415 peek_folders_removed int416 backup string417 repairs []string418 repaired []string419 repair_errors []string420 damaged_objects []string421 missing_saves []string422 strip_residue bool423 remote_bad_objects []string424 remote_missing_saves []string425}