cmd_back.v

9.7 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.storage1819// cmd_undo goes back one save, N saves, or to before the current --session. Unsaved changes refuse it.20pub fn cmd_undo(raw []string) int {21	args := parse('undo', raw, Options{22		flags: ['session', 'yes', 'json']23	}) or { return usage_error(err.msg()) }24	store := open_store() or { return refuse(args.json(), err.msg()) }25	defer {26		store.close()27	}28	effective := history.effective(store) or { return refuse(args.json(), err.msg()) }29	mut target := history.Save{}30	if args.flag('session') {31		start := history.session_start(store, effective.id) or {32			return refuse(args.json(), err.msg())33		}34		if start.parent == '' {35			return refuse(args.json(), 'there is nothing before session "${start.session}"')36		}37		target = history.by_id(store, start.parent) or { return refuse(args.json(), err.msg()) }38	} else {39		mut steps := 140		if args.positional.len > 0 {41			steps = args.positional_at(0).int()42			if steps < 1 {43				return usage_error('undo takes a positive number of saves')44			}45		}46		target = history.step_back(store, effective.id, steps) or {47			return refuse(args.json(), err.msg())48		}49	}50	return going_back(store, target, 'undo', args, false)51}5253// cmd_revert puts the tree back to head, checkpointing what is there first, and finishes a restore that54// stopped part way.55pub fn cmd_revert(raw []string) int {56	args := parse('revert', raw, Options{57		flags: ['yes', 'json']58	}) or { return usage_error(err.msg()) }59	store := open_store() or { return refuse(args.json(), err.msg()) }60	defer {61		store.close()62	}63	head := history.head(store) or {64		return refuse(args.json(), 'this repo has no saves yet — nothing to revert to')65	}66	changes := compare_with_head(store, head) or { return refuse(args.json(), err.msg()) }67	if !changes.dirty {68		// The files all landed before whatever stopped the last restore.69		snapshot.clear_restore_mark(store) or { return refuse(args.json(), err.msg()) }70		if args.json() {71			emit_json(json2.encode(RevertJson{72				reverted: false73				head_id:  head.id74			}))75			return 076		}77		ok('working tree already matches [${head.id}]')78		return 079	}80	// The state about to be discarded is recorded as a checkpoint, so a revert81	// is itself undoable. `record_discard` does that only after the answer.82	return going_back(store, head, 'revert', args, true)83}8485// cmd_load restores any save, or the newest checkpoint with --checkpoint. Unsaved changes refuse it;86// --json needs --yes.87pub fn cmd_load(raw []string) int {88	args := parse('load', raw, Options{89		flags: ['checkpoint', 'yes', 'json']90	}) or { return usage_error(err.msg()) }91	store := open_store() or { return refuse(args.json(), err.msg()) }92	defer {93		store.close()94	}95	mut target := history.Save{}96	if args.flag('checkpoint') {97		target = history.latest_checkpoint(store) or {98			return refuse(args.json(), 'this repo has no checkpoints')99		}100	} else {101		id := args.positional_at(0)102		if id == '' {103			return usage_error('load needs a save ID, or --checkpoint')104		}105		target = history.resolve(store, id) or { return refuse(args.json(), err.msg()) }106	}107	return going_back(store, target, 'load', args, false)108}109110// going_back is the shared path. `discard_first` is revert's extra step: record111// the changes being thrown away before restoring.112fn going_back(store &storage.Store, target history.Save, verb string, args Args, discard_first bool) int {113	as_json := args.json()114	if as_json && !args.flag('yes') {115		return refuse(true, 'needs --yes when not interactive')116	}117	if !discard_first {118		require_clean(store) or { return refuse(as_json, err.msg()) }119	}120	preview := plan_restore(store, target) or { return refuse(as_json, err.msg()) }121	if !as_json {122		ok('${verb}: [${target.id}]  ${target.message}')123		for delta in preview.deltas {124			println('  ${change_mark(delta.kind)} ${delta.path}')125		}126		note('${fmt.plural(preview.deltas.len, 'file')} change  ·  ${fmt.plural(preview.remove.len, 'file')} removed')127	}128	approved := confirm(args.flag('yes')) or { return refuse(as_json, err.msg()) }129	if !approved {130		return 1131	}132	// The tree may have changed while the prompt waited, so the restore acts on133	// a fresh plan; the one above only drew the preview.134	if !discard_first {135		require_clean(store) or { return refuse(as_json, err.msg()) }136	}137	plan := plan_restore(store, target) or { return refuse(as_json, err.msg()) }138	save := apply_restore(store, target, verb, plan, discard_first) or {139		return refuse(as_json, err.msg())140	}141	report := snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files,142		plan.remove) or { return refuse(as_json, err.msg()) }143	// A revert's restore save sits right on the checkpoint of what it threw away.144	kept := if discard_first { save.parent } else { '' }145	if as_json {146		emit_json(json2.encode(BackJson{147			id:         save.id148			restores:   target.id149			written:    report.written150			removed:    report.removed151			discarded:  discard_first152			checkpoint: kept153		}))154		return 0155	}156	ok('restored [${save.id}]  ${save.message}')157	if kept != '' {158		note('the discarded changes are kept in [${kept}] — koh load ${kept} brings them back')159	}160	return 0161}162163// apply_restore records the going-back as a new save. It does not touch the164// working tree and does not ask anything: the history view has already asked,165// and the order still has to be save-first, files-second.166pub fn apply_restore(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool) !history.Save {167	// A revert may be finishing an earlier restore; that one's mark stays168	// until the files are right.169	earlier := snapshot.restore_pending(store)170	snapshot.mark_restore(store)!171	return record_restore(store, target, verb, plan, discard_first) or {172		// Nothing moved, so this restore has nothing to finish. A mark that173		// cannot be lifted only means revert is suggested once needlessly.174		if !earlier {175			snapshot.clear_restore_mark(store) or {}176		}177		return err178	}179}180181fn record_restore(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool) !history.Save {182	discarded := if discard_first {183		snapshot.walk(store, store.root, snapshot.load(store.root))!184	} else {185		[]snapshot.FileEntry{}186	}187	// One transaction, as the document asks of every history change; it also188	// keeps the discard checkpoint the restore save's parent, which is how189	// revert names it.190	store.begin()!191	save := record_both(store, target, verb, plan, discard_first, discarded) or {192		store.rollback()193		return err194	}195	store.end()!196	history.refresh_log_md(store)197	return save198}199200fn record_both(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool, discarded []snapshot.FileEntry) !history.Save {201	if discard_first {202		// Recorded even while a restore is unfinished: finishing it is what203		// revert is for.204		history.record_save(store, store.root, discarded, history.SaveInput{205			message:       'revert: discarded changes'206			is_checkpoint: true207		})!208	}209	return history.record_save(store, '', plan.files, history.SaveInput{210		message:  '${verb}: ${target.id}'211		restores: target.id212	})!213}214215// restore_now performs a whole restore without prompting: the plan, the save216// and the files. The history view uses it for its `l` key.217pub fn restore_now(store &storage.Store, target history.Save, verb string) !history.Save {218	require_clean(store)!219	plan := plan_restore(store, target)!220	save := apply_restore(store, target, verb, plan, false)!221	snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files, plan.remove)!222	return save223}224225struct RestorePlan {226	files  []snapshot.FileEntry227	remove []string228	deltas []snapshot.Delta229}230231const unsaved_changes = 'unsaved changes — save or checkpoint first, or discard them with koh revert'232233// require_clean refuses to restore over work only the disk has, as Koh 2 did.234// `revert` is the one going-back that may discard it, and it records it first.235fn require_clean(store &storage.Store) ! {236	if snapshot.restore_pending(store) {237		return error(snapshot.restore_unfinished)238	}239	head := history.head(store) or { return }240	if compare_with_head(store, head)!.dirty {241		return error(unsaved_changes)242	}243}244245// plan_restore works out what writing `target` back onto disk involves. Every246// file on disk the target lacks goes: undo, load and pluck only run on a clean247// tree, so those are tracked files, and revert has recorded the rest.248fn plan_restore(store &storage.Store, target history.Save) !RestorePlan {249	files := history.files_of(store, target.id)!250	snapshot.check_links(store.root, files)!251	mut wanted := map[string]bool{}252	for file in files {253		wanted[file.path] = true254	}255	from := working_tree(store)!256	mut remove := []string{}257	for file in from.files {258		if file.path !in wanted {259			remove << file.path260		}261	}262	deltas := snapshot.changes(from.files, files)263	return RestorePlan{264		files:  files265		remove: remove266		deltas: deltas267	}268}269270fn change_mark(kind string) string {271	if kind == 'added' {272		return fmt.green('+')273	}274	if kind == 'removed' {275		return fmt.red('-')276	}277	return fmt.amber('~')278}279280struct BackJson {281	id         string282	restores   string283	written    int284	removed    int285	discarded  bool286	checkpoint string // revert only: the checkpoint holding what was discarded287}288289struct RevertJson {290	reverted bool291	head_id  string292}