//! Many landings of file edits at once, in order: what a host does in one //! turn when agents land faster than one at a time. //! //! Edits made on an older version usually touch paths main hasn't touched //! since (each agent its own files): those go straight onto main, one //! folder rewrite per folder on their paths, with no merge. Anything else //! merges three ways, as a single land would. use crate::merge::{self, Conflict, Mode}; use crate::tree::{self, EntryKind, Put}; use crate::{Error, Id, Layered, MemSource, Result, Source, history, secrets}; /// One landing: files to write and delete, made on `base` (None: on main /// as it is, where editing a file that's there needs a base). pub struct Edits { pub base: Option, pub put: Vec, pub delete: Vec, pub allow_secrets: bool, } /// How one landing went. #[derive(Debug, Clone, PartialEq)] pub enum Outcome { /// Main's new tree, and the paths it changed (None: too many to list). Landed { tree: Id, touched: Option> }, /// Refused: `code` and the reason (bad_usage, empty_change, secret). Refused { code: &'static str, text: String }, /// Edits on main as it is to a path that's there (they need a base). Existing { path: String }, Conflict { conflicts: Vec }, } /// What a batch did, as far as it got. pub struct Batch { /// One per landing done (a prefix of the batch). pub outcomes: Vec, /// Main after them. pub main: Id, /// Objects the landings that landed made. pub objects: MemSource, /// (new object, the one it replaced), for a host keeping deltas. pub bases: Vec<(Id, Id)>, /// An object the next landing needs that isn't loaded: the batch /// stopped there, and goes on from `main` once it's loaded. pub need: Option, } /// Lands `items` one after another on `main`. pub fn edits(src: &dyn Source, main: &Id, items: &[Edits], most_touched: usize) -> Result { let mut batch = Batch { outcomes: Vec::new(), main: *main, objects: MemSource::default(), bases: Vec::new(), need: None }; for item in items { match one(src, &batch, item, most_touched) { Ok((outcome, made)) => { if let (Outcome::Landed { tree, .. }, Some((objects, bases))) = (&outcome, made) { batch.main = *tree; for o in objects.objects.into_values() { batch.objects.put(o); } batch.bases.extend(bases); } batch.outcomes.push(outcome); } Err(Error::Missing(id)) => { batch.need = Some(id); break; } Err(e) => return Err(e), } } Ok(batch) } type Made = Option<(MemSource, Vec<(Id, Id)>)>; fn one(src: &dyn Source, batch: &Batch, e: &Edits, most: usize) -> Result<(Outcome, Made)> { let src = Layered { first: &batch.objects, second: src }; let main = batch.main; let paths: Vec<&str> = e.put.iter().map(|p| p.path.as_str()).chain(e.delete.iter().map(String::as_str)).collect(); if let Some(bad) = paths.iter().find(|p| !tree::valid_path(p)) { return Ok((Outcome::Refused { code: "bad_usage", text: format!("{bad:?} isn't a path") }, None)); } if e.base.is_none() { for p in &paths { if tree::lookup(&src, &main, p)?.is_some() { return Ok((Outcome::Existing { path: p.to_string() }, None)); } } } let base = e.base.unwrap_or(main); let mut made = MemSource::default(); let mut bases = Vec::new(); // Straight onto main when main hasn't touched these paths since the base. let direct = base == main || untouched(&src, &base, &main, e)?; let edit_on = if direct { main } else { base }; let Ok((edited, objects, hints)) = tree::edit_based(&src, &edit_on, &e.put, &e.delete)? else { unreachable!("paths were checked") }; for o in objects { made.put(o); } bases.extend(hints); if edited == edit_on { return Ok((Outcome::Refused { code: "empty_change", text: "these edits change nothing".into() }, None)); } let with_made = Layered { first: &made, second: &src }; if !e.allow_secrets && let Some(hit) = secrets::scan(&with_made, &edit_on, &edited)?.into_iter().next() { return Ok((Outcome::Refused { code: "secret", text: format!("{} line {} looks like a {}; remove it, or send allow_secrets", hit.path, hit.line, hit.kind) }, None)); } let result = if direct { edited } else { let m = merge::trees(&with_made, Some(&base), &main, &edited, Mode::Land, ("main", "change"))?; if !m.conflicts.is_empty() { return Ok((Outcome::Conflict { conflicts: m.conflicts }, None)); } for o in m.objects.objects.into_values() { made.put(o); } bases.extend(m.bases); if m.tree == main { return Ok((Outcome::Refused { code: "empty_change", text: "main already has everything in this change".into() }, None)); } m.tree }; let with_made = Layered { first: &made, second: &src }; let touched = match history::touched(&with_made, &[(Some(main), result)], most)? { history::Touched::All(mut found) => found.pop().flatten(), history::Touched::Need(need) => return Err(Error::Missing(need[0])), }; Ok((Outcome::Landed { tree: result, touched }, Some((made, bases)))) } /// Whether edits made on `base` land on `main` exactly as the three-way /// merge would land them, rewriting only main's folders on their paths: /// main has each path as base had it, and each folder above one either as /// base had it or still a folder (another file in it changed). Above a /// deleted path, folders must be as base had them, since a folder the /// delete empties goes too. fn untouched(src: &dyn Source, base: &Id, main: &Id, e: &Edits) -> Result { let puts = e.put.iter().map(|p| (p.path.as_str(), false)); let deletes = e.delete.iter().map(|d| (d.as_str(), true)); for (path, deleted) in puts.chain(deletes) { if tree::lookup(src, base, path)? != tree::lookup(src, main, path)? { return Ok(false); } for (i, _) in path.match_indices('/') { let folder = &path[..i]; let (b, m) = (tree::lookup(src, base, folder)?, tree::lookup(src, main, folder)?); let both_folders = b.is_some_and(|b| b.kind == EntryKind::Tree) && m.is_some_and(|m| m.kind == EntryKind::Tree); if b != m && (deleted || !both_folders) { return Ok(false); } } } Ok(true) } #[cfg(test)] mod tests { use super::*; use crate::object::Kind; fn put(path: &str, content: &str) -> Put { Put { path: path.into(), kind: EntryKind::File, content: content.as_bytes().to_vec() } } fn apply(src: &mut MemSource, tree: &Id, puts: Vec, delete: Vec) -> Id { let (id, objects) = tree::edit(src, tree, &puts, &delete).unwrap().unwrap(); for o in objects { src.put(o); } id } /// Landing a batch gives each landing exactly what landing it alone /// (edit on its base, then the three-way merge onto main) gives. #[test] fn a_batch_lands_as_one_at_a_time() { struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { self.0 ^= self.0 >> 12; self.0 ^= self.0 << 25; self.0 ^= self.0 >> 27; self.0.wrapping_mul(0x2545f4914f6cdd1d) } fn below(&mut self, n: usize) -> usize { (self.next() % n as u64) as usize } } let names = ["a", "b", "c.txt", "d"]; let contents = ["one\n", "two\n", "one\ntwo\nthree\n", "one\nTWO\nthree\n", "x\n"]; let mut rng = Rng(0x5eed_1234_abcd_0001); let mut src = MemSource::default(); src.put(tree::empty()); let mut direct_seen = 0; for case in 0..400 { // History: a few versions of main, each a random edit. let mut versions = vec![tree::empty_id()]; for _ in 0..6 { let mut puts = Vec::new(); for _ in 0..1 + rng.below(4) { let depth = 1 + rng.below(3); let p: Vec<&str> = (0..depth).map(|_| names[rng.below(names.len())]).collect(); puts.push(put(&p.join("/"), contents[rng.below(contents.len())])); } let last = *versions.last().unwrap(); let next = apply(&mut src, &last, puts, Vec::new()); versions.push(next); } // Landings made on random versions, with puts and deletes. let mut items = Vec::new(); for _ in 0..1 + rng.below(5) { let mut puts = Vec::new(); let mut deletes = Vec::new(); for _ in 0..1 + rng.below(3) { let depth = 1 + rng.below(3); let p = (0..depth).map(|_| names[rng.below(names.len())]).collect::>().join("/"); if rng.below(4) == 0 { deletes.push(p); } else { puts.push(put(&p, contents[rng.below(contents.len())])); } } let base = versions[1 + rng.below(versions.len() - 1)]; items.push(Edits { base: Some(base), put: puts, delete: deletes, allow_secrets: false }); } let main = *versions.last().unwrap(); let batch = edits(&src, &main, &items, 1000).unwrap(); assert!(batch.need.is_none()); let mut all = src.clone(); for o in batch.objects.objects.values() { all.put(o.clone()); } // One at a time, the old way. let mut cur = main; for (i, (item, got)) in items.iter().zip(&batch.outcomes).enumerate() { let base = item.base.unwrap(); let (edited, objects) = tree::edit(&all, &base, &item.put, &item.delete).unwrap().unwrap(); for o in objects { all.put(o); } if edited == base { assert!(matches!(got, Outcome::Refused { code: "empty_change", .. }), "case {case} item {i}: {got:?}"); continue; } let m = merge::trees(&all, Some(&base), &cur, &edited, Mode::Land, ("main", "change")).unwrap(); for o in m.objects.objects.values() { all.put(o.clone()); } match got { Outcome::Landed { tree, .. } => { assert!(m.conflicts.is_empty(), "case {case} item {i}: landed where the merge conflicts"); assert_eq!(*tree, m.tree, "case {case} item {i}"); assert_eq!(all.get(tree).unwrap().map(|o| o.kind), Some(Kind::Tree)); if cur != base && untouched(&all, &base, &cur, item).unwrap() { direct_seen += 1; } cur = *tree; } Outcome::Conflict { conflicts } => assert_eq!(*conflicts, m.conflicts, "case {case} item {i}"), Outcome::Refused { code: "empty_change", .. } => assert_eq!(m.tree, cur, "case {case} item {i}"), other => panic!("case {case} item {i}: {other:?}"), } } assert_eq!(batch.main, cur, "case {case}"); } // Many went straight on. assert!(direct_seen > 100, "{direct_seen}"); } #[test] fn edits_on_main_as_it_is_need_a_base_for_files_that_are_there() { let mut src = MemSource::default(); src.put(tree::empty()); let main = apply(&mut src, &tree::empty_id(), vec![put("a.txt", "a\n")], Vec::new()); let items = vec![ Edits { base: None, put: vec![put("a.txt", "b\n")], delete: Vec::new(), allow_secrets: false }, Edits { base: None, put: vec![put("new.txt", "n\n")], delete: Vec::new(), allow_secrets: false }, Edits { base: None, put: vec![put("../x", "x\n")], delete: Vec::new(), allow_secrets: false }, Edits { base: None, put: vec![put("key.txt", &format!("k = \"sk_live_{}\"\n", "a".repeat(24)))], delete: Vec::new(), allow_secrets: false }, ]; let b = edits(&src, &main, &items, 1000).unwrap(); assert_eq!(b.outcomes[0], Outcome::Existing { path: "a.txt".into() }); assert!(matches!(&b.outcomes[1], Outcome::Landed { touched: Some(t), .. } if t == &vec!["new.txt".to_string()])); assert!(matches!(b.outcomes[2], Outcome::Refused { code: "bad_usage", .. })); assert!(matches!(b.outcomes[3], Outcome::Refused { code: "secret", .. })); } #[test] fn a_missing_object_stops_the_batch_where_it_is() { let mut src = MemSource::default(); src.put(tree::empty()); let main = apply(&mut src, &tree::empty_id(), vec![put("a/b.txt", "a\n")], Vec::new()); let gone = Id::of(Kind::Tree as u8, b"not here"); let items = vec![ Edits { base: None, put: vec![put("a/c.txt", "c\n")], delete: Vec::new(), allow_secrets: false }, Edits { base: Some(gone), put: vec![put("a/d.txt", "d\n")], delete: Vec::new(), allow_secrets: false }, ]; let b = edits(&src, &main, &items, 1000).unwrap(); assert_eq!(b.outcomes.len(), 1); assert_eq!(b.need, Some(gone)); assert!(matches!(b.outcomes[0], Outcome::Landed { tree, .. } if tree == b.main)); } }