cmd_doctor.v

14.2 KB · raw

1//! Repository checks, remote verification, repairs and explicit maintenance.2//!3//! Checks only read. Cache resets are automatic; repairs run only with4//! consent; --yes also permits old-peek cleanup and --backup writes a5//! consistent database copy.67module 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	// Before any repair, so the copy holds the repo as Doctor found it.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	// A requested backup that failed leaves nothing to fall back on.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		// A repair that failed may still have written part of its work.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}9899// diagnose runs every check; it changes nothing but the disposable hash cache.100fn 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}149150// An object problem with no hash is a check that could not run.151fn (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}159160// What to do when a check fails. A problem that needs its own command, such as161// a strip of one unsafe path, names it in its text.162fn 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}223224// offer lists the repairs left to run; it is false just before Doctor asks about them.225fn 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}