grove
at @10
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", "--invite"],
288 );
289 match cmd {
290 "setup" => setup(&args),
291 "login" => login(&args),
292 "join" => join(&args),
293 "invite" => invite(&args),
294 "accept" => accept(&args),
295 "watch" => watch(&args),
296 "init" => init(&args),
297 "checkout" => checkout(&args),
298 "status" | "st" => status(),
299 "diff" => diff_cmd(&args),
300 "step" => step(&args),
301 "amend" => amend(&args),
302 "stack" => stack(),
303 "propose" => propose(&args),
304 "say" => say(&args),
305 "attach" => attach(&args),
306 "changes" => changes(),
307 "show" => show(&args),
308 "land" => land(&args),
309 "decline" => decline(&args),
310 "log" => log(),
311 "sync" => sync(),
312 "restart" => restart(),
313 "group" => group(&args),
314 "move" => move_project(&args),
315 "agent" => agent_cmd(&args),
316 "token" => token_cmd(&args),
317 "verify" => verify(),
318 "serve" => serve(&args),
319 "help" | "--help" | "-h" => {
320 print!("{HELP}");
321 Ok(())
322 }
323 other => e!("unknown command {other:?}; see `grove help`"),
324 }
325}
326
327const HELP: &str = "grove: where agents build software for people
328
329 join <server> <username> --invite CODE [--agent NAME] join a grove server
330 accept CODE use a project invite on an account you have
331 invite [OWNER/NAME | --server] invite someone (in person): to this project, or just to join
332 login <server> --token T [--person-token P] work against a grove server
333 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
334 checkout <owner/name> [DIR] get a project's files
335 status what changed since the last step
336 diff [--stat] [#N [rM]] working changes, or a change's revision
337 step -m MESSAGE record the working directory as a new step
338 amend [-m MESSAGE] replace the last step with the working directory
339 stack the local steps
340 propose -m INTENT [--note NOTE] send the steps as a change (or a new revision)
341 say [#N] TEXT add to a change's discussion
342 attach [#N] FILE [--type T] [--title T] | --url U evidence for the latest revision
343 changes a project's changes
344 show [#N] one change: intent, revisions, discussion
345 watch [#N] [--follow] wait for someone else to add to a change
346 land #N land a change (in person)
347 decline #N decline a change (in person)
348 log main, newest first
349 sync move the working directory to the newest main
350 restart move to the newest main, dropping proposed steps
351 group create NAME | group add NAME PERSON groups own projects together (in person)
352 move OWNER give this project to another owner (in person)
353
354 On the machine that holds a store:
355 setup <username> [--agent NAME] [--store DIR] create a person (and agent) in a local store
356 agent add NAME register an agent acting for you
357 token [--agent ID] issue a token for you or one of your agents
358 serve [--addr HOST:PORT] run the HTTP API over the local store
359 verify check every stored object against its id
360";
361
362fn setup(args: &Args) -> Result<()> {
363 check_config_is_ours()?;
364 let username = args.pos(0).ok_or("usage: grove setup <username> [--agent NAME]")?;
365 let store_dir = args.value(&["--store"]).map(PathBuf::from).unwrap_or_else(|| grove_home().join("store"));
366 let mut store = Store::open(&store_dir).map_err(err)?;
367 if store.state().person(username).is_none() {
368 store.add_person(username, args.value(&["--display"]).unwrap_or(username)).map_err(err)?;
369 println!("created person {username}");
370 }
371 let agent = match args.value(&["--agent"]) {
372 Some(name) => Some(ensure_agent(&mut store, username, name)?),
373 None => None,
374 };
375 write_config(vec![("store", store_dir.display().to_string().into()), ("person", username.into()), ("agent", agent.into())])?;
376 println!("config written to {}", config_path().display());
377 Ok(())
378}
379
380fn ensure_agent(store: &mut Store, person: &str, name: &str) -> Result<String> {
381 if let Some(a) = store.state().agents.iter().find(|a| a.person == person && a.name == name && !a.revoked) {
382 return Ok(a.id.clone());
383 }
384 let id = store.add_agent(person, name).map_err(err)?;
385 println!("registered agent {name} ({id}) acting for {person}");
386 Ok(id)
387}
388
389fn login(args: &Args) -> Result<()> {
390 check_config_is_ours()?;
391 let server = args.pos(0).ok_or("usage: grove login <server> --token T [--person-token P]")?;
392 let token = args.value(&["--token"]).ok_or("give a token with --token")?;
393 let me = crate::client::Client::new(server, Some(token.to_string())).get("/api/whoami")?;
394 let person = me.get("person").and_then(Value::as_str).ok_or("the server didn't say who the token is")?.to_string();
395 let agent = me.get("agent").and_then(Value::as_str).map(str::to_string);
396 if let Some(pt) = args.value(&["--person-token"]) {
397 let p = crate::client::Client::new(server, Some(pt.to_string())).get("/api/whoami")?;
398 if p.get("person").and_then(Value::as_str) != Some(&person) || !p.get("agent").is_none_or(Value::is_null) {
399 return e!("--person-token must be {person}'s own token, not an agent's");
400 }
401 }
402 let previous_store = Config::load().ok().and_then(|c| c.store).map(|p| p.display().to_string());
403 write_config(vec![
404 ("server", server.into()),
405 ("token", token.into()),
406 ("person_token", args.value(&["--person-token"]).map(str::to_string).into()),
407 ("person", person.clone().into()),
408 ("agent", agent.clone().into()),
409 ("store", previous_store.into()),
410 ])?;
411 let as_who = match me.get("agent_name").and_then(Value::as_str) {
412 Some(name) => format!("{name} for {person}"),
413 None => person,
414 };
415 println!("signed in to {server} as {as_who}");
416 Ok(())
417}
418
419fn join(args: &Args) -> Result<()> {
420 check_config_is_ours()?;
421 let (server, username) = match (args.pos(0), args.pos(1)) {
422 (Some(s), Some(u)) => (s, u),
423 _ => return e!("usage: grove join <server> <username> --invite CODE [--agent NAME]"),
424 };
425 let code = args.value(&["--invite"]).ok_or("joining needs an invite: ask someone on the server for one")?;
426 let mut body = vec![("username", Value::from(username)), ("invite", Value::from(code))];
427 if let Some(d) = args.value(&["--display"]) {
428 body.push(("display_name", d.into()));
429 }
430 if let Some(a) = args.value(&["--agent"]) {
431 body.push(("agent", a.into()));
432 }
433 let r = crate::client::Client::new(server, None).post("/api/people", &Value::Object(body.into_iter().map(|(k, v)| (k.to_string(), v)).collect()))?;
434 let get = |k: &str| r.get(k).and_then(Value::as_str).map(str::to_string);
435 let person_token = get("person_token").ok_or("the server sent no token")?;
436 let agent_token = get("agent_token");
437 write_config(vec![
438 ("server", server.into()),
439 ("token", agent_token.clone().unwrap_or_else(|| person_token.clone()).into()),
440 ("person_token", person_token.into()),
441 ("person", username.into()),
442 ("agent", get("agent").into()),
443 ])?;
444 match args.value(&["--agent"]) {
445 Some(a) => println!("joined {server} as {username}; {a} acts for you"),
446 None => println!("joined {server} as {username}"),
447 }
448 Ok(())
449}
450
451fn invite(args: &Args) -> Result<()> {
452 let cfg = Config::load()?;
453 let project = match (args.pos(0), args.has("--server")) {
454 (Some(full), _) => Some(full.split_once('/').map(|(o, n)| (o.to_string(), n.to_string())).ok_or("give the project as owner/name")?),
455 (None, true) => None,
456 (None, false) => {
457 let local = Local::find().map_err(|_| "say which project (owner/name), or --server for an invite only to join".to_string())?;
458 Some((local.owner, local.project))
459 }
460 };
461 let code = connect(&cfg)?.invite(&cfg.in_person(), project.as_ref().map(|(o, n)| (o.as_str(), n.as_str())))?;
462 match &project {
463 Some((o, n)) => eprintln!("invite to {o}/{n} (works once):"),
464 None => eprintln!("invite to join (works once):"),
465 }
466 println!("{code}");
467 Ok(())
468}
469
470fn accept(args: &Args) -> Result<()> {
471 let cfg = Config::load()?;
472 let code = args.pos(0).ok_or("usage: grove accept CODE")?;
473 match connect(&cfg)?.accept(&cfg.actor(), code)? {
474 Some(p) => println!("you can now contribute to {p}"),
475 None => println!("invite used"),
476 }
477 Ok(())
478}
479
480fn init(args: &Args) -> Result<()> {
481 let cfg = Config::load()?;
482 let name = args.pos(0).ok_or("usage: grove init <name>")?;
483 let root = std::env::current_dir().map_err(err)?;
484 if root.join(".grove").exists() {
485 return e!("this folder is already a grove working directory");
486 }
487 let mut b = connect(&cfg)?;
488 let me = cfg.in_person();
489 let owner = args.value(&["--owner"]).unwrap_or(&cfg.person).to_string();
490 b.create_project(&me, &owner, name, !args.has("--private"))?;
491 let snap = workdir::snapshot(b.objects(), &root).map_err(err)?;
492 let n = b.record_point(&me, &owner, name, &snap, args.value(&["-m", "--message"]).unwrap_or("Start"))?;
493 let files = b.objects().flatten(&snap).map_err(err)?.len();
494 Local { root, owner: owner.clone(), project: name.to_string(), base: n, change: None, steps: Vec::new() }.save()?;
495 println!("{owner}/{name}@{n}: {}", plural(files, "file"));
496 Ok(())
497}
498
499fn checkout(args: &Args) -> Result<()> {
500 let cfg = Config::load()?;
501 let full = args.pos(0).ok_or("usage: grove checkout <owner/name> [dir]")?;
502 let (owner, name) = full.split_once('/').ok_or("give the project as owner/name")?;
503 let dir = PathBuf::from(args.pos(1).unwrap_or(name));
504 if dir.join(".grove").exists() {
505 return e!("{} is already a working directory", dir.display());
506 }
507 let mut b = connect(&cfg)?;
508 let p = b.project(owner, name)?;
509 let tip = p.points.last().ok_or("the project has no points yet")?;
510 b.need(&tip.snapshot)?;
511 fs::create_dir_all(&dir).map_err(err)?;
512 workdir::materialize(b.objects(), &tip.snapshot, &dir, None).map_err(err)?;
513 Local { root: dir.clone(), owner: owner.into(), project: name.into(), base: tip.n, change: None, steps: Vec::new() }.save()?;
514 println!("{owner}/{name}@{} in {}", tip.n, dir.display());
515 Ok(())
516}
517
518fn working_changes(b: &mut dyn Backend, p: &Project, local: &Local) -> Result<(Id, Id, Vec<(String, FileChange)>)> {
519 let current = local.current(p)?;
520 b.need(&current)?;
521 let now = workdir::snapshot(b.objects(), &local.root).map_err(err)?;
522 let changes = workdir::compare(b.objects(), &current, &now).map_err(err)?;
523 Ok((current, now, changes))
524}
525
526fn status() -> Result<()> {
527 let cfg = Config::load()?;
528 let local = Local::find()?;
529 let mut b = connect(&cfg)?;
530 let p = local.project(&mut *b)?;
531 let behind = p.tip().saturating_sub(local.base);
532 println!(
533 "{}/{} on @{}{}{}",
534 local.owner,
535 local.project,
536 local.base,
537 if behind > 0 { format!(" ({behind} behind main)") } else { String::new() },
538 local.change.map(|c| format!(", change #{c}")).unwrap_or_default()
539 );
540 if !local.steps.is_empty() {
541 println!("{}", plural(local.steps.len(), "local step"));
542 }
543 let (_, _, changes) = working_changes(&mut *b, &p, &local)?;
544 if changes.is_empty() {
545 println!("no changes since the last step");
546 }
547 for (path, c) in changes {
548 let mark = match c {
549 FileChange::Added => 'A',
550 FileChange::Removed => 'D',
551 FileChange::Modified => 'M',
552 };
553 println!("{mark} {path}");
554 }
555 Ok(())
556}
557
558fn print_diff(b: &dyn Backend, a: &Id, c: &Id, stat: bool) -> Result<()> {
559 let o = b.objects();
560 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()) };
561 let (fa, fc) = (files(a)?, files(c)?);
562 for (path, _) in workdir::compare(o, a, c).map_err(err)? {
563 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) };
564 let (old, new) = (read(&fa)?, read(&fc)?);
565 match (std::str::from_utf8(&old), std::str::from_utf8(&new)) {
566 (Ok(x), Ok(y)) => {
567 let d = diff::unified(x, y, 3);
568 if stat {
569 let adds = d.lines().filter(|l| l.starts_with('+')).count();
570 let dels = d.lines().filter(|l| l.starts_with('-')).count();
571 println!("{path} +{adds} -{dels}");
572 } else {
573 println!("── {path}");
574 print!("{d}");
575 }
576 }
577 _ => println!("── {path} binary, {} → {} bytes", old.len(), new.len()),
578 }
579 }
580 Ok(())
581}
582
583fn diff_cmd(args: &Args) -> Result<()> {
584 let cfg = Config::load()?;
585 let local = Local::find()?;
586 let mut b = connect(&cfg)?;
587 let p = local.project(&mut *b)?;
588 if let Some(n) = args.pos(0).filter(|a| a.starts_with('#')) {
589 // A revision of a change: from its base to its last step.
590 let number = change_number(Some(n), None)?;
591 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
592 let r = match args.pos(1) {
593 Some(rv) => {
594 let k: usize = rv.trim_start_matches('r').parse().map_err(|_| format!("{rv:?} isn't a revision"))?;
595 c.revisions.get(k.wrapping_sub(1)).ok_or_else(|| format!("#{number} has no {rv}"))?
596 }
597 None => c.latest().ok_or_else(|| format!("#{number} has no revisions"))?,
598 };
599 let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?;
600 let last = r.steps.last().unwrap().snapshot;
601 b.need(&base)?;
602 b.need(&last)?;
603 println!("#{number} r{} on @{}", r.number, r.base);
604 return print_diff(&*b, &base, &last, args.has("--stat"));
605 }
606 let (current, now, _) = working_changes(&mut *b, &p, &local)?;
607 print_diff(&*b, &current, &now, args.has("--stat"))
608}
609
610fn step(args: &Args) -> Result<()> {
611 let cfg = Config::load()?;
612 let mut local = Local::find()?;
613 let mut b = connect(&cfg)?;
614 let message = args.value(&["-m", "--message"]).ok_or("give the step a message with -m")?;
615 let p = local.project(&mut *b)?;
616 let (_, now, changes) = working_changes(&mut *b, &p, &local)?;
617 if changes.is_empty() {
618 return e!("nothing changed since the last step");
619 }
620 local.steps.push(Step { id: random::hex(4), snapshot: now, message: message.to_string() });
621 local.save()?;
622 println!("step {} recorded ({})", local.steps.len(), plural(changes.len(), "file"));
623 Ok(())
624}
625
626fn amend(args: &Args) -> Result<()> {
627 let cfg = Config::load()?;
628 let mut local = Local::find()?;
629 let b = connect(&cfg)?;
630 if local.steps.is_empty() {
631 return e!("no local step to amend; use `grove step`");
632 }
633 let now = workdir::snapshot(b.objects(), &local.root).map_err(err)?;
634 let last = local.steps.last_mut().unwrap();
635 last.snapshot = now;
636 if let Some(m) = args.value(&["-m", "--message"]) {
637 last.message = m.to_string();
638 }
639 local.save()?;
640 println!("step {} amended", local.steps.len());
641 Ok(())
642}
643
644fn stack() -> Result<()> {
645 let local = Local::find()?;
646 if local.steps.is_empty() {
647 println!("no local steps");
648 }
649 for (i, st) in local.steps.iter().enumerate() {
650 println!("{} {} {}", i + 1, short(&st.snapshot), st.message);
651 }
652 if let Some(c) = local.change {
653 println!("sent as change #{c}");
654 }
655 Ok(())
656}
657
658fn propose(args: &Args) -> Result<()> {
659 let cfg = Config::load()?;
660 let mut local = Local::find()?;
661 let mut b = connect(&cfg)?;
662 if local.steps.is_empty() {
663 return e!("no steps to propose; record some with `grove step`");
664 }
665 let me = cfg.actor();
666 let (owner, name) = (local.owner.clone(), local.project.clone());
667 let number = match local.change {
668 Some(n) => n,
669 None => {
670 let intent = args.value(&["-m", "--message"]).ok_or("say what the change is for with -m")?;
671 b.open_change(&me, &owner, &name, intent)?
672 }
673 };
674 let rev = b.revise(&me, &owner, &name, number, local.base, &local.steps, args.value(&["--note"]))?;
675 let state = b.project(&owner, &name)?.change(number).map(|c| c.state);
676 if state == Some(ChangeState::Draft) {
677 b.set_state(&me, &owner, &name, number, ChangeState::Proposed)?;
678 }
679 local.change = Some(number);
680 local.save()?;
681 println!("{owner}/{name}#{number} r{rev} proposed ({})", plural(local.steps.len(), "step"));
682 Ok(())
683}
684
685fn say(args: &Args) -> Result<()> {
686 let cfg = Config::load()?;
687 let local = Local::find()?;
688 let (number, text) = match args.pos(0) {
689 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.positional[1..].join(" ")),
690 _ => (change_number(None, Some(&local))?, args.positional.join(" ")),
691 };
692 if text.trim().is_empty() {
693 return e!("say what?");
694 }
695 connect(&cfg)?.say(&cfg.actor(), &local.owner, &local.project, number, &text)?;
696 println!("added to #{number}");
697 Ok(())
698}
699
700fn attach(args: &Args) -> Result<()> {
701 let cfg = Config::load()?;
702 let local = Local::find()?;
703 let (number, file) = match args.pos(0) {
704 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.pos(1)),
705 _ => (change_number(None, Some(&local))?, args.pos(0)),
706 };
707 let mut b = connect(&cfg)?;
708 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")?;
709 let me = cfg.actor();
710 if let Some(url) = args.value(&["--url"]) {
711 let title = args.value(&["--title"]).unwrap_or(url);
712 b.attach(&me, &local.owner, &local.project, number, rev, title, "text/uri-list", Content::Url(url))?;
713 } else {
714 let file = file.ok_or("attach a file, or a link with --url")?;
715 let bytes = fs::read(file).map_err(|e| format!("{file}: {e}"))?;
716 let media = args.value(&["--type"]).map(str::to_string).unwrap_or_else(|| guess_type(file));
717 let title = args.value(&["--title"]).unwrap_or(file);
718 b.attach(&me, &local.owner, &local.project, number, rev, title, &media, Content::Bytes(&bytes))?;
719 }
720 println!("attached to #{number} r{rev}");
721 Ok(())
722}
723
724fn guess_type(file: &str) -> String {
725 let ext = file.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
726 match ext.as_str() {
727 "md" => "text/markdown",
728 "txt" | "log" => "text/plain",
729 "json" => "application/json",
730 "png" => "image/png",
731 "jpg" | "jpeg" => "image/jpeg",
732 "gif" => "image/gif",
733 "svg" => "image/svg+xml",
734 "html" => "text/html",
735 "mp4" => "video/mp4",
736 _ => "application/octet-stream",
737 }
738 .to_string()
739}
740
741fn changes() -> Result<()> {
742 let cfg = Config::load()?;
743 let local = Local::find()?;
744 let mut b = connect(&cfg)?;
745 let p = local.project(&mut *b)?;
746 if p.changes.is_empty() {
747 println!("no changes yet");
748 }
749 for c in p.changes.iter().rev() {
750 println!("#{:<4} {:<10} {} ({}, {})", c.number, c.state.name(), c.intent, who(&*b, &c.proposer), ago(c.at));
751 }
752 Ok(())
753}
754
755/// One line (or a few) for an event in a change's timeline.
756fn describe(c: &Change, ev: &Event) -> String {
757 match ev {
758 Event::Opened => "opened".to_string(),
759 Event::Revised(n) => {
760 let r = &c.revisions[*n as usize - 1];
761 let mut l = format!("r{n} on @{}: {}", r.base, plural(r.steps.len(), "step"));
762 for st in &r.steps {
763 l += &format!("\n {} {}", short(&st.snapshot), st.message);
764 }
765 if let Some(note) = &r.note {
766 l += &format!("\n note: {note}");
767 }
768 l
769 }
770 Event::State(s) => format!("→ {}", s.name()),
771 Event::Said(id) => format!("said: {}", c.messages[*id as usize - 1].body),
772 Event::Attached(i) => {
773 let ev = &c.evidence[*i];
774 format!("evidence on r{}: {} ({})", ev.revision, ev.title, ev.url.clone().unwrap_or_else(|| ev.media_type.clone()))
775 }
776 Event::Landed { revision, points } => format!("landed r{revision} as @{}..@{}", points.0, points.1),
777 }
778}
779
780fn show(args: &Args) -> Result<()> {
781 let cfg = Config::load()?;
782 let local = Local::find()?;
783 let number = change_number(args.pos(0), Some(&local))?;
784 let mut b = connect(&cfg)?;
785 let p = local.project(&mut *b)?;
786 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
787 println!("#{} {} [{}]", c.number, c.intent, c.state.name());
788 println!("proposed by {}, {}", who(&*b, &c.proposer), ago(c.at));
789 for (at, by, ev) in &c.timeline {
790 println!(" {:<10} {}: {}", ago(*at), who(&*b, by), describe(c, ev));
791 }
792 if let (Some(r), true) = (c.latest(), c.state.is_open()) {
793 let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?;
794 let last = r.steps.last().unwrap().snapshot;
795 b.need(&base)?;
796 b.need(&last)?;
797 println!();
798 print_diff(&*b, &base, &last, true)?;
799 }
800 Ok(())
801}
802
803/// Wait for activity on a change from someone other than us, print it and stop. With
804/// --follow, keep printing until the change lands or closes.
805fn watch(args: &Args) -> Result<()> {
806 let cfg = Config::load()?;
807 let local = Local::find()?;
808 let number = change_number(args.pos(0), Some(&local))?;
809 let follow = args.has("--follow");
810 let me = cfg.actor();
811 let mut b = connect(&cfg)?;
812 let mut seen = local.project(&mut *b)?.change(number).ok_or_else(|| format!("no change #{number}"))?.timeline.len();
813 loop {
814 std::thread::sleep(std::time::Duration::from_secs(3));
815 let p = local.project(&mut *b)?;
816 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
817 let mut from_others = false;
818 for (at, by, ev) in &c.timeline[seen..] {
819 println!(" {:<10} {}: {}", ago(*at), who(&*b, by), describe(c, ev));
820 from_others |= *by != me;
821 }
822 seen = c.timeline.len();
823 if !c.state.is_open() || (from_others && !follow) {
824 return Ok(());
825 }
826 }
827}
828
829fn land(args: &Args) -> Result<()> {
830 let cfg = Config::load()?;
831 let local = Local::find()?;
832 let number = change_number(args.pos(0), None)?;
833 let (first, last) = connect(&cfg)?.land(&cfg.in_person(), &local.owner, &local.project, number)?;
834 println!("#{number} landed as @{first}..@{last}");
835 Ok(())
836}
837
838fn decline(args: &Args) -> Result<()> {
839 let cfg = Config::load()?;
840 let local = Local::find()?;
841 let number = change_number(args.pos(0), None)?;
842 connect(&cfg)?.set_state(&cfg.in_person(), &local.owner, &local.project, number, ChangeState::Declined)?;
843 println!("#{number} declined");
844 Ok(())
845}
846
847fn log() -> Result<()> {
848 let cfg = Config::load()?;
849 let local = Local::find()?;
850 let mut b = connect(&cfg)?;
851 let p = local.project(&mut *b)?;
852 for pt in p.points.iter().rev() {
853 let from = pt.change.map(|c| format!(" #{c}")).unwrap_or_default();
854 println!("@{:<5} {}{} ({}, {})", pt.n, pt.message, from, who(&*b, &pt.author), ago(pt.at));
855 }
856 Ok(())
857}
858
859fn sync() -> Result<()> {
860 let cfg = Config::load()?;
861 let mut local = Local::find()?;
862 let mut b = connect(&cfg)?;
863 let p = local.project(&mut *b)?;
864 // A change of ours that landed. If what landed is the start of our local stack, the base
865 // moves to the last landed point and any steps recorded after it stay, unsent.
866 if let Some(c) = local.change.and_then(|n| p.change(n)) {
867 if c.state == ChangeState::Landed {
868 let landed = c.timeline.iter().rev().find_map(|(_, _, ev)| match ev {
869 Event::Landed { revision, points } => Some((*revision, points.1)),
870 _ => None,
871 });
872 if let Some((rev, last_point)) = landed {
873 let landed_steps = &c.revisions[rev as usize - 1].steps;
874 let k = landed_steps.len();
875 let prefix = local.steps.len() >= k && local.steps[..k].iter().zip(landed_steps).all(|(x, y)| x.snapshot == y.snapshot);
876 if !prefix {
877 return e!("#{} landed as something other than the local steps; look at `grove show {}` before syncing", c.number, c.number);
878 }
879 local.base = last_point;
880 local.steps.drain(..k);
881 }
882 local.change = None;
883 }
884 }
885 if p.tip() == local.base {
886 local.save()?;
887 println!("already at @{}", local.base);
888 return Ok(());
889 }
890 if !local.steps.is_empty() {
891 let change = local.change.map(|c| format!("#{c}")).unwrap_or_else(|| "your steps".into());
892 return e!(
893 "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",
894 p.tip(),
895 local.base,
896 p.tip()
897 );
898 }
899 let (current, _, pending) = working_changes(&mut *b, &p, &local)?;
900 if !pending.is_empty() {
901 return e!("the working directory has changes; record them as a step first");
902 }
903 let tip = p.points.last().unwrap();
904 b.need(&tip.snapshot)?;
905 workdir::materialize(b.objects(), &tip.snapshot, &local.root, Some(&current)).map_err(err)?;
906 local.base = tip.n;
907 local.save()?;
908 println!("now at @{}", tip.n);
909 Ok(())
910}
911
912/// Move to the newest main, dropping the local steps (they stay on the server as revisions of
913/// the change). The change stays linked, so the next `propose` adds a revision built on the tip.
914fn restart() -> Result<()> {
915 let cfg = Config::load()?;
916 let mut local = Local::find()?;
917 let mut b = connect(&cfg)?;
918 let p = local.project(&mut *b)?;
919 let (current, _, pending) = working_changes(&mut *b, &p, &local)?;
920 if !pending.is_empty() {
921 return e!("the working directory has changes; record them as a step first, so they aren't lost");
922 }
923 if !local.steps.is_empty() {
924 let sent = local.change.and_then(|c| p.change(c)).and_then(Change::latest).and_then(|r| r.steps.last()).map(|s| s.snapshot);
925 if local.steps.last().map(|s| s.snapshot) != sent {
926 return e!("the local steps haven't been proposed as they are; propose them first so they're kept");
927 }
928 }
929 let tip = p.points.last().ok_or("no points")?;
930 b.need(&tip.snapshot)?;
931 workdir::materialize(b.objects(), &tip.snapshot, &local.root, Some(&current)).map_err(err)?;
932 local.steps.clear();
933 local.base = tip.n;
934 local.save()?;
935 println!("now at @{}{}", tip.n, local.change.map(|c| format!("; redo the work and `grove propose` adds a revision to #{c}")).unwrap_or_default());
936 Ok(())
937}
938
939fn group(args: &Args) -> Result<()> {
940 let cfg = Config::load()?;
941 let mut b = connect(&cfg)?;
942 let me = cfg.in_person();
943 match (args.pos(0), args.pos(1), args.pos(2)) {
944 (Some("create"), Some(name), None) => {
945 b.create_group(&me, name)?;
946 println!("group {name} created; members: {}", cfg.person);
947 }
948 (Some("add"), Some(name), Some(person)) => {
949 b.add_member(&me, name, person)?;
950 println!("{person} added to {name}");
951 }
952 _ => return e!("usage: grove group create NAME | grove group add NAME PERSON"),
953 }
954 Ok(())
955}
956
957fn move_project(args: &Args) -> Result<()> {
958 let cfg = Config::load()?;
959 let mut local = Local::find()?;
960 let to = args.pos(0).ok_or("usage: grove move OWNER")?;
961 connect(&cfg)?.move_project(&cfg.in_person(), &local.owner, &local.project, to)?;
962 println!("{}/{} is now {to}/{}", local.owner, local.project, local.project);
963 local.owner = to.to_string();
964 local.save()
965}
966
967// ── On the machine that holds the store ────────────────────
968
969fn agent_cmd(args: &Args) -> Result<()> {
970 let cfg = Config::load()?;
971 let mut store = cfg.store()?;
972 match (args.pos(0), args.pos(1)) {
973 (Some("add"), Some(name)) => {
974 let id = ensure_agent(&mut store, &cfg.person, name)?;
975 println!("{id}");
976 Ok(())
977 }
978 _ => e!("usage: grove agent add NAME"),
979 }
980}
981
982fn token_cmd(args: &Args) -> Result<()> {
983 let cfg = Config::load()?;
984 let mut store = cfg.store()?;
985 let token = store.issue_token(&cfg.person, args.value(&["--agent"])).map_err(err)?;
986 let for_whom = args.value(&["--agent"]).map(|a| format!("agent {a} for {}", cfg.person)).unwrap_or_else(|| cfg.person.clone());
987 eprintln!("token for {for_whom} (shown once):");
988 println!("{token}");
989 Ok(())
990}
991
992fn verify() -> Result<()> {
993 let cfg = Config::load()?;
994 let store = cfg.store()?;
995 let n = store.verify().map_err(err)?;
996 println!("{n} objects checked, all match their ids; {} log records", store.state().records);
997 Ok(())
998}
999
1000fn serve(args: &Args) -> Result<()> {
1001 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())))?;
1002 let store = Store::open(&store_dir).map_err(err)?;
1003 let addr = args.value(&["--addr"]).unwrap_or("127.0.0.1:4800").to_string();
1004 let listener = std::net::TcpListener::bind(&addr).map_err(|e| format!("{addr}: {e}"))?;
1005 eprintln!("grove serving {} on http://{addr} ({} records)", store_dir.display(), store.state().records);
1006 let api = std::sync::Arc::new(crate::api::Api::new(store).map_err(err)?);
1007 crate::http::serve(listener, std::sync::Arc::new(move |req| api.handle(req))).map_err(err)
1008}