//! Going back: `koh undo`, `koh revert` and `koh load`. //! //! Nothing here rewinds history. A going-back command restores an earlier tree //! and records that as a new save whose `restores` field names the save it //! brought back, so the move itself can be undone. //! //! The order matters and is the same everywhere: show what will change, ask, //! record the new save, then write the files. Recording before writing means a //! crash mid-restore shows up in `status` and `revert` finishes it. module cli import json2 import src.fmt import src.history import src.snapshot import src.storage // cmd_undo goes back one save, N saves, or to before the current --session. Unsaved changes refuse it. pub fn cmd_undo(raw []string) int { args := parse('undo', raw, Options{ flags: ['session', 'yes', 'json'] }) or { return usage_error(err.msg()) } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } effective := history.effective(store) or { return refuse(args.json(), err.msg()) } mut target := history.Save{} if args.flag('session') { start := history.session_start(store, effective.id) or { return refuse(args.json(), err.msg()) } if start.parent == '' { return refuse(args.json(), 'there is nothing before session "${start.session}"') } target = history.by_id(store, start.parent) or { return refuse(args.json(), err.msg()) } } else { mut steps := 1 if args.positional.len > 0 { steps = args.positional_at(0).int() if steps < 1 { return usage_error('undo takes a positive number of saves') } } target = history.step_back(store, effective.id, steps) or { return refuse(args.json(), err.msg()) } } return going_back(store, target, 'undo', args, false) } // cmd_revert puts the tree back to head, checkpointing what is there first, and finishes a restore that // stopped part way. pub fn cmd_revert(raw []string) int { args := parse('revert', raw, Options{ flags: ['yes', 'json'] }) or { return usage_error(err.msg()) } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } head := history.head(store) or { return refuse(args.json(), 'this repo has no saves yet โ€” nothing to revert to') } changes := compare_with_head(store, head) or { return refuse(args.json(), err.msg()) } if !changes.dirty { // The files all landed before whatever stopped the last restore. snapshot.clear_restore_mark(store) or { return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(RevertJson{ reverted: false head_id: head.id })) return 0 } ok('working tree already matches [${head.id}]') return 0 } // The state about to be discarded is recorded as a checkpoint, so a revert // is itself undoable. `record_discard` does that only after the answer. return going_back(store, head, 'revert', args, true) } // cmd_load restores any save, or the newest checkpoint with --checkpoint. Unsaved changes refuse it; // --json needs --yes. pub fn cmd_load(raw []string) int { args := parse('load', raw, Options{ flags: ['checkpoint', 'yes', 'json'] }) or { return usage_error(err.msg()) } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } mut target := history.Save{} if args.flag('checkpoint') { target = history.latest_checkpoint(store) or { return refuse(args.json(), 'this repo has no checkpoints') } } else { id := args.positional_at(0) if id == '' { return usage_error('load needs a save ID, or --checkpoint') } target = history.resolve(store, id) or { return refuse(args.json(), err.msg()) } } return going_back(store, target, 'load', args, false) } // going_back is the shared path. `discard_first` is revert's extra step: record // the changes being thrown away before restoring. fn going_back(store &storage.Store, target history.Save, verb string, args Args, discard_first bool) int { as_json := args.json() if as_json && !args.flag('yes') { return refuse(true, 'needs --yes when not interactive') } if !discard_first { require_clean(store) or { return refuse(as_json, err.msg()) } } preview := plan_restore(store, target) or { return refuse(as_json, err.msg()) } if !as_json { ok('${verb}: [${target.id}] ${target.message}') for delta in preview.deltas { println(' ${change_mark(delta.kind)} ${delta.path}') } note('${fmt.plural(preview.deltas.len, 'file')} change ยท ${fmt.plural(preview.remove.len, 'file')} removed') } approved := confirm(args.flag('yes')) or { return refuse(as_json, err.msg()) } if !approved { return 1 } // The tree may have changed while the prompt waited, so the restore acts on // a fresh plan; the one above only drew the preview. if !discard_first { require_clean(store) or { return refuse(as_json, err.msg()) } } plan := plan_restore(store, target) or { return refuse(as_json, err.msg()) } save := apply_restore(store, target, verb, plan, discard_first) or { return refuse(as_json, err.msg()) } report := snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files, plan.remove) or { return refuse(as_json, err.msg()) } // A revert's restore save sits right on the checkpoint of what it threw away. kept := if discard_first { save.parent } else { '' } if as_json { emit_json(json2.encode(BackJson{ id: save.id restores: target.id written: report.written removed: report.removed discarded: discard_first checkpoint: kept })) return 0 } ok('restored [${save.id}] ${save.message}') if kept != '' { note('the discarded changes are kept in [${kept}] โ€” koh load ${kept} brings them back') } return 0 } // apply_restore records the going-back as a new save. It does not touch the // working tree and does not ask anything: the history view has already asked, // and the order still has to be save-first, files-second. pub fn apply_restore(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool) !history.Save { // A revert may be finishing an earlier restore; that one's mark stays // until the files are right. earlier := snapshot.restore_pending(store) snapshot.mark_restore(store)! return record_restore(store, target, verb, plan, discard_first) or { // Nothing moved, so this restore has nothing to finish. A mark that // cannot be lifted only means revert is suggested once needlessly. if !earlier { snapshot.clear_restore_mark(store) or {} } return err } } fn record_restore(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool) !history.Save { discarded := if discard_first { snapshot.walk(store, store.root, snapshot.load(store.root))! } else { []snapshot.FileEntry{} } // One transaction, as the document asks of every history change; it also // keeps the discard checkpoint the restore save's parent, which is how // revert names it. store.begin()! save := record_both(store, target, verb, plan, discard_first, discarded) or { store.rollback() return err } store.end()! history.refresh_log_md(store) return save } fn record_both(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool, discarded []snapshot.FileEntry) !history.Save { if discard_first { // Recorded even while a restore is unfinished: finishing it is what // revert is for. history.record_save(store, store.root, discarded, history.SaveInput{ message: 'revert: discarded changes' is_checkpoint: true })! } return history.record_save(store, '', plan.files, history.SaveInput{ message: '${verb}: ${target.id}' restores: target.id })! } // restore_now performs a whole restore without prompting: the plan, the save // and the files. The history view uses it for its `l` key. pub fn restore_now(store &storage.Store, target history.Save, verb string) !history.Save { require_clean(store)! plan := plan_restore(store, target)! save := apply_restore(store, target, verb, plan, false)! snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files, plan.remove)! return save } struct RestorePlan { files []snapshot.FileEntry remove []string deltas []snapshot.Delta } const unsaved_changes = 'unsaved changes โ€” save or checkpoint first, or discard them with koh revert' // require_clean refuses to restore over work only the disk has, as Koh 2 did. // `revert` is the one going-back that may discard it, and it records it first. fn require_clean(store &storage.Store) ! { if snapshot.restore_pending(store) { return error(snapshot.restore_unfinished) } head := history.head(store) or { return } if compare_with_head(store, head)!.dirty { return error(unsaved_changes) } } // plan_restore works out what writing `target` back onto disk involves. Every // file on disk the target lacks goes: undo, load and pluck only run on a clean // tree, so those are tracked files, and revert has recorded the rest. fn plan_restore(store &storage.Store, target history.Save) !RestorePlan { files := history.files_of(store, target.id)! snapshot.check_links(store.root, files)! mut wanted := map[string]bool{} for file in files { wanted[file.path] = true } from := working_tree(store)! mut remove := []string{} for file in from.files { if file.path !in wanted { remove << file.path } } deltas := snapshot.changes(from.files, files) return RestorePlan{ files: files remove: remove deltas: deltas } } fn change_mark(kind string) string { if kind == 'added' { return fmt.green('+') } if kind == 'removed' { return fmt.red('-') } return fmt.amber('~') } struct BackJson { id string restores string written int removed int discarded bool checkpoint string // revert only: the checkpoint holding what was discarded } struct RevertJson { reverted bool head_id string }