cmd_doctor.v

9.6 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', 'offline', '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	skip := if args.flag('offline') {72		'offline'73	} else if args.flag('fast') { 'fast' } else { '' }74	if skip != '' {75		report.checks << DoctorCheck{'remote audit', 'skipped', '--${skip} requested; remote content not checked'}76	} else {77		working('auditing remote history and releases (read-only)')78		doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }79	}80	report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'81	report.health = if report.problems.len > 0 {82		'unhealthy'83	} else if report.warnings.len > 0 { 'warning' } else { 'healthy' }84	if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }85	return if report.problems.len > 0 { 1 } else { 0 }86}8788fn (mut report DoctorJson) check(name string, problems []string) {89	report.checks << DoctorCheck{name, if problems.len == 0 { 'pass' } else { 'fail' }, if problems.len == 0 {90		'verified'91	} else {92		problems.join('; ')93	}}94	for problem in problems {95		if problem !in report.problems { report.problems << problem }96	}97}9899fn print_doctor(report DoctorJson) {100	root := report.root.replace_once(os.home_dir(), '~')101	if report.problems.len > 0 {102		warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')103	} else if report.warnings.len > 0 {104		ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}')105	} else {106		ok('${root} is healthy')107	}108	println('')109	if report.metadata_available {110		doctor_metadata(report)111	} else {112		println('    ${fmt.dim('repository metadata unavailable; see failed checks')}')113	}114	println('')115	passed := report.checks.filter(it.status == 'pass').map(it.name)116	if passed.len > 0 {117		ok('${fmt.plural(passed.len, 'check')} passed')118		for line in wrap_items(passed, 76) {119			println('    ${fmt.dim(line)}')120		}121	}122	for check in report.checks.filter(it.status != 'pass') {123		mark := match check.status {124			'fail' { fmt.red(fmt.glyph_fail) }125			'skipped' { fmt.dim('–') }126			else { fmt.amber('!') }127		}128		println('  ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')129	}130	for warning in report.warnings {131		warn(warning)132	}133	if report.peek_folders_removed > 0 {134		ok('removed ${report.peek_folders_removed} old peek folders')135	}136	if report.backup != '' { ok('backed up to ${report.backup}') }137	if report.problems.len > 0 {138		note('keep .koh intact before recovering history; use a known-good backup or another verified copy')139	}140	println('')141	note(report.scope)142}143144fn doctor_metadata(report DoctorJson) {145	if report.head.id != '' {146		doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}')147		doctor_row('', fmt.dim([report.head.agent, report.head.session,148			fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · ')))149	} else {150		doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id })151	}152	mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints']153	if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' }154	if report.events > 0 { saves << fmt.plural(report.events, 'event') }155	if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' }156	doctor_row('history', saves.join(' · '))157	doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')158	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'))159	changes := report.changes160	counts := ['${changes.added.len} added', '${changes.modified.len} modified',161		'${changes.removed.len} removed'].filter(!it.starts_with('0 '))162	mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') }163	if report.cache_cleared {164		tree += fmt.dim(' · ${report.cached_files} cached hashes re-read')165	}166	doctor_row('tree', tree)167	if report.remote_target != '' {168		offer := if report.last_offered_head != '' {169			fmt.dim(' · last offer [${report.last_offered_head}]')170		} else {171			''172		}173		doctor_row('remote', report.remote_target + offer)174	}175	if report.source != '' { doctor_row('source', '${report.source} (read-only)') }176	if report.project_version != '' {177		doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}')178	}179}180181fn doctor_row(label string, value string) {182	println('    ${fmt.dim('${label:-9}')}${value}')183}184185// wrap_items joins items with a dot, breaking lines between items so none passes width.186fn wrap_items(items []string, width int) []string {187	mut lines := []string{}188	mut line := ''189	for item in items {190		if line != '' && fmt.visible_width(line) + 3 + item.len > width {191			lines << line192			line = item193		} else {194			line = if line == '' { item } else { '${line} · ${item}' }195		}196	}197	if line != '' {198		lines << line199	}200	return lines201}202203fn shell_quote(text string) string {204	return "'${text.replace("'", "'\\''")}'"205}206207// peek_age is how old a peek folder must be before doctor clears it.208const peek_age = 86400209210// sweep_peek_folders clears the scratch trees `koh peek` leaves in the system211// temp directory, once they are a day old.212fn sweep_peek_folders() int {213	mut removed := 0214	for name in os.ls(os.temp_dir()) or { return 0 } {215		path := os.join_path(os.temp_dir(), name)216		info := os.lstat(path) or { continue }217		if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)218			|| info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {219			continue220		}221		os.rmdir_all(path) or { continue }222		removed++223	}224	return removed225}226227// peek_is_old is true once a peek is more than a day old. A younger one may228// still be open in somebody's editor.229fn peek_is_old(modified i64, now i64) bool {230	return modified < now - peek_age231}232233struct DoctorCheck {234	name   string235	status string236	detail string237}238239struct DoctorJson {240mut:241	health               string242	scope                string243	metadata_available   bool244	ignore_files         map[string]int245	checks               []DoctorCheck246	warnings             []string247	problems             []string248	root                 string249	database             string250	schema_version       int251	checked_at           i64252	koh_version          string253	head_id              string254	head                 history.Save255	head_files           int256	objects              int257	saves                int258	checkpoints          int259	line_saves           int260	off_line_saves       int261	events               int262	flagged_saves        int263	head_flagged         bool264	file_entries         i64265	size_bytes           u64266	wal_bytes            u64267	object_bytes         i64268	packed_bytes         i64269	unreferenced_objects i64270	restore_unfinished   bool271	pending_strips       []string272	changes              Changes273	ignored_saved_files  []string274	build_platforms      []string275	project_version      string276	source               string277	remote_repo          string278	remote_target        string279	last_offered_head    string280	remote_head          string281	remote_objects       int282	remote_saves         int283	remote_releases      int284	cached_files         int285	cache_cleared        bool286	peek_folders_removed int287	backup               string288}