cmd_back.v

6.2 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.history15import src.snapshot16import src.storage1718pub fn cmd_undo(raw []string) int {19	args := parse('undo', raw, Options{20		flags: ['session', 'yes', 'json']21	}) or { return usage_error(err.msg()) }22	store := open_store() or { return refuse(args.json(), err.msg()) }23	defer {24		store.close()25	}26	effective := history.effective(store) or { return refuse(args.json(), err.msg()) }27	mut target := history.Save{}28	if args.flag('session') {29		start := history.session_start(store, effective.id) or {30			return refuse(args.json(), err.msg())31		}32		if start.parent == '' {33			return refuse(args.json(), 'there is nothing before session "${start.session}"')34		}35		target = history.by_id(store, start.parent) or { return refuse(args.json(), err.msg()) }36	} else {37		mut steps := 138		if args.positional.len > 0 {39			steps = args.positional_at(0).int()40			if steps < 1 {41				return usage_error('undo takes a positive number of saves')42			}43		}44		target = history.step_back(store, effective.id, steps) or {45			return refuse(args.json(), err.msg())46		}47	}48	return going_back(store, target, 'undo', args, false)49}5051pub fn cmd_revert(raw []string) int {52	args := parse('revert', raw, Options{53		flags: ['yes', 'json']54	}) or { return usage_error(err.msg()) }55	store := open_store() or { return refuse(args.json(), err.msg()) }56	defer {57		store.close()58	}59	head := history.head(store) or {60		return refuse(args.json(), 'this repo has no saves yet — nothing to revert to')61	}62	changes := compare_with_head(store, head) or { return refuse(args.json(), err.msg()) }63	if !changes.dirty {64		if args.json() {65			emit_json(json.encode(RevertJson{66				reverted: false67				head_id:  head.id68			}))69			return 070		}71		ok('working tree already matches [${head.id}]')72		return 073	}74	// The state about to be discarded is recorded as a checkpoint, so a revert75	// is itself undoable. `record_discard` does that only after the answer.76	return going_back(store, head, 'revert', args, true)77}7879pub fn cmd_load(raw []string) int {80	args := parse('load', raw, Options{81		flags: ['checkpoint', 'yes', 'json']82	}) or { return usage_error(err.msg()) }83	store := open_store() or { return refuse(args.json(), err.msg()) }84	defer {85		store.close()86	}87	mut target := history.Save{}88	if args.flag('checkpoint') {89		target = history.latest_checkpoint(store) or {90			return refuse(args.json(), 'this repo has no checkpoints')91		}92	} else {93		id := args.positional_at(0)94		if id == '' {95			return usage_error('load needs a save ID, or --checkpoint')96		}97		target = history.resolve(store, id) or { return refuse(args.json(), err.msg()) }98	}99	return going_back(store, target, 'load', args, false)100}101102// going_back is the shared path. `discard_first` is revert's extra step: record103// the changes being thrown away before restoring.104fn going_back(store &storage.Store, target history.Save, verb string, args Args, discard_first bool) int {105	as_json := args.json()106	if as_json && !args.flag('yes') {107		return refuse(true, 'needs --yes when not interactive')108	}109	plan := plan_restore(store, target, discard_first) or { return refuse(as_json, err.msg()) }110	if !as_json {111		ok('${verb}: [${target.id}]  ${target.message}')112		for delta in plan.deltas {113			println('  ${change_mark(delta.kind)} ${delta.path}')114		}115		note('${plural(plan.deltas.len, 'file')} change  ·  ${plural(plan.remove.len, 'file')} removed')116	}117	approved := confirm(args.flag('yes')) or { return refuse(as_json, err.msg()) }118	if !approved {119		return 1120	}121	if discard_first {122		history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{123			message:       'revert: discarded changes'124			is_checkpoint: true125		}) or { return refuse(as_json, err.msg()) }126	}127	save := history.commit_files(store, '', plan.files, history.SaveInput{128		message:  '${verb}: ${target.id}'129		restores: target.id130	}) or { return refuse(as_json, err.msg()) }131	report := snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files,132		plan.remove) or { return refuse(as_json, err.msg()) }133	if as_json {134		emit_json(json.encode(BackJson{135			id:        save.id136			restores:  target.id137			written:   report.written138			removed:   report.removed139			discarded: discard_first140		}))141		return 0142	}143	ok('restored [${save.id}]  ${save.message}')144	return 0145}146147struct RestorePlan {148	files  []snapshot.FileEntry149	remove []string150	deltas []snapshot.Delta151}152153// plan_restore works out what writing `target` back onto disk involves.154//155// `prune_untracked` is revert's rule: revert promises to discard unsaved156// changes, so a file that was never saved goes too. undo and load are more157// conservative and only remove paths history already knows about; a file the158// user never saved is not theirs to delete.159fn plan_restore(store &storage.Store, target history.Save, prune_untracked bool) !RestorePlan {160	files := history.files_of(store, target.id)!161	mut wanted := map[string]bool{}162	for file in files {163		wanted[file.path] = true164	}165	from := working_tree(store)!166	mut remove := []string{}167	if prune_untracked {168		for file in from.files {169			if file.path !in wanted {170				remove << file.path171			}172		}173	} else if head := history.head(store) {174		for file in history.files_of(store, head.id) or { []snapshot.FileEntry{} } {175			if file.path !in wanted {176				remove << file.path177			}178		}179	}180	deltas := snapshot.diff_trees(store, from, snapshot.Tree{181		files: files182	})!183	return RestorePlan{184		files:  files185		remove: remove186		deltas: deltas187	}188}189190fn change_mark(kind string) string {191	if kind == 'added' {192		return green('+')193	}194	if kind == 'removed' {195		return red('-')196	}197	return amber('~')198}199200struct BackJson {201	id        string202	restores  string203	written   int204	removed   int205	discarded bool206}207208struct RevertJson {209	reverted bool210	head_id  string211}