//! `koh strip` — remove a path from history, permanently. //! //! This is the only command that destroys information, so it is deliberately //! loud: it needs a terminal, it warns about exactly what it will destroy, and //! it asks once for the batch, then has each path typed out in full. Every //! step is safe to repeat, so an interrupted strip finishes when it is re-run. //! //! The file itself is left on disk. A whole-history strip bans the path: it is //! added to `.kohignore`, and no walk of the tree picks it up again. module cli import json2 import src.fmt import src.history import src.machine import src.snapshot import src.storage import src.exchange struct StripPlan { path string scope exchange.Scope pending bool } // All questions finish before the transaction, so cancelling the last path changes nothing. pub fn cmd_strip(raw []string) int { args := parse('strip', raw, Options{ values: ['save'] flags: ['json', 'yes'] }) or { return usage_error(err.msg()) } if args.positional.len == 0 { return usage_error('strip needs paths, e.g. koh strip secrets.env private/') } // Even --yes cannot replace the exact typed paths. if !interactive() { return refuse(args.json(), 'strip needs a terminal — an agent can never strip') } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } mut only_save := '' if given := args.value_or_none('save') { save := history.resolve(store, given) or { return refuse(args.json(), err.msg()) } only_save = save.id } mut plans := []StripPlan{} for item in args.positional { // Old strips kept the trailing slash in their retry key. path := if exchange.strip_pending(store, item) { item } else { item.trim_right('/') } if plans.any(it.path == path) { continue } scope := exchange.strip_scope(store, path, only_save) or { return refuse(args.json(), err.msg()) } pending := exchange.strip_pending(store, path) malformed := path == '' || path.starts_with('/') || path.split('/').any(it in ['', '.', '..']) // Doctor must still be able to remove illegal paths held by older saves. if path == '' || (malformed && scope.files == 0 && !pending) { return refuse(args.json(), 'strip needs repo-relative paths: ${snapshot.escape_path(item)}') } plans << StripPlan{ path: path, scope: scope, pending: pending } } where := if only_save == '' { 'every save' } else { 'save ${only_save}' } for plan in plans { if plan.scope.files == 0 { if plan.pending { strip_note(args.json(), '${snapshot.escape_path(plan.path)} is already stripped here — finishing the remote') } else { strip_note(args.json(), 'nothing in history matches ${snapshot.escape_path(plan.path)}') } } else { 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}') } } if plans.any(it.scope.files > 0) { strip_note(args.json(), '${fmt.red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information') strip_note(args.json(), 'strip removes paths from history; the files on disk are left alone') approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) } if !approved { return refuse(args.json(), 'nothing was stripped') } for plan in plans { if plan.scope.files == 0 { continue } strip_note(args.json(), 'confirm ${snapshot.escape_path(plan.path)}') if !typed_confirm('type the path to confirm:', plan.path) { return refuse(args.json(), 'nothing was stripped — the path did not match') } } } active := plans.filter(it.scope.files > 0 || it.pending) locals := exchange.strip_local_batch(store, active.map(it.path), only_save) or { return refuse(args.json(), err.msg()) } mut reports := []StripJson{} mut index := 0 mut exit_code := 0 for plan in plans { if plan.scope.files == 0 && !plan.pending { reports << StripJson{ path: plan.path, save: only_save } continue } local := locals[index] index++ path := plan.path // An interrupted remote strip keeps its retry note; try every path in the batch. sent := send_strip_to_remote(store, path) mut ignore_failed := '' ignored := only_save == '' && (local.files_removed > 0 || plan.pending) && (snapshot.add_ignore(store.root, path) or { ignore_failed = '${err.msg()} — add ${snapshot.ignore_rule(path, false, false)} to ${snapshot.ignore_file} by hand; the history ban still applies' exit_code = 1 false }) reports << StripJson{ path: path save: only_save files_removed: local.files_removed objects_dropped: int(local.objects_dropped) records_rewritten: sent.remote.records remote_objects_dropped: sent.remote.objects remote_failed: sent.failed ignore_failed: ignore_failed } if args.json() { continue } if plan.scope.files > 0 { ok('stripped ${snapshot.escape_path(path)} from ${fmt.plural(local.files_removed, 'file')} across ${fmt.plural(plan.scope.saves, 'save')}') strip_note(args.json(), '${fmt.plural(int(local.objects_dropped), 'object')} dropped') } if !sent.skipped { if sent.failed == '' { strip_note(args.json(), 'the remote was updated: ${fmt.plural(sent.remote.records, 'save record')} rewritten, ${fmt.plural(sent.remote.objects, 'object')} dropped') } else { warn('the remote could not be updated: ${sent.failed} — run the same strip again to finish it') } } if ignore_failed != '' { warn('could not update ${snapshot.ignore_file} for ${snapshot.escape_path(path)}: ${ignore_failed}') } if only_save != '' { strip_note(args.json(), 'remember to delete ${snapshot.escape_path(path)} or add it to ${snapshot.ignore_file}') } else if ignored { strip_note(args.json(), 'added /${snapshot.escape_path(path)} to ${snapshot.ignore_file} — it can never be saved again') } } if args.json() { if plans.len == 1 { emit_json(json2.encode(reports[0])) } else { emit_json(json2.encode({ 'strips': reports })) } } return exit_code } struct SendOutcome { mut: remote exchange.SendReport failed string skipped bool } fn send_strip_to_remote(store &storage.Store, path string) SendOutcome { if !machine.has_remote() { return SendOutcome{ skipped: true } } mut backend := watched_backend('updating the remote') or { return SendOutcome{ failed: err.msg() } } report := exchange.send_strip(store, mut backend, path) or { return SendOutcome{ failed: err.msg() } } return SendOutcome{ remote: report } } struct StripJson { path string save string files_removed int objects_dropped int records_rewritten int remote_objects_dropped int remote_failed string ignore_failed string } fn strip_note(as_json bool, message string) { if as_json { eprintln(' ${message}') } else { note(message) } }