@5 No merging: a change lands only on the point it was built on
Files
mVISION.md+2 −2mdocs/model.md+14 −17msrc/cli.rs+53 −14msrc/lib.rs+0 −1dsrc/merge.rs+0 −278msrc/store.rs+21 −34
VISION.mdmodified+2 −2
| 30 | 30 | - **Discussion.** Anyone can take part: the owner, the proposer, other people, and agents |
| 31 | 31 | for any of them. The owner can comment, ask for something different or redirect, and |
| 32 | 32 | the building agent answers by revising the stack. |
| 33 | - **State.** Idea (intent only), draft, proposed, accepted, landed, or declined. | |
| 33 | - **State.** Idea (intent only), draft, proposed, landed, declined, or withdrawn. | |
| 34 | 34 | |
| 35 | 35 | A project's history is the sequence of landed changes, each with its intent and evidence. |
| 36 | 36 | That is what people read. The code is there when someone wants it. |
| 60 | 60 | |
| 61 | 61 | 1. The store (objects and log) with its tests, and a CLI working directly on it. |
| 62 | 62 | 2. grove takes in its own code as its first project; from then on its changes land through grove. |
| 63 | 3. Three-way merge for landing on a moved main; diffs. | |
| 63 | 3. Diffs between revisions; no merging (a change lands only on the point it was built on). | |
| 64 | 64 | 4. The HTTP API and MCP server, tokens for agents; grove runs on grove.host. |
| 65 | 65 | 5. Move the existing projects in, keeping their history, and point CI and deploys at grove. |
| 66 | 66 | 6. The web, built around changes and results. |
docs/model.mdmodified+14 −17
| 92 | 92 | ### States |
| 93 | 93 | |
| 94 | 94 | ``` |
| 95 | idea ──> draft ──> proposed ──> accepted ──> landed | |
| 96 | │ │ │ │ | |
| 95 | idea ──> draft ──> proposed ──> landed | |
| 96 | │ │ │ | |
| 97 | 97 | └────────┴───────────┴──> declined / withdrawn |
| 98 | 98 | ``` |
| 99 | 99 | |
| 100 | 100 | - **idea:** intent and discussion only, no revisions. |
| 101 | 101 | - **draft:** has revisions; the author is still working. Not asking for a decision. |
| 102 | 102 | - **proposed:** the author asks the owner to decide on the latest revision. |
| 103 | - **accepted:** the owner approved a specific revision. grove lands it straight away; this | |
| 104 | state is visible only while landing runs, or if landing failed (see Landing). | |
| 105 | - **landed:** its steps are on main. | |
| 103 | - **landed:** the owner landed it; its steps are on main. | |
| 106 | 104 | - **declined:** the owner said no. **withdrawn:** the proposer gave up. |
| 107 | 105 | |
| 108 | Declined and withdrawn changes can be reopened as draft. Nothing is ever deleted. | |
| 106 | Declined and withdrawn changes can be reopened. Nothing is ever deleted. | |
| 109 | 107 | |
| 110 | 108 | ### Revisions and steps |
| 111 | 109 | |
| 159 | 157 | |
| 160 | 158 | ## Landing |
| 161 | 159 | |
| 162 | When the owner accepts revision R of change C: | |
| 160 | grove never merges. A change lands only on the point it was built on. | |
| 163 | 161 | |
| 164 | 1. If `R.base` is the current tip of main, each step becomes the next point, in order. | |
| 165 | 2. Otherwise grove rebuilds the steps on the current tip with a three-way merge of each file | |
| 166 | (base snapshot, step snapshot, current tip). | |
| 167 | 3. If every file merges cleanly, the rebuilt steps land as above, and the change records | |
| 168 | which revision landed and what it was rebased onto. | |
| 169 | 4. If anything conflicts, nothing lands. The change returns to **proposed** with an event | |
| 170 | listing the conflicting files, and the agent makes a new revision on the current tip. | |
| 162 | 1. When the owner lands revision R of change C, and `R.base` is the tip of main, each step | |
| 163 | becomes the next point, in order, in one record: all of the change lands or none of it. | |
| 164 | 2. If main has moved since R was built, landing is refused. The building agent moves its | |
| 165 | working directory to the tip (`restart`), redoes the work there, and proposes a new | |
| 166 | revision. Its earlier revisions stay readable (`diff #C r1`) to redo from. | |
| 171 | 167 | |
| 172 | Landing is one database transaction: either all of a change's steps land or none do. Points | |
| 173 | are only ever appended. | |
| 168 | Agents rewrite files; they don't need a merge algorithm to do it, and nothing in grove's | |
| 169 | history is ever a mix nobody wrote. | |
| 174 | 170 | |
| 175 | 171 | ## Agents' workflow |
| 176 | 172 | |
| 184 | 180 | sending only blobs the server doesn't have. |
| 185 | 181 | 4. `attach` adds evidence. `say` posts to the discussion. `watch` streams the discussion so |
| 186 | 182 | the agent can respond to steering. |
| 187 | 5. `sync` brings the working directory up to the new tip and rebuilds local steps on it. | |
| 183 | 5. `sync` brings the working directory up to the new tip. With local steps on an older point, | |
| 184 | `restart` moves to the tip and the agent redoes the work as a new revision. | |
| 188 | 185 | |
| 189 | 186 | The MCP server exposes the same operations to agents that speak MCP. |
| 190 | 187 |
src/cli.rsmodified+53 −14
| 246 | 246 | "decline" => decline(&args), |
| 247 | 247 | "log" => log(), |
| 248 | 248 | "sync" => sync(), |
| 249 | "restart" => restart(), | |
| 249 | 250 | "verify" => verify(), |
| 250 | 251 | "group" => group(&args), |
| 251 | 252 | "move" => move_project(&args), |
| 263 | 264 | init <name> [--owner GROUP] [--private] [-m M] make this folder a new project |
| 264 | 265 | checkout <owner/name> [DIR] get a project's files |
| 265 | 266 | status what changed since the last step |
| 266 | diff [--stat] those changes, line by line | |
| 267 | diff [--stat] [#N [rM]] working changes, or a change's revision, line by line | |
| 267 | 268 | step -m MESSAGE record the working directory as a new step |
| 268 | 269 | amend [-m MESSAGE] replace the last step with the working directory |
| 269 | 270 | stack the local steps |
| 276 | 277 | decline #N decline a change (you, in person) |
| 277 | 278 | log main, newest first |
| 278 | 279 | sync move the working directory to the newest main |
| 280 | restart move to the newest main, dropping proposed local steps | |
| 279 | 281 | verify check every stored object against its id |
| 280 | 282 | group create NAME | group add NAME PERSON groups own projects together (in person) |
| 281 | 283 | move OWNER give this project to another owner (in person) |
| 425 | 427 | let cfg = Config::load()?; |
| 426 | 428 | let local = Local::find()?; |
| 427 | 429 | let store = open_store(&cfg)?; |
| 430 | if let Some(n) = args.pos(0).filter(|a| a.starts_with('#')) { | |
| 431 | // A revision of a change: from its base to its last step. | |
| 432 | let number = change_number(Some(n), None)?; | |
| 433 | let p = s(store.project(&local.owner, &local.project))?; | |
| 434 | let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?; | |
| 435 | let r = match args.pos(1) { | |
| 436 | Some(rv) => { | |
| 437 | let k: usize = rv.trim_start_matches('r').parse().map_err(|_| format!("{rv:?} isn't a revision"))?; | |
| 438 | c.revisions.get(k.wrapping_sub(1)).ok_or_else(|| format!("#{number} has no {rv}"))? | |
| 439 | } | |
| 440 | None => c.latest().ok_or_else(|| format!("#{number} has no revisions"))?, | |
| 441 | }; | |
| 442 | let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?; | |
| 443 | println!("#{number} r{} on @{}", r.number, r.base); | |
| 444 | return print_diff(&store, &base, &r.steps.last().unwrap().snapshot, args.has("--stat")); | |
| 445 | } | |
| 428 | 446 | let (current, now, _) = working_changes(&store, &local)?; |
| 429 | 447 | print_diff(&store, ¤t, &now, args.has("--stat")) |
| 430 | 448 | } |
| 686 | 704 | println!("already at @{}", local.base); |
| 687 | 705 | return Ok(()); |
| 688 | 706 | } |
| 707 | if !local.steps.is_empty() { | |
| 708 | let change = local.change.map(|c| format!("#{c}")).unwrap_or_else(|| "your steps".into()); | |
| 709 | return e!( | |
| 710 | "main moved to @{} while your steps sit on @{}. grove doesn't merge: run `grove restart` to move to @{} and redo the work there (`grove diff {change}` shows what it was), then propose again", | |
| 711 | p.tip(), local.base, p.tip() | |
| 712 | ); | |
| 713 | } | |
| 689 | 714 | let (current, _, pending) = working_changes(&store, &local)?; |
| 690 | 715 | if !pending.is_empty() { |
| 691 | 716 | return e!("the working directory has changes; record them as a step first"); |
| 692 | 717 | } |
| 693 | let from = p.point(local.base).map(|pt| pt.snapshot).ok_or("base missing")?; | |
| 694 | 718 | let tip = p.points.last().unwrap(); |
| 695 | // Replay the local steps onto the tip; the working directory follows the last one. | |
| 696 | let snapshots: Vec<Id> = local.steps.iter().map(|st| st.snapshot).collect(); | |
| 697 | let rebuilt = match crate::merge::rebuild(&store.objects, &from, &snapshots, &tip.snapshot).map_err(|e| e.to_string())? { | |
| 698 | Ok(r) => r, | |
| 699 | Err((i, paths)) => return e!("step {} ({}) conflicts with @{} in: {}", i + 1, local.steps[i].message, tip.n, paths.join(", ")), | |
| 700 | }; | |
| 701 | let target = rebuilt.last().copied().unwrap_or(tip.snapshot); | |
| 702 | workdir::materialize(&store.objects, &target, &local.root, Some(¤t)).map_err(|e| e.to_string())?; | |
| 703 | for (st, snap) in local.steps.iter_mut().zip(rebuilt) { | |
| 704 | st.snapshot = snap; | |
| 719 | workdir::materialize(&store.objects, &tip.snapshot, &local.root, Some(¤t)).map_err(|e| e.to_string())?; | |
| 720 | local.base = tip.n; | |
| 721 | local.save()?; | |
| 722 | println!("now at @{}", tip.n); | |
| 723 | Ok(()) | |
| 724 | } | |
| 725 | ||
| 726 | /// Move to the newest main, dropping the local steps (they stay in the store as revisions of | |
| 727 | /// the change). The change stays linked, so the next `propose` adds a revision built on the tip. | |
| 728 | fn restart() -> Result<()> { | |
| 729 | let cfg = Config::load()?; | |
| 730 | let mut local = Local::find()?; | |
| 731 | let store = open_store(&cfg)?; | |
| 732 | let p = s(store.project(&local.owner, &local.project))?; | |
| 733 | let (current, _, pending) = working_changes(&store, &local)?; | |
| 734 | if !pending.is_empty() { | |
| 735 | return e!("the working directory has changes; record them as a step first, so they aren't lost"); | |
| 705 | 736 | } |
| 737 | if !local.steps.is_empty() { | |
| 738 | let sent = local.change.and_then(|c| p.change(c)).and_then(Change::latest).and_then(|r| r.steps.last()).map(|s| s.snapshot); | |
| 739 | if local.steps.last().map(|s| s.snapshot) != sent { | |
| 740 | return e!("the local steps haven't been proposed as they are; propose them first so they're kept"); | |
| 741 | } | |
| 742 | } | |
| 743 | let tip = p.points.last().ok_or("no points")?; | |
| 744 | workdir::materialize(&store.objects, &tip.snapshot, &local.root, Some(¤t)).map_err(|e| e.to_string())?; | |
| 745 | local.steps.clear(); | |
| 706 | 746 | local.base = tip.n; |
| 707 | 747 | local.save()?; |
| 708 | let kept = local.steps.len(); | |
| 709 | println!("now at @{}{}", tip.n, if kept > 0 { format!(", {kept} local step{} rebuilt on it", if kept == 1 { "" } else { "s" }) } else { String::new() }); | |
| 748 | println!("now at @{}{}", tip.n, local.change.map(|c| format!("; redo the work and `grove propose` adds a revision to #{c}")).unwrap_or_default()); | |
| 710 | 749 | Ok(()) |
| 711 | 750 | } |
| 712 | 751 |
src/lib.rsmodified+0 −1
| 7 | 7 | pub mod ignore; |
| 8 | 8 | pub mod json; |
| 9 | 9 | pub mod log; |
| 10 | pub mod merge; | |
| 11 | 10 | pub mod model; |
| 12 | 11 | pub mod objects; |
| 13 | 12 | pub mod random; |
src/merge.rsdeleted+0 −278
| 1 | //! Three-way merges: of text (line by line) and of snapshots (file by file). | |
| 2 | //! | |
| 3 | //! Used to rebuild a change on a main that has moved since the change was made: each step's | |
| 4 | //! edits (from the step before it to the step) are replayed onto the newer snapshot. | |
| 5 | ||
| 6 | use crate::diff::{diff, lines, Op}; | |
| 7 | use crate::objects::{self, Entry, Id, Kind, Objects}; | |
| 8 | use std::collections::BTreeMap; | |
| 9 | ||
| 10 | /// Base line index → index of the same line in `other`, where the diff keeps it. | |
| 11 | fn alignment(base: &[&str], other: &[&str]) -> Vec<Option<usize>> { | |
| 12 | let mut map = vec![None; base.len()]; | |
| 13 | let (mut i, mut j) = (0, 0); | |
| 14 | for (op, _) in diff(base, other) { | |
| 15 | match op { | |
| 16 | Op::Same => { | |
| 17 | map[i] = Some(j); | |
| 18 | i += 1; | |
| 19 | j += 1; | |
| 20 | } | |
| 21 | Op::Del => i += 1, | |
| 22 | Op::Add => j += 1, | |
| 23 | } | |
| 24 | } | |
| 25 | map | |
| 26 | } | |
| 27 | ||
| 28 | /// Merge two edits of `base`. Ok(text), or Err(number of conflicting regions). | |
| 29 | pub fn merge_text(base: &str, ours: &str, theirs: &str) -> Result<String, usize> { | |
| 30 | if ours == theirs || theirs == base { | |
| 31 | return Ok(ours.to_string()); | |
| 32 | } | |
| 33 | if ours == base { | |
| 34 | return Ok(theirs.to_string()); | |
| 35 | } | |
| 36 | let (b, a, t) = (lines(base), lines(ours), lines(theirs)); | |
| 37 | let (ia, it) = (alignment(&b, &a), alignment(&b, &t)); | |
| 38 | let mut out: Vec<&str> = Vec::new(); | |
| 39 | let mut conflicts = 0; | |
| 40 | let (mut i, mut ja, mut jt) = (0usize, 0usize, 0usize); | |
| 41 | loop { | |
| 42 | // The next base line both sides kept: everything before it is one unstable chunk. | |
| 43 | let sync = (i..b.len()).find(|&k| matches!((ia[k], it[k]), (Some(x), Some(y)) if x >= ja && y >= jt)); | |
| 44 | let (k, ka, kt) = match sync { | |
| 45 | Some(k) => (k, ia[k].unwrap(), it[k].unwrap()), | |
| 46 | None => (b.len(), a.len(), t.len()), | |
| 47 | }; | |
| 48 | let (cb, ca, ct) = (&b[i..k], &a[ja..ka], &t[jt..kt]); | |
| 49 | if !(cb.is_empty() && ca.is_empty() && ct.is_empty()) { | |
| 50 | if ca == cb { | |
| 51 | out.extend_from_slice(ct); | |
| 52 | } else if ct == cb || ca == ct { | |
| 53 | out.extend_from_slice(ca); | |
| 54 | } else { | |
| 55 | conflicts += 1; | |
| 56 | } | |
| 57 | } | |
| 58 | if sync.is_none() { | |
| 59 | break; | |
| 60 | } | |
| 61 | out.push(b[k]); | |
| 62 | i = k + 1; | |
| 63 | ja = ka + 1; | |
| 64 | jt = kt + 1; | |
| 65 | } | |
| 66 | if conflicts > 0 { | |
| 67 | return Err(conflicts); | |
| 68 | } | |
| 69 | // The trailing newline merges like everything else. | |
| 70 | let nl = |s: &str| s.ends_with('\n'); | |
| 71 | let ends = if nl(ours) == nl(base) { nl(theirs) } else { nl(ours) }; | |
| 72 | let mut text = out.join("\n"); | |
| 73 | if ends && !out.is_empty() { | |
| 74 | text.push('\n'); | |
| 75 | } | |
| 76 | Ok(text) | |
| 77 | } | |
| 78 | ||
| 79 | type Files = BTreeMap<String, (Kind, Id)>; | |
| 80 | ||
| 81 | fn files(objects: &Objects, tree: &Id) -> objects::Result<Files> { | |
| 82 | Ok(objects.flatten(tree)?.into_iter().map(|(p, k, i)| (p, (k, i))).collect()) | |
| 83 | } | |
| 84 | ||
| 85 | /// One three-way choice: unchanged on one side means take the other. | |
| 86 | fn pick<T: PartialEq + Clone>(base: &T, ours: &T, theirs: &T) -> Option<T> { | |
| 87 | if ours == theirs || theirs == base { | |
| 88 | Some(ours.clone()) | |
| 89 | } else if ours == base { | |
| 90 | Some(theirs.clone()) | |
| 91 | } else { | |
| 92 | None | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | #[derive(Debug, PartialEq)] | |
| 97 | pub enum Outcome { | |
| 98 | Merged(Id), | |
| 99 | /// Paths that changed on both sides in ways that can't be combined. | |
| 100 | Conflicts(Vec<String>), | |
| 101 | } | |
| 102 | ||
| 103 | /// Merge the edits `base → ours` and `base → theirs` into one snapshot. | |
| 104 | pub fn merge_trees(objects: &Objects, base: &Id, ours: &Id, theirs: &Id) -> objects::Result<Outcome> { | |
| 105 | if ours == theirs || theirs == base { | |
| 106 | return Ok(Outcome::Merged(*ours)); | |
| 107 | } | |
| 108 | if ours == base { | |
| 109 | return Ok(Outcome::Merged(*theirs)); | |
| 110 | } | |
| 111 | let (fb, fo, ft) = (files(objects, base)?, files(objects, ours)?, files(objects, theirs)?); | |
| 112 | let mut paths: Vec<&String> = fb.keys().chain(fo.keys()).chain(ft.keys()).collect(); | |
| 113 | paths.sort(); | |
| 114 | paths.dedup(); | |
| 115 | ||
| 116 | let mut result: Files = BTreeMap::new(); | |
| 117 | let mut conflicts = Vec::new(); | |
| 118 | for path in paths { | |
| 119 | let (b, o, t) = (fb.get(path).copied(), fo.get(path).copied(), ft.get(path).copied()); | |
| 120 | if let Some(choice) = pick(&b, &o, &t) { | |
| 121 | if let Some(f) = choice { | |
| 122 | result.insert(path.clone(), f); | |
| 123 | } | |
| 124 | continue; | |
| 125 | } | |
| 126 | // Changed on both sides, differently. Combinable only if both still have a regular | |
| 127 | // file there and the contents merge as text. | |
| 128 | let merged = (|| -> objects::Result<Option<(Kind, Id)>> { | |
| 129 | let (Some((ko, io)), Some((kt, it))) = (o, t) else { return Ok(None) }; | |
| 130 | let regular = |k: Kind| matches!(k, Kind::File | Kind::Exec); | |
| 131 | if !regular(ko) || !regular(kt) { | |
| 132 | return Ok(None); | |
| 133 | } | |
| 134 | let kb = b.map(|(k, _)| k).unwrap_or(ko); | |
| 135 | let Some(kind) = pick(&kb, &ko, &kt) else { return Ok(None) }; | |
| 136 | let read = |id: &Id| objects.get_blob(id).map(|bytes| String::from_utf8(bytes).ok()); | |
| 137 | let base_text = match b { | |
| 138 | Some((_, ib)) => read(&ib)?, | |
| 139 | None => Some(String::new()), | |
| 140 | }; | |
| 141 | let (Some(bt), Some(ot), Some(tt)) = (base_text, read(&io)?, read(&it)?) else { return Ok(None) }; | |
| 142 | match merge_text(&bt, &ot, &tt) { | |
| 143 | Ok(text) => Ok(Some((kind, objects.put_blob(text.as_bytes())?))), | |
| 144 | Err(_) => Ok(None), | |
| 145 | } | |
| 146 | })()?; | |
| 147 | match merged { | |
| 148 | Some(f) => { | |
| 149 | result.insert(path.clone(), f); | |
| 150 | } | |
| 151 | None => conflicts.push(path.clone()), | |
| 152 | } | |
| 153 | } | |
| 154 | // A file on one side where the other has a directory. | |
| 155 | for path in result.keys() { | |
| 156 | let dir = format!("{path}/"); | |
| 157 | if result.range(dir.clone()..).next().is_some_and(|(p, _)| p.starts_with(&dir)) { | |
| 158 | conflicts.push(path.clone()); | |
| 159 | } | |
| 160 | } | |
| 161 | if !conflicts.is_empty() { | |
| 162 | conflicts.sort(); | |
| 163 | conflicts.dedup(); | |
| 164 | return Ok(Outcome::Conflicts(conflicts)); | |
| 165 | } | |
| 166 | Ok(Outcome::Merged(build_tree(objects, &result)?)) | |
| 167 | } | |
| 168 | ||
| 169 | /// Store a nested tree for a flat path → file map. | |
| 170 | pub fn build_tree(objects: &Objects, files: &Files) -> objects::Result<Id> { | |
| 171 | #[derive(Default)] | |
| 172 | struct Dir { | |
| 173 | files: Vec<Entry>, | |
| 174 | dirs: BTreeMap<String, Dir>, | |
| 175 | } | |
| 176 | let mut root = Dir::default(); | |
| 177 | for (path, (kind, id)) in files { | |
| 178 | let mut parts: Vec<&str> = path.split('/').collect(); | |
| 179 | let name = parts.pop().unwrap(); | |
| 180 | let mut dir = &mut root; | |
| 181 | for p in parts { | |
| 182 | dir = dir.dirs.entry(p.to_string()).or_default(); | |
| 183 | } | |
| 184 | dir.files.push(Entry { name: name.to_string(), kind: *kind, id: *id }); | |
| 185 | } | |
| 186 | fn store(objects: &Objects, dir: Dir) -> objects::Result<Id> { | |
| 187 | let mut entries = dir.files; | |
| 188 | for (name, sub) in dir.dirs { | |
| 189 | entries.push(Entry { name, kind: Kind::Dir, id: store(objects, sub)? }); | |
| 190 | } | |
| 191 | objects.put_tree(&entries) | |
| 192 | } | |
| 193 | store(objects, root) | |
| 194 | } | |
| 195 | ||
| 196 | /// Rebuild a stack of steps, made on `from`, onto `onto`. Returns the new snapshot of each | |
| 197 | /// step, or the first step that conflicts and its paths. | |
| 198 | pub fn rebuild(objects: &Objects, from: &Id, steps: &[Id], onto: &Id) -> objects::Result<Result<Vec<Id>, (usize, Vec<String>)>> { | |
| 199 | let mut out = Vec::with_capacity(steps.len()); | |
| 200 | let (mut prev, mut target) = (*from, *onto); | |
| 201 | for (i, step) in steps.iter().enumerate() { | |
| 202 | match merge_trees(objects, &prev, step, &target)? { | |
| 203 | Outcome::Merged(id) => { | |
| 204 | out.push(id); | |
| 205 | prev = *step; | |
| 206 | target = id; | |
| 207 | } | |
| 208 | Outcome::Conflicts(paths) => return Ok(Err((i, paths))), | |
| 209 | } | |
| 210 | } | |
| 211 | Ok(Ok(out)) | |
| 212 | } | |
| 213 | ||
| 214 | #[cfg(test)] | |
| 215 | mod tests { | |
| 216 | use super::*; | |
| 217 | use crate::testdir::TestDir; | |
| 218 | ||
| 219 | #[test] | |
| 220 | fn text() { | |
| 221 | let base = "a\nb\nc\nd\ne\n"; | |
| 222 | // Separate edits combine. | |
| 223 | assert_eq!(merge_text(base, "A\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n"), Ok("A\nb\nc\nd\nE\n".into())); | |
| 224 | // Insertions in different places. | |
| 225 | assert_eq!(merge_text(base, "a\nx\nb\nc\nd\ne\n", "a\nb\nc\nd\ny\ne\n"), Ok("a\nx\nb\nc\nd\ny\ne\n".into())); | |
| 226 | // The same edit on both sides is not a conflict. | |
| 227 | assert_eq!(merge_text(base, "a\nB\nc\nd\ne\n", "a\nB\nc\nd\ne\n"), Ok("a\nB\nc\nd\ne\n".into())); | |
| 228 | // Deletion on one side, an edit elsewhere on the other. | |
| 229 | assert_eq!(merge_text(base, "a\nc\nd\ne\n", "a\nb\nc\nd\nE\n"), Ok("a\nc\nd\nE\n".into())); | |
| 230 | // Different edits to the same line conflict. | |
| 231 | assert_eq!(merge_text(base, "a\nX\nc\nd\ne\n", "a\nY\nc\nd\ne\n"), Err(1)); | |
| 232 | // Trailing newline changes merge too. | |
| 233 | assert_eq!(merge_text("a\nb\n", "a\nb", "A\nb\n"), Ok("A\nb".into())); | |
| 234 | assert_eq!(merge_text("", "x\n", ""), Ok("x\n".into())); | |
| 235 | } | |
| 236 | ||
| 237 | fn snap(objects: &Objects, files: &[(&str, &str)]) -> Id { | |
| 238 | let map: Files = files.iter().map(|(p, c)| (p.to_string(), (Kind::File, objects.put_blob(c.as_bytes()).unwrap()))).collect(); | |
| 239 | build_tree(objects, &map).unwrap() | |
| 240 | } | |
| 241 | ||
| 242 | #[test] | |
| 243 | fn trees() { | |
| 244 | let dir = TestDir::new("merge-trees"); | |
| 245 | let o = Objects::open(dir.path()).unwrap(); | |
| 246 | let base = snap(&o, &[("a.txt", "1\n2\n3\n"), ("dir/b.txt", "b\n"), ("gone.txt", "x\n")]); | |
| 247 | let ours = snap(&o, &[("a.txt", "one\n2\n3\n"), ("dir/b.txt", "b\n"), ("new.txt", "n\n")]); | |
| 248 | let theirs = snap(&o, &[("a.txt", "1\n2\nthree\n"), ("dir/b.txt", "B\n"), ("gone.txt", "x\n")]); | |
| 249 | let expected = snap(&o, &[("a.txt", "one\n2\nthree\n"), ("dir/b.txt", "B\n"), ("new.txt", "n\n")]); | |
| 250 | assert_eq!(merge_trees(&o, &base, &ours, &theirs).unwrap(), Outcome::Merged(expected)); | |
| 251 | ||
| 252 | // Deleted on one side, edited on the other. | |
| 253 | let edited = snap(&o, &[("a.txt", "1\n2\n3\n"), ("dir/b.txt", "b\n"), ("gone.txt", "changed\n")]); | |
| 254 | assert_eq!(merge_trees(&o, &base, &ours, &edited).unwrap(), Outcome::Conflicts(vec!["gone.txt".into()])); | |
| 255 | ||
| 256 | // A file where the other side made a directory. | |
| 257 | let as_file = snap(&o, &[("a.txt", "1\n2\n3\n"), ("dir/b.txt", "b\n"), ("gone.txt", "x\n"), ("x", "file\n")]); | |
| 258 | let as_dir = snap(&o, &[("a.txt", "1\n2\n3\n"), ("dir/b.txt", "b\n"), ("gone.txt", "x\n"), ("x/inner", "in\n")]); | |
| 259 | assert_eq!(merge_trees(&o, &base, &as_file, &as_dir).unwrap(), Outcome::Conflicts(vec!["x".into()])); | |
| 260 | } | |
| 261 | ||
| 262 | #[test] | |
| 263 | fn rebuilding_a_stack() { | |
| 264 | let dir = TestDir::new("merge-rebuild"); | |
| 265 | let o = Objects::open(dir.path()).unwrap(); | |
| 266 | let base = snap(&o, &[("f", "1\n2\n3\n4\n5\n6\n")]); | |
| 267 | let s1 = snap(&o, &[("f", "one\n2\n3\n4\n5\n6\n")]); | |
| 268 | let s2 = snap(&o, &[("f", "one\n2\nthree\n4\n5\n6\n")]); | |
| 269 | let main = snap(&o, &[("f", "1\n2\n3\n4\n5\nsix\n"), ("other", "x\n")]); | |
| 270 | let rebuilt = rebuild(&o, &base, &[s1, s2], &main).unwrap().unwrap(); | |
| 271 | assert_eq!(rebuilt[0], snap(&o, &[("f", "one\n2\n3\n4\n5\nsix\n"), ("other", "x\n")])); | |
| 272 | assert_eq!(rebuilt[1], snap(&o, &[("f", "one\n2\nthree\n4\n5\nsix\n"), ("other", "x\n")])); | |
| 273 | ||
| 274 | // Main changed line 3 differently: the first step merges, the second conflicts. | |
| 275 | let clash = snap(&o, &[("f", "1\n2\nTHREE\n4\n5\n6\n")]); | |
| 276 | assert_eq!(rebuild(&o, &base, &[s1, s2], &clash).unwrap(), Err((1, vec!["f".into()]))); | |
| 277 | } | |
| 278 | } |
src/store.rsmodified+21 −34
| 21 | 21 | Damaged { record: u64, reason: String }, |
| 22 | 22 | /// The request isn't allowed or doesn't make sense. |
| 23 | 23 | Refused(String), |
| 24 | /// The change can't be rebuilt on the current tip without someone resolving these paths. | |
| 25 | Conflict { step: usize, paths: Vec<String> }, | |
| 24 | /// The change was built on an older point. grove doesn't merge: the agent rebuilds the | |
| 25 | /// change on the tip and proposes a new revision. | |
| 26 | Behind { base: u64, tip: u64 }, | |
| 26 | 27 | } |
| 27 | 28 | |
| 28 | 29 | impl std::fmt::Display for Error { |
| 33 | 34 | Error::Io(e) => write!(f, "{e}"), |
| 34 | 35 | Error::Damaged { record, reason } => write!(f, "log record {record} is damaged: {reason}"), |
| 35 | 36 | Error::Refused(r) => write!(f, "{r}"), |
| 36 | Error::Conflict { step, paths } => write!(f, "step {} conflicts with main in {}", step + 1, paths.join(", ")), | |
| 37 | Error::Behind { base, tip } => write!(f, "built on @{base}, but main is at @{tip}; the change needs a new revision built on @{tip}"), | |
| 37 | 38 | } |
| 38 | 39 | } |
| 39 | 40 | } |
| 259 | 260 | let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?; |
| 260 | 261 | let rev = c.latest().ok_or_else(|| Error::Refused(format!("#{change} has no revisions")))?; |
| 261 | 262 | let tip = p.tip(); |
| 262 | let steps: Vec<Id> = rev.steps.iter().map(|s| s.snapshot).collect(); | |
| 263 | // Built on an older point: replay each step's edits onto the current tip. | |
| 264 | let snapshots = if rev.base == tip { | |
| 265 | steps | |
| 266 | } else { | |
| 267 | let from = p.point(rev.base).map(|pt| pt.snapshot).ok_or_else(|| Error::Refused("base point missing".into()))?; | |
| 268 | let onto = p.point(tip).map(|pt| pt.snapshot).unwrap(); | |
| 269 | match crate::merge::rebuild(&self.objects, &from, &steps, &onto)? { | |
| 270 | Ok(rebuilt) => rebuilt, | |
| 271 | Err((step, paths)) => return Err(Error::Conflict { step, paths }), | |
| 272 | } | |
| 273 | }; | |
| 263 | // No merging: a change lands only on the point it was built on. | |
| 264 | if rev.base != tip { | |
| 265 | return Err(Error::Behind { base: rev.base, tip }); | |
| 266 | } | |
| 267 | let snapshots: Vec<Id> = rev.steps.iter().map(|s| s.snapshot).collect(); | |
| 274 | 268 | let first = tip + 1; |
| 275 | 269 | let count = snapshots.len() as u64; |
| 276 | 270 | let snapshots: Vec<Value> = snapshots.iter().map(id_to_json).collect(); |
| 413 | 407 | } |
| 414 | 408 | |
| 415 | 409 | #[test] |
| 416 | fn landing_on_a_moved_main() { | |
| 410 | fn landing_behind_the_tip_is_refused() { | |
| 417 | 411 | let dir = TestDir::new("store-behind"); |
| 418 | 412 | let (mut store, me, _) = setup(&dir); |
| 419 | 413 | let owner = Actor::person("anton"); |
| 420 | // Both changes start from @1 (README = "hello"). | |
| 421 | let ta = tree(&store, &[("README", "hello"), ("a.txt", "from a\n")]); | |
| 422 | let tb = tree(&store, &[("README", "hello"), ("b.txt", "from b\n")]); | |
| 423 | let tc = tree(&store, &[("README", "clash")]); | |
| 414 | let t = tree(&store, &[("README", "a")]); | |
| 415 | let step = |id: &str| vec![Step { id: id.into(), snapshot: t, message: "m".into() }]; | |
| 424 | 416 | let a = store.open_change(&me, "anton", "grove", "a").unwrap(); |
| 425 | 417 | let b = store.open_change(&me, "anton", "grove", "b").unwrap(); |
| 426 | let c = store.open_change(&me, "anton", "grove", "c").unwrap(); | |
| 427 | let tl = tree(&store, &[("README", "landed")]); | |
| 428 | for (n, t) in [(a, tl), (b, tb), (c, tc)] { | |
| 429 | store.revise(&me, "anton", "grove", n, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap(); | |
| 430 | store.set_state(&me, "anton", "grove", n, ChangeState::Proposed).unwrap(); | |
| 431 | } | |
| 418 | store.revise(&me, "anton", "grove", a, 1, &step("x"), None).unwrap(); | |
| 419 | store.revise(&me, "anton", "grove", b, 1, &step("y"), None).unwrap(); | |
| 420 | store.set_state(&me, "anton", "grove", a, ChangeState::Proposed).unwrap(); | |
| 421 | store.set_state(&me, "anton", "grove", b, ChangeState::Proposed).unwrap(); | |
| 432 | 422 | store.land(&owner, "anton", "grove", a).unwrap(); |
| 433 | // b touched other files: it's rebuilt on @2 and lands. | |
| 423 | assert!(matches!(store.land(&owner, "anton", "grove", b), Err(Error::Behind { base: 1, tip: 2 }))); | |
| 424 | assert_eq!(store.project("anton", "grove").unwrap().change(b).unwrap().state, ChangeState::Proposed); | |
| 425 | // A new revision built on the tip lands. | |
| 426 | let t2 = tree(&store, &[("README", "rebuilt")]); | |
| 427 | store.revise(&me, "anton", "grove", b, 2, &[Step { id: "y".into(), snapshot: t2, message: "m".into() }], None).unwrap(); | |
| 434 | 428 | assert_eq!(store.land(&owner, "anton", "grove", b).unwrap(), (3, 3)); |
| 435 | let p = store.project("anton", "grove").unwrap(); | |
| 436 | assert_eq!(p.point(3).unwrap().snapshot, tree(&store, &[("README", "landed"), ("b.txt", "from b\n")])); | |
| 437 | // c changed README differently from what landed: a conflict, and nothing changes. | |
| 438 | let before = store.state().records; | |
| 439 | assert!(matches!(store.land(&owner, "anton", "grove", c), Err(Error::Conflict { step: 0, .. }))); | |
| 440 | assert_eq!(store.state().records, before); | |
| 441 | assert_eq!(store.project("anton", "grove").unwrap().change(c).unwrap().state, ChangeState::Proposed); | |
| 442 | 429 | } |
| 443 | 430 | |
| 444 | 431 | #[test] |