grove
at @8
1//! The `grove` command, against a store on this machine or a grove server.
2//!
3//! `~/.grove2/config.json` (not `~/.grove`, which the old grove CLI uses) says who's acting and
4//! where. With `server` and `token` set, commands go to that server, acting as the token's
5//! agent; `person_token` is used for decisions made in person (land, decline, groups). Without
6//! a server, commands work on the local store at `store`. Environment variables override the
7//! file: GROVE_STORE, GROVE_SERVER, GROVE_TOKEN, GROVE_PERSON_TOKEN, GROVE_PERSON, GROVE_AGENT.
8
9use crate::backend::{Backend, Local as LocalStore, Remote};
10use crate::diff;
11use crate::json::{self, obj, Value};
12use crate::model::{Actor, Change, ChangeState, Event, Project, Step};
13use crate::objects::{Id, Objects};
14use crate::store::{self, Content, Store};
15use crate::workdir::{self, FileChange};
16use crate::{hex, random};
17use std::fs;
18use std::path::{Path, PathBuf};
19
20type Result<T> = std::result::Result<T, String>;
21
22macro_rules! e {
23 ($($arg:tt)*) => { Err(format!($($arg)*)) };
24}
25
26// ── Arguments ──────────────────────────────────────────────
27
28struct Args {
29 positional: Vec<String>,
30 flags: Vec<(String, Option<String>)>,
31}
32
33impl Args {
34 fn parse(raw: &[String], with_values: &[&str]) -> Args {
35 let mut positional = Vec::new();
36 let mut flags = Vec::new();
37 let mut i = 0;
38 while i < raw.len() {
39 let a = &raw[i];
40 if a.starts_with('-') && a.len() > 1 {
41 if with_values.contains(&a.as_str()) && i + 1 < raw.len() {
42 flags.push((a.clone(), Some(raw[i + 1].clone())));
43 i += 1;
44 } else {
45 flags.push((a.clone(), None));
46 }
47 } else {
48 positional.push(a.clone());
49 }
50 i += 1;
51 }
52 Args { positional, flags }
53 }
54 fn value(&self, names: &[&str]) -> Option<&str> {
55 self.flags.iter().rev().find(|(f, _)| names.contains(&f.as_str())).and_then(|(_, v)| v.as_deref())
56 }
57 fn has(&self, name: &str) -> bool {
58 self.flags.iter().any(|(f, _)| f == name)
59 }
60 fn pos(&self, i: usize) -> Option<&str> {
61 self.positional.get(i).map(String::as_str)
62 }
63}
64
65// ── Config ─────────────────────────────────────────────────
66
67fn home() -> PathBuf {
68 PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into()))
69}
70
71fn grove_home() -> PathBuf {
72 home().join(".grove2")
73}
74
75fn config_path() -> PathBuf {
76 grove_home().join("config.json")
77}
78
79struct Config {
80 store: Option<PathBuf>,
81 server: Option<String>,
82 token: Option<String>,
83 person_token: Option<String>,
84 person: String,
85 agent: Option<String>,
86}
87
88impl Config {
89 fn load() -> Result<Config> {
90 let file = match fs::read_to_string(config_path()) {
91 Ok(text) => json::parse(&text).map_err(|e| format!("{}: {e}", config_path().display()))?,
92 Err(_) => Value::Object(Vec::new()),
93 };
94 // An empty value (in the environment or the file) counts as not set.
95 let get = |env: &str, key: &str| {
96 std::env::var(env).ok().or_else(|| file.get(key).and_then(Value::as_str).map(str::to_string)).filter(|v| !v.is_empty())
97 };
98 Ok(Config {
99 store: get("GROVE_STORE", "store").map(PathBuf::from),
100 server: get("GROVE_SERVER", "server"),
101 token: get("GROVE_TOKEN", "token"),
102 person_token: get("GROVE_PERSON_TOKEN", "person_token"),
103 person: get("GROVE_PERSON", "person").ok_or("not set up yet: run `grove setup <username>` or `grove login <server>`")?,
104 agent: get("GROVE_AGENT", "agent"),
105 })
106 }
107 fn actor(&self) -> Actor {
108 Actor { person: self.person.clone(), agent: self.agent.clone() }
109 }
110 fn in_person(&self) -> Actor {
111 Actor::person(&self.person)
112 }
113 fn store(&self) -> Result<Store> {
114 let dir = self.store.as_ref().ok_or("no local store configured")?;
115 let store = Store::open(dir).map_err(|e| e.to_string())?;
116 if store.recovery.truncated > 0 {
117 eprintln!("note: recovered from an interrupted write ({} bytes discarded)", store.recovery.truncated);
118 }
119 Ok(store)
120 }
121}
122
123/// The server, if one is configured; otherwise the local store.
124fn connect(cfg: &Config) -> Result<Box<dyn Backend>> {
125 match (&cfg.server, &cfg.token) {
126 (Some(server), Some(token)) => {
127 let cache = Objects::open(&grove_home().join("cache")).map_err(|e| e.to_string())?;
128 Ok(Box::new(Remote::new(server, token, cfg.person_token.as_deref(), cache)))
129 }
130 (Some(_), None) => e!("a server is configured but no token; run `grove login`"),
131 _ => Ok(Box::new(LocalStore(cfg.store()?))),
132 }
133}
134
135fn write_config(fields: Vec<(&str, Value)>) -> Result<()> {
136 let v = Value::Object(fields.into_iter().filter(|(_, v)| !v.is_null()).map(|(k, v)| (k.to_string(), v)).collect());
137 fs::create_dir_all(grove_home()).map_err(|e| e.to_string())?;
138 let path = config_path();
139 fs::write(&path, v.encode() + "\n").map_err(|e| e.to_string())?;
140 #[cfg(unix)]
141 {
142 use std::os::unix::fs::PermissionsExt;
143 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
144 }
145 Ok(())
146}
147
148/// Never overwrite a config grove didn't write.
149fn check_config_is_ours() -> Result<()> {
150 if let Ok(existing) = fs::read_to_string(config_path()) {
151 let ours = json::parse(&existing).map(|v| v.get("person").is_some()).unwrap_or(false);
152 if !ours {
153 return e!("{} exists and isn't a grove config; move it aside first", config_path().display());
154 }
155 }
156 Ok(())
157}
158
159// ── Working directory state ────────────────────────────────
160
161/// A working directory's local state: which project, which point it's on, and the steps
162/// not yet sent anywhere.
163struct Local {
164 root: PathBuf,
165 owner: String,
166 project: String,
167 base: u64,
168 change: Option<u64>,
169 steps: Vec<Step>,
170}
171
172impl Local {
173 fn find() -> Result<Local> {
174 let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
175 loop {
176 let path = dir.join(".grove").join("state.json");
177 if path.is_file() {
178 return Local::load(&dir, &path);
179 }
180 if !dir.pop() {
181 return e!("not inside a grove working directory");
182 }
183 }
184 }
185
186 fn load(root: &Path, path: &Path) -> Result<Local> {
187 let v = json::parse(&fs::read_to_string(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
188 let project = v.get("project").and_then(Value::as_str).ok_or("state has no project")?;
189 let (owner, name) = project.split_once('/').ok_or("bad project in state")?;
190 let steps = v
191 .get("steps")
192 .and_then(Value::as_array)
193 .unwrap_or(&[])
194 .iter()
195 .map(|s| {
196 let h = s.get("snapshot").and_then(Value::as_str).ok_or("bad step")?;
197 Ok(Step {
198 id: s.get("id").and_then(Value::as_str).ok_or("bad step")?.to_string(),
199 snapshot: hex::decode(h).and_then(|b| b.try_into().ok()).ok_or("bad step")?,
200 message: s.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
201 })
202 })
203 .collect::<Result<Vec<_>>>()?;
204 Ok(Local {
205 root: root.to_path_buf(),
206 owner: owner.to_string(),
207 project: name.to_string(),
208 base: v.get("base").and_then(Value::as_i64).ok_or("state has no base")? as u64,
209 change: v.get("change").and_then(Value::as_i64).map(|n| n as u64),
210 steps,
211 })
212 }
213
214 fn save(&self) -> Result<()> {
215 let v = obj([
216 ("project", format!("{}/{}", self.owner, self.project).into()),
217 ("base", self.base.into()),
218 ("change", self.change.into()),
219 ("steps", Value::Array(self.steps.iter().map(crate::model::step_to_json).collect())),
220 ]);
221 let dir = self.root.join(".grove");
222 fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
223 let tmp = dir.join("state.json.tmp");
224 fs::write(&tmp, v.encode() + "\n").map_err(|e| e.to_string())?;
225 fs::rename(&tmp, dir.join("state.json")).map_err(|e| e.to_string())
226 }
227
228 /// What the working directory should be compared against: the last local step, or the base.
229 fn current(&self, project: &Project) -> Result<Id> {
230 match self.steps.last() {
231 Some(s) => Ok(s.snapshot),
232 None => project.point(self.base).map(|p| p.snapshot).ok_or_else(|| format!("point {} doesn't exist", self.base)),
233 }
234 }
235
236 fn project(&self, b: &mut dyn Backend) -> Result<Project> {
237 b.project(&self.owner, &self.project)
238 }
239}
240
241// ── Formatting ─────────────────────────────────────────────
242
243fn ago(ms: i64) -> String {
244 let secs = (store::now_ms() - ms).max(0) / 1000;
245 match secs {
246 0..=59 => "just now".into(),
247 60..=3599 => format!("{}m ago", secs / 60),
248 3600..=86399 => format!("{}h ago", secs / 3600),
249 _ => format!("{}d ago", secs / 86400),
250 }
251}
252
253/// "claude for anton", using the agent's name rather than its id.
254fn who(b: &dyn Backend, a: &Actor) -> String {
255 match &a.agent {
256 Some(id) => format!("{} for {}", b.agent_name(id).unwrap_or_else(|| id.clone()), a.person),
257 None => a.person.clone(),
258 }
259}
260
261fn short(id: &Id) -> String {
262 hex::encode(&id[..6])
263}
264
265fn plural(n: usize, word: &str) -> String {
266 format!("{n} {word}{}", if n == 1 { "" } else { "s" })
267}
268
269fn change_number(arg: Option<&str>, local: Option<&Local>) -> Result<u64> {
270 match arg {
271 Some(a) => a.trim_start_matches('#').parse().map_err(|_| format!("{a:?} isn't a change number")),
272 None => local.and_then(|l| l.change).ok_or_else(|| "which change? give its number".to_string()),
273 }
274}
275
276fn err<E: std::fmt::Display>(e: E) -> String {
277 e.to_string()
278}
279
280// ── Commands ───────────────────────────────────────────────
281
282pub fn run(argv: Vec<String>) -> Result<()> {
283 let cmd = argv.get(1).map(String::as_str).unwrap_or("help");
284 let rest = &argv[argv.len().min(2)..];
285 let args = Args::parse(
286 rest,
287 &["-m", "--message", "--note", "--agent", "--display", "--store", "--type", "--title", "--url", "--owner", "--token", "--person-token", "--addr"],
288 );
289 match cmd {
290 "setup" => setup(&args),
291 "login" => login(&args),
292 "join" => join(&args),
293 "watch" => watch(&args),
294 "init" => init(&args),
295 "checkout" => checkout(&args),
296 "status" | "st" => status(),
297 "diff" => diff_cmd(&args),
298 "step" => step(&args),
299 "amend" => amend(&args),
300 "stack" => stack(),
301 "propose" => propose(&args),
302 "say" => say(&args),
303 "attach" => attach(&args),
304 "changes" => changes(),
305 "show" => show(&args),
306 "land" => land(&args),
307 "decline" => decline(&args),
308 "log" => log(),
309 "sync" => sync(),
310 "restart" => restart(),
311 "group" => group(&args),
312 "move" => move_project(&args),
313 "agent" => agent_cmd(&args),
314 "token" => token_cmd(&args),
315 "verify" => verify(),
316 "serve" => serve(&args),
317 "help" | "--help" | "-h" => {
318 print!("{HELP}");
319 Ok(())
320 }
321 other => e!("unknown command {other:?}; see `grove help`"),
322 }
323}
324
325const HELP: &str = "grove: where agents build software for people
326
327 join <server> <username> [--agent NAME] create an account on a grove server
328 login <server> --token T [--person-token P] work against a grove server
329 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
330 checkout <owner/name> [DIR] get a project's files
331 status what changed since the last step
332 diff [--stat] [#N [rM]] working changes, or a change's revision
333 step -m MESSAGE record the working directory as a new step
334 amend [-m MESSAGE] replace the last step with the working directory
335 stack the local steps
336 propose -m INTENT [--note NOTE] send the steps as a change (or a new revision)
337 say [#N] TEXT add to a change's discussion
338 attach [#N] FILE [--type T] [--title T] | --url U evidence for the latest revision
339 changes a project's changes
340 show [#N] one change: intent, revisions, discussion
341 watch [#N] [--follow] wait for someone else to add to a change
342 land #N land a change (in person)
343 decline #N decline a change (in person)
344 log main, newest first
345 sync move the working directory to the newest main
346 restart move to the newest main, dropping proposed steps
347 group create NAME | group add NAME PERSON groups own projects together (in person)
348 move OWNER give this project to another owner (in person)
349
350 On the machine that holds a store:
351 setup <username> [--agent NAME] [--store DIR] create a person (and agent) in a local store
352 agent add NAME register an agent acting for you
353 token [--agent ID] issue a token for you or one of your agents
354 serve [--addr HOST:PORT] run the HTTP API over the local store
355 verify check every stored object against its id
356";
357
358fn setup(args: &Args) -> Result<()> {
359 check_config_is_ours()?;
360 let username = args.pos(0).ok_or("usage: grove setup <username> [--agent NAME]")?;
361 let store_dir = args.value(&["--store"]).map(PathBuf::from).unwrap_or_else(|| grove_home().join("store"));
362 let mut store = Store::open(&store_dir).map_err(err)?;
363 if store.state().person(username).is_none() {
364 store.add_person(username, args.value(&["--display"]).unwrap_or(username)).map_err(err)?;
365 println!("created person {username}");
366 }
367 let agent = match args.value(&["--agent"]) {
368 Some(name) => Some(ensure_agent(&mut store, username, name)?),
369 None => None,
370 };
371 write_config(vec![("store", store_dir.display().to_string().into()), ("person", username.into()), ("agent", agent.into())])?;
372 println!("config written to {}", config_path().display());
373 Ok(())
374}
375
376fn ensure_agent(store: &mut Store, person: &str, name: &str) -> Result<String> {
377 if let Some(a) = store.state().agents.iter().find(|a| a.person == person && a.name == name && !a.revoked) {
378 return Ok(a.id.clone());
379 }
380 let id = store.add_agent(person, name).map_err(err)?;
381 println!("registered agent {name} ({id}) acting for {person}");
382 Ok(id)
383}
384
385fn login(args: &Args) -> Result<()> {
386 check_config_is_ours()?;
387 let server = args.pos(0).ok_or("usage: grove login <server> --token T [--person-token P]")?;
388 let token = args.value(&["--token"]).ok_or("give a token with --token")?;
389 let me = crate::client::Client::new(server, Some(token.to_string())).get("/api/whoami")?;
390 let person = me.get("person").and_then(Value::as_str).ok_or("the server didn't say who the token is")?.to_string();
391 let agent = me.get("agent").and_then(Value::as_str).map(str::to_string);
392 if let Some(pt) = args.value(&["--person-token"]) {
393 let p = crate::client::Client::new(server, Some(pt.to_string())).get("/api/whoami")?;
394 if p.get("person").and_then(Value::as_str) != Some(&person) || !p.get("agent").is_none_or(Value::is_null) {
395 return e!("--person-token must be {person}'s own token, not an agent's");
396 }
397 }
398 let previous_store = Config::load().ok().and_then(|c| c.store).map(|p| p.display().to_string());
399 write_config(vec![
400 ("server", server.into()),
401 ("token", token.into()),
402 ("person_token", args.value(&["--person-token"]).map(str::to_string).into()),
403 ("person", person.clone().into()),
404 ("agent", agent.clone().into()),
405 ("store", previous_store.into()),
406 ])?;
407 let as_who = match me.get("agent_name").and_then(Value::as_str) {
408 Some(name) => format!("{name} for {person}"),
409 None => person,
410 };
411 println!("signed in to {server} as {as_who}");
412 Ok(())
413}
414
415fn join(args: &Args) -> Result<()> {
416 check_config_is_ours()?;
417 let (server, username) = match (args.pos(0), args.pos(1)) {
418 (Some(s), Some(u)) => (s, u),
419 _ => return e!("usage: grove join <server> <username> [--agent NAME]"),
420 };
421 let mut body = vec![("username", Value::from(username))];
422 if let Some(d) = args.value(&["--display"]) {
423 body.push(("display_name", d.into()));
424 }
425 if let Some(a) = args.value(&["--agent"]) {
426 body.push(("agent", a.into()));
427 }
428 let r = crate::client::Client::new(server, None).post("/api/people", &Value::Object(body.into_iter().map(|(k, v)| (k.to_string(), v)).collect()))?;
429 let get = |k: &str| r.get(k).and_then(Value::as_str).map(str::to_string);
430 let person_token = get("person_token").ok_or("the server sent no token")?;
431 let agent_token = get("agent_token");
432 write_config(vec![
433 ("server", server.into()),
434 ("token", agent_token.clone().unwrap_or_else(|| person_token.clone()).into()),
435 ("person_token", person_token.into()),
436 ("person", username.into()),
437 ("agent", get("agent").into()),
438 ])?;
439 match args.value(&["--agent"]) {
440 Some(a) => println!("joined {server} as {username}; {a} acts for you"),
441 None => println!("joined {server} as {username}"),
442 }
443 Ok(())
444}
445
446fn init(args: &Args) -> Result<()> {
447 let cfg = Config::load()?;
448 let name = args.pos(0).ok_or("usage: grove init <name>")?;
449 let root = std::env::current_dir().map_err(err)?;
450 if root.join(".grove").exists() {
451 return e!("this folder is already a grove working directory");
452 }
453 let mut b = connect(&cfg)?;
454 let me = cfg.in_person();
455 let owner = args.value(&["--owner"]).unwrap_or(&cfg.person).to_string();
456 b.create_project(&me, &owner, name, !args.has("--private"))?;
457 let snap = workdir::snapshot(b.objects(), &root).map_err(err)?;
458 let n = b.record_point(&me, &owner, name, &snap, args.value(&["-m", "--message"]).unwrap_or("Start"))?;
459 let files = b.objects().flatten(&snap).map_err(err)?.len();
460 Local { root, owner: owner.clone(), project: name.to_string(), base: n, change: None, steps: Vec::new() }.save()?;
461 println!("{owner}/{name}@{n}: {}", plural(files, "file"));
462 Ok(())
463}
464
465fn checkout(args: &Args) -> Result<()> {
466 let cfg = Config::load()?;
467 let full = args.pos(0).ok_or("usage: grove checkout <owner/name> [dir]")?;
468 let (owner, name) = full.split_once('/').ok_or("give the project as owner/name")?;
469 let dir = PathBuf::from(args.pos(1).unwrap_or(name));
470 if dir.join(".grove").exists() {
471 return e!("{} is already a working directory", dir.display());
472 }
473 let mut b = connect(&cfg)?;
474 let p = b.project(owner, name)?;
475 let tip = p.points.last().ok_or("the project has no points yet")?;
476 b.need(&tip.snapshot)?;
477 fs::create_dir_all(&dir).map_err(err)?;
478 workdir::materialize(b.objects(), &tip.snapshot, &dir, None).map_err(err)?;
479 Local { root: dir.clone(), owner: owner.into(), project: name.into(), base: tip.n, change: None, steps: Vec::new() }.save()?;
480 println!("{owner}/{name}@{} in {}", tip.n, dir.display());
481 Ok(())
482}
483
484fn working_changes(b: &mut dyn Backend, p: &Project, local: &Local) -> Result<(Id, Id, Vec<(String, FileChange)>)> {
485 let current = local.current(p)?;
486 b.need(&current)?;
487 let now = workdir::snapshot(b.objects(), &local.root).map_err(err)?;
488 let changes = workdir::compare(b.objects(), &current, &now).map_err(err)?;
489 Ok((current, now, changes))
490}
491
492fn status() -> Result<()> {
493 let cfg = Config::load()?;
494 let local = Local::find()?;
495 let mut b = connect(&cfg)?;
496 let p = local.project(&mut *b)?;
497 let behind = p.tip().saturating_sub(local.base);
498 println!(
499 "{}/{} on @{}{}{}",
500 local.owner,
501 local.project,
502 local.base,
503 if behind > 0 { format!(" ({behind} behind main)") } else { String::new() },
504 local.change.map(|c| format!(", change #{c}")).unwrap_or_default()
505 );
506 if !local.steps.is_empty() {
507 println!("{}", plural(local.steps.len(), "local step"));
508 }
509 let (_, _, changes) = working_changes(&mut *b, &p, &local)?;
510 if changes.is_empty() {
511 println!("no changes since the last step");
512 }
513 for (path, c) in changes {
514 let mark = match c {
515 FileChange::Added => 'A',
516 FileChange::Removed => 'D',
517 FileChange::Modified => 'M',
518 };
519 println!("{mark} {path}");
520 }
521 Ok(())
522}
523
524fn print_diff(b: &dyn Backend, a: &Id, c: &Id, stat: bool) -> Result<()> {
525 let o = b.objects();
526 let files = |t: &Id| -> Result<std::collections::BTreeMap<String, Id>> { Ok(o.flatten(t).map_err(err)?.into_iter().map(|(p, _, i)| (p, i)).collect()) };
527 let (fa, fc) = (files(a)?, files(c)?);
528 for (path, _) in workdir::compare(o, a, c).map_err(err)? {
529 let read = |m: &std::collections::BTreeMap<String, Id>| -> Result<Vec<u8>> { m.get(&path).map(|id| o.get_blob(id).map_err(err)).transpose().map(Option::unwrap_or_default) };
530 let (old, new) = (read(&fa)?, read(&fc)?);
531 match (std::str::from_utf8(&old), std::str::from_utf8(&new)) {
532 (Ok(x), Ok(y)) => {
533 let d = diff::unified(x, y, 3);
534 if stat {
535 let adds = d.lines().filter(|l| l.starts_with('+')).count();
536 let dels = d.lines().filter(|l| l.starts_with('-')).count();
537 println!("{path} +{adds} -{dels}");
538 } else {
539 println!("── {path}");
540 print!("{d}");
541 }
542 }
543 _ => println!("── {path} binary, {} → {} bytes", old.len(), new.len()),
544 }
545 }
546 Ok(())
547}
548
549fn diff_cmd(args: &Args) -> Result<()> {
550 let cfg = Config::load()?;
551 let local = Local::find()?;
552 let mut b = connect(&cfg)?;
553 let p = local.project(&mut *b)?;
554 if let Some(n) = args.pos(0).filter(|a| a.starts_with('#')) {
555 // A revision of a change: from its base to its last step.
556 let number = change_number(Some(n), None)?;
557 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
558 let r = match args.pos(1) {
559 Some(rv) => {
560 let k: usize = rv.trim_start_matches('r').parse().map_err(|_| format!("{rv:?} isn't a revision"))?;
561 c.revisions.get(k.wrapping_sub(1)).ok_or_else(|| format!("#{number} has no {rv}"))?
562 }
563 None => c.latest().ok_or_else(|| format!("#{number} has no revisions"))?,
564 };
565 let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?;
566 let last = r.steps.last().unwrap().snapshot;
567 b.need(&base)?;
568 b.need(&last)?;
569 println!("#{number} r{} on @{}", r.number, r.base);
570 return print_diff(&*b, &base, &last, args.has("--stat"));
571 }
572 let (current, now, _) = working_changes(&mut *b, &p, &local)?;
573 print_diff(&*b, &current, &now, args.has("--stat"))
574}
575
576fn step(args: &Args) -> Result<()> {
577 let cfg = Config::load()?;
578 let mut local = Local::find()?;
579 let mut b = connect(&cfg)?;
580 let message = args.value(&["-m", "--message"]).ok_or("give the step a message with -m")?;
581 let p = local.project(&mut *b)?;
582 let (_, now, changes) = working_changes(&mut *b, &p, &local)?;
583 if changes.is_empty() {
584 return e!("nothing changed since the last step");
585 }
586 local.steps.push(Step { id: random::hex(4), snapshot: now, message: message.to_string() });
587 local.save()?;
588 println!("step {} recorded ({})", local.steps.len(), plural(changes.len(), "file"));
589 Ok(())
590}
591
592fn amend(args: &Args) -> Result<()> {
593 let cfg = Config::load()?;
594 let mut local = Local::find()?;
595 let b = connect(&cfg)?;
596 if local.steps.is_empty() {
597 return e!("no local step to amend; use `grove step`");
598 }
599 let now = workdir::snapshot(b.objects(), &local.root).map_err(err)?;
600 let last = local.steps.last_mut().unwrap();
601 last.snapshot = now;
602 if let Some(m) = args.value(&["-m", "--message"]) {
603 last.message = m.to_string();
604 }
605 local.save()?;
606 println!("step {} amended", local.steps.len());
607 Ok(())
608}
609
610fn stack() -> Result<()> {
611 let local = Local::find()?;
612 if local.steps.is_empty() {
613 println!("no local steps");
614 }
615 for (i, st) in local.steps.iter().enumerate() {
616 println!("{} {} {}", i + 1, short(&st.snapshot), st.message);
617 }
618 if let Some(c) = local.change {
619 println!("sent as change #{c}");
620 }
621 Ok(())
622}
623
624fn propose(args: &Args) -> Result<()> {
625 let cfg = Config::load()?;
626 let mut local = Local::find()?;
627 let mut b = connect(&cfg)?;
628 if local.steps.is_empty() {
629 return e!("no steps to propose; record some with `grove step`");
630 }
631 let me = cfg.actor();
632 let (owner, name) = (local.owner.clone(), local.project.clone());
633 let number = match local.change {
634 Some(n) => n,
635 None => {
636 let intent = args.value(&["-m", "--message"]).ok_or("say what the change is for with -m")?;
637 b.open_change(&me, &owner, &name, intent)?
638 }
639 };
640 let rev = b.revise(&me, &owner, &name, number, local.base, &local.steps, args.value(&["--note"]))?;
641 let state = b.project(&owner, &name)?.change(number).map(|c| c.state);
642 if state == Some(ChangeState::Draft) {
643 b.set_state(&me, &owner, &name, number, ChangeState::Proposed)?;
644 }
645 local.change = Some(number);
646 local.save()?;
647 println!("{owner}/{name}#{number} r{rev} proposed ({})", plural(local.steps.len(), "step"));
648 Ok(())
649}
650
651fn say(args: &Args) -> Result<()> {
652 let cfg = Config::load()?;
653 let local = Local::find()?;
654 let (number, text) = match args.pos(0) {
655 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.positional[1..].join(" ")),
656 _ => (change_number(None, Some(&local))?, args.positional.join(" ")),
657 };
658 if text.trim().is_empty() {
659 return e!("say what?");
660 }
661 connect(&cfg)?.say(&cfg.actor(), &local.owner, &local.project, number, &text)?;
662 println!("added to #{number}");
663 Ok(())
664}
665
666fn attach(args: &Args) -> Result<()> {
667 let cfg = Config::load()?;
668 let local = Local::find()?;
669 let (number, file) = match args.pos(0) {
670 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.pos(1)),
671 _ => (change_number(None, Some(&local))?, args.pos(0)),
672 };
673 let mut b = connect(&cfg)?;
674 let rev = local.project(&mut *b)?.change(number).and_then(Change::latest).map(|r| r.number).ok_or("that change has no revision to attach to")?;
675 let me = cfg.actor();
676 if let Some(url) = args.value(&["--url"]) {
677 let title = args.value(&["--title"]).unwrap_or(url);
678 b.attach(&me, &local.owner, &local.project, number, rev, title, "text/uri-list", Content::Url(url))?;
679 } else {
680 let file = file.ok_or("attach a file, or a link with --url")?;
681 let bytes = fs::read(file).map_err(|e| format!("{file}: {e}"))?;
682 let media = args.value(&["--type"]).map(str::to_string).unwrap_or_else(|| guess_type(file));
683 let title = args.value(&["--title"]).unwrap_or(file);
684 b.attach(&me, &local.owner, &local.project, number, rev, title, &media, Content::Bytes(&bytes))?;
685 }
686 println!("attached to #{number} r{rev}");
687 Ok(())
688}
689
690fn guess_type(file: &str) -> String {
691 let ext = file.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
692 match ext.as_str() {
693 "md" => "text/markdown",
694 "txt" | "log" => "text/plain",
695 "json" => "application/json",
696 "png" => "image/png",
697 "jpg" | "jpeg" => "image/jpeg",
698 "gif" => "image/gif",
699 "svg" => "image/svg+xml",
700 "html" => "text/html",
701 "mp4" => "video/mp4",
702 _ => "application/octet-stream",
703 }
704 .to_string()
705}
706
707fn changes() -> Result<()> {
708 let cfg = Config::load()?;
709 let local = Local::find()?;
710 let mut b = connect(&cfg)?;
711 let p = local.project(&mut *b)?;
712 if p.changes.is_empty() {
713 println!("no changes yet");
714 }
715 for c in p.changes.iter().rev() {
716 println!("#{:<4} {:<10} {} ({}, {})", c.number, c.state.name(), c.intent, who(&*b, &c.proposer), ago(c.at));
717 }
718 Ok(())
719}
720
721/// One line (or a few) for an event in a change's timeline.
722fn describe(c: &Change, ev: &Event) -> String {
723 match ev {
724 Event::Opened => "opened".to_string(),
725 Event::Revised(n) => {
726 let r = &c.revisions[*n as usize - 1];
727 let mut l = format!("r{n} on @{}: {}", r.base, plural(r.steps.len(), "step"));
728 for st in &r.steps {
729 l += &format!("\n {} {}", short(&st.snapshot), st.message);
730 }
731 if let Some(note) = &r.note {
732 l += &format!("\n note: {note}");
733 }
734 l
735 }
736 Event::State(s) => format!("→ {}", s.name()),
737 Event::Said(id) => format!("said: {}", c.messages[*id as usize - 1].body),
738 Event::Attached(i) => {
739 let ev = &c.evidence[*i];
740 format!("evidence on r{}: {} ({})", ev.revision, ev.title, ev.url.clone().unwrap_or_else(|| ev.media_type.clone()))
741 }
742 Event::Landed { revision, points } => format!("landed r{revision} as @{}..@{}", points.0, points.1),
743 }
744}
745
746fn show(args: &Args) -> Result<()> {
747 let cfg = Config::load()?;
748 let local = Local::find()?;
749 let number = change_number(args.pos(0), Some(&local))?;
750 let mut b = connect(&cfg)?;
751 let p = local.project(&mut *b)?;
752 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
753 println!("#{} {} [{}]", c.number, c.intent, c.state.name());
754 println!("proposed by {}, {}", who(&*b, &c.proposer), ago(c.at));
755 for (at, by, ev) in &c.timeline {
756 println!(" {:<10} {}: {}", ago(*at), who(&*b, by), describe(c, ev));
757 }
758 if let (Some(r), true) = (c.latest(), c.state.is_open()) {
759 let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?;
760 let last = r.steps.last().unwrap().snapshot;
761 b.need(&base)?;
762 b.need(&last)?;
763 println!();
764 print_diff(&*b, &base, &last, true)?;
765 }
766 Ok(())
767}
768
769/// Wait for activity on a change from someone other than us, print it and stop. With
770/// --follow, keep printing until the change lands or closes.
771fn watch(args: &Args) -> Result<()> {
772 let cfg = Config::load()?;
773 let local = Local::find()?;
774 let number = change_number(args.pos(0), Some(&local))?;
775 let follow = args.has("--follow");
776 let me = cfg.actor();
777 let mut b = connect(&cfg)?;
778 let mut seen = local.project(&mut *b)?.change(number).ok_or_else(|| format!("no change #{number}"))?.timeline.len();
779 loop {
780 std::thread::sleep(std::time::Duration::from_secs(3));
781 let p = local.project(&mut *b)?;
782 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
783 let mut from_others = false;
784 for (at, by, ev) in &c.timeline[seen..] {
785 println!(" {:<10} {}: {}", ago(*at), who(&*b, by), describe(c, ev));
786 from_others |= *by != me;
787 }
788 seen = c.timeline.len();
789 if !c.state.is_open() || (from_others && !follow) {
790 return Ok(());
791 }
792 }
793}
794
795fn land(args: &Args) -> Result<()> {
796 let cfg = Config::load()?;
797 let local = Local::find()?;
798 let number = change_number(args.pos(0), None)?;
799 let (first, last) = connect(&cfg)?.land(&cfg.in_person(), &local.owner, &local.project, number)?;
800 println!("#{number} landed as @{first}..@{last}");
801 Ok(())
802}
803
804fn decline(args: &Args) -> Result<()> {
805 let cfg = Config::load()?;
806 let local = Local::find()?;
807 let number = change_number(args.pos(0), None)?;
808 connect(&cfg)?.set_state(&cfg.in_person(), &local.owner, &local.project, number, ChangeState::Declined)?;
809 println!("#{number} declined");
810 Ok(())
811}
812
813fn log() -> Result<()> {
814 let cfg = Config::load()?;
815 let local = Local::find()?;
816 let mut b = connect(&cfg)?;
817 let p = local.project(&mut *b)?;
818 for pt in p.points.iter().rev() {
819 let from = pt.change.map(|c| format!(" #{c}")).unwrap_or_default();
820 println!("@{:<5} {}{} ({}, {})", pt.n, pt.message, from, who(&*b, &pt.author), ago(pt.at));
821 }
822 Ok(())
823}
824
825fn sync() -> Result<()> {
826 let cfg = Config::load()?;
827 let mut local = Local::find()?;
828 let mut b = connect(&cfg)?;
829 let p = local.project(&mut *b)?;
830 // A change of ours that landed. If what landed is the start of our local stack, the base
831 // moves to the last landed point and any steps recorded after it stay, unsent.
832 if let Some(c) = local.change.and_then(|n| p.change(n)) {
833 if c.state == ChangeState::Landed {
834 let landed = c.timeline.iter().rev().find_map(|(_, _, ev)| match ev {
835 Event::Landed { revision, points } => Some((*revision, points.1)),
836 _ => None,
837 });
838 if let Some((rev, last_point)) = landed {
839 let landed_steps = &c.revisions[rev as usize - 1].steps;
840 let k = landed_steps.len();
841 let prefix = local.steps.len() >= k && local.steps[..k].iter().zip(landed_steps).all(|(x, y)| x.snapshot == y.snapshot);
842 if !prefix {
843 return e!("#{} landed as something other than the local steps; look at `grove show {}` before syncing", c.number, c.number);
844 }
845 local.base = last_point;
846 local.steps.drain(..k);
847 }
848 local.change = None;
849 }
850 }
851 if p.tip() == local.base {
852 local.save()?;
853 println!("already at @{}", local.base);
854 return Ok(());
855 }
856 if !local.steps.is_empty() {
857 let change = local.change.map(|c| format!("#{c}")).unwrap_or_else(|| "your steps".into());
858 return e!(
859 "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",
860 p.tip(),
861 local.base,
862 p.tip()
863 );
864 }
865 let (current, _, pending) = working_changes(&mut *b, &p, &local)?;
866 if !pending.is_empty() {
867 return e!("the working directory has changes; record them as a step first");
868 }
869 let tip = p.points.last().unwrap();
870 b.need(&tip.snapshot)?;
871 workdir::materialize(b.objects(), &tip.snapshot, &local.root, Some(&current)).map_err(err)?;
872 local.base = tip.n;
873 local.save()?;
874 println!("now at @{}", tip.n);
875 Ok(())
876}
877
878/// Move to the newest main, dropping the local steps (they stay on the server as revisions of
879/// the change). The change stays linked, so the next `propose` adds a revision built on the tip.
880fn restart() -> Result<()> {
881 let cfg = Config::load()?;
882 let mut local = Local::find()?;
883 let mut b = connect(&cfg)?;
884 let p = local.project(&mut *b)?;
885 let (current, _, pending) = working_changes(&mut *b, &p, &local)?;
886 if !pending.is_empty() {
887 return e!("the working directory has changes; record them as a step first, so they aren't lost");
888 }
889 if !local.steps.is_empty() {
890 let sent = local.change.and_then(|c| p.change(c)).and_then(Change::latest).and_then(|r| r.steps.last()).map(|s| s.snapshot);
891 if local.steps.last().map(|s| s.snapshot) != sent {
892 return e!("the local steps haven't been proposed as they are; propose them first so they're kept");
893 }
894 }
895 let tip = p.points.last().ok_or("no points")?;
896 b.need(&tip.snapshot)?;
897 workdir::materialize(b.objects(), &tip.snapshot, &local.root, Some(&current)).map_err(err)?;
898 local.steps.clear();
899 local.base = tip.n;
900 local.save()?;
901 println!("now at @{}{}", tip.n, local.change.map(|c| format!("; redo the work and `grove propose` adds a revision to #{c}")).unwrap_or_default());
902 Ok(())
903}
904
905fn group(args: &Args) -> Result<()> {
906 let cfg = Config::load()?;
907 let mut b = connect(&cfg)?;
908 let me = cfg.in_person();
909 match (args.pos(0), args.pos(1), args.pos(2)) {
910 (Some("create"), Some(name), None) => {
911 b.create_group(&me, name)?;
912 println!("group {name} created; members: {}", cfg.person);
913 }
914 (Some("add"), Some(name), Some(person)) => {
915 b.add_member(&me, name, person)?;
916 println!("{person} added to {name}");
917 }
918 _ => return e!("usage: grove group create NAME | grove group add NAME PERSON"),
919 }
920 Ok(())
921}
922
923fn move_project(args: &Args) -> Result<()> {
924 let cfg = Config::load()?;
925 let mut local = Local::find()?;
926 let to = args.pos(0).ok_or("usage: grove move OWNER")?;
927 connect(&cfg)?.move_project(&cfg.in_person(), &local.owner, &local.project, to)?;
928 println!("{}/{} is now {to}/{}", local.owner, local.project, local.project);
929 local.owner = to.to_string();
930 local.save()
931}
932
933// ── On the machine that holds the store ────────────────────
934
935fn agent_cmd(args: &Args) -> Result<()> {
936 let cfg = Config::load()?;
937 let mut store = cfg.store()?;
938 match (args.pos(0), args.pos(1)) {
939 (Some("add"), Some(name)) => {
940 let id = ensure_agent(&mut store, &cfg.person, name)?;
941 println!("{id}");
942 Ok(())
943 }
944 _ => e!("usage: grove agent add NAME"),
945 }
946}
947
948fn token_cmd(args: &Args) -> Result<()> {
949 let cfg = Config::load()?;
950 let mut store = cfg.store()?;
951 let token = store.issue_token(&cfg.person, args.value(&["--agent"])).map_err(err)?;
952 let for_whom = args.value(&["--agent"]).map(|a| format!("agent {a} for {}", cfg.person)).unwrap_or_else(|| cfg.person.clone());
953 eprintln!("token for {for_whom} (shown once):");
954 println!("{token}");
955 Ok(())
956}
957
958fn verify() -> Result<()> {
959 let cfg = Config::load()?;
960 let store = cfg.store()?;
961 let n = store.verify().map_err(err)?;
962 println!("{n} objects checked, all match their ids; {} log records", store.state().records);
963 Ok(())
964}
965
966fn serve(args: &Args) -> Result<()> {
967 let store_dir = std::env::var("GROVE_STORE").map(PathBuf::from).or_else(|_| Config::load().and_then(|c| c.store.ok_or_else(|| "no store".into())))?;
968 let store = Store::open(&store_dir).map_err(err)?;
969 let addr = args.value(&["--addr"]).unwrap_or("127.0.0.1:4800").to_string();
970 let listener = std::net::TcpListener::bind(&addr).map_err(|e| format!("{addr}: {e}"))?;
971 eprintln!("grove serving {} on http://{addr} ({} records)", store_dir.display(), store.state().records);
972 let api = std::sync::Arc::new(crate::api::Api::new(store).map_err(err)?);
973 crate::http::serve(listener, std::sync::Arc::new(move |req| api.handle(req))).map_err(err)
974}