cmd_doctor.v

14.3 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.exchange13import 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(), open_remedy(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, true) }34		return 135	}36	defer { store.close() }37	yes, fast := args.flag('yes'), args.flag('fast')38	// Before any repair, so the copy holds the repo as Doctor found it.39	backup_path := args.value_or_none('backup') or { '' }40	mut backup_error := ''41	if backup_path != '' {42		store.backup(backup_path) or { backup_error = err.msg() }43	}44	mut report := diagnose(store, fast)45	repairs := plan_repairs(store, report)46	report.repairs = repairs.map(it.action)47	mut repaired := []string{}48	mut repair_errors := []string{}49	mut attempted := false50	// A requested backup that failed leaves nothing to fall back on.51	if repairs.len > 0 && backup_error == '' && (yes || (interactive() && !args.json())) {52		if !yes {53			print_doctor(report, false)54			println('')55		}56		for repair in repairs {57			if !yes {58				println('  repair: ${repair.action}')59			}60			if !(confirm(yes) or { false }) {61				continue62			}63			attempted = true64			repair.run() or {65				repair_errors << '${repair.action}: ${err.msg()}'66				if err is exchange.Partway {67					repaired << err.done68				}69				continue70			}71			repaired << repair.action72		}73		// A repair that failed may still have written part of its work.74		if attempted {75			report = diagnose(store, fast)76			report.repairs = plan_repairs(store, report).map(it.action)77		}78	}79	report.repaired = repaired80	report.repair_errors = repair_errors81	if yes {82		report.peek_folders_removed = sweep_peek_folders()83	}84	if backup_error != '' {85		report.check('backup', [backup_error])86	} else {87		report.backup = backup_path88	}89	report.health = if report.problems.len > 0 {90		'unhealthy'91	} else if report.warnings.len > 0 { 'warning' } else { 'healthy' }92	if args.json() {93		emit_json(json2.encode(report))94	} else if repairs.len == 0 || yes || attempted || backup_error != '' || !interactive() {95		print_doctor(report, true)96	}97	return if report.problems.len > 0 { 1 } else { 0 }98}99100// diagnose runs every check; it changes nothing but the disposable hash cache.101fn diagnose(store &storage.Store, fast bool) DoctorJson {102	mut report := DoctorJson{103		root:           store.root104		database:       os.join_path(store.root, storage.file_name)105		schema_version: storage.schema_version106		checked_at:     time.now().unix()107		koh_version:    version108	}109	working('checking database, objects and all stored history')110	store.db.exec('BEGIN') or {111		report.check('read transaction', [err.msg()])112		return report113	}114	report.check('SQLite integrity', store.integrity_check() or { [err.msg()] })115	report.object_check('object hashes and sizes', store.check_objects() or {116		[storage.ObjectProblem{'', err.msg()}]117	})118	report.object_check('save object references', store.check_save_files() or {119		[storage.ObjectProblem{'', err.msg()}]120	})121	doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) }122	store.end() or { report.check('read transaction', [err.msg()]) }123	working('reading working files without cached hashes')124	doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) }125	doctor_build(store.root, mut report)126	report.size_bytes = os.file_size(report.database)127	report.wal_bytes = os.file_size('${report.database}-wal')128	doctor_remote_config(mut report)129	if fast {130		report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked', ''}131	} else {132		working('auditing remote history and releases (read-only)')133		doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }134	}135	report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'136	return report137}138139fn (mut report DoctorJson) check(name string, problems []string) {140	failed := problems.len > 0141	report.checks << DoctorCheck{name, if failed { 'fail' } else { 'pass' }, if failed {142		problems.join('; ')143	} else {144		'verified'145	}, if failed { remedy_for(name) } else { '' }}146	for problem in problems {147		if problem !in report.problems { report.problems << problem }148	}149}150151// An object problem with no hash is a check that could not run.152fn (mut report DoctorJson) object_check(name string, problems []storage.ObjectProblem) {153	for problem in problems {154		if problem.hash != '' && problem.hash !in report.damaged_objects {155			report.damaged_objects << problem.hash156		}157	}158	report.check(name, problems.map(it.detail))159}160161// What to do when a check fails. A problem that needs its own command, such as162// a strip of one unsafe path, names it in its text.163fn remedy_for(check string) string {164	return match check {165		'SQLite integrity' {166			'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'167		}168		'object hashes and sizes', 'save object references' {169			'koh doctor restores verified copies from working files or the remote; with neither, that content is lost'170		}171		'history links and cycles' {172			'koh doctor fetches missing saves from the remote; a cycle needs a backup or the remote copy'173		}174		'saved paths and events' {175			'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'176		}177		'restore state' {178			'run koh revert; it keeps the current files in a checkpoint first'179		}180		'working tree safety' {181			'rename files whose names Koh cannot save, and move links that block a restore'182		}183		'ignore rules' {184			'make the ignore file readable'185		}186		'build configuration' {187			'fix koh.build; koh man build describes it'188		}189		'machine remote', 'machine remote configuration' {190			'run koh remote to set the remote and its credentials again'191		}192		'remote audit' {193			'check the network and the remote settings (koh remote), then run koh doctor again'194		}195		'remote history, events and objects' {196			'koh doctor uploads what this repo holds; anything else must be offered from a machine that has it'197		}198		'remote releases' {199			'publish that version again with koh offer --builds from the save it names'200		}201		'remote stability' {202			'another machine changed the remote during the audit; run koh doctor again'203		}204		'backup' {205			'choose a backup path that does not exist yet'206		}207		else {208			'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'209		}210	}211}212213fn open_remedy(message string) string {214	return if message.contains('newer koh') {215		'run koh update'216	} else if message.contains('another koh command') || message.contains('locked') {217		'wait for the other koh command to finish, then run koh doctor again'218	} else if message.contains('koh init') {219		'run koh init here, or koh steal <repo> to fetch one'220	} else {221		'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'222	}223}224225// offer lists the repairs left to run; it is false just before Doctor asks about them.226fn print_doctor(report DoctorJson, offer bool) {227	root := report.root.replace_once(os.home_dir(), '~')228	if report.problems.len > 0 {229		warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')230	} else if report.warnings.len > 0 {231		ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}')232	} else {233		ok('${root} is healthy')234	}235	println('')236	if report.metadata_available {237		doctor_metadata(report)238	} else {239		println('    ${fmt.dim('repository metadata unavailable; see failed checks')}')240	}241	println('')242	passed := report.checks.filter(it.status == 'pass').map(it.name)243	if passed.len > 0 {244		ok('${fmt.plural(passed.len, 'check')} passed')245		for line in wrap_items(passed, 76) {246			println('    ${fmt.dim(line)}')247		}248	}249	for check in report.checks.filter(it.status != 'pass') {250		mark := match check.status {251			'fail' { fmt.red(fmt.glyph_fail) }252			'skipped' { fmt.dim('–') }253			else { fmt.amber('!') }254		}255		println('  ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')256		if check.remedy != '' {257			println('      fix: ${check.remedy}')258		}259	}260	for warning in report.warnings {261		warn(warning)262	}263	if report.peek_folders_removed > 0 {264		ok('removed ${report.peek_folders_removed} old peek folders')265	}266	if report.backup != '' { ok('backed up to ${report.backup}') }267	for action in report.repaired {268		ok('repaired: ${action}')269	}270	for failure in report.repair_errors {271		warn('repair failed: ${failure}')272	}273	if offer && report.repairs.len > 0 {274		println('')275		warn('koh doctor can repair ${fmt.plural(report.repairs.len, 'finding')}:')276		for action in report.repairs {277			println('    ${action}')278		}279		note('run koh doctor again to choose, or koh doctor --yes to run them all')280	}281	println('')282	note(report.scope)283}284285fn doctor_metadata(report DoctorJson) {286	if report.head.id != '' {287		doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}')288		doctor_row('', fmt.dim([report.head.agent, report.head.session,289			fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · ')))290	} else {291		doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id })292	}293	mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints']294	if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' }295	if report.events > 0 { saves << fmt.plural(report.events, 'event') }296	if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' }297	doctor_row('history', saves.join(' · '))298	doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')299	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'))300	changes := report.changes301	counts := ['${changes.added.len} added', '${changes.modified.len} modified',302		'${changes.removed.len} removed'].filter(!it.starts_with('0 '))303	mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') }304	if report.cache_cleared {305		tree += fmt.dim(' · ${report.cached_files} cached hashes re-read')306	}307	doctor_row('tree', tree)308	if report.remote_target != '' {309		offer := if report.last_offered_head != '' {310			fmt.dim(' · last offer [${report.last_offered_head}]')311		} else {312			''313		}314		doctor_row('remote', report.remote_target + offer)315	}316	if report.source != '' { doctor_row('source', '${report.source} (read-only)') }317	if report.project_version != '' {318		doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}')319	}320}321322fn doctor_row(label string, value string) {323	println('    ${fmt.dim('${label:-9}')}${value}')324}325326fn wrap_items(items []string, width int) []string {327	mut lines := []string{}328	mut line := ''329	for item in items {330		if line != '' && fmt.visible_width(line) + 3 + item.len > width {331			lines << line332			line = item333		} else {334			line = if line == '' { item } else { '${line} · ${item}' }335		}336	}337	if line != '' {338		lines << line339	}340	return lines341}342343fn shell_quote(text string) string {344	return "'${text.replace("'", "'\\''")}'"345}346347const peek_age = 86400348349fn sweep_peek_folders() int {350	mut removed := 0351	for name in os.ls(os.temp_dir()) or { return 0 } {352		path := os.join_path(os.temp_dir(), name)353		info := os.lstat(path) or { continue }354		if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)355			|| info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {356			continue357		}358		os.rmdir_all(path) or { continue }359		removed++360	}361	return removed362}363364// A recent peek may still be open in an editor.365fn peek_is_old(modified i64, now i64) bool {366	return modified < now - peek_age367}368369struct DoctorCheck {370	name   string371	status string372	detail string373	remedy string374}375376struct DoctorJson {377mut:378	health               string379	scope                string380	metadata_available   bool381	ignore_files         map[string]int382	checks               []DoctorCheck383	warnings             []string384	problems             []string385	root                 string386	database             string387	schema_version       int388	checked_at           i64389	koh_version          string390	head_id              string391	head                 history.Save392	head_files           int393	objects              int394	saves                int395	checkpoints          int396	line_saves           int397	off_line_saves       int398	events               int399	flagged_saves        int400	head_flagged         bool401	file_entries         i64402	size_bytes           u64403	wal_bytes            u64404	object_bytes         i64405	packed_bytes         i64406	unreferenced_objects i64407	restore_unfinished   bool408	pending_strips       []string409	changes              Changes410	ignored_saved_files  []string411	build_platforms      []string412	project_version      string413	source               string414	remote_repo          string415	remote_target        string416	last_offered_head    string417	remote_head          string418	remote_objects       int419	remote_saves         int420	remote_releases      int421	cached_files         int422	cache_cleared        bool423	peek_folders_removed int424	backup               string425	repairs              []string426	repaired             []string427	repair_errors        []string428	damaged_objects      []string429	missing_saves        []string430	strip_residue        bool431	strip_residue_paths  []string432	remote_strip_residue []string433	remote_bad_objects   []string434	remote_missing_saves []string435}