26b642c1ae8a

verify repository health with comprehensive Doctor checks (3.8.0)

time
2026-10-06 20:43 UTC · 6 days ago
machine
Junes-MacBook-Pro.local
agent
gpt-6
session
complete repo health checks
parent
130bd1c5c7cf
11 files README.md+4 −1VERSION+1 −1shell/koh3.plugin.zsh+2 −2src/cli/cmd_doctor.v+149 −48src/cli/cmd_doctor_test.v+111 −1src/cli/doctor_checks.v+217 −0src/cli/doctor_remote.v+200 −0src/storage/doctor.v+77 −0src/storage/objects.v+7 −1tests/cli_test.v+68 −0v.mod+1 −1
README.md+4 −1
@@ -174,7 +174,10 @@174174 koh doctor --backup /path/to/new-backup.koh175175 ```176176 177-`doctor` checks database integrity, object hashes, and saved file references.177+`doctor` checks the database, stored content, history, events, working files178+and configuration. It also verifies remote history and release files by default.179+Use `--offline` for a local check. The report names each check, its result,180+and any part it could not verify.178181 Its backup command writes a consistent copy of `.koh`. Use it instead of179182 copying the database while Koh is using it. Keep a live `.koh` out of cloud180183 folder sync services; use `offer` and `steal` between machines.

view file

VERSION+1 −1
@@ -1 +1 @@1-3.7.41+3.8.0

view file

shell/koh3.plugin.zsh+2 −2
@@ -135,7 +135,7 @@135135     'pluck:apply one save, or its inverse'136136     'peek:restore a save into a temp folder'137137     'init:create .koh here'138-    'doctor:integrity check and cleanup'138+    'doctor:check repository health'139139     'ignore:ignore or unignore files and folders'140140     'strip:remove a path from history'141141     'build:build from koh.build'@@ -167,7 +167,7 @@167167     steal)           _arguments '--yes' '--list' '--mine' '--theirs' '--json' ;;168168     ignore)          _arguments '--global[edit ~/.kohignore for every repo]' '--yes[skip the question]' '--json' '*:path:_files' ;;169169     strip)           _arguments '--yes[skip the yes/no question; paths still typed]' '--save[one save]:save:_koh3_ids' '*:path:_files' ;;170-    doctor)          _arguments '--yes[remove old peek folders]' '--backup[copy .koh to]:path:_files' ;;170+    doctor)          _arguments '--offline[skip remote content checks]' '--json[print the report as JSON]' '--yes[remove old peek folders]' '--backup[copy .koh to]:path:_files' ;;171171     build)           _arguments '*--platform[target platform]:platform:(macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64)' '--all' ;;172172     update)          _arguments '--check' '--yes' '--json' ;;173173     migrate)         _arguments '--lane[Koh 2 lane]:lane:(dev main)' '*--with[a lost version, from a backup]:file:_files' '--yes[skip the question]' '--json' ;;

view file

src/cli/cmd_doctor.v+149 −48
@@ -12,78 +12,133 @@1212 import time1313 import src.fmt1414 import src.history15-import src.snapshot1615 import src.storage1716 1817 pub fn cmd_doctor(raw []string) int {1918 	args := parse('doctor', raw, Options{2019 		values: ['backup']21-		flags:  ['json', 'yes']20+		flags:  ['json', 'yes', 'offline']2221 	}) or { return usage_error(err.msg()) }23-	store := open_store() or { return refuse(args.json(), err.msg()) }24-	defer {25-		store.close()22+	if args.positional.len > 0 {23+		return usage_error('doctor takes no positional arguments')2624 	}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() }2737 	mut report := DoctorJson{28-		problems: []string{}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:    version2943 	}30-31-	working('checking ${fmt.plural(int(store.object_count() or { 0 }), 'object')} and every save')32-	report.problems << store.integrity_check() or { return refuse(args.json(), err.msg()) }33-	report.problems << store.check_objects() or { return refuse(args.json(), err.msg()) }34-	report.problems << store.check_save_files() or { return refuse(args.json(), err.msg()) }35-	report.problems << unsafe_save_paths(store) or { return refuse(args.json(), err.msg()) }36-37-	report.objects = int(store.object_count() or { 0 })38-	report.saves = int(history.save_count(store) or { 0 })39-	report.checkpoints = int(history.checkpoint_count(store) or { 0 })40-	report.cached_files = int(store.cache_count() or { 0 })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)4154 	if args.flag('yes') {4255 		report.peek_folders_removed = sweep_peek_folders()4356 	}44-	store.drop_cache() or { return refuse(args.json(), err.msg()) }45-4657 	if path := args.value_or_none('backup') {47-		if os.exists(path) {48-			return refuse(args.json(), '${path} already exists')58+		mut backed_up := true59+		store.backup(path) or {60+			report.check('backup', [err.msg()])61+			backed_up = false4962 		}50-		store.backup(path) or { return refuse(args.json(), err.msg()) }51-		report.backup = path63+		if backed_up {64+			report.backup = path65+		}5266 	}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+	if !args.flag('offline') {71+		working('auditing remote history and releases (read-only)')72+		doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }73+	} else {74+		report.checks << DoctorCheck{'remote audit', 'skipped', '--offline requested; remote content not checked'}75+	}76+	report.scope = 'repository data and configuration; application tests, backup recovery and remote write permissions are not verified'77+	report.health = if report.problems.len > 0 {78+		'unhealthy'79+	} else if report.warnings.len > 0 { 'warning' } else { 'healthy' }80+	if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }81+	return if report.problems.len > 0 { 1 } else { 0 }82+}5383 54-	if args.json() {55-		emit_json(json2.encode(report))84+fn (mut report DoctorJson) check(name string, problems []string) {85+	report.checks << DoctorCheck{name, if problems.len == 0 { 'pass' } else { 'fail' }, if problems.len == 0 {86+		'verified'5687 	} else {57-		if report.problems.len == 0 {58-			ok('${store.root} is sound')88+		problems.join('; ')89+	}}90+	for problem in problems {91+		if problem !in report.problems { report.problems << problem }92+	}93+}94+95+fn print_doctor(report DoctorJson) {96+	if report.problems.len == 0 {97+		ok('${report.root} is sound · local health: ${report.health}')98+	} else {99+		warn('${report.root} has ${fmt.plural(report.problems.len, 'problem')}')100+	}101+	note('database: ${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')102+	if report.metadata_available {103+		if report.head.id != '' {104+			note('head [${report.head.id}] ${report.head.message.all_before('\n')} · ${fmt.stamp(report.head.created_at)}')105+			note('${report.head_files} files at head · agent: ${report.head.agent} · session: ${report.head.session}')59106 		} else {60-			for problem in report.problems {61-				warn(problem)62-			}107+			note('head: ${if report.saves == 0 { 'no saves yet' } else { report.head_id }}')63108 		}64-		note('${fmt.plural(report.objects, 'object')} · ${fmt.plural(report.saves, 'save')} · ${fmt.plural(report.checkpoints, 'checkpoint')}')65-		if report.backup != '' {66-			ok('backed up to ${report.backup}')67-		}109+		note('${report.saves} stored saves · ${report.line_saves} on line · ${report.off_line_saves} off line · ${report.checkpoints} checkpoints')110+		note('${report.objects} objects · ${fmt.human_bytes(report.object_bytes)} content · ${fmt.human_bytes(report.packed_bytes)} compressed')111+		note('${report.events} events · ${report.flagged_saves} flagged saves · ${report.file_entries} file entries')112+	} else {113+		note('repository metadata unavailable; see failed checks')68114 	}115+	for check in report.checks {116+		println('  ${check.status}: ${check.name} · ${check.detail}')117+	}118+	for warning in report.warnings {119+		warn(warning)120+	}121+	if report.remote_target != '' { note('remote: ${report.remote_target}') }122+	if report.source != '' { note('read-only source: ${report.source}') }123+	if report.last_offered_head != '' {124+		note('last successful offer: [${report.last_offered_head}] (stored value)')125+	}126+	if report.project_version != '' {127+		note('project ${report.project_version} · ${report.build_platforms.join(', ')}')128+	}129+	if report.cache_cleared {130+		note('cleared ${report.cached_files} cached hashes; working files read again')131+	}132+	if report.peek_folders_removed > 0 {133+		note('removed ${report.peek_folders_removed} old peek folders')134+	}135+	if report.backup != '' { ok('backed up to ${report.backup}') }69136 	if report.problems.len > 0 {70-		return 1137+		note('keep .koh intact before recovering history; use a known-good backup or another verified copy')71138 	}72-	return 0139+	note('scope: ${report.scope}')73140 }74141 75-fn unsafe_save_paths(store &storage.Store) ![]string {76-	mut problems := []string{}77-	for save in history.line(store)! {78-		for file in history.files_of(store, save.id)! {79-			if !snapshot.legal_path(file.path) {80-				problems << 'save [${save.id}] has unsafe path ${snapshot.escape_path(file.path)} — koh strip ${shell_quote(file.path)}'81-			}82-		}83-	}84-	return problems85-}86-87142 fn shell_quote(text string) string {88143 	return "'${text.replace("'", "'\\''")}'"89144 }@@ -114,13 +169,59 @@114169 	return modified < now - peek_age115170 }116171 172+struct DoctorCheck {173+	name   string174+	status string175+	detail string176+}177+117178 struct DoctorJson {118179 mut:180+	health               string181+	scope                string182+	metadata_available   bool183+	ignore_files         map[string]int184+	checks               []DoctorCheck185+	warnings             []string119186 	problems             []string187+	root                 string188+	database             string189+	schema_version       int190+	checked_at           i64191+	koh_version          string192+	head_id              string193+	head                 history.Save194+	head_files           int120195 	objects              int121196 	saves                int122197 	checkpoints          int198+	line_saves           int199+	off_line_saves       int200+	events               int201+	flagged_saves        int202+	head_flagged         bool203+	file_entries         i64204+	size_bytes           u64205+	wal_bytes            u64206+	object_bytes         i64207+	packed_bytes         i64208+	unreferenced_objects i64209+	restore_unfinished   bool210+	pending_strips       []string211+	changes              Changes212+	ignored_saved_files  []string213+	build_platforms      []string214+	project_version      string215+	source               string216+	remote_repo          string217+	remote_target        string218+	last_offered_head    string219+	remote_head          string220+	remote_objects       int221+	remote_saves         int222+	remote_releases      int123223 	cached_files         int224+	cache_cleared        bool124225 	peek_folders_removed int125226 	backup               string126227 }

view file

src/cli/cmd_doctor_test.v+111 −1
@@ -1,10 +1,37 @@11 module cli22 33 import os4+import compress.zlib5+import crypto.sha2566+import json27+import src.exchange8+import src.release9+import src.remote10+import src.events411 import src.history512 import src.snapshot613 import src.storage714 15+fn test_doctor_checks_cycles_and_missing_heads_without_walking_forever() {16+	saves := [history.Save{ id: 'aaaaaaaaaaaa', parent: 'bbbbbbbbbbbb' },17+		history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' }]18+	assert doctor_history(saves, 'cccccccccccc').any(it.contains('head'))19+	assert doctor_history(saves, 'aaaaaaaaaaaa').any(it.contains('cycle'))20+	assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa', parent: 'cccccccccccc' }],21+		'aaaaaaaaaaaa').any(it.contains('missing parent'))22+	assert doctor_history([]history.Save{}, '').len == 023+	assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa' }], '').any(it.contains('no head'))24+}25+26+fn test_doctor_checks_off_line_paths_and_file_directory_collisions() {27+	mut report := DoctorJson{}28+	doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: '../unsafe' },29+		snapshot.FileEntry{ path: 'a' }, snapshot.FileEntry{ path: 'a/b' }], []events.Event{},30+		mut report)31+	assert report.problems.any(it.contains('unsafe path'))32+	assert report.problems.any(it.contains('file and a directory'))33+}34+835 fn test_doctor_reports_unsafe_save_paths_with_a_quoted_strip_command() {936 	dir := os.join_path(os.temp_dir(), 'koh3_doctor_paths_${os.getpid()}')1037 	os.rmdir_all(dir) or {}@@ -26,7 +53,90 @@2653 	}])!2754 	history.set_head(store, 'badbadbadbad')!2855 	store.end()!29-	assert unsafe_save_paths(store)! == [56+	mut report := DoctorJson{}57+	doctor_files('badbadbadbad', history.files_of(store, 'badbadbadbad')!, []events.Event{}, mut report)58+	assert report.problems == [3059 		"save [badbadbadbad] has unsafe path ../June's file — koh strip '../June'\\''s file'",3160 	]61+}62+63+struct DoctorReadOnly {64+mut:65+	items map[string][]u866+}67+68+fn (mut backend DoctorReadOnly) get(key string) !remote.Object {69+	if body := backend.items[key] {70+		return remote.Object{ found: true, body: body }71+	}72+	return remote.Object{}73+}74+75+fn (mut backend DoctorReadOnly) list(prefix string) ![]string {76+	return backend.items.keys().filter(it.starts_with('${prefix}/'))77+}78+79+fn (mut backend DoctorReadOnly) put_if_absent(key string, body []u8) !bool {80+	panic('Doctor attempted a remote write')81+}82+83+fn (mut backend DoctorReadOnly) put_if_match(key string, body []u8, tag string) !bool {84+	panic('Doctor attempted a remote write')85+}86+87+fn (mut backend DoctorReadOnly) delete(key string) ! {88+	panic('Doctor attempted a remote delete')89+}90+91+fn test_doctor_remote_audits_unreferenced_objects_and_release_checksums_without_writes() {92+	data := 'file'.bytes()93+	hash := storage.hash_hex(data)94+	mut backend := DoctorReadOnly{95+		items: {96+			'repo/head':                             'aaaaaaaaaaaa'.bytes()97+			'repo/saves/aaaaaaaaaaaa':               json2.encode(exchange.SaveRecord{98+				id:    'aaaaaaaaaaaa'99+				files: [exchange.FileRecord{ path: 'file', hash: hash }]100+			}).bytes()101+			'repo/objects/${hash}':                  zlib.compress(data)!102+			'repo/releases/latest':                  '1.0.0'.bytes()103+			'repo/releases/1.0.0/release.json':      json2.encode(release.Record{104+				repo:    'repo'105+				version: '1.0.0'106+				save:    'aaaaaaaaaaaa'107+				files:   [release.File{108+					platform: 'linux-aarch64'109+					name:     'app'110+					size:     data.len111+					blake3:   hash112+					sha256:   sha256.sum256(data).hex()113+				}]114+			}).bytes()115+			'repo/releases/1.0.0/linux-aarch64/app': data116+		}117+	}118+	mut report := DoctorJson{ head_id: 'aaaaaaaaaaaa' }119+	doctor_remote_content(mut backend, 'repo', mut report)!120+	assert report.problems.len == 0121+	assert report.remote_saves == 1122+	assert report.remote_releases == 1123+	backend.items['repo/objects/${storage.hash_hex('unreferenced'.bytes())}'] = 'bad zlib'.bytes()124+	backend.items['repo/releases/1.0.0/linux-aarch64/app'] = 'corrupt'.bytes()125+	mut damaged := DoctorJson{}126+	doctor_remote_content(mut backend, 'repo', mut damaged)!127+	assert damaged.problems.any(it.contains('damaged remote object'))128+	assert damaged.problems.any(it.contains('checksum verification'))129+}130+131+fn test_doctor_reports_event_errors_strip_residue_and_flags() {132+	mut report := DoctorJson{}133+	saves := [history.Save{ id: 'aaaaaaaaaaaa' }]134+	event_list := [events.Event{ id: 'strip', kind: 'strip', path: 'private' },135+		events.Event{ id: 'flag', kind: 'flag', save_id: 'aaaaaaaaaaaa' },136+		events.Event{ id: 'bad', kind: 'unknown' }]137+	doctor_events(event_list, saves, mut report)138+	doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)139+	assert report.flagged_saves == 1140+	assert report.problems.any(it.contains('unknown kind'))141+	assert report.problems.any(it.contains('stripped path'))32142 }

view file

src/cli/doctor_checks.v+217 −0
@@ -0,0 +1,217 @@1+//! Doctor's local history, event and working-tree checks.2+module cli3+4+import os5+import src.build6+import src.events7+import src.history8+import src.snapshot9+import src.storage10+11+fn doctor_history(saves []history.Save, head string) []string {12+	mut problems := []string{}13+	mut known := map[string]history.Save{}14+	for save in saves {15+		known[save.id] = save16+	}17+	if head == '' && saves.len > 0 { problems << 'history has saves but no head' }18+	if head != '' && head !in known { problems << 'head ${head} names a missing save' }19+	mut done := map[string]bool{}20+	for save in saves {21+		if save.parent != '' && save.parent !in known {22+			problems << 'save ${save.id} has missing parent ${save.parent}'23+		}24+		if save.restores != '' && save.restores !in known {25+			problems << 'save ${save.id} restores missing save ${save.restores}'26+		}27+		mut cursor := save.id28+		mut seen := map[string]bool{}29+		for cursor != '' && cursor in known && cursor !in done {30+			if cursor in seen {31+				problems << 'history has a parent cycle at ${cursor}'32+				break33+			}34+			seen[cursor] = true35+			cursor = known[cursor].parent36+		}37+		for id in seen.keys() {38+			done[id] = true39+		}40+	}41+	return problems42+}43+44+fn doctor_files(id string, files []snapshot.FileEntry, event_list []events.Event,45+	mut report DoctorJson) {46+	mut paths := map[string]bool{}47+	for file in files {48+		paths[file.path] = true49+	}50+	for file in files {51+		if !snapshot.legal_path(file.path) {52+			report.problems << 'save [${id}] has unsafe path ${snapshot.escape_path(file.path)} — koh strip ${shell_quote(file.path)}'53+		}54+		mut parent := os.dir(file.path)55+		for parent != '.' && parent != '' && parent != '/' {56+			if parent in paths {57+				report.problems << 'save ${id}: ${snapshot.escape_path(parent)} is both a file and a directory'58+				break59+			}60+			parent = os.dir(parent)61+		}62+		for event in event_list {63+			if event.kind == events.kind_strip && event.path != ''64+				&& (event.save_id == '' || event.save_id == id)65+				&& (file.path == event.path || file.path.starts_with('${event.path.trim_right('/')}/')) {66+				report.problems << 'save ${id} still contains stripped path ${snapshot.escape_path(file.path)}'67+			}68+		}69+	}70+}71+72+fn doctor_events(event_list []events.Event, saves []history.Save, mut report DoctorJson) {73+	ids := saves.map(it.id)74+	mut flags := map[string]events.Event{}75+	for event in event_list {76+		if event.created_at < 0 { report.problems << 'event ${event.id} has a negative timestamp' }77+		if event.kind !in [events.kind_flag, events.kind_clear, events.kind_strip] {78+			report.problems << 'event ${event.id} has unknown kind ${event.kind}'79+		} else if event.kind != events.kind_strip && event.save_id == '' {80+			report.problems << 'event ${event.id} has no save target'81+		} else if event.kind == events.kind_strip && event.path == '' {82+			report.problems << 'strip event ${event.id} has no path'83+		}84+		// Sync may retain an event for an off-line save this receiver never fetched.85+		if event.save_id != '' && event.save_id !in ids {86+			report.warnings << 'event ${event.id} targets unavailable save ${event.save_id}'87+		}88+		if event.kind in [events.kind_flag, events.kind_clear] {89+			old := flags[event.save_id] or { events.Event{} }90+			if event.created_at > old.created_at || (event.created_at == old.created_at && event.id > old.id) {91+				flags[event.save_id] = event92+			}93+		}94+	}95+	report.events = event_list.len96+	report.flagged_saves = flags.values().filter(it.kind == events.kind_flag).len97+	if head_flag := flags[report.head_id] {98+		report.head_flagged = head_flag.kind == events.kind_flag99+		if report.head_flagged { report.warnings << 'head is flagged: ${head_flag.reason}' }100+	}101+}102+103+fn doctor_local(store &storage.Store, mut report DoctorJson) ! {104+	stats := store.doctor_stats()!105+	report.metadata_available = true106+	report.objects = int(stats.objects)107+	report.saves = int(stats.saves)108+	report.checkpoints = int(stats.checkpoints)109+	report.cached_files = int(stats.cached_files)110+	report.file_entries = stats.file_entries111+	report.object_bytes = stats.object_bytes112+	report.packed_bytes = stats.packed_bytes113+	report.unreferenced_objects = stats.unused114+	report.source = stats.state['source']115+	report.remote_repo = stats.state['remote_repo']116+	report.last_offered_head = stats.state['remote_head']117+	report.head_id = stats.state[storage.head_key]118+	report.restore_unfinished = 'restoring' in stats.state119+	report.pending_strips = stats.pending120+	report.check('references and metadata', stats.problems)121+	saves := history.all_saves(store)!122+	report.check('history links and cycles', doctor_history(saves, report.head_id))123+	mut known := map[string]history.Save{}124+	for save in saves {125+		known[save.id] = save126+	}127+	mut seen := map[string]bool{}128+	mut cursor := report.head_id129+	for cursor != '' && cursor in known && cursor !in seen {130+		seen[cursor] = true131+		cursor = known[cursor].parent132+	}133+	report.line_saves = seen.len134+	report.off_line_saves = saves.len - seen.len135+	if head := known[report.head_id] {136+		report.head = head137+		report.head_files = history.files_of(store, head.id)!.len138+	}139+	event_list := events.all(store)!140+	before := report.problems.len141+	doctor_events(event_list, saves, mut report)142+	for save in saves {143+		doctor_files(save.id, history.files_of(store, save.id)!, event_list, mut report)144+		if save.replayed_from != '' && save.replayed_from !in known {145+			report.warnings << 'save ${save.id} was replayed from unavailable save ${save.replayed_from}'146+		}147+	}148+	report.check('saved paths and events', report.problems[before..].clone())149+	if report.restore_unfinished {150+		report.check('restore state', [snapshot.restore_unfinished])151+	} else {152+		report.check('restore state', []string{})153+	}154+	for path in report.pending_strips {155+		report.warnings << 'remote strip unfinished for ${snapshot.escape_path(path)}; retry koh strip ${shell_quote(path)}'156+	}157+	if stats.unused > 0 {158+		report.warnings << '${stats.unused} unreferenced objects retained; Doctor does not remove history'159+	}160+}161+162+fn doctor_working_tree(store &storage.Store, mut report DoctorJson) ! {163+	store.drop_cache()!164+	report.cache_cleared = true165+	if !report.metadata_available || (report.saves > 0 && report.head.id == '') {166+		report.checks << DoctorCheck{'working tree', 'skipped', 'head is missing'}167+		return168+	}169+	for path in [os.join_path(store.root, snapshot.ignore_file), snapshot.global_ignore_path()] {170+		if !os.exists(path) {171+			continue172+		}173+		text := os.read_file(path) or {174+			report.check('ignore rules', [err.msg()])175+			continue176+		}177+		report.ignore_files[path] = text.split_into_lines().filter(it.trim_space() != '' && !it.trim_space().starts_with('#')).len178+	}179+	files := history.files_of(store, report.head_id)!180+	ig := snapshot.load(store.root)181+	report.ignored_saved_files = files.filter(ig.matches(it.path, false)).map(it.path)182+	report.changes = compare_with_head(store, report.head)!183+	mut problems := []string{}184+	snapshot.check_links(store.root, files) or { problems << err.msg() }185+	for path in report.changes.unsafe_paths {186+		problems << 'unsafe working name ${path}; this file cannot be saved'187+	}188+	report.check('working tree safety', problems)189+	report.checks << DoctorCheck{'working tree changes', 'pass', '${report.changes.added.len} added, ${report.changes.modified.len} modified, ${report.changes.removed.len} removed'}190+	if report.ignored_saved_files.len > 0 {191+		report.warnings << '${report.ignored_saved_files.len} saved files now match ignore rules; older versions remain in history'192+	}193+}194+195+fn doctor_build(root string, mut report DoctorJson) {196+	if !os.exists(os.join_path(root, build.config_name)) {197+		report.checks << DoctorCheck{'build configuration', 'skipped', 'no koh.build'}198+		return199+	}200+	config := build.read_config(root) or {201+		report.check('build configuration', [err.msg()])202+		return203+	}204+	report.build_platforms = config.platforms205+	report.project_version = build.project_version(root) or {206+		report.check('build configuration', [err.msg()])207+		return208+	}209+	mut paths := map[string]bool{}210+	mut problems := []string{}211+	for platform in config.platforms {212+		path := build.output_path(config, platform, report.project_version)213+		if path in paths { problems << 'multiple platforms share build output ${path}' }214+		paths[path] = true215+	}216+	report.check('build configuration', problems)217+}

view file

src/cli/doctor_remote.v+200 −0
@@ -0,0 +1,200 @@1+//! Read-only remote inspection; Doctor never calls sync or the write probe.2+module cli3+4+import compress.zlib5+import crypto.sha2566+import json27+import os8+import src.events9+import src.exchange10+import src.history11+import src.machine12+import src.release13+import src.remote14+import src.snapshot15+import src.storage16+17+fn doctor_remote_config(mut report DoctorJson) {18+	if !machine.has_remote() {19+		report.checks << DoctorCheck{'machine remote', 'skipped', 'no machine remote configured'}20+		return21+	}22+	settings := machine.load_remote() or {23+		report.check('machine remote', [err.msg()])24+		return25+	}26+	report.remote_target = describe(settings)27+	if settings.kind == machine.kind_s3 {28+		machine.load_credentials() or {29+			report.check('machine remote', [err.msg()])30+			return31+		}32+	}33+	report.check('machine remote configuration', []string{})34+}35+36+fn doctor_remote(store &storage.Store, mut report DoctorJson) ! {37+	if report.source == '' && !machine.has_remote() {38+		report.checks << DoctorCheck{'remote audit', 'skipped', 'no remote or source configured'}39+		return40+	}41+	mut backend := if report.source != '' {42+		exchange.read_only(report.source)!43+	} else {44+		this_machine_backend()!45+	}46+	repo := if report.source != '' {47+		report.source.all_after_last('/')48+	} else if report.remote_repo != '' {49+		report.remote_repo50+	} else {51+		os.base(store.root).to_lower()52+	}53+	if !snapshot.legal_path(repo) || repo.contains('/') {54+		return error('unsafe remote repo name')55+	}56+	doctor_remote_content(mut backend, repo, mut report)!57+}58+59+fn doctor_remote_content(mut backend remote.Backend, repo string, mut report DoctorJson) ! {60+	format := backend.get(exchange.format_key(repo))!61+	if format.found && format.body.bytestr().trim_space() != 'koh-remote 1' {62+		return error('remote format is unsupported; update Koh')63+	}64+	head := backend.get(exchange.head_key(repo))!65+	report.remote_head = if head.found { head.body.bytestr().trim_space() } else { '' }66+	mut saves := []history.Save{}67+	mut files := map[string][]snapshot.FileEntry{}68+	mut hashes := map[string]bool{}69+	mut event_list := []events.Event{}70+	for key in backend.list('${repo}/saves')! {71+		if !snapshot.legal_path(key) || !key.starts_with('${repo}/saves/') {72+			return error('unsafe remote key ${key}')73+		}74+		working('checking remote saves · ${saves.len}')75+		object := backend.get(key)!76+		if !object.found {77+			return error('remote save disappeared: ${key}')78+		}79+		record := exchange.decode_save(object.body)!80+		if key != exchange.save_key(repo, record.id) {81+			return error('remote save ID disagrees with key ${key}')82+		}83+		saves << history.Save{84+			id:            record.id85+			parent:        record.parent86+			restores:      record.restores87+			replayed_from: record.replayed_from88+		}89+		files[record.id] = record.files.map(snapshot.FileEntry{ path: it.path, hash: it.hash })90+		for file in record.files {91+			hashes[file.hash] = true92+		}93+	}94+	for key in backend.list('${repo}/events')! {95+		if !snapshot.legal_path(key) || !key.starts_with('${repo}/events/') {96+			return error('unsafe remote key ${key}')97+		}98+		working('checking remote events · ${event_list.len}')99+		object := backend.get(key)!100+		if !object.found {101+			return error('remote event disappeared: ${key}')102+		}103+		event := exchange.decode_event(object.body)!104+		if key != exchange.event_key(repo, event.id) {105+			return error('remote event ID disagrees with key ${key}')106+		}107+		event_list << event108+	}109+	for key in backend.list('${repo}/objects')! {110+		if !key.starts_with('${repo}/objects/') || key.all_after('${repo}/objects/').contains('/') {111+			return error('invalid remote object key ${key}')112+		}113+		hashes[key.all_after_last('/')] = true114+	}115+	mut remote_report := DoctorJson{ head_id: report.remote_head }116+	remote_report.problems = doctor_history(saves, report.remote_head)117+	doctor_events(event_list, saves, mut remote_report)118+	for save in saves {119+		doctor_files(save.id, files[save.id], event_list, mut remote_report)120+	}121+	for hash in hashes.keys() {122+		if hash.len != 64 || !hash.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) {123+			remote_report.problems << 'invalid remote object hash ${hash}'124+			continue125+		}126+		working('checking remote object ${hash[..12]}')127+		object := backend.get(exchange.object_key(repo, hash))!128+		if !object.found {129+			remote_report.problems << 'missing remote object ${hash}'130+			continue131+		}132+		data := zlib.decompress(object.body) or {133+			remote_report.problems << 'damaged remote object ${hash}'134+			continue135+		}136+		if storage.hash_hex(data) != hash {137+			remote_report.problems << 'remote object ${hash} fails Blake3 verification'138+		}139+	}140+	report.remote_objects = hashes.len141+	report.remote_saves = saves.len142+	report.check('remote history, events and objects', remote_report.problems)143+	report.warnings << remote_report.warnings.map('remote: ${it}')144+	remote_report.problems = []string{}145+	if mut backend is remote.Web {146+		report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings'}147+	} else {148+		latest := backend.get(release.latest_key(repo))!149+		mut versions := []string{}150+		for key in backend.list('${repo}/releases')! {151+			if !snapshot.legal_path(key) || !key.starts_with('${repo}/releases/') {152+				return error('unsafe release key')153+			}154+			if !key.ends_with('/release.json') {155+				continue156+			}157+			object := backend.get(key)!158+			if !object.found {159+				return error('remote release disappeared: ${key}')160+			}161+			record := json2.decode[release.Record](object.body.bytestr())!162+			if record.format != 1 || record.repo != repo || key != release.release_key(repo, record.version)163+				|| !snapshot.legal_path(record.version) || record.version.contains('/') {164+				return error('invalid remote release record ${key}')165+			}166+			if !saves.any(it.id == record.save) {167+				remote_report.problems << 'release ${record.version} refers to missing save ${record.save}'168+			}169+			if record.files.len == 0 { remote_report.problems << 'release ${record.version} has no files' }170+			for file in record.files {171+				if !snapshot.legal_path(file.name) || file.name.contains('/') || !snapshot.legal_path(file.platform)172+					|| file.platform.contains('/') {173+					return error('unsafe release file in ${key}')174+				}175+				artifact := backend.get(release.file_key(repo, record.version, file.platform, file.name))!176+				if !artifact.found || artifact.body.len != file.size177+					|| storage.hash_hex(artifact.body) != file.blake3178+					|| sha256.sum256(artifact.body).hex() != file.sha256 {179+					remote_report.problems << 'release ${record.version} ${file.name} is missing or fails size/checksum verification'180+				}181+			}182+			versions << record.version183+		}184+		if latest.found && latest.body.bytestr().trim_space() !in versions {185+			remote_report.problems << 'remote latest release names a missing record'186+		}187+		report.remote_releases = versions.len188+		report.check('remote releases', remote_report.problems)189+	}190+	after := backend.get(exchange.head_key(repo))!191+	if after.found != head.found || after.body != head.body || after.tag != head.tag {192+		report.check('remote stability', ['remote head changed during audit; retry Doctor'])193+	}194+	if !head.found {195+		report.warnings << 'remote has no head for ${repo}; it may never have been offered or may have been renamed'196+	}197+	if report.remote_head != '' && report.remote_head != report.head_id {198+		report.warnings << 'local and remote heads differ; inspect koh steal before applying changes'199+	}200+}

view file

src/storage/doctor.v+77 −0
@@ -0,0 +1,77 @@1+//! Repository structure and storage measurements used by Doctor.2+module storage3+4+/// Storage counts and structural findings from one database snapshot.5+pub struct DoctorStats {6+pub mut:7+	objects      i648+	saves        i649+	checkpoints  i6410+	cached_files i6411+	file_entries i6412+	object_bytes i6413+	packed_bytes i6414+	unused       i6415+	pending      []string16+	state        map[string]string17+	problems     []string18+}19+20+/// Inspect references, scalar storage invariants and machine-local state.21+pub fn (s &Store) doctor_stats() !DoctorStats {22+	mut out := DoctorStats{}23+	mut sizes := s.db.prepare('SELECT COUNT(*), COALESCE(SUM(size), 0),24+		COALESCE(SUM(length(data)), 0) FROM objects')!25+	defer { sizes.finalize() }26+	if sizes.step()! {27+		out.objects = sizes.int(0)28+		out.object_bytes = sizes.int(1)29+		out.packed_bytes = sizes.int(2)30+	}31+	mut counts := s.db.prepare('SELECT32+		(SELECT COUNT(*) FROM saves),33+		(SELECT COUNT(*) FROM saves WHERE is_checkpoint = 1),34+		(SELECT COUNT(*) FROM file_cache),35+		(SELECT COUNT(*) FROM save_files),36+		(SELECT COUNT(*) FROM objects WHERE hash NOT IN (SELECT hash FROM save_files))')!37+	defer { counts.finalize() }38+	if counts.step()! {39+		out.saves = counts.int(0)40+		out.checkpoints = counts.int(1)41+		out.cached_files = counts.int(2)42+		out.file_entries = counts.int(3)43+		out.unused = counts.int(4)44+	}45+	mut foreign := s.db.prepare('PRAGMA foreign_key_check')!46+	defer { foreign.finalize() }47+	for foreign.step()! {48+		out.problems << 'broken reference in ${foreign.text(0)} row ${foreign.int(1)} to ${foreign.text(2)}'49+	}50+	mut invalid := s.db.prepare("SELECT save_id, path FROM save_files51+		WHERE executable NOT IN (0, 1)52+		OR typeof(executable) != 'integer'53+		UNION ALL54+		SELECT id, 'invalid save metadata' FROM saves55+		WHERE is_checkpoint NOT IN (0, 1)56+		OR typeof(is_checkpoint) != 'integer'57+		OR id = ''58+		OR typeof(created_at) != 'integer'59+		OR created_at < 060+		OR typeof(size_bytes) != 'integer'61+		OR size_bytes < 0")!62+	defer { invalid.finalize() }63+	for invalid.step()! {64+		out.problems << 'invalid metadata in save ${invalid.text(0)}: ${invalid.text(1)}'65+	}66+	mut state := s.db.prepare('SELECT key, value FROM state')!67+	defer { state.finalize() }68+	for state.step()! {69+		key := state.text(0)70+		out.state[key] = state.text(1)71+		if key.starts_with('strip_pending:') {72+			out.pending << key.all_after('strip_pending:')73+		}74+	}75+	out.pending.sort()76+	return out77+}

view file

src/storage/objects.v+7 −1
@@ -147,7 +147,13 @@147147 		hashes << stmt.text(0)148148 	}149149 	for hash in hashes {150-		s.get_object(hash) or { problems << 'object ${hash}: ${err.msg()}' }150+		data := s.get_object(hash) or {151+			problems << 'object ${hash}: ${err.msg()}'152+			continue153+		}154+		if s.object_size(hash)! != data.len {155+			problems << 'object ${hash}: stored size does not match ${data.len} bytes'156+		}151157 	}152158 	return problems153159 }

view file

tests/cli_test.v+68 −0
@@ -2074,3 +2074,71 @@20742074 	removed.finalize()20752075 	checked.close()20762076 }2077+2078+fn test_doctor_reports_corruption_restores_and_dirty_work_in_one_document() {2079+	repo := temp_repo('doctor-health')2080+	init_repo(repo)2081+	write(repo, 'app', 'original')2082+	assert run(repo, 'save', 'baseline').code == 02083+	write(repo, 'app', 'unsaved work')2084+	ordinary := run(repo, 'doctor', '--json')2085+	assert ordinary.code == 02086+	assert ordinary.out.contains('"health":"healthy"')2087+	assert ordinary.out.contains('"modified":["app"]')2088+	assert ordinary.out.contains('"line_saves":1')2089+	assert ordinary.out.contains('"scope":')2090+	store := storage.open(repo)!2091+	store.db.exec('UPDATE objects SET size = size + 1')!2092+	store.set_state('restoring', 'yes')!2093+	store.close()2094+	broken := run(repo, 'doctor', '--json')2095+	assert broken.code == 12096+	assert broken.out.contains('"health":"unhealthy"')2097+	assert broken.out.contains('stored size does not match')2098+	assert broken.out.contains('"restore_unfinished":true')2099+	assert broken.out.contains('"checks":')2100+	assert broken.out.contains('"root":')2101+	assert run(repo, 'doctor', 'unexpected').code == 22102+}2103+2104+fn test_doctor_checks_the_remote_by_default_and_offline_explicitly_skips_it() {2105+	repo := temp_repo('doctor-remote')2106+	home := temp_repo('doctor-remote-home')2107+	bucket := temp_repo('doctor-remote-bucket')2108+	assert run_home(repo, home, 'remote', 'folder', bucket).code == 02109+	assert run_home(repo, home, 'init').code == 02110+	write(repo, 'app', 'original')2111+	assert run_home(repo, home, 'save', 'baseline').code == 02112+	before := run_home(repo, home, 'view', '--json').out2113+	healthy := run_home(repo, home, 'doctor', '--json')2114+	assert healthy.code == 02115+	assert healthy.out.contains('remote history, events and objects')2116+	assert healthy.out.contains('"remote_saves":1')2117+	assert run_home(repo, home, 'view', '--json').out == before2118+	store := storage.open(repo)!2119+	name := store.state('remote_repo') or { panic('missing remote repo') }2120+	store.close()2121+	os.write_file(os.join_path(bucket, name, 'objects', storage.hash_hex('original'.bytes())),2122+		'corrupt')!2123+	bad := run_home(repo, home, 'doctor', '--json')2124+	assert bad.code == 12125+	assert bad.out.contains('damaged remote object')2126+	assert bad.out.contains('"name":"SQLite integrity","status":"pass"')2127+	offline := run_home(repo, home, 'doctor', '--offline', '--json')2128+	assert offline.code == 02129+	assert offline.out.contains('--offline requested')2130+	assert offline.out.contains('"status":"skipped"')2131+}2132+2133+fn test_doctor_returns_a_report_when_the_database_cannot_be_opened() {2134+	repo := temp_repo('doctor-unreadable')2135+	init_repo(repo)2136+	os.write_file(os.join_path(repo, '.koh'), 'not a SQLite database')!2137+	result := run(repo, 'doctor', '--json')2138+	assert result.code == 12139+	assert result.out.contains('"health":"unhealthy"')2140+	assert result.out.contains('"metadata_available":false')2141+	assert result.out.contains('"name":"database open","status":"fail"')2142+	assert result.out.contains('further checks could not run')2143+	assert result.err == ''2144+}

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.7.4'3+	version: '3.8.0'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file