//! `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.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()) } if args.json() { emit_json(json.encode(StripJson{ path: path save: only_save files_removed: count objects_dropped: int(dropped) })) return 0 } ok('stripped ${path} from ${fmt.plural(count, 'file')} across ${fmt.plural(saves.len, 'save')}') note('${dropped} objects dropped') 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 StripJson { path string save string files_removed int objects_dropped int }