grove
at @3
1//! The `grove` command. For now it works directly on a store on this machine; the same
2//! operations will go over HTTP once grove runs as a server.
3//!
4//! Who's acting: `~/.grove2/config.json` names a person and, usually, the agent acting for them.
5//! (`~/.grove2`, not `~/.grove`: the old grove CLI keeps its own config in `~/.grove`.)
6//! Every command acts as that agent, except `land` and `decline`, which are decisions and act
7//! as the person. Locally this rests on trust; on a server, tokens will enforce it.
8
9use crate::diff;
10use crate::json::{self, obj, Value};
11use crate::model::{Actor, Change, ChangeState, Event, Project, Step};
12use crate::objects::Id;
13use crate::store::{self, Content, Store};
14use crate::workdir::{self, FileChange};
15use crate::{hex, random};
16use std::fs;
17use std::path::{Path, PathBuf};
18
19type Result<T> = std::result::Result<T, String>;
20
21macro_rules! e {
22 ($($arg:tt)*) => { Err(format!($($arg)*)) };
23}
24
25// ── Arguments ──────────────────────────────────────────────
26
27struct Args {
28 positional: Vec<String>,
29 flags: Vec<(String, Option<String>)>,
30}
31
32impl Args {
33 fn parse(raw: &[String], with_values: &[&str]) -> Args {
34 let mut positional = Vec::new();
35 let mut flags = Vec::new();
36 let mut i = 0;
37 while i < raw.len() {
38 let a = &raw[i];
39 if a.starts_with('-') && a.len() > 1 {
40 if with_values.contains(&a.as_str()) && i + 1 < raw.len() {
41 flags.push((a.clone(), Some(raw[i + 1].clone())));
42 i += 1;
43 } else {
44 flags.push((a.clone(), None));
45 }
46 } else {
47 positional.push(a.clone());
48 }
49 i += 1;
50 }
51 Args { positional, flags }
52 }
53 fn value(&self, names: &[&str]) -> Option<&str> {
54 self.flags.iter().rev().find(|(f, _)| names.contains(&f.as_str())).and_then(|(_, v)| v.as_deref())
55 }
56 fn has(&self, name: &str) -> bool {
57 self.flags.iter().any(|(f, _)| f == name)
58 }
59 fn pos(&self, i: usize) -> Option<&str> {
60 self.positional.get(i).map(String::as_str)
61 }
62}
63
64// ── Config and working directory state ─────────────────────
65
66fn home() -> PathBuf {
67 PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into()))
68}
69
70fn grove_home() -> PathBuf {
71 home().join(".grove2")
72}
73
74fn config_path() -> PathBuf {
75 grove_home().join("config.json")
76}
77
78struct Config {
79 store: PathBuf,
80 person: String,
81 agent: Option<String>,
82}
83
84impl Config {
85 fn load() -> Result<Config> {
86 let text = fs::read_to_string(config_path()).map_err(|_| "not set up yet: run `grove setup <username>`".to_string())?;
87 let v = json::parse(&text).map_err(|e| format!("{}: {e}", config_path().display()))?;
88 let store = std::env::var("GROVE_STORE").ok().or_else(|| v.get("store").and_then(Value::as_str).map(str::to_string)).ok_or("config has no store")?;
89 Ok(Config {
90 store: PathBuf::from(store),
91 person: v.get("person").and_then(Value::as_str).ok_or("config has no person")?.to_string(),
92 agent: v.get("agent").and_then(Value::as_str).map(str::to_string),
93 })
94 }
95 fn actor(&self) -> Actor {
96 Actor { person: self.person.clone(), agent: self.agent.clone() }
97 }
98 fn in_person(&self) -> Actor {
99 Actor::person(&self.person)
100 }
101}
102
103/// A working directory's local state: which project, which point it's on, and the steps
104/// not yet sent anywhere.
105struct Local {
106 root: PathBuf,
107 owner: String,
108 project: String,
109 base: u64,
110 change: Option<u64>,
111 steps: Vec<Step>,
112}
113
114impl Local {
115 fn find() -> Result<Local> {
116 let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
117 loop {
118 let path = dir.join(".grove").join("state.json");
119 if path.is_file() {
120 return Local::load(&dir, &path);
121 }
122 if !dir.pop() {
123 return e!("not inside a grove working directory");
124 }
125 }
126 }
127
128 fn load(root: &Path, path: &Path) -> Result<Local> {
129 let v = json::parse(&fs::read_to_string(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
130 let project = v.get("project").and_then(Value::as_str).ok_or("state has no project")?;
131 let (owner, name) = project.split_once('/').ok_or("bad project in state")?;
132 let steps = v
133 .get("steps")
134 .and_then(Value::as_array)
135 .unwrap_or(&[])
136 .iter()
137 .map(|s| {
138 let id = |k: &str| -> Result<Id> {
139 let h = s.get(k).and_then(Value::as_str).ok_or("bad step")?;
140 hex::decode(h).and_then(|b| b.try_into().ok()).ok_or_else(|| "bad step".to_string())
141 };
142 Ok(Step {
143 id: s.get("id").and_then(Value::as_str).ok_or("bad step")?.to_string(),
144 snapshot: id("snapshot")?,
145 message: s.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
146 })
147 })
148 .collect::<Result<Vec<_>>>()?;
149 Ok(Local {
150 root: root.to_path_buf(),
151 owner: owner.to_string(),
152 project: name.to_string(),
153 base: v.get("base").and_then(Value::as_i64).ok_or("state has no base")? as u64,
154 change: v.get("change").and_then(Value::as_i64).map(|n| n as u64),
155 steps,
156 })
157 }
158
159 fn save(&self) -> Result<()> {
160 let v = obj([
161 ("project", format!("{}/{}", self.owner, self.project).into()),
162 ("base", self.base.into()),
163 ("change", self.change.into()),
164 ("steps", Value::Array(self.steps.iter().map(crate::model::step_to_json).collect())),
165 ]);
166 let dir = self.root.join(".grove");
167 fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
168 let tmp = dir.join("state.json.tmp");
169 fs::write(&tmp, v.encode() + "\n").map_err(|e| e.to_string())?;
170 fs::rename(&tmp, dir.join("state.json")).map_err(|e| e.to_string())
171 }
172
173 /// What the working directory should be compared against: the last local step, or the base.
174 fn current(&self, project: &Project) -> Result<Id> {
175 match self.steps.last() {
176 Some(s) => Ok(s.snapshot),
177 None => project.point(self.base).map(|p| p.snapshot).ok_or_else(|| format!("point {} doesn't exist", self.base)),
178 }
179 }
180}
181
182fn open_store(cfg: &Config) -> Result<Store> {
183 let store = Store::open(&cfg.store).map_err(|e| e.to_string())?;
184 if store.recovery.truncated > 0 {
185 eprintln!("note: recovered from an interrupted write ({} bytes discarded)", store.recovery.truncated);
186 }
187 Ok(store)
188}
189
190fn s<T>(r: store::Result<T>) -> Result<T> {
191 r.map_err(|e| e.to_string())
192}
193
194// ── Formatting ─────────────────────────────────────────────
195
196fn ago(ms: i64) -> String {
197 let secs = (store::now_ms() - ms).max(0) / 1000;
198 match secs {
199 0..=59 => "just now".into(),
200 60..=3599 => format!("{}m ago", secs / 60),
201 3600..=86399 => format!("{}h ago", secs / 3600),
202 _ => format!("{}d ago", secs / 86400),
203 }
204}
205
206/// "claude for anton", using the agent's name rather than its id.
207fn who(store: &Store, a: &Actor) -> String {
208 match &a.agent {
209 Some(id) => format!("{} for {}", store.state().agent(id).map(|g| g.name.as_str()).unwrap_or(id), a.person),
210 None => a.person.clone(),
211 }
212}
213
214fn short(id: &Id) -> String {
215 hex::encode(&id[..6])
216}
217
218fn change_number(arg: Option<&str>, local: Option<&Local>) -> Result<u64> {
219 match arg {
220 Some(a) => a.trim_start_matches('#').parse().map_err(|_| format!("{a:?} isn't a change number")),
221 None => local.and_then(|l| l.change).ok_or_else(|| "which change? give its number".to_string()),
222 }
223}
224
225// ── Commands ───────────────────────────────────────────────
226
227pub fn run(argv: Vec<String>) -> Result<()> {
228 let cmd = argv.get(1).map(String::as_str).unwrap_or("help");
229 let rest = &argv[argv.len().min(2)..];
230 let args = Args::parse(rest, &["-m", "--message", "--note", "--agent", "--display", "--store", "--type", "--title", "--url", "--owner"]);
231 match cmd {
232 "setup" => setup(&args),
233 "init" => init(&args),
234 "checkout" => checkout(&args),
235 "status" | "st" => status(),
236 "diff" => diff_cmd(&args),
237 "step" => step(&args),
238 "amend" => amend(&args),
239 "stack" => stack(),
240 "propose" => propose(&args),
241 "say" => say(&args),
242 "attach" => attach(&args),
243 "changes" => changes(),
244 "show" => show(&args),
245 "land" => land(&args),
246 "decline" => decline(&args),
247 "log" => log(),
248 "sync" => sync(),
249 "verify" => verify(),
250 "group" => group(&args),
251 "move" => move_project(&args),
252 "help" | "--help" | "-h" => {
253 print!("{HELP}");
254 Ok(())
255 }
256 other => e!("unknown command {other:?}; see `grove help`"),
257 }
258}
259
260const HELP: &str = "grove: where agents build software for people
261
262 setup <username> [--agent NAME] [--store DIR] who you are, and the agent acting for you
263 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
264 checkout <owner/name> [DIR] get a project's files
265 status what changed since the last step
266 diff [--stat] those changes, line by line
267 step -m MESSAGE record the working directory as a new step
268 amend [-m MESSAGE] replace the last step with the working directory
269 stack the local steps
270 propose -m INTENT [--note NOTE] send the steps as a change (or a new revision)
271 say [#N] TEXT add to a change's discussion
272 attach [#N] FILE [--type TYPE] [--title T] attach evidence to the latest revision
273 changes a project's changes
274 show [#N] one change: intent, revisions, discussion
275 land #N land a change (you, in person)
276 decline #N decline a change (you, in person)
277 log main, newest first
278 sync move the working directory to the newest main
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)
282";
283
284fn setup(args: &Args) -> Result<()> {
285 let username = args.pos(0).ok_or("usage: grove setup <username> [--agent NAME]")?;
286 // Never overwrite a config grove didn't write.
287 if let Ok(existing) = fs::read_to_string(config_path()) {
288 let ours = json::parse(&existing).map(|v| v.get("store").is_some() && v.get("person").is_some()).unwrap_or(false);
289 if !ours {
290 return e!("{} exists and isn't a grove config; move it aside first", config_path().display());
291 }
292 }
293 let store_dir = args.value(&["--store"]).map(PathBuf::from).unwrap_or_else(|| grove_home().join("store"));
294 let mut store = Store::open(&store_dir).map_err(|e| e.to_string())?;
295 if store.state().person(username).is_none() {
296 s(store.add_person(username, args.value(&["--display"]).unwrap_or(username)))?;
297 println!("created person {username}");
298 }
299 let agent = match args.value(&["--agent"]) {
300 Some(name) => {
301 let existing = store.state().agents.iter().find(|a| a.person == username && a.name == name && !a.revoked).map(|a| a.id.clone());
302 Some(match existing {
303 Some(id) => id,
304 None => {
305 let id = s(store.add_agent(username, name))?;
306 println!("registered agent {name} ({id}) acting for {username}");
307 id
308 }
309 })
310 }
311 None => None,
312 };
313 let cfg = obj([("store", store_dir.display().to_string().into()), ("person", username.into()), ("agent", agent.into())]);
314 fs::create_dir_all(config_path().parent().unwrap()).map_err(|e| e.to_string())?;
315 fs::write(config_path(), cfg.encode() + "\n").map_err(|e| e.to_string())?;
316 println!("config written to {}", config_path().display());
317 Ok(())
318}
319
320fn init(args: &Args) -> Result<()> {
321 let cfg = Config::load()?;
322 let name = args.pos(0).ok_or("usage: grove init <name>")?;
323 let root = std::env::current_dir().map_err(|e| e.to_string())?;
324 if root.join(".grove").exists() {
325 return e!("this folder is already a grove working directory");
326 }
327 let mut store = open_store(&cfg)?;
328 let me = cfg.in_person();
329 let owner = args.value(&["--owner"]).unwrap_or(&cfg.person).to_string();
330 s(store.create_project(&me, &owner, name, !args.has("--private")))?;
331 let snap = workdir::snapshot(&store.objects, &root).map_err(|e| e.to_string())?;
332 let message = args.value(&["-m", "--message"]).unwrap_or("Start");
333 let n = s(store.record_point(&me, &owner, name, &snap, message, Some(&cfg.actor())))?;
334 let files = store.objects.flatten(&snap).map_err(|e| e.to_string())?.len();
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");
337 Ok(())
338}
339
340fn checkout(args: &Args) -> Result<()> {
341 let cfg = Config::load()?;
342 let full = args.pos(0).ok_or("usage: grove checkout <owner/name> [dir]")?;
343 let (owner, name) = full.split_once('/').ok_or("give the project as owner/name")?;
344 let dir = PathBuf::from(args.pos(1).unwrap_or(name));
345 if dir.join(".grove").exists() {
346 return e!("{} is already a working directory", dir.display());
347 }
348 let store = open_store(&cfg)?;
349 let p = s(store.project(owner, name))?;
350 let tip = p.points.last().ok_or("the project has no points yet")?;
351 fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
352 workdir::materialize(&store.objects, &tip.snapshot, &dir, None).map_err(|e| e.to_string())?;
353 Local { root: dir.clone(), owner: owner.into(), project: name.into(), base: tip.n, change: None, steps: Vec::new() }.save()?;
354 println!("{owner}/{name}@{} in {}", tip.n, dir.display());
355 Ok(())
356}
357
358fn working_changes(store: &Store, local: &Local) -> Result<(Id, Id, Vec<(String, FileChange)>)> {
359 let p = s(store.project(&local.owner, &local.project))?;
360 let current = local.current(p)?;
361 let now = workdir::snapshot(&store.objects, &local.root).map_err(|e| e.to_string())?;
362 let changes = workdir::compare(&store.objects, &current, &now).map_err(|e| e.to_string())?;
363 Ok((current, now, changes))
364}
365
366fn status() -> Result<()> {
367 let cfg = Config::load()?;
368 let local = Local::find()?;
369 let store = open_store(&cfg)?;
370 let p = s(store.project(&local.owner, &local.project))?;
371 let behind = p.tip().saturating_sub(local.base);
372 println!(
373 "{}/{} on @{}{}{}",
374 local.owner,
375 local.project,
376 local.base,
377 if behind > 0 { format!(" ({behind} behind main)") } else { String::new() },
378 local.change.map(|c| format!(", change #{c}")).unwrap_or_default()
379 );
380 if !local.steps.is_empty() {
381 println!("{} local step{}", local.steps.len(), if local.steps.len() == 1 { "" } else { "s" });
382 }
383 let (_, _, changes) = working_changes(&store, &local)?;
384 if changes.is_empty() {
385 println!("no changes since the last step");
386 }
387 for (path, c) in changes {
388 let mark = match c {
389 FileChange::Added => 'A',
390 FileChange::Removed => 'D',
391 FileChange::Modified => 'M',
392 };
393 println!("{mark} {path}");
394 }
395 Ok(())
396}
397
398fn print_diff(store: &Store, a: &Id, b: &Id, stat: bool) -> Result<()> {
399 let files_a: std::collections::BTreeMap<_, _> = store.objects.flatten(a).map_err(|e| e.to_string())?.into_iter().map(|(p, _, i)| (p, i)).collect();
400 let files_b: std::collections::BTreeMap<_, _> = store.objects.flatten(b).map_err(|e| e.to_string())?.into_iter().map(|(p, _, i)| (p, i)).collect();
401 for (path, _) in workdir::compare(&store.objects, a, b).map_err(|e| e.to_string())? {
402 let read = |m: &std::collections::BTreeMap<String, Id>| -> Result<Vec<u8>> {
403 m.get(&path).map(|id| store.objects.get_blob(id).map_err(|e| e.to_string())).transpose().map(Option::unwrap_or_default)
404 };
405 let (old, new) = (read(&files_a)?, read(&files_b)?);
406 match (std::str::from_utf8(&old), std::str::from_utf8(&new)) {
407 (Ok(o), Ok(n)) => {
408 let d = diff::unified(o, n, 3);
409 if stat {
410 let adds = d.lines().filter(|l| l.starts_with('+')).count();
411 let dels = d.lines().filter(|l| l.starts_with('-')).count();
412 println!("{path} +{adds} -{dels}");
413 } else {
414 println!("── {path}");
415 print!("{d}");
416 }
417 }
418 _ => println!("── {path} binary, {} → {} bytes", old.len(), new.len()),
419 }
420 }
421 Ok(())
422}
423
424fn diff_cmd(args: &Args) -> Result<()> {
425 let cfg = Config::load()?;
426 let local = Local::find()?;
427 let store = open_store(&cfg)?;
428 let (current, now, _) = working_changes(&store, &local)?;
429 print_diff(&store, &current, &now, args.has("--stat"))
430}
431
432fn step(args: &Args) -> Result<()> {
433 let cfg = Config::load()?;
434 let mut local = Local::find()?;
435 let store = open_store(&cfg)?;
436 let message = args.value(&["-m", "--message"]).ok_or("give the step a message with -m")?;
437 let (_, now, changes) = working_changes(&store, &local)?;
438 if changes.is_empty() {
439 return e!("nothing changed since the last step");
440 }
441 local.steps.push(Step { id: random::hex(4), snapshot: now, message: message.to_string() });
442 local.save()?;
443 println!("step {} recorded ({} file{})", local.steps.len(), changes.len(), if changes.len() == 1 { "" } else { "s" });
444 Ok(())
445}
446
447fn amend(args: &Args) -> Result<()> {
448 let cfg = Config::load()?;
449 let mut local = Local::find()?;
450 let store = open_store(&cfg)?;
451 if local.steps.is_empty() {
452 return e!("no local step to amend; use `grove step`");
453 }
454 let now = workdir::snapshot(&store.objects, &local.root).map_err(|e| e.to_string())?;
455 let last = local.steps.last_mut().unwrap();
456 last.snapshot = now;
457 if let Some(m) = args.value(&["-m", "--message"]) {
458 last.message = m.to_string();
459 }
460 local.save()?;
461 println!("step {} amended", local.steps.len());
462 Ok(())
463}
464
465fn stack() -> Result<()> {
466 let local = Local::find()?;
467 if local.steps.is_empty() {
468 println!("no local steps");
469 }
470 for (i, st) in local.steps.iter().enumerate() {
471 println!("{} {} {}", i + 1, short(&st.snapshot), st.message);
472 }
473 if let Some(c) = local.change {
474 println!("sent as change #{c}");
475 }
476 Ok(())
477}
478
479fn propose(args: &Args) -> Result<()> {
480 let cfg = Config::load()?;
481 let mut local = Local::find()?;
482 let mut store = open_store(&cfg)?;
483 if local.steps.is_empty() {
484 return e!("no steps to propose; record some with `grove step`");
485 }
486 let me = cfg.actor();
487 let (owner, name) = (local.owner.clone(), local.project.clone());
488 let number = match local.change {
489 Some(n) => n,
490 None => {
491 let intent = args.value(&["-m", "--message"]).ok_or("say what the change is for with -m")?;
492 s(store.open_change(&me, &owner, &name, intent))?
493 }
494 };
495 let rev = s(store.revise(&me, &owner, &name, number, local.base, &local.steps, args.value(&["--note"])))?;
496 let state = s(store.project(&owner, &name))?.change(number).unwrap().state;
497 if state == ChangeState::Draft {
498 s(store.set_state(&me, &owner, &name, number, ChangeState::Proposed))?;
499 }
500 local.change = Some(number);
501 local.save()?;
502 println!("{owner}/{name}#{number} r{rev} proposed ({} step{})", local.steps.len(), if local.steps.len() == 1 { "" } else { "s" });
503 Ok(())
504}
505
506fn say(args: &Args) -> Result<()> {
507 let cfg = Config::load()?;
508 let local = Local::find().ok();
509 let (number, text) = match args.pos(0) {
510 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.positional[1..].join(" ")),
511 _ => (change_number(None, local.as_ref())?, args.positional.join(" ")),
512 };
513 if text.trim().is_empty() {
514 return e!("say what?");
515 }
516 let local = local.ok_or("run this inside the project's working directory")?;
517 let mut store = open_store(&cfg)?;
518 s(store.say(&cfg.actor(), &local.owner, &local.project, number, &text))?;
519 println!("added to #{number}");
520 Ok(())
521}
522
523fn attach(args: &Args) -> Result<()> {
524 let cfg = Config::load()?;
525 let local = Local::find()?;
526 let (number, file) = match args.pos(0) {
527 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.pos(1)),
528 _ => (change_number(None, Some(&local))?, args.pos(0)),
529 };
530 let mut store = open_store(&cfg)?;
531 let rev = s(store.project(&local.owner, &local.project))?
532 .change(number)
533 .and_then(Change::latest)
534 .map(|r| r.number)
535 .ok_or("that change has no revision to attach to")?;
536 let me = cfg.actor();
537 if let Some(url) = args.value(&["--url"]) {
538 let title = args.value(&["--title"]).unwrap_or(url);
539 s(store.attach(&me, &local.owner, &local.project, number, rev, title, "text/uri-list", Content::Url(url)))?;
540 } else {
541 let file = file.ok_or("attach a file, or a link with --url")?;
542 let bytes = fs::read(file).map_err(|e| format!("{file}: {e}"))?;
543 let media = args.value(&["--type"]).map(str::to_string).unwrap_or_else(|| guess_type(file));
544 let title = args.value(&["--title"]).unwrap_or(file);
545 s(store.attach(&me, &local.owner, &local.project, number, rev, title, &media, Content::Bytes(&bytes)))?;
546 }
547 println!("attached to #{number} r{rev}");
548 Ok(())
549}
550
551fn guess_type(file: &str) -> String {
552 let ext = file.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
553 match ext.as_str() {
554 "md" => "text/markdown",
555 "txt" | "log" => "text/plain",
556 "json" => "application/json",
557 "png" => "image/png",
558 "jpg" | "jpeg" => "image/jpeg",
559 "gif" => "image/gif",
560 "svg" => "image/svg+xml",
561 "html" => "text/html",
562 "mp4" => "video/mp4",
563 _ => "application/octet-stream",
564 }
565 .to_string()
566}
567
568fn changes() -> Result<()> {
569 let cfg = Config::load()?;
570 let local = Local::find()?;
571 let store = open_store(&cfg)?;
572 let p = s(store.project(&local.owner, &local.project))?;
573 if p.changes.is_empty() {
574 println!("no changes yet");
575 }
576 for c in p.changes.iter().rev() {
577 println!("#{:<4} {:<10} {} ({}, {})", c.number, c.state.name(), c.intent, who(&store, &c.proposer), ago(c.at));
578 }
579 Ok(())
580}
581
582fn show(args: &Args) -> Result<()> {
583 let cfg = Config::load()?;
584 let local = Local::find()?;
585 let number = change_number(args.pos(0), Some(&local))?;
586 let store = open_store(&cfg)?;
587 let p = s(store.project(&local.owner, &local.project))?;
588 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
589 println!("#{} {} [{}]", c.number, c.intent, c.state.name());
590 println!("proposed by {}, {}", who(&store, &c.proposer), ago(c.at));
591 for (at, by, ev) in &c.timeline {
592 let line = match ev {
593 Event::Opened => "opened".to_string(),
594 Event::Revised(n) => {
595 let r = &c.revisions[*n as usize - 1];
596 let mut l = format!("r{n} on @{}: {} step{}", r.base, r.steps.len(), if r.steps.len() == 1 { "" } else { "s" });
597 for st in &r.steps {
598 l += &format!("\n {} {}", short(&st.snapshot), st.message);
599 }
600 if let Some(note) = &r.note {
601 l += &format!("\n note: {note}");
602 }
603 l
604 }
605 Event::State(s) => format!("→ {}", s.name()),
606 Event::Said(id) => format!("said: {}", c.messages[*id as usize - 1].body),
607 Event::Attached(i) => {
608 let ev = &c.evidence[*i];
609 format!("evidence on r{}: {} ({})", ev.revision, ev.title, ev.url.clone().unwrap_or_else(|| ev.media_type.clone()))
610 }
611 Event::Landed { revision, points } => format!("landed r{revision} as @{}..@{}", points.0, points.1),
612 };
613 println!(" {:<10} {}: {}", ago(*at), who(&store, by), line);
614 }
615 if let Some(r) = c.latest() {
616 if c.state.is_open() {
617 let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?;
618 println!();
619 print_diff(&store, &base, &r.steps.last().unwrap().snapshot, true)?;
620 }
621 }
622 Ok(())
623}
624
625fn land(args: &Args) -> Result<()> {
626 let cfg = Config::load()?;
627 let local = Local::find()?;
628 let number = change_number(args.pos(0), None)?;
629 let mut store = open_store(&cfg)?;
630 let (first, last) = s(store.land(&cfg.in_person(), &local.owner, &local.project, number))?;
631 println!("#{number} landed as @{first}..@{last}");
632 Ok(())
633}
634
635fn decline(args: &Args) -> Result<()> {
636 let cfg = Config::load()?;
637 let local = Local::find()?;
638 let number = change_number(args.pos(0), None)?;
639 let mut store = open_store(&cfg)?;
640 s(store.set_state(&cfg.in_person(), &local.owner, &local.project, number, ChangeState::Declined))?;
641 println!("#{number} declined");
642 Ok(())
643}
644
645fn log() -> Result<()> {
646 let cfg = Config::load()?;
647 let local = Local::find()?;
648 let store = open_store(&cfg)?;
649 let p = s(store.project(&local.owner, &local.project))?;
650 for pt in p.points.iter().rev() {
651 let from = pt.change.map(|c| format!(" #{c}")).unwrap_or_default();
652 println!("@{:<5} {}{} ({}, {})", pt.n, pt.message, from, who(&store, &pt.author), ago(pt.at));
653 }
654 Ok(())
655}
656
657fn sync() -> Result<()> {
658 let cfg = Config::load()?;
659 let mut local = Local::find()?;
660 let store = open_store(&cfg)?;
661 let p = s(store.project(&local.owner, &local.project))?;
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.
664 if let Some(c) = local.change.and_then(|n| p.change(n)) {
665 if c.state == ChangeState::Landed {
666 let landed = c.timeline.iter().rev().find_map(|(_, _, ev)| match ev {
667 Event::Landed { revision, points } => Some((*revision, points.1)),
668 _ => None,
669 });
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 {
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);
679 }
680 }
681 local.change = None;
682 }
683 }
684 if p.tip() == local.base {
685 local.save()?;
686 println!("already at @{}", local.base);
687 return Ok(());
688 }
689 if !local.steps.is_empty() {
690 return e!("there are local steps on @{}; rebuilding them on @{} needs merging, which isn't built yet", local.base, p.tip());
691 }
692 let (_, _, pending) = working_changes(&store, &local)?;
693 if !pending.is_empty() {
694 return e!("the working directory has changes; record them as a step first");
695 }
696 let from = p.point(local.base).map(|pt| pt.snapshot).ok_or("base missing")?;
697 let tip = p.points.last().unwrap();
698 workdir::materialize(&store.objects, &tip.snapshot, &local.root, Some(&from)).map_err(|e| e.to_string())?;
699 local.base = tip.n;
700 local.save()?;
701 println!("now at @{}", tip.n);
702 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()
732}
733
734fn verify() -> Result<()> {
735 let cfg = Config::load()?;
736 let store = open_store(&cfg)?;
737 let n = s(store.verify())?;
738 println!("{n} objects checked, all match their ids; {} log records", store.state().records);
739 Ok(())
740}