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