123456789101112module cli1314import json15import src.fmt16import src.events17import src.history18import src.snapshot19import src.storage2021pub fn cmd_strip(raw []string) int {22 args := parse('strip', raw, Options{23 values: ['save']24 flags: ['json']25 }) or { return usage_error(err.msg()) }26 path := args.positional_at(0)27 if path == '' {28 return usage_error('strip needs a path, e.g. koh strip secrets.env')29 }30 31 if !interactive() {32 return refuse(args.json(), 'strip needs a terminal — an agent can never strip')33 }34 store := open_store() or { return refuse(args.json(), err.msg()) }35 defer {36 store.close()37 }38 mut only_save := ''39 if given := args.value_or_none('save') {40 save := history.resolve(store, given) or { return refuse(args.json(), err.msg()) }41 only_save = save.id42 }43 rows := affected_rows(store, path, only_save) or { return refuse(args.json(), err.msg()) }44 if rows.len == 0 {45 ok('nothing in history matches ${path}')46 return 047 }48 mut saves := map[string]bool{}49 mut objects := map[string]bool{}50 for row in rows {51 saves[row.save_id] = true52 objects[row.hash] = true53 }54 scope := if only_save == '' { 'every save' } else { 'save ${only_save}' }5556 println(' ${red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')57 println(' ${amber('!')} ${fmt.plural(rows.len, 'file')} in ${fmt.plural(saves.len, 'save')}, ${fmt.plural(objects.len,58 'object')} — ${scope}')59 println(' ${dim('strip removes the path from history; the file on disk is left alone')}')60 approved := confirm(false) or { return refuse(args.json(), err.msg()) }61 if !approved {62 note('nothing was stripped')63 return 164 }65 if !typed_confirm('type the path to confirm:', path) {66 note('nothing was stripped — the path did not match')67 return 168 }6970 store.begin() or { return refuse(args.json(), err.msg()) }71 count := remove_rows(store, rows) or {72 store.rollback()73 return refuse(args.json(), err.msg())74 }75 dropped := store.drop_unreferenced_objects() or {76 store.rollback()77 return refuse(args.json(), err.msg())78 }79 events.strip(store, only_save, path) or {80 store.rollback()81 return refuse(args.json(), err.msg())82 }83 store.commit() or { return refuse(args.json(), err.msg()) }8485 if args.json() {86 emit_json(json.encode(StripJson{87 path: path88 save: only_save89 files_removed: count90 objects_dropped: int(dropped)91 }))92 return 093 }94 ok('stripped ${path} from ${fmt.plural(count, 'file')} across ${fmt.plural(saves.len, 'save')}')95 note('${dropped} objects dropped')96 note('remember to delete ${path} or add it to ${snapshot.ignore_file}')97 return 098}99100pub struct StripRow {101pub:102 save_id string103 path string104 hash string105}106107108109fn affected_rows(store &storage.Store, path string, only_save string) ![]StripRow {110 mut query := "SELECT save_id, path, hash FROM save_files WHERE (path = ?1 OR path LIKE ?2 ESCAPE '\\')"111 if only_save != '' {112 query += ' AND save_id = ?3'113 }114 mut stmt := store.db.prepare(query)!115 defer {116 stmt.finalize()117 }118 stmt.bind_text(1, path)!119 stmt.bind_text(2, like_under(path))!120 if only_save != '' {121 stmt.bind_text(3, only_save)!122 }123 mut rows := []StripRow{}124 for stmt.step()! {125 rows << StripRow{126 save_id: stmt.text(0)127 path: stmt.text(1)128 hash: stmt.text(2)129 }130 }131 return rows132}133134fn remove_rows(store &storage.Store, rows []StripRow) !int {135 mut stmt := store.db.prepare('DELETE FROM save_files WHERE save_id = ? AND path = ?')!136 defer {137 stmt.finalize()138 }139 mut removed := 0140 for row in rows {141 stmt.bind_text(1, row.save_id)!142 stmt.bind_text(2, row.path)!143 stmt.step()!144 removed += store.db.changes()145 stmt.reset()146 }147 return removed148}149150151152fn like_under(folder string) string {153 escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')154 return '${escaped}/%'155}156157struct StripJson {158 path string159 save string160 files_removed int161 objects_dropped int162}