cmd_doctor.v

9.5 KB · raw

1//! `koh doctor` — integrity check and cleanup.2//!3//! One source of truth means there is nothing to rebuild; what is left to check4//! is that the file is sound and that every object a save names is really5//! there. Doctor also tidies the things that are safe to lose: the file cache6//! and, with --yes, old peek folders.78module cli910import json211import os12import time13import 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()}]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) }34		return 135	}36	defer { store.close() }37	mut report := DoctorJson{38		root:           store.root39		database:       os.join_path(store.root, storage.file_name)40		schema_version: storage.schema_version41		checked_at:     time.now().unix()42		koh_version:    version43	}44	working('checking database, objects and all stored history')45	store.db.exec('BEGIN') or { return refuse(args.json(), err.msg()) }46	report.check('SQLite integrity', store.integrity_check() or { [err.msg()] })47	report.check('object hashes and sizes', store.check_objects() or { [err.msg()] })48	report.check('save object references', store.check_save_files() or { [err.msg()] })49	doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) }50	store.end() or { report.check('read transaction', [err.msg()]) }51	working('reading working files without cached hashes')52	doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) }53	doctor_build(store.root, mut report)54	if args.flag('yes') {55		report.peek_folders_removed = sweep_peek_folders()56	}57	if path := args.value_or_none('backup') {58		mut backed_up := true59		store.backup(path) or {60			report.check('backup', [err.msg()])61			backed_up = false62		}63		if backed_up {64			report.backup = path65		}66	}67	report.size_bytes = os.file_size(report.database)68	report.wal_bytes = os.file_size('${report.database}-wal')69	doctor_remote_config(mut report)70	// The remote audit is nearly all of Doctor's time, so --fast skips exactly that.71	if args.flag('fast') {72		report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked'}73	} else {74		working('auditing remote history and releases (read-only)')75		doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }76	}77	report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'78	report.health = if report.problems.len > 0 {79		'unhealthy'80	} else if report.warnings.len > 0 { 'warning' } else { 'healthy' }81	if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }82	return if report.problems.len > 0 { 1 } else { 0 }83}8485fn (mut report DoctorJson) check(name string, problems []string) {86	report.checks << DoctorCheck{name, if problems.len == 0 { 'pass' } else { 'fail' }, if problems.len == 0 {87		'verified'88	} else {89		problems.join('; ')90	}}91	for problem in problems {92		if problem !in report.problems { report.problems << problem }93	}94}9596fn print_doctor(report DoctorJson) {97	root := report.root.replace_once(os.home_dir(), '~')98	if report.problems.len > 0 {99		warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')100	} else if report.warnings.len > 0 {101		ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}')102	} else {103		ok('${root} is healthy')104	}105	println('')106	if report.metadata_available {107		doctor_metadata(report)108	} else {109		println('    ${fmt.dim('repository metadata unavailable; see failed checks')}')110	}111	println('')112	passed := report.checks.filter(it.status == 'pass').map(it.name)113	if passed.len > 0 {114		ok('${fmt.plural(passed.len, 'check')} passed')115		for line in wrap_items(passed, 76) {116			println('    ${fmt.dim(line)}')117		}118	}119	for check in report.checks.filter(it.status != 'pass') {120		mark := match check.status {121			'fail' { fmt.red(fmt.glyph_fail) }122			'skipped' { fmt.dim('–') }123			else { fmt.amber('!') }124		}125		println('  ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')126	}127	for warning in report.warnings {128		warn(warning)129	}130	if report.peek_folders_removed > 0 {131		ok('removed ${report.peek_folders_removed} old peek folders')132	}133	if report.backup != '' { ok('backed up to ${report.backup}') }134	if report.problems.len > 0 {135		note('keep .koh intact before recovering history; use a known-good backup or another verified copy')136	}137	println('')138	note(report.scope)139}140141fn doctor_metadata(report DoctorJson) {142	if report.head.id != '' {143		doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}')144		doctor_row('', fmt.dim([report.head.agent, report.head.session,145			fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · ')))146	} else {147		doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id })148	}149	mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints']150	if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' }151	if report.events > 0 { saves << fmt.plural(report.events, 'event') }152	if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' }153	doctor_row('history', saves.join(' · '))154	doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')155	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'))156	changes := report.changes157	counts := ['${changes.added.len} added', '${changes.modified.len} modified',158		'${changes.removed.len} removed'].filter(!it.starts_with('0 '))159	mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') }160	if report.cache_cleared {161		tree += fmt.dim(' · ${report.cached_files} cached hashes re-read')162	}163	doctor_row('tree', tree)164	if report.remote_target != '' {165		offer := if report.last_offered_head != '' {166			fmt.dim(' · last offer [${report.last_offered_head}]')167		} else {168			''169		}170		doctor_row('remote', report.remote_target + offer)171	}172	if report.source != '' { doctor_row('source', '${report.source} (read-only)') }173	if report.project_version != '' {174		doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}')175	}176}177178fn doctor_row(label string, value string) {179	println('    ${fmt.dim('${label:-9}')}${value}')180}181182// wrap_items joins items with a dot, breaking lines between items so none passes width.183fn wrap_items(items []string, width int) []string {184	mut lines := []string{}185	mut line := ''186	for item in items {187		if line != '' && fmt.visible_width(line) + 3 + item.len > width {188			lines << line189			line = item190		} else {191			line = if line == '' { item } else { '${line} · ${item}' }192		}193	}194	if line != '' {195		lines << line196	}197	return lines198}199200fn shell_quote(text string) string {201	return "'${text.replace("'", "'\\''")}'"202}203204// peek_age is how old a peek folder must be before doctor clears it.205const peek_age = 86400206207// sweep_peek_folders clears the scratch trees `koh peek` leaves in the system208// temp directory, once they are a day old.209fn sweep_peek_folders() int {210	mut removed := 0211	for name in os.ls(os.temp_dir()) or { return 0 } {212		path := os.join_path(os.temp_dir(), name)213		info := os.lstat(path) or { continue }214		if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)215			|| info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {216			continue217		}218		os.rmdir_all(path) or { continue }219		removed++220	}221	return removed222}223224// peek_is_old is true once a peek is more than a day old. A younger one may225// still be open in somebody's editor.226fn peek_is_old(modified i64, now i64) bool {227	return modified < now - peek_age228}229230struct DoctorCheck {231	name   string232	status string233	detail string234}235236struct DoctorJson {237mut:238	health               string239	scope                string240	metadata_available   bool241	ignore_files         map[string]int242	checks               []DoctorCheck243	warnings             []string244	problems             []string245	root                 string246	database             string247	schema_version       int248	checked_at           i64249	koh_version          string250	head_id              string251	head                 history.Save252	head_files           int253	objects              int254	saves                int255	checkpoints          int256	line_saves           int257	off_line_saves       int258	events               int259	flagged_saves        int260	head_flagged         bool261	file_entries         i64262	size_bytes           u64263	wal_bytes            u64264	object_bytes         i64265	packed_bytes         i64266	unreferenced_objects i64267	restore_unfinished   bool268	pending_strips       []string269	changes              Changes270	ignored_saved_files  []string271	build_platforms      []string272	project_version      string273	source               string274	remote_repo          string275	remote_target        string276	last_offered_head    string277	remote_head          string278	remote_objects       int279	remote_saves         int280	remote_releases      int281	cached_files         int282	cache_cleared        bool283	peek_folders_removed int284	backup               string285}