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