grove
at @2
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"]);
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 "verify" => verify(),
250 "help" | "--help" | "-h" => {
251 print!("{HELP}");
252 Ok(())
253 }
254 other => e!("unknown command {other:?}; see `grove help`"),
255 }
256}
257
258const HELP: &str = "grove: where agents build software for people
259
260 setup <username> [--agent NAME] [--store DIR] who you are, and the agent acting for you
261 init <name> [--private] [-m MESSAGE] make this folder a new project
262 checkout <owner/name> [DIR] get a project's files
263 status what changed since the last step
264 diff [--stat] those changes, line by line
265 step -m MESSAGE record the working directory as a new step
266 amend [-m MESSAGE] replace the last step with the working directory
267 stack the local steps
268 propose -m INTENT [--note NOTE] send the steps as a change (or a new revision)
269 say [#N] TEXT add to a change's discussion
270 attach [#N] FILE [--type TYPE] [--title T] attach evidence to the latest revision
271 changes a project's changes
272 show [#N] one change: intent, revisions, discussion
273 land #N land a change (you, in person)
274 decline #N decline a change (you, in person)
275 log main, newest first
276 sync move the working directory to the newest main
277 verify check every stored object against its id
278";
279
280fn setup(args: &Args) -> Result<()> {
281 let username = args.pos(0).ok_or("usage: grove setup <username> [--agent NAME]")?;
282 // Never overwrite a config grove didn't write.
283 if let Ok(existing) = fs::read_to_string(config_path()) {
284 let ours = json::parse(&existing).map(|v| v.get("store").is_some() && v.get("person").is_some()).unwrap_or(false);
285 if !ours {
286 return e!("{} exists and isn't a grove config; move it aside first", config_path().display());
287 }
288 }
289 let store_dir = args.value(&["--store"]).map(PathBuf::from).unwrap_or_else(|| grove_home().join("store"));
290 let mut store = Store::open(&store_dir).map_err(|e| e.to_string())?;
291 if store.state().person(username).is_none() {
292 s(store.add_person(username, args.value(&["--display"]).unwrap_or(username)))?;
293 println!("created person {username}");
294 }
295 let agent = match args.value(&["--agent"]) {
296 Some(name) => {
297 let existing = store.state().agents.iter().find(|a| a.person == username && a.name == name && !a.revoked).map(|a| a.id.clone());
298 Some(match existing {
299 Some(id) => id,
300 None => {
301 let id = s(store.add_agent(username, name))?;
302 println!("registered agent {name} ({id}) acting for {username}");
303 id
304 }
305 })
306 }
307 None => None,
308 };
309 let cfg = obj([("store", store_dir.display().to_string().into()), ("person", username.into()), ("agent", agent.into())]);
310 fs::create_dir_all(config_path().parent().unwrap()).map_err(|e| e.to_string())?;
311 fs::write(config_path(), cfg.encode() + "\n").map_err(|e| e.to_string())?;
312 println!("config written to {}", config_path().display());
313 Ok(())
314}
315
316fn init(args: &Args) -> Result<()> {
317 let cfg = Config::load()?;
318 let name = args.pos(0).ok_or("usage: grove init <name>")?;
319 let root = std::env::current_dir().map_err(|e| e.to_string())?;
320 if root.join(".grove").exists() {
321 return e!("this folder is already a grove working directory");
322 }
323 let mut store = open_store(&cfg)?;
324 let me = cfg.in_person();
325 s(store.create_project(&me, name, !args.has("--private")))?;
326 let snap = workdir::snapshot(&store.objects, &root).map_err(|e| e.to_string())?;
327 let message = args.value(&["-m", "--message"]).unwrap_or("Start");
328 let n = s(store.record_point(&me, &cfg.person, name, &snap, message, Some(&cfg.actor())))?;
329 let files = store.objects.flatten(&snap).map_err(|e| e.to_string())?.len();
330 Local { root, owner: cfg.person.clone(), project: name.to_string(), base: n, change: None, steps: Vec::new() }.save()?;
331 println!("{}/{name}@{n}: {files} files", cfg.person);
332 Ok(())
333}
334
335fn checkout(args: &Args) -> Result<()> {
336 let cfg = Config::load()?;
337 let full = args.pos(0).ok_or("usage: grove checkout <owner/name> [dir]")?;
338 let (owner, name) = full.split_once('/').ok_or("give the project as owner/name")?;
339 let dir = PathBuf::from(args.pos(1).unwrap_or(name));
340 if dir.join(".grove").exists() {
341 return e!("{} is already a working directory", dir.display());
342 }
343 let store = open_store(&cfg)?;
344 let p = s(store.project(owner, name))?;
345 let tip = p.points.last().ok_or("the project has no points yet")?;
346 fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
347 workdir::materialize(&store.objects, &tip.snapshot, &dir, None).map_err(|e| e.to_string())?;
348 Local { root: dir.clone(), owner: owner.into(), project: name.into(), base: tip.n, change: None, steps: Vec::new() }.save()?;
349 println!("{owner}/{name}@{} in {}", tip.n, dir.display());
350 Ok(())
351}
352
353fn working_changes(store: &Store, local: &Local) -> Result<(Id, Id, Vec<(String, FileChange)>)> {
354 let p = s(store.project(&local.owner, &local.project))?;
355 let current = local.current(p)?;
356 let now = workdir::snapshot(&store.objects, &local.root).map_err(|e| e.to_string())?;
357 let changes = workdir::compare(&store.objects, &current, &now).map_err(|e| e.to_string())?;
358 Ok((current, now, changes))
359}
360
361fn status() -> Result<()> {
362 let cfg = Config::load()?;
363 let local = Local::find()?;
364 let store = open_store(&cfg)?;
365 let p = s(store.project(&local.owner, &local.project))?;
366 let behind = p.tip().saturating_sub(local.base);
367 println!(
368 "{}/{} on @{}{}{}",
369 local.owner,
370 local.project,
371 local.base,
372 if behind > 0 { format!(" ({behind} behind main)") } else { String::new() },
373 local.change.map(|c| format!(", change #{c}")).unwrap_or_default()
374 );
375 if !local.steps.is_empty() {
376 println!("{} local step{}", local.steps.len(), if local.steps.len() == 1 { "" } else { "s" });
377 }
378 let (_, _, changes) = working_changes(&store, &local)?;
379 if changes.is_empty() {
380 println!("no changes since the last step");
381 }
382 for (path, c) in changes {
383 let mark = match c {
384 FileChange::Added => 'A',
385 FileChange::Removed => 'D',
386 FileChange::Modified => 'M',
387 };
388 println!("{mark} {path}");
389 }
390 Ok(())
391}
392
393fn print_diff(store: &Store, a: &Id, b: &Id, stat: bool) -> Result<()> {
394 let files_a: std::collections::BTreeMap<_, _> = store.objects.flatten(a).map_err(|e| e.to_string())?.into_iter().map(|(p, _, i)| (p, i)).collect();
395 let files_b: std::collections::BTreeMap<_, _> = store.objects.flatten(b).map_err(|e| e.to_string())?.into_iter().map(|(p, _, i)| (p, i)).collect();
396 for (path, _) in workdir::compare(&store.objects, a, b).map_err(|e| e.to_string())? {
397 let read = |m: &std::collections::BTreeMap<String, Id>| -> Result<Vec<u8>> {
398 m.get(&path).map(|id| store.objects.get_blob(id).map_err(|e| e.to_string())).transpose().map(Option::unwrap_or_default)
399 };
400 let (old, new) = (read(&files_a)?, read(&files_b)?);
401 match (std::str::from_utf8(&old), std::str::from_utf8(&new)) {
402 (Ok(o), Ok(n)) => {
403 let d = diff::unified(o, n, 3);
404 if stat {
405 let adds = d.lines().filter(|l| l.starts_with('+')).count();
406 let dels = d.lines().filter(|l| l.starts_with('-')).count();
407 println!("{path} +{adds} -{dels}");
408 } else {
409 println!("── {path}");
410 print!("{d}");
411 }
412 }
413 _ => println!("── {path} binary, {} → {} bytes", old.len(), new.len()),
414 }
415 }
416 Ok(())
417}
418
419fn diff_cmd(args: &Args) -> Result<()> {
420 let cfg = Config::load()?;
421 let local = Local::find()?;
422 let store = open_store(&cfg)?;
423 let (current, now, _) = working_changes(&store, &local)?;
424 print_diff(&store, &current, &now, args.has("--stat"))
425}
426
427fn step(args: &Args) -> Result<()> {
428 let cfg = Config::load()?;
429 let mut local = Local::find()?;
430 let store = open_store(&cfg)?;
431 let message = args.value(&["-m", "--message"]).ok_or("give the step a message with -m")?;
432 let (_, now, changes) = working_changes(&store, &local)?;
433 if changes.is_empty() {
434 return e!("nothing changed since the last step");
435 }
436 local.steps.push(Step { id: random::hex(4), snapshot: now, message: message.to_string() });
437 local.save()?;
438 println!("step {} recorded ({} file{})", local.steps.len(), changes.len(), if changes.len() == 1 { "" } else { "s" });
439 Ok(())
440}
441
442fn amend(args: &Args) -> Result<()> {
443 let cfg = Config::load()?;
444 let mut local = Local::find()?;
445 let store = open_store(&cfg)?;
446 if local.steps.is_empty() {
447 return e!("no local step to amend; use `grove step`");
448 }
449 let now = workdir::snapshot(&store.objects, &local.root).map_err(|e| e.to_string())?;
450 let last = local.steps.last_mut().unwrap();
451 last.snapshot = now;
452 if let Some(m) = args.value(&["-m", "--message"]) {
453 last.message = m.to_string();
454 }
455 local.save()?;
456 println!("step {} amended", local.steps.len());
457 Ok(())
458}
459
460fn stack() -> Result<()> {
461 let local = Local::find()?;
462 if local.steps.is_empty() {
463 println!("no local steps");
464 }
465 for (i, st) in local.steps.iter().enumerate() {
466 println!("{} {} {}", i + 1, short(&st.snapshot), st.message);
467 }
468 if let Some(c) = local.change {
469 println!("sent as change #{c}");
470 }
471 Ok(())
472}
473
474fn propose(args: &Args) -> Result<()> {
475 let cfg = Config::load()?;
476 let mut local = Local::find()?;
477 let mut store = open_store(&cfg)?;
478 if local.steps.is_empty() {
479 return e!("no steps to propose; record some with `grove step`");
480 }
481 let me = cfg.actor();
482 let (owner, name) = (local.owner.clone(), local.project.clone());
483 let number = match local.change {
484 Some(n) => n,
485 None => {
486 let intent = args.value(&["-m", "--message"]).ok_or("say what the change is for with -m")?;
487 s(store.open_change(&me, &owner, &name, intent))?
488 }
489 };
490 let rev = s(store.revise(&me, &owner, &name, number, local.base, &local.steps, args.value(&["--note"])))?;
491 let state = s(store.project(&owner, &name))?.change(number).unwrap().state;
492 if state == ChangeState::Draft {
493 s(store.set_state(&me, &owner, &name, number, ChangeState::Proposed))?;
494 }
495 local.change = Some(number);
496 local.save()?;
497 println!("{owner}/{name}#{number} r{rev} proposed ({} step{})", local.steps.len(), if local.steps.len() == 1 { "" } else { "s" });
498 Ok(())
499}
500
501fn say(args: &Args) -> Result<()> {
502 let cfg = Config::load()?;
503 let local = Local::find().ok();
504 let (number, text) = match args.pos(0) {
505 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.positional[1..].join(" ")),
506 _ => (change_number(None, local.as_ref())?, args.positional.join(" ")),
507 };
508 if text.trim().is_empty() {
509 return e!("say what?");
510 }
511 let local = local.ok_or("run this inside the project's working directory")?;
512 let mut store = open_store(&cfg)?;
513 s(store.say(&cfg.actor(), &local.owner, &local.project, number, &text))?;
514 println!("added to #{number}");
515 Ok(())
516}
517
518fn attach(args: &Args) -> Result<()> {
519 let cfg = Config::load()?;
520 let local = Local::find()?;
521 let (number, file) = match args.pos(0) {
522 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.pos(1)),
523 _ => (change_number(None, Some(&local))?, args.pos(0)),
524 };
525 let mut store = open_store(&cfg)?;
526 let rev = s(store.project(&local.owner, &local.project))?
527 .change(number)
528 .and_then(Change::latest)
529 .map(|r| r.number)
530 .ok_or("that change has no revision to attach to")?;
531 let me = cfg.actor();
532 if let Some(url) = args.value(&["--url"]) {
533 let title = args.value(&["--title"]).unwrap_or(url);
534 s(store.attach(&me, &local.owner, &local.project, number, rev, title, "text/uri-list", Content::Url(url)))?;
535 } else {
536 let file = file.ok_or("attach a file, or a link with --url")?;
537 let bytes = fs::read(file).map_err(|e| format!("{file}: {e}"))?;
538 let media = args.value(&["--type"]).map(str::to_string).unwrap_or_else(|| guess_type(file));
539 let title = args.value(&["--title"]).unwrap_or(file);
540 s(store.attach(&me, &local.owner, &local.project, number, rev, title, &media, Content::Bytes(&bytes)))?;
541 }
542 println!("attached to #{number} r{rev}");
543 Ok(())
544}
545
546fn guess_type(file: &str) -> String {
547 let ext = file.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
548 match ext.as_str() {
549 "md" => "text/markdown",
550 "txt" | "log" => "text/plain",
551 "json" => "application/json",
552 "png" => "image/png",
553 "jpg" | "jpeg" => "image/jpeg",
554 "gif" => "image/gif",
555 "svg" => "image/svg+xml",
556 "html" => "text/html",
557 "mp4" => "video/mp4",
558 _ => "application/octet-stream",
559 }
560 .to_string()
561}
562
563fn changes() -> Result<()> {
564 let cfg = Config::load()?;
565 let local = Local::find()?;
566 let store = open_store(&cfg)?;
567 let p = s(store.project(&local.owner, &local.project))?;
568 if p.changes.is_empty() {
569 println!("no changes yet");
570 }
571 for c in p.changes.iter().rev() {
572 println!("#{:<4} {:<10} {} ({}, {})", c.number, c.state.name(), c.intent, who(&store, &c.proposer), ago(c.at));
573 }
574 Ok(())
575}
576
577fn show(args: &Args) -> Result<()> {
578 let cfg = Config::load()?;
579 let local = Local::find()?;
580 let number = change_number(args.pos(0), Some(&local))?;
581 let store = open_store(&cfg)?;
582 let p = s(store.project(&local.owner, &local.project))?;
583 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
584 println!("#{} {} [{}]", c.number, c.intent, c.state.name());
585 println!("proposed by {}, {}", who(&store, &c.proposer), ago(c.at));
586 for (at, by, ev) in &c.timeline {
587 let line = match ev {
588 Event::Opened => "opened".to_string(),
589 Event::Revised(n) => {
590 let r = &c.revisions[*n as usize - 1];
591 let mut l = format!("r{n} on @{}: {} step{}", r.base, r.steps.len(), if r.steps.len() == 1 { "" } else { "s" });
592 for st in &r.steps {
593 l += &format!("\n {} {}", short(&st.snapshot), st.message);
594 }
595 if let Some(note) = &r.note {
596 l += &format!("\n note: {note}");
597 }
598 l
599 }
600 Event::State(s) => format!("→ {}", s.name()),
601 Event::Said(id) => format!("said: {}", c.messages[*id as usize - 1].body),
602 Event::Attached(i) => {
603 let ev = &c.evidence[*i];
604 format!("evidence on r{}: {} ({})", ev.revision, ev.title, ev.url.clone().unwrap_or_else(|| ev.media_type.clone()))
605 }
606 Event::Landed { revision, points } => format!("landed r{revision} as @{}..@{}", points.0, points.1),
607 };
608 println!(" {:<10} {}: {}", ago(*at), who(&store, by), line);
609 }
610 if let Some(r) = c.latest() {
611 if c.state.is_open() {
612 let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?;
613 println!();
614 print_diff(&store, &base, &r.steps.last().unwrap().snapshot, true)?;
615 }
616 }
617 Ok(())
618}
619
620fn land(args: &Args) -> Result<()> {
621 let cfg = Config::load()?;
622 let local = Local::find()?;
623 let number = change_number(args.pos(0), None)?;
624 let mut store = open_store(&cfg)?;
625 let (first, last) = s(store.land(&cfg.in_person(), &local.owner, &local.project, number))?;
626 println!("#{number} landed as @{first}..@{last}");
627 Ok(())
628}
629
630fn decline(args: &Args) -> Result<()> {
631 let cfg = Config::load()?;
632 let local = Local::find()?;
633 let number = change_number(args.pos(0), None)?;
634 let mut store = open_store(&cfg)?;
635 s(store.set_state(&cfg.in_person(), &local.owner, &local.project, number, ChangeState::Declined))?;
636 println!("#{number} declined");
637 Ok(())
638}
639
640fn log() -> Result<()> {
641 let cfg = Config::load()?;
642 let local = Local::find()?;
643 let store = open_store(&cfg)?;
644 let p = s(store.project(&local.owner, &local.project))?;
645 for pt in p.points.iter().rev() {
646 let from = pt.change.map(|c| format!(" #{c}")).unwrap_or_default();
647 println!("@{:<5} {}{} ({}, {})", pt.n, pt.message, from, who(&store, &pt.author), ago(pt.at));
648 }
649 Ok(())
650}
651
652fn sync() -> Result<()> {
653 let cfg = Config::load()?;
654 let mut local = Local::find()?;
655 let store = open_store(&cfg)?;
656 let p = s(store.project(&local.owner, &local.project))?;
657 // A change of ours that landed: if what landed is what we have locally, the working
658 // directory already matches the last landed point, so move the base there.
659 if let Some(c) = local.change.and_then(|n| p.change(n)) {
660 if c.state == ChangeState::Landed {
661 let landed_last = c.timeline.iter().rev().find_map(|(_, _, ev)| match ev {
662 Event::Landed { revision, points } => Some((*revision, points.1)),
663 _ => None,
664 });
665 if let Some((rev, last_point)) = landed_last {
666 let revision = &c.revisions[rev as usize - 1];
667 if revision.steps.last().map(|s| s.snapshot) == local.steps.last().map(|s| s.snapshot) {
668 local.base = last_point;
669 }
670 }
671 local.steps.clear();
672 local.change = None;
673 }
674 }
675 if p.tip() == local.base {
676 local.save()?;
677 println!("already at @{}", local.base);
678 return Ok(());
679 }
680 if !local.steps.is_empty() {
681 return e!("there are local steps on @{}; rebuilding them on @{} needs merging, which isn't built yet", local.base, p.tip());
682 }
683 let (_, _, pending) = working_changes(&store, &local)?;
684 if !pending.is_empty() {
685 return e!("the working directory has changes; record them as a step first");
686 }
687 let from = p.point(local.base).map(|pt| pt.snapshot).ok_or("base missing")?;
688 let tip = p.points.last().unwrap();
689 workdir::materialize(&store.objects, &tip.snapshot, &local.root, Some(&from)).map_err(|e| e.to_string())?;
690 local.base = tip.n;
691 local.save()?;
692 println!("now at @{}", tip.n);
693 Ok(())
694}
695
696fn verify() -> Result<()> {
697 let cfg = Config::load()?;
698 let store = open_store(&cfg)?;
699 let n = s(store.verify())?;
700 println!("{n} objects checked, all match their ids; {} log records", store.state().records);
701 Ok(())
702}