//! Doctor's local history, event and working-tree checks. module cli import os import src.build import src.events import src.history import src.snapshot import src.storage fn doctor_history(saves []history.Save, head string) []string { mut problems := []string{} mut known := map[string]history.Save{} for save in saves { known[save.id] = save } if head == '' && saves.len > 0 { problems << 'history has saves but no head' } if head != '' && head !in known { problems << 'head ${head} names a missing save' } mut done := map[string]bool{} for save in saves { if save.parent != '' && save.parent !in known { problems << 'save ${save.id} has missing parent ${save.parent}' } if save.restores != '' && save.restores !in known { problems << 'save ${save.id} restores missing save ${save.restores}' } mut cursor := save.id mut seen := map[string]bool{} for cursor != '' && cursor in known && cursor !in done { if cursor in seen { problems << 'history has a parent cycle at ${cursor}' break } seen[cursor] = true cursor = known[cursor].parent } for id in seen.keys() { done[id] = true } } return problems } fn doctor_files(id string, files []snapshot.FileEntry, event_list []events.Event, mut report DoctorJson) { mut paths := map[string]bool{} for file in files { paths[file.path] = true } for file in files { if !snapshot.legal_path(file.path) { report.problems << 'save [${id}] has unsafe path ${snapshot.escape_path(file.path)} — koh strip ${shell_quote(file.path)}' } mut parent := os.dir(file.path) for parent != '.' && parent != '' && parent != '/' { if parent in paths { report.problems << 'save ${id}: ${snapshot.escape_path(parent)} is both a file and a directory' break } parent = os.dir(parent) } for event in event_list { if event.kind == events.kind_strip && event.path != '' && (event.save_id == '' || event.save_id == id) && (file.path == event.path || file.path.starts_with('${event.path.trim_right('/')}/')) { report.problems << 'save ${id} still contains stripped path ${snapshot.escape_path(file.path)}' } } } } fn doctor_events(event_list []events.Event, saves []history.Save, mut report DoctorJson) { ids := saves.map(it.id) mut flags := map[string]events.Event{} for event in event_list { if event.created_at < 0 { report.problems << 'event ${event.id} has a negative timestamp' } if event.kind !in [events.kind_flag, events.kind_clear, events.kind_strip] { report.problems << 'event ${event.id} has unknown kind ${event.kind}' } else if event.kind != events.kind_strip && event.save_id == '' { report.problems << 'event ${event.id} has no save target' } else if event.kind == events.kind_strip && event.path == '' { report.problems << 'strip event ${event.id} has no path' } // Sync may retain an event for an off-line save this receiver never fetched. if event.save_id != '' && event.save_id !in ids { report.warnings << 'event ${event.id} targets unavailable save ${event.save_id}' } if event.kind in [events.kind_flag, events.kind_clear] { old := flags[event.save_id] or { events.Event{} } if event.created_at > old.created_at || (event.created_at == old.created_at && event.id > old.id) { flags[event.save_id] = event } } } report.events = event_list.len report.flagged_saves = flags.values().filter(it.kind == events.kind_flag).len if head_flag := flags[report.head_id] { report.head_flagged = head_flag.kind == events.kind_flag if report.head_flagged { report.warnings << 'head is flagged: ${head_flag.reason}' } } } fn doctor_local(store &storage.Store, mut report DoctorJson) ! { stats := store.doctor_stats()! report.metadata_available = true report.objects = int(stats.objects) report.saves = int(stats.saves) report.checkpoints = int(stats.checkpoints) report.cached_files = int(stats.cached_files) report.file_entries = stats.file_entries report.object_bytes = stats.object_bytes report.packed_bytes = stats.packed_bytes report.unreferenced_objects = stats.unused report.source = stats.state['source'] report.remote_repo = stats.state['remote_repo'] report.last_offered_head = stats.state['remote_head'] report.head_id = stats.state[storage.head_key] report.restore_unfinished = 'restoring' in stats.state report.pending_strips = stats.pending report.check('references and metadata', stats.problems) saves := history.all_saves(store)! report.check('history links and cycles', doctor_history(saves, report.head_id)) mut known := map[string]history.Save{} for save in saves { known[save.id] = save } mut seen := map[string]bool{} mut cursor := report.head_id for cursor != '' && cursor in known && cursor !in seen { seen[cursor] = true cursor = known[cursor].parent } report.line_saves = seen.len report.off_line_saves = saves.len - seen.len if head := known[report.head_id] { report.head = head report.head_files = history.files_of(store, head.id)!.len } event_list := events.all(store)! before := report.problems.len doctor_events(event_list, saves, mut report) for save in saves { doctor_files(save.id, history.files_of(store, save.id)!, event_list, mut report) if save.replayed_from != '' && save.replayed_from !in known { report.warnings << 'save ${save.id} was replayed from unavailable save ${save.replayed_from}' } } report.check('saved paths and events', report.problems[before..].clone()) if report.restore_unfinished { report.check('restore state', [snapshot.restore_unfinished]) } else { report.check('restore state', []string{}) } for path in report.pending_strips { report.warnings << 'remote strip unfinished for ${snapshot.escape_path(path)}; retry koh strip ${shell_quote(path)}' } if stats.unused > 0 { report.warnings << '${stats.unused} unreferenced objects retained; Doctor does not remove history' } } fn doctor_working_tree(store &storage.Store, mut report DoctorJson) ! { store.drop_cache()! report.cache_cleared = true if !report.metadata_available || (report.saves > 0 && report.head.id == '') { report.checks << DoctorCheck{'working tree', 'skipped', 'head is missing'} return } for path in [os.join_path(store.root, snapshot.ignore_file), snapshot.global_ignore_path()] { if !os.exists(path) { continue } text := os.read_file(path) or { report.check('ignore rules', [err.msg()]) continue } report.ignore_files[path] = text.split_into_lines().filter(it.trim_space() != '' && !it.trim_space().starts_with('#')).len } files := history.files_of(store, report.head_id)! ig := snapshot.load(store.root) report.ignored_saved_files = files.filter(ig.matches(it.path, false)).map(it.path) report.changes = compare_with_head(store, report.head)! mut problems := []string{} snapshot.check_links(store.root, files) or { problems << err.msg() } for path in report.changes.unsafe_paths { problems << 'unsafe working name ${path}; this file cannot be saved' } report.check('working tree safety', problems) report.checks << DoctorCheck{'working tree changes', 'pass', '${report.changes.added.len} added, ${report.changes.modified.len} modified, ${report.changes.removed.len} removed'} if report.ignored_saved_files.len > 0 { report.warnings << '${report.ignored_saved_files.len} saved files now match ignore rules; older versions remain in history' } } fn doctor_build(root string, mut report DoctorJson) { if !os.exists(os.join_path(root, build.config_name)) { report.checks << DoctorCheck{'build configuration', 'skipped', 'no koh.build'} return } config := build.read_config(root) or { report.check('build configuration', [err.msg()]) return } report.build_platforms = config.platforms report.project_version = build.project_version(root) or { report.check('build configuration', [err.msg()]) return } mut paths := map[string]bool{} mut problems := []string{} for platform in config.platforms { path := build.output_path(config, platform, report.project_version) if path in paths { problems << 'multiple platforms share build output ${path}' } paths[path] = true } report.check('build configuration', problems) }