grove

@5 No merging: a change lands only on the point it was built on

by claude for antonky · landed by antonky · · from #4 · files at @5

Files

VISION.mdmodified+2 −2
3030- **Discussion.** Anyone can take part: the owner, the proposer, other people, and agents
3131 for any of them. The owner can comment, ask for something different or redirect, and
3232 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.
3434
3535A project's history is the sequence of landed changes, each with its intent and evidence.
3636That is what people read. The code is there when someone wants it.
6060
61611. The store (objects and log) with its tests, and a CLI working directly on it.
62622. grove takes in its own code as its first project; from then on its changes land through grove.
633. Three-way merge for landing on a moved main; diffs.
633. Diffs between revisions; no merging (a change lands only on the point it was built on).
64644. The HTTP API and MCP server, tokens for agents; grove runs on grove.host.
65655. Move the existing projects in, keeping their history, and point CI and deploys at grove.
66666. The web, built around changes and results.
docs/model.mdmodified+14 −17
9292### States
9393
9494```
95idea ──> draft ──> proposed ──> accepted ──> landed
96 │ │ │ │
95idea ──> draft ──> proposed ──> landed
96 │ │ │
9797 └────────┴───────────┴──> declined / withdrawn
9898```
9999
100100- **idea:** intent and discussion only, no revisions.
101101- **draft:** has revisions; the author is still working. Not asking for a decision.
102102- **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.
106104- **declined:** the owner said no. **withdrawn:** the proposer gave up.
107105
108Declined and withdrawn changes can be reopened as draft. Nothing is ever deleted.
106Declined and withdrawn changes can be reopened. Nothing is ever deleted.
109107
110108### Revisions and steps
111109
159157
160158## Landing
161159
162When the owner accepts revision R of change C:
160grove never merges. A change lands only on the point it was built on.
163161
1641. If `R.base` is the current tip of main, each step becomes the next point, in order.
1652. Otherwise grove rebuilds the steps on the current tip with a three-way merge of each file
166 (base snapshot, step snapshot, current tip).
1673. 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.
1694. 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.
1621. 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.
1642. 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.
171167
172Landing is one database transaction: either all of a change's steps land or none do. Points
173are only ever appended.
168Agents rewrite files; they don't need a merge algorithm to do it, and nothing in grove's
169history is ever a mix nobody wrote.
174170
175171## Agents' workflow
176172
184180 sending only blobs the server doesn't have.
1851814. `attach` adds evidence. `say` posts to the discussion. `watch` streams the discussion so
186182 the agent can respond to steering.
1875. `sync` brings the working directory up to the new tip and rebuilds local steps on it.
1835. `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.
188185
189186The MCP server exposes the same operations to agents that speak MCP.
190187
src/cli.rsmodified+53 −14
246246 "decline" => decline(&args),
247247 "log" => log(),
248248 "sync" => sync(),
249 "restart" => restart(),
249250 "verify" => verify(),
250251 "group" => group(&args),
251252 "move" => move_project(&args),
263264 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
264265 checkout <owner/name> [DIR] get a project's files
265266 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
267268 step -m MESSAGE record the working directory as a new step
268269 amend [-m MESSAGE] replace the last step with the working directory
269270 stack the local steps
276277 decline #N decline a change (you, in person)
277278 log main, newest first
278279 sync move the working directory to the newest main
280 restart move to the newest main, dropping proposed local steps
279281 verify check every stored object against its id
280282 group create NAME | group add NAME PERSON groups own projects together (in person)
281283 move OWNER give this project to another owner (in person)
425427 let cfg = Config::load()?;
426428 let local = Local::find()?;
427429 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 }
428446 let (current, now, _) = working_changes(&store, &local)?;
429447 print_diff(&store, &current, &now, args.has("--stat"))
430448}
686704 println!("already at @{}", local.base);
687705 return Ok(());
688706 }
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 }
689714 let (current, _, pending) = working_changes(&store, &local)?;
690715 if !pending.is_empty() {
691716 return e!("the working directory has changes; record them as a step first");
692717 }
693 let from = p.point(local.base).map(|pt| pt.snapshot).ok_or("base missing")?;
694718 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(&current)).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(&current)).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.
728fn 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");
705736 }
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(&current)).map_err(|e| e.to_string())?;
745 local.steps.clear();
706746 local.base = tip.n;
707747 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());
710749 Ok(())
711750}
712751
src/lib.rsmodified+0 −1
77pub mod ignore;
88pub mod json;
99pub mod log;
10pub mod merge;
1110pub mod model;
1211pub mod objects;
1312pub 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
6use crate::diff::{diff, lines, Op};
7use crate::objects::{self, Entry, Id, Kind, Objects};
8use std::collections::BTreeMap;
9
10/// Base line index → index of the same line in `other`, where the diff keeps it.
11fn 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).
29pub 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
79type Files = BTreeMap<String, (Kind, Id)>;
80
81fn 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.
86fn 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)]
97pub 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.
104pub 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.
170pub 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.
198pub 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)]
215mod 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
2121 Damaged { record: u64, reason: String },
2222 /// The request isn't allowed or doesn't make sense.
2323 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 },
2627}
2728
2829impl std::fmt::Display for Error {
3334 Error::Io(e) => write!(f, "{e}"),
3435 Error::Damaged { record, reason } => write!(f, "log record {record} is damaged: {reason}"),
3536 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}"),
3738 }
3839 }
3940}
259260 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
260261 let rev = c.latest().ok_or_else(|| Error::Refused(format!("#{change} has no revisions")))?;
261262 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();
274268 let first = tip + 1;
275269 let count = snapshots.len() as u64;
276270 let snapshots: Vec<Value> = snapshots.iter().map(id_to_json).collect();
413407 }
414408
415409 #[test]
416 fn landing_on_a_moved_main() {
410 fn landing_behind_the_tip_is_refused() {
417411 let dir = TestDir::new("store-behind");
418412 let (mut store, me, _) = setup(&dir);
419413 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() }];
424416 let a = store.open_change(&me, "anton", "grove", "a").unwrap();
425417 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();
432422 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();
434428 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);
442429 }
443430
444431 #[test]