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