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