123456789101112module cli1314import json15import src.fmt16import src.events17import src.history18import src.machine19import src.sync20import src.snapshot21import src.storage2223pub fn cmd_strip(raw []string) int {24 args := parse('strip', raw, Options{25 values: ['save']26 flags: ['json']27 }) or { return usage_error(err.msg()) }28 path := args.positional_at(0)29 if path == '' {30 return usage_error('strip needs a path, e.g. koh strip secrets.env')31 }32 33 if !interactive() {34 return refuse(args.json(), 'strip needs a terminal — an agent can never strip')35 }36 store := open_store() or { return refuse(args.json(), err.msg()) }37 defer {38 store.close()39 }40 mut only_save := ''41 if given := args.value_or_none('save') {42 save := history.resolve(store, given) or { return refuse(args.json(), err.msg()) }43 only_save = save.id44 }45 rows := affected_rows(store, path, only_save) or { return refuse(args.json(), err.msg()) }46 if rows.len == 0 {47 ok('nothing in history matches ${path}')48 return 049 }50 mut saves := map[string]bool{}51 mut objects := map[string]bool{}52 for row in rows {53 saves[row.save_id] = true54 objects[row.hash] = true55 }56 scope := if only_save == '' { 'every save' } else { 'save ${only_save}' }5758 println(' ${red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')59 println(' ${amber('!')} ${fmt.plural(rows.len, 'file')} in ${fmt.plural(saves.len, 'save')}, ${fmt.plural(objects.len,60 'object')} — ${scope}')61 println(' ${dim('strip removes the path from history; the file on disk is left alone')}')62 approved := confirm(false) or { return refuse(args.json(), err.msg()) }63 if !approved {64 note('nothing was stripped')65 return 166 }67 if !typed_confirm('type the path to confirm:', path) {68 note('nothing was stripped — the path did not match')69 return 170 }7172 store.begin() or { return refuse(args.json(), err.msg()) }73 count := remove_rows(store, rows) or {74 store.rollback()75 return refuse(args.json(), err.msg())76 }77 dropped := store.drop_unreferenced_objects() or {78 store.rollback()79 return refuse(args.json(), err.msg())80 }81 events.strip(store, only_save, path) or {82 store.rollback()83 return refuse(args.json(), err.msg())84 }85 store.commit() or { return refuse(args.json(), err.msg()) }8687 88 89 90 push := push_strip_to_remote(store, path, touched_saves(rows), touched_objects(rows),91 only_save == '')92 if args.json() {93 emit_json(json.encode(StripJson{94 path: path95 save: only_save96 files_removed: count97 objects_dropped: int(dropped)98 records_rewritten: push.pushed.records99 remote_objects_dropped: push.pushed.objects100 remote_failed: push.failed101 }))102 return 0103 }104 ok('stripped ${path} from ${fmt.plural(count, 'file')} across ${fmt.plural(saves.len, 'save')}')105 note('${dropped} objects dropped')106 if !push.skipped {107 if push.failed == '' {108 note('the remote was updated: ${push.pushed.records} save records rewritten, ${push.pushed.objects} objects dropped')109 } else {110 warn('the remote could not be updated: ${push.failed} — run koh offer, then strip again')111 }112 }113 note('remember to delete ${path} or add it to ${snapshot.ignore_file}')114 return 0115}116117pub struct StripRow {118pub:119 save_id string120 path string121 hash string122}123124125126fn affected_rows(store &storage.Store, path string, only_save string) ![]StripRow {127 mut query := "SELECT save_id, path, hash FROM save_files WHERE (path = ?1 OR path LIKE ?2 ESCAPE '\\')"128 if only_save != '' {129 query += ' AND save_id = ?3'130 }131 mut stmt := store.db.prepare(query)!132 defer {133 stmt.finalize()134 }135 stmt.bind_text(1, path)!136 stmt.bind_text(2, like_under(path))!137 if only_save != '' {138 stmt.bind_text(3, only_save)!139 }140 mut rows := []StripRow{}141 for stmt.step()! {142 rows << StripRow{143 save_id: stmt.text(0)144 path: stmt.text(1)145 hash: stmt.text(2)146 }147 }148 return rows149}150151fn remove_rows(store &storage.Store, rows []StripRow) !int {152 mut stmt := store.db.prepare('DELETE FROM save_files WHERE save_id = ? AND path = ?')!153 defer {154 stmt.finalize()155 }156 mut removed := 0157 for row in rows {158 stmt.bind_text(1, row.save_id)!159 stmt.bind_text(2, row.path)!160 stmt.step()!161 removed += store.db.changes()162 stmt.reset()163 }164 return removed165}166167168169fn like_under(folder string) string {170 escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')171 return '${escaped}/%'172}173174struct PushOutcome {175mut:176 pushed sync.PushReport177 failed string178 skipped bool179}180181182183fn touched_saves(rows []StripRow) []string {184 mut seen := map[string]bool{}185 mut ids := []string{}186 for row in rows {187 if row.save_id !in seen {188 seen[row.save_id] = true189 ids << row.save_id190 }191 }192 return ids193}194195fn touched_objects(rows []StripRow) []string {196 mut seen := map[string]bool{}197 mut hashes := []string{}198 for row in rows {199 if row.hash !in seen {200 seen[row.hash] = true201 hashes << row.hash202 }203 }204 return hashes205}206207208fn push_strip_to_remote(store &storage.Store, path string, save_ids []string, hashes []string, drop_objects bool) PushOutcome {209 if !machine.has_remote() {210 return PushOutcome{211 skipped: true212 }213 }214 mut backend := this_machine_backend() or { return PushOutcome{215 failed: err.msg()216 } }217 report := sync.push_strip(store, mut backend, path, save_ids, hashes, drop_objects) or {218 return PushOutcome{219 failed: err.msg()220 }221 }222 return PushOutcome{223 pushed: report224 }225}226227struct StripJson {228 path string229 save string230 files_removed int231 objects_dropped int232 records_rewritten int233 remote_objects_dropped int234 remote_failed string235}