cmd_back.v

9.3 KB · raw

1//! Going back: `koh undo`, `koh revert` and `koh load`.2//!3//! Nothing here rewinds history. A going-back command restores an earlier tree4//! and records that as a new save whose `restores` field names the save it5//! brought back, so the move itself can be undone.6//!7//! The order matters and is the same everywhere: show what will change, ask,8//! record the new save, then write the files. Recording before writing means a9//! crash mid-restore shows up in `status` and `revert` finishes it.1011module cli1213import json214import src.fmt15import src.history16import src.snapshot17import src.storage1819pub fn cmd_undo(raw []string) int {20	args := parse('undo', raw, Options{21		flags: ['session', 'yes', 'json']22	}) or { return usage_error(err.msg()) }23	store := open_store() or { return refuse(args.json(), err.msg()) }24	defer {25		store.close()26	}27	effective := history.effective(store) or { return refuse(args.json(), err.msg()) }28	mut target := history.Save{}29	if args.flag('session') {30		start := history.session_start(store, effective.id) or {31			return refuse(args.json(), err.msg())32		}33		if start.parent == '' {34			return refuse(args.json(), 'there is nothing before session "${start.session}"')35		}36		target = history.by_id(store, start.parent) or { return refuse(args.json(), err.msg()) }37	} else {38		mut steps := 139		if args.positional.len > 0 {40			steps = args.positional_at(0).int()41			if steps < 1 {42				return usage_error('undo takes a positive number of saves')43			}44		}45		target = history.step_back(store, effective.id, steps) or {46			return refuse(args.json(), err.msg())47		}48	}49	return going_back(store, target, 'undo', args, false)50}5152pub fn cmd_revert(raw []string) int {53	args := parse('revert', raw, Options{54		flags: ['yes', 'json']55	}) or { return usage_error(err.msg()) }56	store := open_store() or { return refuse(args.json(), err.msg()) }57	defer {58		store.close()59	}60	head := history.head(store) or {61		return refuse(args.json(), 'this repo has no saves yet — nothing to revert to')62	}63	changes := compare_with_head(store, head) or { return refuse(args.json(), err.msg()) }64	if !changes.dirty {65		// The files all landed before whatever stopped the last restore.66		snapshot.clear_restore_mark(store) or { return refuse(args.json(), err.msg()) }67		if args.json() {68			emit_json(json2.encode(RevertJson{69				reverted: false70				head_id:  head.id71			}))72			return 073		}74		ok('working tree already matches [${head.id}]')75		return 076	}77	// The state about to be discarded is recorded as a checkpoint, so a revert78	// is itself undoable. `record_discard` does that only after the answer.79	return going_back(store, head, 'revert', args, true)80}8182pub fn cmd_load(raw []string) int {83	args := parse('load', raw, Options{84		flags: ['checkpoint', 'yes', 'json']85	}) or { return usage_error(err.msg()) }86	store := open_store() or { return refuse(args.json(), err.msg()) }87	defer {88		store.close()89	}90	mut target := history.Save{}91	if args.flag('checkpoint') {92		target = history.latest_checkpoint(store) or {93			return refuse(args.json(), 'this repo has no checkpoints')94		}95	} else {96		id := args.positional_at(0)97		if id == '' {98			return usage_error('load needs a save ID, or --checkpoint')99		}100		target = history.resolve(store, id) or { return refuse(args.json(), err.msg()) }101	}102	return going_back(store, target, 'load', args, false)103}104105// going_back is the shared path. `discard_first` is revert's extra step: record106// the changes being thrown away before restoring.107fn going_back(store &storage.Store, target history.Save, verb string, args Args, discard_first bool) int {108	as_json := args.json()109	if as_json && !args.flag('yes') {110		return refuse(true, 'needs --yes when not interactive')111	}112	if !discard_first {113		require_clean(store) or { return refuse(as_json, err.msg()) }114	}115	preview := plan_restore(store, target) or { return refuse(as_json, err.msg()) }116	if !as_json {117		ok('${verb}: [${target.id}]  ${target.message}')118		for delta in preview.deltas {119			println('  ${change_mark(delta.kind)} ${delta.path}')120		}121		note('${fmt.plural(preview.deltas.len, 'file')} change  ·  ${fmt.plural(preview.remove.len,122			'file')} removed')123	}124	approved := confirm(args.flag('yes')) or { return refuse(as_json, err.msg()) }125	if !approved {126		return 1127	}128	// The tree may have changed while the prompt waited, so the restore acts on129	// a fresh plan; the one above only drew the preview.130	if !discard_first {131		require_clean(store) or { return refuse(as_json, err.msg()) }132	}133	plan := plan_restore(store, target) or { return refuse(as_json, err.msg()) }134	save := apply_restore(store, target, verb, plan, discard_first) or {135		return refuse(as_json, err.msg())136	}137	report := snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files,138		plan.remove) or { return refuse(as_json, err.msg()) }139	// A revert's restore save sits right on the checkpoint of what it threw away.140	kept := if discard_first { save.parent } else { '' }141	if as_json {142		emit_json(json2.encode(BackJson{143			id:         save.id144			restores:   target.id145			written:    report.written146			removed:    report.removed147			discarded:  discard_first148			checkpoint: kept149		}))150		return 0151	}152	ok('restored [${save.id}]  ${save.message}')153	if kept != '' {154		note('the discarded changes are kept in [${kept}] — koh load ${kept} brings them back')155	}156	return 0157}158159// apply_restore records the going-back as a new save. It does not touch the160// working tree and does not ask anything: the history view has already asked,161// and the order still has to be save-first, files-second.162pub fn apply_restore(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool) !history.Save {163	// A revert may be finishing an earlier restore; that one's mark stays164	// until the files are right.165	earlier := snapshot.restore_pending(store)166	snapshot.mark_restore(store)!167	return record_restore(store, target, verb, plan, discard_first) or {168		// Nothing moved, so this restore has nothing to finish. A mark that169		// cannot be lifted only means revert is suggested once needlessly.170		if !earlier {171			snapshot.clear_restore_mark(store) or {}172		}173		return err174	}175}176177fn record_restore(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool) !history.Save {178	discarded := if discard_first {179		snapshot.walk(store, store.root, snapshot.load(store.root))!180	} else {181		[]snapshot.FileEntry{}182	}183	// One transaction, as the document asks of every history change; it also184	// keeps the discard checkpoint the restore save's parent, which is how185	// revert names it.186	store.begin()!187	save := record_both(store, target, verb, plan, discard_first, discarded) or {188		store.rollback()189		return err190	}191	store.commit()!192	history.refresh_log_md(store)193	return save194}195196fn record_both(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool, discarded []snapshot.FileEntry) !history.Save {197	if discard_first {198		// Recorded even while a restore is unfinished: finishing it is what199		// revert is for.200		history.record_save(store, store.root, discarded, history.SaveInput{201			message:       'revert: discarded changes'202			is_checkpoint: true203		})!204	}205	return history.record_save(store, '', plan.files, history.SaveInput{206		message:  '${verb}: ${target.id}'207		restores: target.id208	})!209}210211// restore_now performs a whole restore without prompting: the plan, the save212// and the files. The history view uses it for its `l` key.213pub fn restore_now(store &storage.Store, target history.Save, verb string) !history.Save {214	require_clean(store)!215	plan := plan_restore(store, target)!216	save := apply_restore(store, target, verb, plan, false)!217	snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files, plan.remove)!218	return save219}220221struct RestorePlan {222	files  []snapshot.FileEntry223	remove []string224	deltas []snapshot.Delta225}226227const unsaved_changes = 'unsaved changes — save or checkpoint first, or discard them with koh revert'228229// require_clean refuses to restore over work only the disk has, as Koh 2 did.230// `revert` is the one going-back that may discard it, and it records it first.231fn require_clean(store &storage.Store) ! {232	if snapshot.restore_pending(store) {233		return error(snapshot.restore_unfinished)234	}235	head := history.head(store) or { return }236	if compare_with_head(store, head)!.dirty {237		return error(unsaved_changes)238	}239}240241// plan_restore works out what writing `target` back onto disk involves. Every242// file on disk the target lacks goes: undo, load and pluck only run on a clean243// tree, so those are tracked files, and revert has recorded the rest.244fn plan_restore(store &storage.Store, target history.Save) !RestorePlan {245	files := history.files_of(store, target.id)!246	snapshot.check_links(store.root, files)!247	mut wanted := map[string]bool{}248	for file in files {249		wanted[file.path] = true250	}251	from := working_tree(store)!252	mut remove := []string{}253	for file in from.files {254		if file.path !in wanted {255			remove << file.path256		}257	}258	deltas := snapshot.changes(from.files, files)259	return RestorePlan{260		files:  files261		remove: remove262		deltas: deltas263	}264}265266fn change_mark(kind string) string {267	if kind == 'added' {268		return fmt.green('+')269	}270	if kind == 'removed' {271		return fmt.red('-')272	}273	return fmt.amber('~')274}275276struct BackJson {277	id         string278	restores   string279	written    int280	removed    int281	discarded  bool282	checkpoint string // revert only: the checkpoint holding what was discarded283}284285struct RevertJson {286	reverted bool287	head_id  string288}