cmd_doctor.v

13.9 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	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}9192// diagnose runs every check; it changes nothing but the disposable hash cache.93fn 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}142143// An object problem with no hash is a check that could not run.144fn (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}152153// What to do when a check fails. A problem that needs its own command, such as154// a strip of one unsafe path, names it in its text.155fn 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}216217// offer lists the repairs left to run; it is false just before Doctor asks about them.218fn 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}