cmd_strip.v

4.7 KB · raw

1//! `koh strip` — remove a path from history, permanently.2//!3//! This is the only command that destroys information, so it is deliberately4//! loud: it needs a terminal, it warns about exactly what it will destroy, and5//! it asks twice — once the strict `y`, then the path typed out in full. Every6//! step is safe to repeat, so an interrupted strip finishes when it is re-run.7//!8//! The working tree is never touched. Afterwards the file is still on disk and9//! still untracked by the saves it was removed from, so the reminder to delete10//! or ignore it matters.1112module 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	// There is no --yes: a person has to be at the keyboard for this one.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}106107// affected_rows finds every saved file the path covers: the file itself, or108// anything beneath it when it names a folder.109fn 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}149150// like_under escapes LIKE's own wildcards so a path containing `%` or `_`151// matches itself and nothing else.152fn 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}