cmd_strip.v

6.8 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.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	// There is no --yes: a person has to be at the keyboard for this one.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	// The remote hears about it too: the event travels, the affected save88	// records are rewritten, and objects nobody references are deleted. A89	// failure here is reported, never fatal: the local strip has happened.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}123124// affected_rows finds every saved file the path covers: the file itself, or125// anything beneath it when it names a folder.126fn 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}166167// like_under escapes LIKE's own wildcards so a path containing `%` or `_`168// matches itself and nothing else.169fn 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}180181// touched_saves and touched_objects are what the remote needs to know about:182// which save records to rewrite, and which objects may now be unreferenced.183fn 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}206207// push_strip_to_remote tells this machine's remote about a strip.208fn 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}