#2 Groups own projects, and projects can move between owners
landedAdds 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
| 227 | 227 | pub fn run(argv: Vec<String>) -> Result<()> { |
| 228 | 228 | let cmd = argv.get(1).map(String::as_str).unwrap_or("help"); |
| 229 | 229 | 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"]); | |
| 231 | 231 | match cmd { |
| 232 | 232 | "setup" => setup(&args), |
| 233 | 233 | "init" => init(&args), |
| 247 | 247 | "log" => log(), |
| 248 | 248 | "sync" => sync(), |
| 249 | 249 | "verify" => verify(), |
| 250 | "group" => group(&args), | |
| 251 | "move" => move_project(&args), | |
| 250 | 252 | "help" | "--help" | "-h" => { |
| 251 | 253 | print!("{HELP}"); |
| 252 | 254 | Ok(()) |
| 258 | 260 | const HELP: &str = "grove: where agents build software for people |
| 259 | 261 | |
| 260 | 262 | 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 | |
| 262 | 264 | checkout <owner/name> [DIR] get a project's files |
| 263 | 265 | status what changed since the last step |
| 264 | 266 | diff [--stat] those changes, line by line |
| 275 | 277 | log main, newest first |
| 276 | 278 | sync move the working directory to the newest main |
| 277 | 279 | 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) | |
| 278 | 282 | "; |
| 279 | 283 | |
| 280 | 284 | fn setup(args: &Args) -> Result<()> { |
| 322 | 326 | } |
| 323 | 327 | let mut store = open_store(&cfg)?; |
| 324 | 328 | 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")))?; | |
| 326 | 331 | let snap = workdir::snapshot(&store.objects, &root).map_err(|e| e.to_string())?; |
| 327 | 332 | 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())))?; | |
| 329 | 334 | 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"); | |
| 332 | 337 | Ok(()) |
| 333 | 338 | } |
| 334 | 339 | |
| 654 | 659 | let mut local = Local::find()?; |
| 655 | 660 | let store = open_store(&cfg)?; |
| 656 | 661 | 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. | |
| 659 | 664 | if let Some(c) = local.change.and_then(|n| p.change(n)) { |
| 660 | 665 | 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 { | |
| 662 | 667 | Event::Landed { revision, points } => Some((*revision, points.1)), |
| 663 | 668 | _ => None, |
| 664 | 669 | }); |
| 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 { | |
| 668 | 675 | 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); | |
| 669 | 679 | } |
| 670 | 680 | } |
| 671 | local.steps.clear(); | |
| 672 | 681 | local.change = None; |
| 673 | 682 | } |
| 674 | 683 | } |
| 691 | 700 | local.save()?; |
| 692 | 701 | println!("now at @{}", tip.n); |
| 693 | 702 | Ok(()) |
| 703 | } | |
| 704 | ||
| 705 | fn 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 | ||
| 723 | fn 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() | |
| 694 | 732 | } |
| 695 | 733 | |
| 696 | 734 | fn verify() -> Result<()> { |
src/model.rsmodified+79 −14
| 93 | 93 | pub at: i64, |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | /// An owner made of people: any member can act for it, and make its decisions in person. | |
| 96 | 97 | #[derive(Clone, Debug)] |
| 98 | pub struct Group { | |
| 99 | pub name: String, | |
| 100 | pub members: Vec<String>, | |
| 101 | pub at: i64, | |
| 102 | } | |
| 103 | ||
| 104 | #[derive(Clone, Debug)] | |
| 97 | 105 | pub struct Agent { |
| 98 | 106 | pub id: String, |
| 99 | 107 | pub person: String, |
| 210 | 218 | pub struct State { |
| 211 | 219 | pub people: Vec<Person>, |
| 212 | 220 | pub agents: Vec<Agent>, |
| 221 | pub groups: Vec<Group>, | |
| 213 | 222 | pub projects: Vec<Project>, |
| 214 | 223 | pub records: u64, |
| 215 | 224 | } |
| 264 | 273 | pub fn person(&self, username: &str) -> Option<&Person> { |
| 265 | 274 | self.people.iter().find(|p| p.username == username) |
| 266 | 275 | } |
| 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 | } | |
| 267 | 287 | pub fn agent(&self, id: &str) -> Option<&Agent> { |
| 268 | 288 | self.agents.iter().find(|a| a.id == id) |
| 269 | 289 | } |
| 294 | 314 | if username != by.person { |
| 295 | 315 | return Err("a person record is made by that person".into()); |
| 296 | 316 | } |
| 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")); | |
| 299 | 319 | } |
| 300 | 320 | self.people.push(Person { display_name: str_field(rec, "display_name")?, username, at }); |
| 301 | 321 | } |
| 311 | 331 | let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?; |
| 312 | 332 | a.revoked = true; |
| 313 | 333 | } |
| 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 | } | |
| 314 | 362 | "project" => { |
| 315 | 363 | 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)); | |
| 318 | 367 | } |
| 368 | if self.project(&owner, &name).is_some() { | |
| 369 | return Err(format!("project {owner}/{name} exists")); | |
| 370 | } | |
| 319 | 371 | 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 }); | |
| 321 | 373 | } |
| 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 | } | |
| 322 | 385 | "point" => { |
| 323 | 386 | // 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) { | |
| 326 | 389 | return Err("only the owner records points".into()); |
| 327 | 390 | } |
| 391 | let p = self.project_mut(&owner, &str_field(rec, "project")?)?; | |
| 328 | 392 | let n = uint(rec, "n")?; |
| 329 | 393 | if n != p.tip() + 1 { |
| 330 | 394 | return Err(format!("point {n} doesn't follow {}", p.tip())); |
| 384 | 448 | c.timeline.push((at, by, Event::Revised(number))); |
| 385 | 449 | } |
| 386 | 450 | "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")?)?; | |
| 389 | 454 | let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?; |
| 390 | 455 | 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)?; | |
| 392 | 457 | c.state = to; |
| 393 | 458 | c.timeline.push((at, by, Event::State(to))); |
| 394 | 459 | } |
| 395 | 460 | "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() { | |
| 398 | 463 | return Err("only the owner, in person, lands".into()); |
| 399 | 464 | } |
| 465 | let p = self.project_mut(&owner, &str_field(rec, "project")?)?; | |
| 400 | 466 | let number = uint(rec, "change")?; |
| 401 | 467 | let revision = uint(rec, "revision")? as u32; |
| 402 | 468 | let first = uint(rec, "first")?; |
| 462 | 528 | } |
| 463 | 529 | |
| 464 | 530 | /// Who may move a change between which states. |
| 465 | fn check_transition(c: &Change, to: ChangeState, by: &Actor, owner: &str) -> Result<(), String> { | |
| 531 | fn check_transition(c: &Change, to: ChangeState, by: &Actor, is_owner: bool) -> Result<(), String> { | |
| 466 | 532 | use ChangeState::*; |
| 467 | let is_owner = by.person == owner; | |
| 468 | 533 | // Decisions are the owner's own: an agent acting for them can't make them. |
| 469 | 534 | let owner_in_person = is_owner && by.agent.is_none(); |
| 470 | 535 | let is_proposer = by.person == c.proposer.person; |
src/store.rsmodified+53 −7
| 161 | 161 | |
| 162 | 162 | // ── Projects and main ────────────────────────────────── |
| 163 | 163 | |
| 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<()> { | |
| 165 | 166 | if !objects::valid_name(name) || name.contains(char::is_whitespace) { |
| 166 | 167 | return refused(format!("{name:?} can't be a project name")); |
| 167 | 168 | } |
| 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())])) | |
| 169 | 170 | } |
| 170 | 171 | |
| 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 | ||
| 171 | 188 | /// Record a point directly on main: a project's first snapshot, or imported history. |
| 172 | 189 | pub fn record_point(&mut self, by: &Actor, owner: &str, name: &str, snapshot: &Id, message: &str, author: Option<&Actor>) -> Result<u64> { |
| 173 | 190 | self.require_tree(snapshot)?; |
| 191 | 208 | /// Open a change as an idea: an intent, no code yet. Its first revision makes it a draft. |
| 192 | 209 | pub fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> Result<u64> { |
| 193 | 210 | 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) { | |
| 195 | 212 | return refused(format!("{owner}/{name} is private")); |
| 196 | 213 | } |
| 197 | 214 | let number = p.changes.len() as u64 + 1; |
| 206 | 223 | } |
| 207 | 224 | let p = self.project(owner, name)?; |
| 208 | 225 | 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) { | |
| 210 | 227 | return refused("only the proposer or the owner can revise a change"); |
| 211 | 228 | } |
| 212 | 229 | let number = c.revisions.len() as u32 + 1; |
| 236 | 253 | /// Land a change's latest revision. Its steps become the next points on main, all at once. |
| 237 | 254 | pub fn land(&mut self, by: &Actor, owner: &str, name: &str, change: u64) -> Result<(u64, u64)> { |
| 238 | 255 | 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() { | |
| 240 | 257 | return refused("only the owner, in person, lands changes"); |
| 241 | 258 | } |
| 242 | 259 | let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?; |
| 338 | 355 | store.add_person("anton", "Anton").unwrap(); |
| 339 | 356 | let agent = store.add_agent("anton", "claude").unwrap(); |
| 340 | 357 | 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(); | |
| 342 | 359 | let t = tree(&store, &[("README", "hello")]); |
| 343 | 360 | store.record_point(&Actor::person("anton"), "anton", "grove", &t, "Start", None).unwrap(); |
| 344 | 361 | (store, me, agent) |
| 423 | 440 | } |
| 424 | 441 | |
| 425 | 442 | #[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] | |
| 426 | 472 | fn strangers_and_private_projects() { |
| 427 | 473 | let dir = TestDir::new("store-strangers"); |
| 428 | 474 | let (mut store, _, _) = setup(&dir); |
| 436 | 482 | assert!(store.land(&visitor, "anton", "grove", c).is_err()); |
| 437 | 483 | store.set_state(&Actor::person("anton"), "anton", "grove", c, ChangeState::Declined).unwrap(); |
| 438 | 484 | // 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(); | |
| 440 | 486 | assert!(store.open_change(&visitor, "anton", "secret", "let me in").is_err()); |
| 441 | 487 | } |
| 442 | 488 | } |