//! `koh strip` — remove a path from history, permanently. //! //! This is the only command that destroys information, so it is deliberately //! loud: it needs a terminal, it warns about exactly what it will destroy, and //! it asks twice — once the strict `y`, then the path typed out in full. Every //! step is safe to repeat, so an interrupted strip finishes when it is re-run. //! //! The working tree is never touched. Afterwards the file is still on disk and //! still untracked by the saves it was removed from, so the reminder to delete //! or ignore it matters. module cli import json import src.fmt import src.events import src.history import src.machine import src.sync import src.snapshot import src.storage pub fn cmd_strip(raw []string) int { args := parse('strip', raw, Options{ values: ['save'] flags: ['json'] }) or { return usage_error(err.msg()) } path := args.positional_at(0) if path == '' { return usage_error('strip needs a path, e.g. koh strip secrets.env') } // There is no --yes: a person has to be at the keyboard for this one. if !interactive() { return refuse(args.json(), 'strip needs a terminal — an agent can never strip') } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } mut only_save := '' if given := args.value_or_none('save') { save := history.resolve(store, given) or { return refuse(args.json(), err.msg()) } only_save = save.id } rows := affected_rows(store, path, only_save) or { return refuse(args.json(), err.msg()) } if rows.len == 0 { ok('nothing in history matches ${path}') return 0 } mut saves := map[string]bool{} mut objects := map[string]bool{} for row in rows { saves[row.save_id] = true objects[row.hash] = true } scope := if only_save == '' { 'every save' } else { 'save ${only_save}' } println(' ${red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information') println(' ${amber('!')} ${fmt.plural(rows.len, 'file')} in ${fmt.plural(saves.len, 'save')}, ${fmt.plural(objects.len, 'object')} — ${scope}') println(' ${dim('strip removes the path from history; the file on disk is left alone')}') approved := confirm(false) or { return refuse(args.json(), err.msg()) } if !approved { note('nothing was stripped') return 1 } if !typed_confirm('type the path to confirm:', path) { note('nothing was stripped — the path did not match') return 1 } store.begin() or { return refuse(args.json(), err.msg()) } count := remove_rows(store, rows) or { store.rollback() return refuse(args.json(), err.msg()) } dropped := store.drop_unreferenced_objects() or { store.rollback() return refuse(args.json(), err.msg()) } events.strip(store, only_save, path) or { store.rollback() return refuse(args.json(), err.msg()) } store.commit() or { return refuse(args.json(), err.msg()) } // The remote hears about it too: the event travels, the affected save // records are rewritten, and objects nobody references are deleted. A // failure here is reported, never fatal: the local strip has happened. push := push_strip_to_remote(store, path, touched_saves(rows), touched_objects(rows), only_save == '') if args.json() { emit_json(json.encode(StripJson{ path: path save: only_save files_removed: count objects_dropped: int(dropped) records_rewritten: push.pushed.records remote_objects_dropped: push.pushed.objects remote_failed: push.failed })) return 0 } ok('stripped ${path} from ${fmt.plural(count, 'file')} across ${fmt.plural(saves.len, 'save')}') note('${dropped} objects dropped') if !push.skipped { if push.failed == '' { note('the remote was updated: ${push.pushed.records} save records rewritten, ${push.pushed.objects} objects dropped') } else { warn('the remote could not be updated: ${push.failed} — run koh offer, then strip again') } } note('remember to delete ${path} or add it to ${snapshot.ignore_file}') return 0 } pub struct StripRow { pub: save_id string path string hash string } // affected_rows finds every saved file the path covers: the file itself, or // anything beneath it when it names a folder. fn affected_rows(store &storage.Store, path string, only_save string) ![]StripRow { mut query := "SELECT save_id, path, hash FROM save_files WHERE (path = ?1 OR path LIKE ?2 ESCAPE '\\')" if only_save != '' { query += ' AND save_id = ?3' } mut stmt := store.db.prepare(query)! defer { stmt.finalize() } stmt.bind_text(1, path)! stmt.bind_text(2, like_under(path))! if only_save != '' { stmt.bind_text(3, only_save)! } mut rows := []StripRow{} for stmt.step()! { rows << StripRow{ save_id: stmt.text(0) path: stmt.text(1) hash: stmt.text(2) } } return rows } fn remove_rows(store &storage.Store, rows []StripRow) !int { mut stmt := store.db.prepare('DELETE FROM save_files WHERE save_id = ? AND path = ?')! defer { stmt.finalize() } mut removed := 0 for row in rows { stmt.bind_text(1, row.save_id)! stmt.bind_text(2, row.path)! stmt.step()! removed += store.db.changes() stmt.reset() } return removed } // like_under escapes LIKE's own wildcards so a path containing `%` or `_` // matches itself and nothing else. fn like_under(folder string) string { escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') return '${escaped}/%' } struct PushOutcome { mut: pushed sync.PushReport failed string skipped bool } // touched_saves and touched_objects are what the remote needs to know about: // which save records to rewrite, and which objects may now be unreferenced. fn touched_saves(rows []StripRow) []string { mut seen := map[string]bool{} mut ids := []string{} for row in rows { if row.save_id !in seen { seen[row.save_id] = true ids << row.save_id } } return ids } fn touched_objects(rows []StripRow) []string { mut seen := map[string]bool{} mut hashes := []string{} for row in rows { if row.hash !in seen { seen[row.hash] = true hashes << row.hash } } return hashes } // push_strip_to_remote tells this machine's remote about a strip. fn push_strip_to_remote(store &storage.Store, path string, save_ids []string, hashes []string, drop_objects bool) PushOutcome { if !machine.has_remote() { return PushOutcome{ skipped: true } } mut backend := this_machine_backend() or { return PushOutcome{ failed: err.msg() } } report := sync.push_strip(store, mut backend, path, save_ids, hashes, drop_objects) or { return PushOutcome{ failed: err.msg() } } return PushOutcome{ pushed: report } } struct StripJson { path string save string files_removed int objects_dropped int records_rewritten int remote_objects_dropped int remote_failed string }