cmd_back.v

7.0 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 json14import 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		if args.json() {66			emit_json(json.encode(RevertJson{67				reverted: false68				head_id:  head.id69			}))70			return 071		}72		ok('working tree already matches [${head.id}]')73		return 074	}75	// The state about to be discarded is recorded as a checkpoint, so a revert76	// is itself undoable. `record_discard` does that only after the answer.77	return going_back(store, head, 'revert', args, true)78}7980pub fn cmd_load(raw []string) int {81	args := parse('load', raw, Options{82		flags: ['checkpoint', 'yes', 'json']83	}) or { return usage_error(err.msg()) }84	store := open_store() or { return refuse(args.json(), err.msg()) }85	defer {86		store.close()87	}88	mut target := history.Save{}89	if args.flag('checkpoint') {90		target = history.latest_checkpoint(store) or {91			return refuse(args.json(), 'this repo has no checkpoints')92		}93	} else {94		id := args.positional_at(0)95		if id == '' {96			return usage_error('load needs a save ID, or --checkpoint')97		}98		target = history.resolve(store, id) or { return refuse(args.json(), err.msg()) }99	}100	return going_back(store, target, 'load', args, false)101}102103// going_back is the shared path. `discard_first` is revert's extra step: record104// the changes being thrown away before restoring.105fn going_back(store &storage.Store, target history.Save, verb string, args Args, discard_first bool) int {106	as_json := args.json()107	if as_json && !args.flag('yes') {108		return refuse(true, 'needs --yes when not interactive')109	}110	plan := plan_restore(store, target, discard_first) or { return refuse(as_json, err.msg()) }111	if !as_json {112		ok('${verb}: [${target.id}]  ${target.message}')113		for delta in plan.deltas {114			println('  ${change_mark(delta.kind)} ${delta.path}')115		}116		note('${fmt.plural(plan.deltas.len, 'file')} change  ·  ${fmt.plural(plan.remove.len,117			'file')} removed')118	}119	approved := confirm(args.flag('yes')) or { return refuse(as_json, err.msg()) }120	if !approved {121		return 1122	}123	save := apply_restore(store, target, verb, plan, discard_first) or {124		return refuse(as_json, err.msg())125	}126	report := snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files,127		plan.remove) or { return refuse(as_json, err.msg()) }128	if as_json {129		emit_json(json.encode(BackJson{130			id:        save.id131			restores:  target.id132			written:   report.written133			removed:   report.removed134			discarded: discard_first135		}))136		return 0137	}138	ok('restored [${save.id}]  ${save.message}')139	return 0140}141142// apply_restore records the going-back as a new save. It does not touch the143// working tree and does not ask anything: the history view has already asked,144// and the order still has to be save-first, files-second.145pub fn apply_restore(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool) !history.Save {146	if discard_first {147		history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{148			message:       'revert: discarded changes'149			is_checkpoint: true150		})!151	}152	return history.commit_files(store, '', plan.files, history.SaveInput{153		message:  '${verb}: ${target.id}'154		restores: target.id155	})!156}157158// restore_now performs a whole restore without prompting: the plan, the save159// and the files. The history view uses it for its `l` key.160pub fn restore_now(store &storage.Store, target history.Save, verb string) !history.Save {161	plan := plan_restore(store, target, false)!162	save := apply_restore(store, target, verb, plan, false)!163	snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files, plan.remove)!164	return save165}166167struct RestorePlan {168	files  []snapshot.FileEntry169	remove []string170	deltas []snapshot.Delta171}172173// plan_restore works out what writing `target` back onto disk involves.174//175// `prune_untracked` is revert's rule: revert promises to discard unsaved176// changes, so a file that was never saved goes too. undo and load are more177// conservative and only remove paths history already knows about; a file the178// user never saved is not theirs to delete.179fn plan_restore(store &storage.Store, target history.Save, prune_untracked bool) !RestorePlan {180	files := history.files_of(store, target.id)!181	mut wanted := map[string]bool{}182	for file in files {183		wanted[file.path] = true184	}185	from := working_tree(store)!186	mut remove := []string{}187	if prune_untracked {188		for file in from.files {189			if file.path !in wanted {190				remove << file.path191			}192		}193	} else if head := history.head(store) {194		for file in history.files_of(store, head.id) or { []snapshot.FileEntry{} } {195			if file.path !in wanted {196				remove << file.path197			}198		}199	}200	deltas := snapshot.diff_trees(store, from, snapshot.Tree{201		files: files202	})!203	return RestorePlan{204		files:  files205		remove: remove206		deltas: deltas207	}208}209210fn change_mark(kind string) string {211	if kind == 'added' {212		return green('+')213	}214	if kind == 'removed' {215		return red('-')216	}217	return amber('~')218}219220struct BackJson {221	id        string222	restores  string223	written   int224	removed   int225	discarded bool226}227228struct RevertJson {229	reverted bool230	head_id  string231}