Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | //! folder rewrite per folder on their paths, with no merge. Anything else |
| 7 | //! merges three ways, as a single land would. |
| 8 |
| 9 | use crate::merge::{self, Conflict, Mode}; |
| 10 | use crate::tree::{self, EntryKind, Put}; |
| 11 | use crate::{Error, Id, Layered, MemSource, Result, Source, history, secrets}; |
| 12 |
| 13 | /// One landing: files to write and delete, made on `base` (None: on main |
| 14 | /// as it is, where editing a file that's there needs a base). |
| 15 | pub struct Edits { |
| 16 | pub base: Option<Id>, |
| 17 | pub put: Vec<Put>, |
| 18 | pub delete: Vec<String>, |
| 19 | pub allow_secrets: bool, |
| 20 | } |
| 21 |
| 22 | /// How one landing went. |
| 23 | #[derive(Debug, Clone, PartialEq)] |
| 24 | pub enum Outcome { |
| 25 | /// Main's new tree, and the paths it changed (None: too many to list). |
| 26 | Landed { tree: Id, touched: Option<Vec<String>> }, |
| 27 | /// Refused: `code` and the reason (bad_usage, empty_change, secret). |
| 28 | Refused { code: &'static str, text: String }, |
| 29 | /// Edits on main as it is to a path that's there (they need a base). |
| 30 | Existing { path: String }, |
| 31 | Conflict { conflicts: Vec<Conflict> }, |
| 32 | } |
| 33 |
| 34 | /// What a batch did, as far as it got. |
| 35 | pub struct Batch { |
| 36 | /// One per landing done (a prefix of the batch). |
| 37 | pub outcomes: Vec<Outcome>, |
| 38 | /// Main after them. |
| 39 | pub main: Id, |
| 40 | /// Objects the landings that landed made. |
| 41 | pub objects: MemSource, |
| 42 | /// (new object, the one it replaced), for a host keeping deltas. |
| 43 | pub bases: Vec<(Id, Id)>, |
| 44 | /// An object the next landing needs that isn't loaded: the batch |
| 45 | /// stopped there, and goes on from `main` once it's loaded. |
| 46 | pub need: Option<Id>, |
| 47 | } |
| 48 |
| 49 | /// Lands `items` one after another on `main`. |
| 50 | pub fn edits(src: &dyn Source, main: &Id, items: &[Edits], most_touched: usize) -> Result<Batch> { |
| 51 | let mut batch = Batch { outcomes: Vec::new(), main: *main, objects: MemSource::default(), bases: Vec::new(), need: None }; |
| 52 | for item in items { |
| 53 | match one(src, &batch, item, most_touched) { |
| 54 | Ok((outcome, made)) => { |
| 55 | if let (Outcome::Landed { tree, .. }, Some((objects, bases))) = (&outcome, made) { |
| 56 | batch.main = *tree; |
| 57 | for o in objects.objects.into_values() { |
| 58 | batch.objects.put(o); |
| 59 | } |
| 60 | batch.bases.extend(bases); |
| 61 | } |
| 62 | batch.outcomes.push(outcome); |
| 63 | } |
| 64 | Err(Error::Missing(id)) => { |
| 65 | batch.need = Some(id); |
| 66 | break; |
| 67 | } |
| 68 | Err(e) => return Err(e), |
| 69 | } |
| 70 | } |
| 71 | Ok(batch) |
| 72 | } |
| 73 |
| 74 | type Made = Option<(MemSource, Vec<(Id, Id)>)>; |
| 75 |
| 76 | fn one(src: &dyn Source, batch: &Batch, e: &Edits, most: usize) -> Result<(Outcome, Made)> { |
| 77 | let src = Layered { first: &batch.objects, second: src }; |
| 78 | let main = batch.main; |
| 79 | let paths: Vec<&str> = e.put.iter().map(|p| p.path.as_str()).chain(e.delete.iter().map(String::as_str)).collect(); |
| 80 | if let Some(bad) = paths.iter().find(|p| !tree::valid_path(p)) { |
| 81 | return Ok((Outcome::Refused { code: "bad_usage", text: format!("{bad:?} isn't a path") }, None)); |
| 82 | } |
| 83 | if e.base.is_none() { |
| 84 | for p in &paths { |
| 85 | if tree::lookup(&src, &main, p)?.is_some() { |
| 86 | return Ok((Outcome::Existing { path: p.to_string() }, None)); |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | let base = e.base.unwrap_or(main); |
| 91 | let mut made = MemSource::default(); |
| 92 | let mut bases = Vec::new(); |
| 93 | // Straight onto main when main hasn't touched these paths since the base. |
| 94 | let direct = base == main || untouched(&src, &base, &main, e)?; |
| 95 | let edit_on = if direct { main } else { base }; |
| 96 | let Ok((edited, objects, hints)) = tree::edit_based(&src, &edit_on, &e.put, &e.delete)? else { |
| 97 | unreachable!("paths were checked") |
| 98 | }; |
| 99 | for o in objects { |
| 100 | made.put(o); |
| 101 | } |
| 102 | bases.extend(hints); |
| 103 | if edited == edit_on { |
| 104 | return Ok((Outcome::Refused { code: "empty_change", text: "these edits change nothing".into() }, None)); |
| 105 | } |
| 106 | let with_made = Layered { first: &made, second: &src }; |
| 107 | if !e.allow_secrets |
| 108 | && let Some(hit) = secrets::scan(&with_made, &edit_on, &edited)?.into_iter().next() |
| 109 | { |
| 110 | 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)); |
| 111 | } |
| 112 | let result = if direct { |
| 113 | edited |
| 114 | } else { |
| 115 | let m = merge::trees(&with_made, Some(&base), &main, &edited, Mode::Land, ("main", "change"))?; |
| 116 | if !m.conflicts.is_empty() { |
| 117 | return Ok((Outcome::Conflict { conflicts: m.conflicts }, None)); |
| 118 | } |
| 119 | for o in m.objects.objects.into_values() { |
| 120 | made.put(o); |
| 121 | } |
| 122 | bases.extend(m.bases); |
| 123 | if m.tree == main { |
| 124 | return Ok((Outcome::Refused { code: "empty_change", text: "main already has everything in this change".into() }, None)); |
| 125 | } |
| 126 | m.tree |
| 127 | }; |
| 128 | let with_made = Layered { first: &made, second: &src }; |
| 129 | let touched = match history::touched(&with_made, &[(Some(main), result)], most)? { |
| 130 | history::Touched::All(mut found) => found.pop().flatten(), |
| 131 | history::Touched::Need(need) => return Err(Error::Missing(need[0])), |
| 132 | }; |
| 133 | Ok((Outcome::Landed { tree: result, touched }, Some((made, bases)))) |
| 134 | } |
| 135 |
| 136 | /// Whether edits made on `base` land on `main` exactly as the three-way |
| 137 | /// merge would land them, rewriting only main's folders on their paths: |
| 138 | /// main has each path as base had it, and each folder above one either as |
| 139 | /// base had it or still a folder (another file in it changed). Above a |
| 140 | /// deleted path, folders must be as base had them, since a folder the |
| 141 | /// delete empties goes too. |
| 142 | fn untouched(src: &dyn Source, base: &Id, main: &Id, e: &Edits) -> Result<bool> { |
| 143 | let puts = e.put.iter().map(|p| (p.path.as_str(), false)); |
| 144 | let deletes = e.delete.iter().map(|d| (d.as_str(), true)); |
| 145 | for (path, deleted) in puts.chain(deletes) { |
| 146 | if tree::lookup(src, base, path)? != tree::lookup(src, main, path)? { |
| 147 | return Ok(false); |
| 148 | } |
| 149 | for (i, _) in path.match_indices('/') { |
| 150 | let folder = &path[..i]; |
| 151 | let (b, m) = (tree::lookup(src, base, folder)?, tree::lookup(src, main, folder)?); |
| 152 | let both_folders = b.is_some_and(|b| b.kind == EntryKind::Tree) && m.is_some_and(|m| m.kind == EntryKind::Tree); |
| 153 | if b != m && (deleted || !both_folders) { |
| 154 | return Ok(false); |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | Ok(true) |
| 159 | } |
| 160 |
| 161 | #[cfg(test)] |
| 162 | mod tests { |
| 163 | use super::*; |
| 164 | use crate::object::Kind; |
| 165 |
| 166 | fn put(path: &str, content: &str) -> Put { |
| 167 | Put { path: path.into(), kind: EntryKind::File, content: content.as_bytes().to_vec() } |
| 168 | } |
| 169 |
| 170 | fn apply(src: &mut MemSource, tree: &Id, puts: Vec<Put>, delete: Vec<String>) -> Id { |
| 171 | let (id, objects) = tree::edit(src, tree, &puts, &delete).unwrap().unwrap(); |
| 172 | for o in objects { |
| 173 | src.put(o); |
| 174 | } |
| 175 | id |
| 176 | } |
| 177 |
| 178 | /// Landing a batch gives each landing exactly what landing it alone |
| 179 | /// (edit on its base, then the three-way merge onto main) gives. |
| 180 | #[test] |
| 181 | fn a_batch_lands_as_one_at_a_time() { |
| 182 | struct Rng(u64); |
| 183 | impl Rng { |
| 184 | fn next(&mut self) -> u64 { |
| 185 | self.0 ^= self.0 >> 12; |
| 186 | self.0 ^= self.0 << 25; |
| 187 | self.0 ^= self.0 >> 27; |
| 188 | self.0.wrapping_mul(0x2545f4914f6cdd1d) |
| 189 | } |
| 190 | fn below(&mut self, n: usize) -> usize { |
| 191 | (self.next() % n as u64) as usize |
| 192 | } |
| 193 | } |
| 194 | let names = ["a", "b", "c.txt", "d"]; |
| 195 | let contents = ["one\n", "two\n", "one\ntwo\nthree\n", "one\nTWO\nthree\n", "x\n"]; |
| 196 | let mut rng = Rng(0x5eed_1234_abcd_0001); |
| 197 | let mut src = MemSource::default(); |
| 198 | src.put(tree::empty()); |
| 199 | let mut direct_seen = 0; |
| 200 | for case in 0..400 { |
| 201 | // History: a few versions of main, each a random edit. |
| 202 | let mut versions = vec![tree::empty_id()]; |
| 203 | for _ in 0..6 { |
| 204 | let mut puts = Vec::new(); |
| 205 | for _ in 0..1 + rng.below(4) { |
| 206 | let depth = 1 + rng.below(3); |
| 207 | let p: Vec<&str> = (0..depth).map(|_| names[rng.below(names.len())]).collect(); |
| 208 | puts.push(put(&p.join("/"), contents[rng.below(contents.len())])); |
| 209 | } |
| 210 | let last = *versions.last().unwrap(); |
| 211 | let next = apply(&mut src, &last, puts, Vec::new()); |
| 212 | versions.push(next); |
| 213 | } |
| 214 | // Landings made on random versions, with puts and deletes. |
| 215 | let mut items = Vec::new(); |
| 216 | for _ in 0..1 + rng.below(5) { |
| 217 | let mut puts = Vec::new(); |
| 218 | let mut deletes = Vec::new(); |
| 219 | for _ in 0..1 + rng.below(3) { |
| 220 | let depth = 1 + rng.below(3); |
| 221 | let p = (0..depth).map(|_| names[rng.below(names.len())]).collect::<Vec<_>>().join("/"); |
| 222 | if rng.below(4) == 0 { |
| 223 | deletes.push(p); |
| 224 | } else { |
| 225 | puts.push(put(&p, contents[rng.below(contents.len())])); |
| 226 | } |
| 227 | } |
| 228 | let base = versions[1 + rng.below(versions.len() - 1)]; |
| 229 | items.push(Edits { base: Some(base), put: puts, delete: deletes, allow_secrets: false }); |
| 230 | } |
| 231 | let main = *versions.last().unwrap(); |
| 232 | let batch = edits(&src, &main, &items, 1000).unwrap(); |
| 233 | assert!(batch.need.is_none()); |
| 234 | let mut all = src.clone(); |
| 235 | for o in batch.objects.objects.values() { |
| 236 | all.put(o.clone()); |
| 237 | } |
| 238 | // One at a time, the old way. |
| 239 | let mut cur = main; |
| 240 | for (i, (item, got)) in items.iter().zip(&batch.outcomes).enumerate() { |
| 241 | let base = item.base.unwrap(); |
| 242 | let (edited, objects) = tree::edit(&all, &base, &item.put, &item.delete).unwrap().unwrap(); |
| 243 | for o in objects { |
| 244 | all.put(o); |
| 245 | } |
| 246 | if edited == base { |
| 247 | assert!(matches!(got, Outcome::Refused { code: "empty_change", .. }), "case {case} item {i}: {got:?}"); |
| 248 | continue; |
| 249 | } |
| 250 | let m = merge::trees(&all, Some(&base), &cur, &edited, Mode::Land, ("main", "change")).unwrap(); |
| 251 | for o in m.objects.objects.values() { |
| 252 | all.put(o.clone()); |
| 253 | } |
| 254 | match got { |
| 255 | Outcome::Landed { tree, .. } => { |
| 256 | assert!(m.conflicts.is_empty(), "case {case} item {i}: landed where the merge conflicts"); |
| 257 | assert_eq!(*tree, m.tree, "case {case} item {i}"); |
| 258 | assert_eq!(all.get(tree).unwrap().map(|o| o.kind), Some(Kind::Tree)); |
| 259 | if cur != base && untouched(&all, &base, &cur, item).unwrap() { |
| 260 | direct_seen += 1; |
| 261 | } |
| 262 | cur = *tree; |
| 263 | } |
| 264 | Outcome::Conflict { conflicts } => assert_eq!(*conflicts, m.conflicts, "case {case} item {i}"), |
| 265 | Outcome::Refused { code: "empty_change", .. } => assert_eq!(m.tree, cur, "case {case} item {i}"), |
| 266 | other => panic!("case {case} item {i}: {other:?}"), |
| 267 | } |
| 268 | } |
| 269 | assert_eq!(batch.main, cur, "case {case}"); |
| 270 | } |
| 271 | // Many went straight on. |
| 272 | assert!(direct_seen > 100, "{direct_seen}"); |
| 273 | } |
| 274 |
| 275 | #[test] |
| 276 | fn edits_on_main_as_it_is_need_a_base_for_files_that_are_there() { |
| 277 | let mut src = MemSource::default(); |
| 278 | src.put(tree::empty()); |
| 279 | let main = apply(&mut src, &tree::empty_id(), vec![put("a.txt", "a\n")], Vec::new()); |
| 280 | let items = vec![ |
| 281 | Edits { base: None, put: vec![put("a.txt", "b\n")], delete: Vec::new(), allow_secrets: false }, |
| 282 | Edits { base: None, put: vec![put("new.txt", "n\n")], delete: Vec::new(), allow_secrets: false }, |
| 283 | Edits { base: None, put: vec![put("../x", "x\n")], delete: Vec::new(), allow_secrets: false }, |
| 284 | Edits { base: None, put: vec![put("key.txt", &format!("k = \"sk_live_{}\"\n", "a".repeat(24)))], delete: Vec::new(), allow_secrets: false }, |
| 285 | ]; |
| 286 | let b = edits(&src, &main, &items, 1000).unwrap(); |
| 287 | assert_eq!(b.outcomes[0], Outcome::Existing { path: "a.txt".into() }); |
| 288 | assert!(matches!(&b.outcomes[1], Outcome::Landed { touched: Some(t), .. } if t == &vec!["new.txt".to_string()])); |
| 289 | assert!(matches!(b.outcomes[2], Outcome::Refused { code: "bad_usage", .. })); |
| 290 | assert!(matches!(b.outcomes[3], Outcome::Refused { code: "secret", .. })); |
| 291 | } |
| 292 |
| 293 | #[test] |
| 294 | fn a_missing_object_stops_the_batch_where_it_is() { |
| 295 | let mut src = MemSource::default(); |
| 296 | src.put(tree::empty()); |
| 297 | let main = apply(&mut src, &tree::empty_id(), vec![put("a/b.txt", "a\n")], Vec::new()); |
| 298 | let gone = Id::of(Kind::Tree as u8, b"not here"); |
| 299 | let items = vec![ |
| 300 | Edits { base: None, put: vec![put("a/c.txt", "c\n")], delete: Vec::new(), allow_secrets: false }, |
| 301 | Edits { base: Some(gone), put: vec![put("a/d.txt", "d\n")], delete: Vec::new(), allow_secrets: false }, |
| 302 | ]; |
| 303 | let b = edits(&src, &main, &items, 1000).unwrap(); |
| 304 | assert_eq!(b.outcomes.len(), 1); |
| 305 | assert_eq!(b.need, Some(gone)); |
| 306 | assert!(matches!(b.outcomes[0], Outcome::Landed { tree, .. } if tree == b.main)); |
| 307 | } |
| 308 | } |
| 309 |