//! The per-file three-way engine, shared by `pluck` and by combine's replay. //! //! One save's change is applied onto the current tree file by file. For each //! path in scope the current content must match either the base (clean apply, //! take the new content) or the target (already applied, leave it); anything //! else refuses rather than guessing. There is no line-level merging: the unit //! of resolution is the file, which is why a clash is always a hard conflict. module apply import src.snapshot pub struct Plan { pub mut: files []snapshot.FileEntry // the tree that results from applying applied []string // paths the plan changes conflict string // first path that does not apply cleanly } pub fn conflicts(plan Plan) bool { return plan.conflict != '' } // three_way applies the change from `base` to `target` onto `current`. // // `filters` limits it to those paths or folders; an empty list means // everything. `exclude` drops paths outright, which is how a combine leaves the // clash files for the caller to decide. pub fn three_way(current []snapshot.FileEntry, base []snapshot.FileEntry, target []snapshot.FileEntry, filters []string, exclude []string) Plan { base_map := snapshot.index_files(base) target_map := snapshot.index_files(target) current_map := snapshot.index_files(current) mut paths := []string{} for path in base_map.keys() { if in_scope(path, filters) && !excluded(path, exclude) { paths << path } } for path in target_map.keys() { if in_scope(path, filters) && !excluded(path, exclude) && path !in base_map { paths << path } } paths.sort() mut plan := Plan{ files: current.clone() applied: []string{} } for path in paths { from := base_map[path] or { snapshot.FileEntry{ path: path } } to := target_map[path] or { snapshot.FileEntry{ path: path } } if same_entry(from, to) { continue } here := current_map[path] or { snapshot.FileEntry{ path: path } } if same_entry(here, from) { plan.files = set_entry(plan.files, path, to, path in target_map) plan.applied << path continue } if same_entry(here, to) { // Somebody already applied this change; nothing to do. continue } plan.conflict = path return plan } return plan } pub fn in_scope(path string, filters []string) bool { if filters.len == 0 { return true } for raw in filters { filter := raw.trim_right('/') if path == filter || path.starts_with('${filter}/') { return true } } return false } fn excluded(path string, exclude []string) bool { for name in exclude { if path == name { return true } } return false } // same_entry treats two absent paths as equal, and compares presence, content // and the executable bit otherwise. fn same_entry(a snapshot.FileEntry, b snapshot.FileEntry) bool { if a.hash == '' && b.hash == '' { return true } return a.hash == b.hash && a.executable == b.executable } // set_entry replaces one path in a sorted tree, adding or removing it, and // keeps the list ordered by path. fn set_entry(files []snapshot.FileEntry, path string, entry snapshot.FileEntry, keep bool) []snapshot.FileEntry { mut out := []snapshot.FileEntry{} mut placed := false for file in files { if file.path == path { if keep { out << entry } placed = true continue } if !placed && file.path > path { if keep { out << entry } placed = true } out << file } if !placed && keep { out << entry } return out }