//! `koh pluck` — put one save's change onto the current tree. //! //! The engine is per file, never per line: for every path in scope the current //! content must match either the save's parent (clean apply) or the save's own //! version (already applied). Anything else is a hard conflict, reported by //! name, and nothing is applied. module cli import json2 import src.apply import src.fmt import src.history import src.snapshot import src.storage pub fn cmd_pluck(raw []string) int { args := parse('pluck', raw, Options{ flags: ['reverse', 'dry-run', 'yes', 'json'] }) or { return usage_error(err.msg()) } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } id := args.positional_at(0) if id == '' { return usage_error('pluck needs a save ID') } source := history.resolve(store, id) or { return refuse(args.json(), err.msg()) } mut filters := []string{} if args.positional.len > 1 { filters = args.positional[1..] } // A dry run only reads, so it may look at a dirty tree; a real pluck // rewrites files and must not overwrite unsaved work. if !args.flag('dry-run') { require_clean(store) or { return refuse(args.json(), err.msg()) } } reverse := args.flag('reverse') verb := if reverse { 'pluck reverse' } else { 'pluck' } plan, _ := plan_pluck(store, source, reverse, filters) or { return refuse(args.json(), err.msg()) } if args.flag('dry-run') || plan.applied.len == 0 { if args.json() { emit_json(json2.encode(PluckJson{ source: source.id reverse: reverse applied: plan.applied applied_ok: true })) return 0 } if plan.applied.len == 0 { ok('nothing to apply from [${source.id}]') return 0 } ok('${verb} [${source.id}] would change:') for path in plan.applied { println(' ${fmt.amber('~')} ${path}') } note('${fmt.plural(plan.applied.len, 'file')} — run again without --dry-run to apply') return 0 } ok('${verb}: [${source.id}] ${source.message}') for path in plan.applied { println(' ${fmt.amber('~')} ${path}') } note('${fmt.plural(plan.applied.len, 'file')} change') approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) } if !approved { return 1 } // The tree may have changed while the prompt waited, so the pluck acts on a // fresh plan; the one above only drew the preview. require_clean(store) or { return refuse(args.json(), err.msg()) } fresh, current := plan_pluck(store, source, reverse, filters) or { return refuse(args.json(), err.msg()) } if fresh.applied.len == 0 { return refuse(args.json(), 'nothing to apply from [${source.id}] any more — the tree changed while waiting') } // The resulting tree is the working tree with the change applied, so its // object bytes come from disk. snapshot.mark_restore(store) or { return refuse(args.json(), err.msg()) } save := history.save_tree(store, store.root, fresh.files, history.SaveInput{ message: '${verb}: ${source.id}' }) or { snapshot.clear_restore_mark(store) or {} return refuse(args.json(), err.msg()) } remove := not_in(current, fresh.files) report := snapshot.restore_files(store, store.root, snapshot.load(store.root), fresh.files, remove) or { return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(PluckedJson{ id: save.id source: source.id reverse: reverse applied: fresh.applied written: report.written removed: report.removed })) return 0 } ok('plucked [${source.id}] into [${save.id}]') return 0 } // plan_pluck applies the source save's change (or, reversed, its inverse) to // the working tree, and returns the plan with the tree it was made from. fn plan_pluck(store &storage.Store, source history.Save, reverse bool, filters []string) !(apply.Plan, []snapshot.FileEntry) { source_tree := tree_of(store, source.id)! mut parent_tree := snapshot.Tree{} if source.parent != '' { parent_tree = tree_of(store, source.parent)! } current := working_tree(store)! base := if reverse { source_tree } else { parent_tree } target := if reverse { parent_tree } else { source_tree } plan := apply.three_way(current.files, base.files, target.files, filters, []) if apply.conflicts(plan) { return error('${plan.conflict} has changed since [${source.id}]; koh does not blend lines — resolve it by hand, or load the save and re-edit') } snapshot.check_links(store.root, plan.files)! return plan, current.files } // not_in lists the paths of `before` that `after` no longer has. fn not_in(before []snapshot.FileEntry, after []snapshot.FileEntry) []string { mut kept := map[string]bool{} for entry in after { kept[entry.path] = true } mut missing := []string{} for entry in before { if entry.path !in kept { missing << entry.path } } return missing } struct PluckJson { source string reverse bool applied []string applied_ok bool } struct PluckedJson { id string source string reverse bool applied []string written int removed int }