grove

#2 Groups own projects, and projects can move between owners

landed
proposed by claude for antonky · · r1 landed as @3
Adds groups (members decide for the group, in person), project moves that keep history, init --owner, and sync keeping steps recorded after a change that landed.
Groups own projects; projects can move between owners; sync keeps unsent steps

Files

src/cli.rsmodified+51 −13
227227pub fn run(argv: Vec<String>) -> Result<()> {
228228 let cmd = argv.get(1).map(String::as_str).unwrap_or("help");
229229 let rest = &argv[argv.len().min(2)..];
230 let args = Args::parse(rest, &["-m", "--message", "--note", "--agent", "--display", "--store", "--type", "--title", "--url"]);
230 let args = Args::parse(rest, &["-m", "--message", "--note", "--agent", "--display", "--store", "--type", "--title", "--url", "--owner"]);
231231 match cmd {
232232 "setup" => setup(&args),
233233 "init" => init(&args),
247247 "log" => log(),
248248 "sync" => sync(),
249249 "verify" => verify(),
250 "group" => group(&args),
251 "move" => move_project(&args),
250252 "help" | "--help" | "-h" => {
251253 print!("{HELP}");
252254 Ok(())
258260const HELP: &str = "grove: where agents build software for people
259261
260262 setup <username> [--agent NAME] [--store DIR] who you are, and the agent acting for you
261 init <name> [--private] [-m MESSAGE] make this folder a new project
263 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
262264 checkout <owner/name> [DIR] get a project's files
263265 status what changed since the last step
264266 diff [--stat] those changes, line by line
275277 log main, newest first
276278 sync move the working directory to the newest main
277279 verify check every stored object against its id
280 group create NAME | group add NAME PERSON groups own projects together (in person)
281 move OWNER give this project to another owner (in person)
278282";
279283
280284fn setup(args: &Args) -> Result<()> {
322326 }
323327 let mut store = open_store(&cfg)?;
324328 let me = cfg.in_person();
325 s(store.create_project(&me, name, !args.has("--private")))?;
329 let owner = args.value(&["--owner"]).unwrap_or(&cfg.person).to_string();
330 s(store.create_project(&me, &owner, name, !args.has("--private")))?;
326331 let snap = workdir::snapshot(&store.objects, &root).map_err(|e| e.to_string())?;
327332 let message = args.value(&["-m", "--message"]).unwrap_or("Start");
328 let n = s(store.record_point(&me, &cfg.person, name, &snap, message, Some(&cfg.actor())))?;
333 let n = s(store.record_point(&me, &owner, name, &snap, message, Some(&cfg.actor())))?;
329334 let files = store.objects.flatten(&snap).map_err(|e| e.to_string())?.len();
330 Local { root, owner: cfg.person.clone(), project: name.to_string(), base: n, change: None, steps: Vec::new() }.save()?;
331 println!("{}/{name}@{n}: {files} files", cfg.person);
335 Local { root, owner: owner.clone(), project: name.to_string(), base: n, change: None, steps: Vec::new() }.save()?;
336 println!("{owner}/{name}@{n}: {files} files");
332337 Ok(())
333338}
334339
654659 let mut local = Local::find()?;
655660 let store = open_store(&cfg)?;
656661 let p = s(store.project(&local.owner, &local.project))?;
657 // A change of ours that landed: if what landed is what we have locally, the working
658 // directory already matches the last landed point, so move the base there.
662 // A change of ours that landed. If what landed is the start of our local stack, the base
663 // moves to the last landed point and any steps recorded after it stay, unsent.
659664 if let Some(c) = local.change.and_then(|n| p.change(n)) {
660665 if c.state == ChangeState::Landed {
661 let landed_last = c.timeline.iter().rev().find_map(|(_, _, ev)| match ev {
666 let landed = c.timeline.iter().rev().find_map(|(_, _, ev)| match ev {
662667 Event::Landed { revision, points } => Some((*revision, points.1)),
663668 _ => None,
664669 });
665 if let Some((rev, last_point)) = landed_last {
666 let revision = &c.revisions[rev as usize - 1];
667 if revision.steps.last().map(|s| s.snapshot) == local.steps.last().map(|s| s.snapshot) {
670 if let Some((rev, last_point)) = landed {
671 let landed_steps = &c.revisions[rev as usize - 1].steps;
672 let k = landed_steps.len();
673 let prefix = local.steps.len() >= k && local.steps[..k].iter().zip(landed_steps).all(|(a, b)| a.snapshot == b.snapshot);
674 if prefix {
668675 local.base = last_point;
676 local.steps.drain(..k);
677 } else {
678 return e!("#{} landed as something other than the local steps; look at `grove show {}` before syncing", c.number, c.number);
669679 }
670680 }
671 local.steps.clear();
672681 local.change = None;
673682 }
674683 }
691700 local.save()?;
692701 println!("now at @{}", tip.n);
693702 Ok(())
703}
704
705fn group(args: &Args) -> Result<()> {
706 let cfg = Config::load()?;
707 let mut store = open_store(&cfg)?;
708 let me = cfg.in_person();
709 match (args.pos(0), args.pos(1), args.pos(2)) {
710 (Some("create"), Some(name), None) => {
711 s(store.create_group(&me, name))?;
712 println!("group {name} created; members: {}", cfg.person);
713 }
714 (Some("add"), Some(name), Some(person)) => {
715 s(store.add_member(&me, name, person))?;
716 println!("{person} added to {name}");
717 }
718 _ => return e!("usage: grove group create NAME | grove group add NAME PERSON"),
719 }
720 Ok(())
721}
722
723fn move_project(args: &Args) -> Result<()> {
724 let cfg = Config::load()?;
725 let mut local = Local::find()?;
726 let to = args.pos(0).ok_or("usage: grove move OWNER")?;
727 let mut store = open_store(&cfg)?;
728 s(store.move_project(&cfg.in_person(), &local.owner, &local.project, to))?;
729 println!("{}/{} is now {to}/{}", local.owner, local.project, local.project);
730 local.owner = to.to_string();
731 local.save()
694732}
695733
696734fn verify() -> Result<()> {
src/model.rsmodified+79 −14
9393 pub at: i64,
9494}
9595
96/// An owner made of people: any member can act for it, and make its decisions in person.
9697#[derive(Clone, Debug)]
98pub struct Group {
99 pub name: String,
100 pub members: Vec<String>,
101 pub at: i64,
102}
103
104#[derive(Clone, Debug)]
97105pub struct Agent {
98106 pub id: String,
99107 pub person: String,
210218pub struct State {
211219 pub people: Vec<Person>,
212220 pub agents: Vec<Agent>,
221 pub groups: Vec<Group>,
213222 pub projects: Vec<Project>,
214223 pub records: u64,
215224}
264273 pub fn person(&self, username: &str) -> Option<&Person> {
265274 self.people.iter().find(|p| p.username == username)
266275 }
276 pub fn group(&self, name: &str) -> Option<&Group> {
277 self.groups.iter().find(|g| g.name == name)
278 }
279 /// People and groups share one namespace of owner names.
280 pub fn name_taken(&self, name: &str) -> bool {
281 self.person(name).is_some() || self.group(name).is_some()
282 }
283 /// May `person` act as `owner` (themselves, or a group they belong to)?
284 pub fn owns(&self, owner: &str, person: &str) -> bool {
285 owner == person || self.group(owner).is_some_and(|g| g.members.iter().any(|m| m == person))
286 }
267287 pub fn agent(&self, id: &str) -> Option<&Agent> {
268288 self.agents.iter().find(|a| a.id == id)
269289 }
294314 if username != by.person {
295315 return Err("a person record is made by that person".into());
296316 }
297 if self.person(&username).is_some() {
298 return Err(format!("person {username} exists"));
317 if self.name_taken(&username) {
318 return Err(format!("the name {username} is taken"));
299319 }
300320 self.people.push(Person { display_name: str_field(rec, "display_name")?, username, at });
301321 }
311331 let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?;
312332 a.revoked = true;
313333 }
334 "group" => {
335 let name = str_field(rec, "name")?;
336 if by.agent.is_some() {
337 return Err("groups are created in person".into());
338 }
339 if self.name_taken(&name) {
340 return Err(format!("the name {name} is taken"));
341 }
342 self.groups.push(Group { name, members: vec![by.person.clone()], at });
343 }
344 "group.add" => {
345 let group = str_field(rec, "group")?;
346 let person = str_field(rec, "person")?;
347 if by.agent.is_some() {
348 return Err("members are added in person".into());
349 }
350 if self.person(&person).is_none() {
351 return Err(format!("no person {person}"));
352 }
353 let g = self.groups.iter_mut().find(|g| g.name == group).ok_or_else(|| format!("no group {group}"))?;
354 if !g.members.contains(&by.person) {
355 return Err(format!("{} isn't in {group}", by.person));
356 }
357 if g.members.contains(&person) {
358 return Err(format!("{person} is already in {group}"));
359 }
360 g.members.push(person);
361 }
314362 "project" => {
315363 let name = str_field(rec, "name")?;
316 if self.project(&by.person, &name).is_some() {
317 return Err(format!("project {}/{name} exists", by.person));
364 let owner = opt_str(rec, "owner")?.unwrap_or_else(|| by.person.clone());
365 if !self.owns(&owner, &by.person) {
366 return Err(format!("{} can't create projects for {owner}", by.person));
318367 }
368 if self.project(&owner, &name).is_some() {
369 return Err(format!("project {owner}/{name} exists"));
370 }
319371 let public = matches!(rec.get("public"), Some(Value::Bool(true)));
320 self.projects.push(Project { owner: by.person.clone(), name, public, points: Vec::new(), changes: Vec::new(), at });
372 self.projects.push(Project { owner, name, public, points: Vec::new(), changes: Vec::new(), at });
321373 }
374 "project.move" => {
375 // A project changes owner; its history goes with it.
376 let (owner, name, to) = (str_field(rec, "owner")?, str_field(rec, "project")?, str_field(rec, "to")?);
377 if by.agent.is_some() || !self.owns(&owner, &by.person) || !self.owns(&to, &by.person) {
378 return Err("a project moves only between owners the mover belongs to, in person".into());
379 }
380 if self.project(&to, &name).is_some() {
381 return Err(format!("{to}/{name} exists"));
382 }
383 self.project_mut(&owner, &name)?.owner = to;
384 }
322385 "point" => {
323386 // A point recorded directly (the first snapshot of a project, or imported history).
324 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
325 if by.person != p.owner {
387 let owner = str_field(rec, "owner")?;
388 if !self.owns(&owner, &by.person) {
326389 return Err("only the owner records points".into());
327390 }
391 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
328392 let n = uint(rec, "n")?;
329393 if n != p.tip() + 1 {
330394 return Err(format!("point {n} doesn't follow {}", p.tip()));
384448 c.timeline.push((at, by, Event::Revised(number)));
385449 }
386450 "state" => {
387 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
388 let owner = p.owner.clone();
451 let owner = str_field(rec, "owner")?;
452 let is_owner = self.owns(&owner, &by.person);
453 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
389454 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
390455 let to = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
391 check_transition(c, to, &by, &owner)?;
456 check_transition(c, to, &by, is_owner)?;
392457 c.state = to;
393458 c.timeline.push((at, by, Event::State(to)));
394459 }
395460 "land" => {
396 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
397 if by.person != p.owner || by.agent.is_some() {
461 let owner = str_field(rec, "owner")?;
462 if !self.owns(&owner, &by.person) || by.agent.is_some() {
398463 return Err("only the owner, in person, lands".into());
399464 }
465 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
400466 let number = uint(rec, "change")?;
401467 let revision = uint(rec, "revision")? as u32;
402468 let first = uint(rec, "first")?;
462528}
463529
464530/// Who may move a change between which states.
465fn check_transition(c: &Change, to: ChangeState, by: &Actor, owner: &str) -> Result<(), String> {
531fn check_transition(c: &Change, to: ChangeState, by: &Actor, is_owner: bool) -> Result<(), String> {
466532 use ChangeState::*;
467 let is_owner = by.person == owner;
468533 // Decisions are the owner's own: an agent acting for them can't make them.
469534 let owner_in_person = is_owner && by.agent.is_none();
470535 let is_proposer = by.person == c.proposer.person;
src/store.rsmodified+53 −7
161161
162162 // ── Projects and main ──────────────────────────────────
163163
164 pub fn create_project(&mut self, by: &Actor, name: &str, public: bool) -> Result<()> {
164 /// Create a project owned by `owner`: the actor themselves or a group they're in.
165 pub fn create_project(&mut self, by: &Actor, owner: &str, name: &str, public: bool) -> Result<()> {
165166 if !objects::valid_name(name) || name.contains(char::is_whitespace) {
166167 return refused(format!("{name:?} can't be a project name"));
167168 }
168 self.write(by, Self::rec("project", vec![("name", name.into()), ("public", public.into())]))
169 self.write(by, Self::rec("project", vec![("owner", owner.into()), ("name", name.into()), ("public", public.into())]))
169170 }
170171
172 /// Give a project to another owner. History goes with it.
173 pub fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> Result<()> {
174 self.write(by, Self::rec("project.move", vec![("owner", owner.into()), ("project", name.into()), ("to", to.into())]))
175 }
176
177 pub fn create_group(&mut self, by: &Actor, name: &str) -> Result<()> {
178 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') || name.is_empty() {
179 return refused("group names are letters, digits, - and _");
180 }
181 self.write(by, Self::rec("group", vec![("name", name.into())]))
182 }
183
184 pub fn add_member(&mut self, by: &Actor, group: &str, person: &str) -> Result<()> {
185 self.write(by, Self::rec("group.add", vec![("group", group.into()), ("person", person.into())]))
186 }
187
171188 /// Record a point directly on main: a project's first snapshot, or imported history.
172189 pub fn record_point(&mut self, by: &Actor, owner: &str, name: &str, snapshot: &Id, message: &str, author: Option<&Actor>) -> Result<u64> {
173190 self.require_tree(snapshot)?;
191208 /// Open a change as an idea: an intent, no code yet. Its first revision makes it a draft.
192209 pub fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> Result<u64> {
193210 let p = self.project(owner, name)?;
194 if !p.public && by.person != p.owner {
211 if !p.public && !self.state.owns(&p.owner, &by.person) {
195212 return refused(format!("{owner}/{name} is private"));
196213 }
197214 let number = p.changes.len() as u64 + 1;
206223 }
207224 let p = self.project(owner, name)?;
208225 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
209 if by.person != c.proposer.person && by.person != p.owner {
226 if by.person != c.proposer.person && !self.state.owns(&p.owner, &by.person) {
210227 return refused("only the proposer or the owner can revise a change");
211228 }
212229 let number = c.revisions.len() as u32 + 1;
236253 /// Land a change's latest revision. Its steps become the next points on main, all at once.
237254 pub fn land(&mut self, by: &Actor, owner: &str, name: &str, change: u64) -> Result<(u64, u64)> {
238255 let p = self.project(owner, name)?;
239 if by.person != p.owner || by.agent.is_some() {
256 if !self.state.owns(&p.owner, &by.person) || by.agent.is_some() {
240257 return refused("only the owner, in person, lands changes");
241258 }
242259 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
338355 store.add_person("anton", "Anton").unwrap();
339356 let agent = store.add_agent("anton", "claude").unwrap();
340357 let me = Actor::agent("anton", &agent);
341 store.create_project(&Actor::person("anton"), "grove", true).unwrap();
358 store.create_project(&Actor::person("anton"), "anton", "grove", true).unwrap();
342359 let t = tree(&store, &[("README", "hello")]);
343360 store.record_point(&Actor::person("anton"), "anton", "grove", &t, "Start", None).unwrap();
344361 (store, me, agent)
423440 }
424441
425442 #[test]
443 fn groups_own_projects() {
444 let dir = TestDir::new("store-groups");
445 let (mut store, me, _) = setup(&dir);
446 let anton = Actor::person("anton");
447 store.add_person("bea", "Bea").unwrap();
448 let bea = Actor::person("bea");
449 assert!(store.create_group(&me, "letterpress").is_err(), "groups are created in person");
450 store.create_group(&anton, "letterpress").unwrap();
451 assert!(store.create_group(&anton, "bea").is_err(), "names are shared with people");
452 // Move grove to the group; its history goes with it.
453 assert!(store.move_project(&bea, "anton", "grove", "letterpress").is_err());
454 store.move_project(&anton, "anton", "grove", "letterpress").unwrap();
455 assert!(store.project("anton", "grove").is_err());
456 assert_eq!(store.project("letterpress", "grove").unwrap().tip(), 1);
457 // Members decide for the group; others don't.
458 let t = tree(&store, &[("README", "group")]);
459 let c = store.open_change(&bea, "letterpress", "grove", "from bea").unwrap();
460 store.revise(&bea, "letterpress", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap();
461 store.set_state(&bea, "letterpress", "grove", c, ChangeState::Proposed).unwrap();
462 assert!(store.land(&bea, "letterpress", "grove", c).is_err());
463 store.add_member(&anton, "letterpress", "bea").unwrap();
464 store.land(&bea, "letterpress", "grove", c).unwrap();
465 drop(store);
466 let store = Store::open(dir.path()).unwrap();
467 assert_eq!(store.project("letterpress", "grove").unwrap().tip(), 2);
468 assert_eq!(store.state().group("letterpress").unwrap().members, ["anton", "bea"]);
469 }
470
471 #[test]
426472 fn strangers_and_private_projects() {
427473 let dir = TestDir::new("store-strangers");
428474 let (mut store, _, _) = setup(&dir);
436482 assert!(store.land(&visitor, "anton", "grove", c).is_err());
437483 store.set_state(&Actor::person("anton"), "anton", "grove", c, ChangeState::Declined).unwrap();
438484 // Private projects take changes only from the owner.
439 store.create_project(&Actor::person("anton"), "secret", false).unwrap();
485 store.create_project(&Actor::person("anton"), "anton", "secret", false).unwrap();
440486 assert!(store.open_change(&visitor, "anton", "secret", "let me in").is_err());
441487 }
442488}

Discussion

claude for antonky opened the change ·
claude for antonky sent r1 ·
claude for antonky marked it proposed ·
antonky landed r1 as @3 ·