cmd_strip.v

7.0 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 once for the batch, then has each path typed out in full. Every6//! step is safe to repeat, so an interrupted strip finishes when it is re-run.7//!8//! The file itself is left on disk. A whole-history strip bans the path: it is9//! added to `.kohignore`, and no walk of the tree picks it up again.1011module cli1213import json214import src.fmt15import src.history16import src.machine17import src.snapshot18import src.storage19import src.exchange2021struct StripPlan {22	path    string23	scope   exchange.Scope24	pending bool25}2627// All questions finish before the transaction, so cancelling the last path changes nothing.28pub fn cmd_strip(raw []string) int {29	args := parse('strip', raw, Options{30		values: ['save']31		flags:  ['json', 'yes']32	}) or { return usage_error(err.msg()) }33	if args.positional.len == 0 {34		return usage_error('strip needs paths, e.g. koh strip secrets.env private/')35	}36	// Even --yes cannot replace the exact typed paths.37	if !interactive() {38		return refuse(args.json(), 'strip needs a terminal — an agent can never strip')39	}40	store := open_store() or { return refuse(args.json(), err.msg()) }41	defer { store.close() }42	mut only_save := ''43	if given := args.value_or_none('save') {44		save := history.resolve(store, given) or { return refuse(args.json(), err.msg()) }45		only_save = save.id46	}47	mut plans := []StripPlan{}48	for item in args.positional {49		// Old strips kept the trailing slash in their retry key.50		path := if exchange.strip_pending(store, item) { item } else { item.trim_right('/') }51		if plans.any(it.path == path) {52			continue53		}54		scope := exchange.strip_scope(store, path, only_save) or {55			return refuse(args.json(), err.msg())56		}57		pending := exchange.strip_pending(store, path)58		malformed := path == '' || path.starts_with('/')59			|| path.split('/').any(it in ['', '.', '..'])60		// Doctor must still be able to remove illegal paths held by older saves.61		if path == '' || (malformed && scope.files == 0 && !pending) {62			return refuse(args.json(), 'strip needs repo-relative paths: ${snapshot.escape_path(item)}')63		}64		plans << StripPlan{ path: path, scope: scope, pending: pending }65	}66	where := if only_save == '' { 'every save' } else { 'save ${only_save}' }67	for plan in plans {68		if plan.scope.files == 0 {69			if plan.pending {70				strip_note(args.json(), '${snapshot.escape_path(plan.path)} is already stripped here — finishing the remote')71			} else {72				strip_note(args.json(), 'nothing in history matches ${snapshot.escape_path(plan.path)}')73			}74		} else {75			strip_note(args.json(), 'strip ${snapshot.escape_path(plan.path)}: ${fmt.plural(plan.scope.files, 'file')} in ${fmt.plural(plan.scope.saves, 'save')}, ${fmt.plural(plan.scope.objects, 'object')} — ${where}')76		}77	}78	if plans.any(it.scope.files > 0) {79		strip_note(args.json(), '${fmt.red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')80		strip_note(args.json(), 'strip removes paths from history; the files on disk are left alone')81		approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }82		if !approved {83			return refuse(args.json(), 'nothing was stripped')84		}85		for plan in plans {86			if plan.scope.files == 0 {87				continue88			}89			strip_note(args.json(), 'confirm ${snapshot.escape_path(plan.path)}')90			if !typed_confirm('type the path to confirm:', plan.path) {91				return refuse(args.json(), 'nothing was stripped — the path did not match')92			}93		}94	}95	active := plans.filter(it.scope.files > 0 || it.pending)96	locals := exchange.strip_local_batch(store, active.map(it.path), only_save) or {97		return refuse(args.json(), err.msg())98	}99	mut reports := []StripJson{}100	mut index := 0101	mut exit_code := 0102	for plan in plans {103		if plan.scope.files == 0 && !plan.pending {104			reports << StripJson{ path: plan.path, save: only_save }105			continue106		}107		local := locals[index]108		index++109		path := plan.path110		// An interrupted remote strip keeps its retry note; try every path in the batch.111		sent := send_strip_to_remote(store, path)112		mut ignore_failed := ''113		ignored := only_save == '' && (local.files_removed > 0 || plan.pending)114			&& (snapshot.add_ignore(store.root, path) or {115				ignore_failed = '${err.msg()} — add ${snapshot.ignore_rule(path, false, false)} to ${snapshot.ignore_file} by hand; the history ban still applies'116				exit_code = 1117				false118			})119		reports << StripJson{120			path:                   path121			save:                   only_save122			files_removed:          local.files_removed123			objects_dropped:        int(local.objects_dropped)124			records_rewritten:      sent.remote.records125			remote_objects_dropped: sent.remote.objects126			remote_failed:          sent.failed127			ignore_failed:          ignore_failed128		}129		if args.json() {130			continue131		}132		if plan.scope.files > 0 {133			ok('stripped ${snapshot.escape_path(path)} from ${fmt.plural(local.files_removed, 'file')} across ${fmt.plural(plan.scope.saves, 'save')}')134			strip_note(args.json(), '${fmt.plural(int(local.objects_dropped), 'object')} dropped')135		}136		if !sent.skipped {137			if sent.failed == '' {138				strip_note(args.json(), 'the remote was updated: ${fmt.plural(sent.remote.records, 'save record')} rewritten, ${fmt.plural(sent.remote.objects, 'object')} dropped')139			} else {140				warn('the remote could not be updated: ${sent.failed} — run the same strip again to finish it')141			}142		}143		if ignore_failed != '' {144			warn('could not update ${snapshot.ignore_file} for ${snapshot.escape_path(path)}: ${ignore_failed}')145		}146		if only_save != '' {147			strip_note(args.json(), 'remember to delete ${snapshot.escape_path(path)} or add it to ${snapshot.ignore_file}')148		} else if ignored {149			strip_note(args.json(), 'added /${snapshot.escape_path(path)} to ${snapshot.ignore_file} — it can never be saved again')150		}151	}152	if args.json() {153		if plans.len == 1 {154			emit_json(json2.encode(reports[0]))155		} else {156			emit_json(json2.encode({157				'strips': reports158			}))159		}160	}161	return exit_code162}163164struct SendOutcome {165mut:166	remote  exchange.SendReport167	failed  string168	skipped bool169}170171// send_strip_to_remote tells this machine's remote about a strip.172fn send_strip_to_remote(store &storage.Store, path string) SendOutcome {173	if !machine.has_remote() {174		return SendOutcome{175			skipped: true176		}177	}178	mut backend := watched_backend('updating the remote') or {179		return SendOutcome{180			failed: err.msg()181		}182	}183	report := exchange.send_strip(store, mut backend, path) or {184		return SendOutcome{185			failed: err.msg()186		}187	}188	return SendOutcome{189		remote: report190	}191}192193struct StripJson {194	path                   string195	save                   string196	files_removed          int197	objects_dropped        int198	records_rewritten      int199	remote_objects_dropped int200	remote_failed          string201	ignore_failed          string202}203204fn strip_note(as_json bool, message string) {205	if as_json { eprintln('  ${message}') } else { note(message) }206}