grove

@1 Start: BLAKE3, the log, objects, the model and store, working directories, diffs, and the CLI

by claude for antonky · landed by antonky · · files at @1

Files

.groveignoreadded+2 −0
1# Build output
2target/
Cargo.lockadded+7 −0
1# This file is automatically @generated by Cargo.
2# It is not intended for manual editing.
3version = 4
4
5[[package]]
6name = "grove"
7version = "0.1.0"
Cargo.tomladded+17 −0
1[package]
2name = "grove"
3version = "0.1.0"
4edition = "2021"
5description = "Where agents build software for people."
6publish = false
7
8# No dependencies: grove controls its own code. See VISION.md.
9[dependencies]
10
11[[bin]]
12name = "grove"
13path = "src/main.rs"
14
15[profile.release]
16lto = true
17codegen-units = 1
VISION.mdadded+67 −0
1# grove
2
3grove is where agents build and maintain software for people. The owner of a project
4doesn't write its code; agents do, the owner's own and anyone else's. The owner steers
5the work and decides what goes in.
6
7## People and agents
8
9- **Owner.** A person who owns a project. Nothing lands without them. They can redirect
10 work while it's in progress, not only accept or reject it at the end.
11- **Agent.** Writes code. Every agent acts for a person, and grove records both: which
12 agent made a change and on whose behalf.
13- **Contributor.** Anyone, through their agents. Anyone can propose a change to a public
14 project. The owner is under no obligation to take it.
15
16## Changes
17
18The unit of work is a change: something someone wants done to a project. A change can
19start as only an intent and a discussion, before any code exists. When an agent builds it,
20the same change gains its steps and evidence; the discussion stays attached. One object
21holds the idea, the argument about it, the work and the decision.
22
23- **Intent.** What the change is for, in plain words. Usually the task the agent was given.
24- **Stack.** A change is made of small steps that build on each other and are kept in
25 order. A step can be revised at any time; every earlier version of it is kept.
26- **Evidence.** Whatever the agent decides shows its change works: a written account,
27 screenshots, logs, data, a running preview, anything else. grove doesn't prescribe the
28 kinds; it stores what the agent attaches and shows it well. If the owner wants more or
29 different evidence, they ask in the discussion.
30- **Discussion.** Anyone can take part: the owner, the proposer, other people, and agents
31 for any of them. The owner can comment, ask for something different or redirect, and
32 the building agent answers by revising the stack.
33- **State.** Idea (intent only), draft, proposed, accepted, landed, or declined.
34
35A project's history is the sequence of landed changes, each with its intent and evidence.
36That is what people read. The code is there when someone wants it.
37
38## Interfaces
39
40- **Agents** use an API and an MCP server, and a small CLI to get a project's files into
41 a working directory and send changes back.
42- **People** use the web, to see what's in flight, judge results and steer. Code views
43 exist but are secondary.
44
45## What grove builds on
46
47grove is written in Rust with no dependencies beyond the standard library. It controls its
48own code: hashing, storage, HTTP and JSON are all grove's. The one planned exception is
49signature verification for passkeys, where a small audited library is safer than our own.
50
51- No Git. No Sapling, Mononoke or EdenFS code. grove takes Sapling's ideas (stacks, revising
52 freely while keeping history, a view of everything in flight) and implements them itself.
53- grove's own tools (CI, deploys, agents) get code from grove.
54- Storage has to be simple and safe above everything else: content-addressed files and an
55 append-only log, tested, backed up from the start. Losing work is the one failure grove
56 can't have.
57- grove holds its own code from the first day it can.
58
59## First steps
60
611. The store (objects and log) with its tests, and a CLI working directly on it.
622. grove takes in its own code as its first project; from then on its changes land through grove.
633. Three-way merge for landing on a moved main; diffs.
644. The HTTP API and MCP server, tokens for agents; grove runs on grove.host.
655. Move the existing projects in, keeping their history, and point CI and deploys at grove.
666. The web, built around changes and results.
677. Retire Mononoke, the bridge and the Sapling fork.
docs/model.mdadded+231 −0
1# grove core model
2
3The objects grove stores, the rules they obey, and the operations people and agents perform.
4Read with [VISION.md](../VISION.md). Built in Rust with no dependencies beyond the standard library.
5
6## Overview
7
8```
9person ── owns ──> project ── main: point 1 → point 2 → … → point n (landed history)
10 │ │
11 └─ agents └─ changes ── discussion (messages, events)
12 (act for │
13 the person) └─ revisions ── steps (snapshot + message)
14 └─ evidence (anything the agent attaches)
15```
16
17A project's main line is a numbered sequence of snapshots. Work happens in changes. A change
18collects revisions of a stack of steps; the owner lands one revision, and its steps become the
19next points on main.
20
21## Identity
22
23**Person.** A human account: username, display name, passkeys for signing in.
24
25**Agent.** Belongs to one person and acts only for them. It has a name the person chose
26("claude on laptop", "operator: grove"), and its own tokens. Every write an agent makes is
27recorded as *agent A for person P*. A person can revoke an agent, which kills its tokens and
28leaves its history intact.
29
30People can also act directly, from the web. Then the actor is just the person.
31
32## Projects
33
34A project has an owner (a person), a name unique within the owner, a description, and a
35visibility:
36
37- **public:** anyone can read it, open changes and join discussions.
38- **private:** only the owner and people they invite.
39
40Only the owner (and people they make co-owners) can land, decline, or change settings, and
41only in person: these are decisions, so an agent acting for the owner can't make them.
42
43## Content
44
45Content is stored by hash, so identical files are stored once and nothing can change in place.
46
47- **Blob.** The bytes of one file. Id: BLAKE3 of `"grove blob\0"` followed by the bytes.
48- **Tree.** One directory: a sorted list of entries, each `name`, `kind` (`file`, `exec`,
49 `link`, `dir`) and the id of a blob or tree. A `link` is a blob holding the link target.
50 Id: BLAKE3 of `"grove tree\0"` followed by the tree's canonical encoding.
51 The prefixes keep a file and a tree with the same bytes from sharing an id.
52- **Snapshot.** The id of a root tree: the whole project at one moment.
53
54Names are UTF-8 and may not contain `/` or be `.` or `..`. Binary and text files are the same
55to storage; diffs are computed for text and shown as size changes otherwise.
56
57Ignored paths come from a `.groveignore` file in the project, same syntax as the usual ignore
58files. The CLI applies it; the server stores whatever it's given.
59
60## Main
61
62Main is the project's history: a linear sequence of **points**, numbered from 1.
63
64| field | |
65|---|---|
66| `n` | 1, 2, 3, … with no gaps |
67| `snapshot` | the project after this point |
68| `change`, `step` | the change and step that produced it |
69| `message` | the step's message |
70| `landed_by` | who landed it (person), and when |
71| `authored_by` | agent-for-person that wrote the step |
72
73There are no branches and no merges on main. Work in progress lives in changes. Main only
74ever grows; a mistake is undone by landing a change that reverts it.
75
76Agents and the web refer to versions as `owner/project@n`, and `@main` for the latest.
77
78## Changes
79
80A change is something someone wants done to a project. It is numbered within the project
81(`owner/project#12`).
82
83| field | |
84|---|---|
85| `number` | per project |
86| `intent` | what it's for, in plain words; editable, every version kept |
87| `proposer` | person or agent-for-person who opened it |
88| `state` | see below |
89| `revisions` | zero or more, in order |
90| `discussion` | messages and events |
91
92### States
93
94```
95idea ──> draft ──> proposed ──> accepted ──> landed
96 │ │ │ │
97 └────────┴───────────┴──> declined / withdrawn
98```
99
100- **idea:** intent and discussion only, no revisions.
101- **draft:** has revisions; the author is still working. Not asking for a decision.
102- **proposed:** the author asks the owner to decide on the latest revision.
103- **accepted:** the owner approved a specific revision. grove lands it straight away; this
104 state is visible only while landing runs, or if landing failed (see Landing).
105- **landed:** its steps are on main.
106- **declined:** the owner said no. **withdrawn:** the proposer gave up.
107
108Declined and withdrawn changes can be reopened as draft. Nothing is ever deleted.
109
110### Revisions and steps
111
112A **revision** is one complete version of the work, immutable once created:
113
114| field | |
115|---|---|
116| `number` | r1, r2, … within the change |
117| `base` | the main point it was built on |
118| `steps` | ordered list; each step builds on the one before |
119| `note` | what changed since the previous revision, optional |
120| `created_by` | agent-for-person |
121
122A **step** within a revision is a snapshot plus a message. Each step has a stable id inside
123the change, so the same step can be followed across revisions ("step 2 in r1, r2 and r4"),
124even as it's edited, reordered, split or dropped. Revising one step means creating a new
125revision; later steps carry forward, rebuilt on top of it if needed.
126
127This keeps Sapling's useful property, that amending is free and nothing is lost, without
128tracking each amend as its own object. The diff between two revisions (or between two
129versions of one step) shows exactly what the agent did in response to feedback.
130
131### Evidence
132
133Evidence is attached to a revision. The agent decides what to attach.
134
135| field | |
136|---|---|
137| `title` | short label |
138| `type` | a media type (`text/markdown`, `image/png`, `application/json`, `text/uri-list`, …) |
139| `content` | a blob, or a URL for something running elsewhere (a preview) |
140| `step` | optional: the step it's about |
141| `created_by` | agent-for-person |
142
143grove stores evidence as given and renders what it knows how to: markdown, images, video,
144logs, tables, links to previews. Anything else downloads.
145
146### Discussion
147
148A change's discussion is one timeline of **messages** and **events**.
149
150A message has an author (person, or agent-for-person), a markdown body, optional attachments
151(stored like evidence), an optional reply-to, and an optional anchor: a revision, step, file
152or line it's about. Messages can be edited; every version is kept.
153
154Events are written by grove: state changes, new revisions, evidence added, landing results.
155
156Steering is a message. The owner (or their agent) writes in the discussion; the building
157agent reads it and responds with a new revision, a reply, or both. grove doesn't interpret
158the request.
159
160## Landing
161
162When the owner accepts revision R of change C:
163
1641. If `R.base` is the current tip of main, each step becomes the next point, in order.
1652. Otherwise grove rebuilds the steps on the current tip with a three-way merge of each file
166 (base snapshot, step snapshot, current tip).
1673. If every file merges cleanly, the rebuilt steps land as above, and the change records
168 which revision landed and what it was rebased onto.
1694. If anything conflicts, nothing lands. The change returns to **proposed** with an event
170 listing the conflicting files, and the agent makes a new revision on the current tip.
171
172Landing is one database transaction: either all of a change's steps land or none do. Points
173are only ever appended.
174
175## Agents' workflow
176
177From an agent's side, through the CLI or the API:
178
1791. `checkout owner/project`: files at `@main` in a working directory, with `.grove/`
180 holding local state.
1812. Edit files. `step "message"` records the working directory as a new step; `amend` replaces
182 the current step; `stack` shows the local steps.
1833. `propose` (or `revise` for an existing change) uploads the local steps as a new revision,
184 sending only blobs the server doesn't have.
1854. `attach` adds evidence. `say` posts to the discussion. `watch` streams the discussion so
186 the agent can respond to steering.
1875. `sync` brings the working directory up to the new tip and rebuilds local steps on it.
188
189The MCP server exposes the same operations to agents that speak MCP.
190
191## Storage
192
193grove has no database. Two kinds of files hold everything:
194
195- **Objects** (blobs and trees) live under `objects/<first 2 hex>/<rest of id>`. An object is
196 written to a temporary file, flushed to disk, renamed into place, and the directory flushed,
197 before any record refers to it. Objects are never rewritten.
198- **The log** holds every other fact as an append-only sequence of records: person and agent
199 created, project created, change opened, revision added, message posted, state changed,
200 point landed. Each record is `length, checksum, payload` (payload is JSON; checksum is the
201 first 8 bytes of its BLAKE3). A write is acknowledged only after the record is flushed to
202 disk. On start, grove replays the log into memory and answers every query from memory.
203- **Torn writes.** If the process dies mid-append, the last record fails its length or
204 checksum. On start, grove cuts the file back to the last whole record. Damage anywhere
205 but the tail stops grove with an error instead of guessing.
206- **One writer.** The store takes an exclusive file lock while open.
207- **Backups** are a copy of the log and the objects directory, both append-only, so backups
208 are incremental. An integrity check confirms every object a record refers to exists and
209 hashes to its id; it runs after every backup and on demand.
210- **Fast start.** When the log grows large, grove writes a checkpoint (the in-memory state at
211 record N, checksummed) and replays only the records after it. The log itself is kept whole.
212
213## Moving existing projects in
214
215Each repository's current history on Mononoke is read commit by commit, oldest first, and
216written as points on main with the original messages, authors and times. Each imported point
217records that it came from an import and the original commit hash. Open diffs on the old system
218become changes in proposed state with one revision each. Nothing on the old system is removed
219until the import of every project has been checked against it.
220
221## Open decisions
222
2231. **Shared ownership.** Today there are orgs. Should a project be owned by a group of
224 people, or by one person with co-owners?
2252. **Agents acting for someone else.** Should a person be able to lend their agent to
226 another project's owner (an agent working *for* the owner, run by someone else)?
2273. **Who runs previews.** Evidence can link to a running preview. Does grove host previews
228 itself (as Pages does today), or only link to what the agent runs?
2294. **Notifications.** How you hear that something needs your decision: web only, email,
230 or through your own agent.
2315. **Size limits.** A maximum file and revision size, so an agent can't fill the disk by mistake.
src/blake3.rsadded+289 −0
1//! BLAKE3, the hash behind every object id.
2//!
3//! A direct implementation of the BLAKE3 specification (portable, single-threaded), checked
4//! against the official test vectors below. Only the default hash mode is needed.
5
6const OUT_LEN: usize = 32;
7const BLOCK_LEN: usize = 64;
8const CHUNK_LEN: usize = 1024;
9
10const CHUNK_START: u32 = 1 << 0;
11const CHUNK_END: u32 = 1 << 1;
12const PARENT: u32 = 1 << 2;
13const ROOT: u32 = 1 << 3;
14
15const IV: [u32; 8] = [
16 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
17];
18
19const MSG_PERMUTATION: [usize; 16] = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8];
20
21#[inline(always)]
22fn g(state: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize, mx: u32, my: u32) {
23 state[a] = state[a].wrapping_add(state[b]).wrapping_add(mx);
24 state[d] = (state[d] ^ state[a]).rotate_right(16);
25 state[c] = state[c].wrapping_add(state[d]);
26 state[b] = (state[b] ^ state[c]).rotate_right(12);
27 state[a] = state[a].wrapping_add(state[b]).wrapping_add(my);
28 state[d] = (state[d] ^ state[a]).rotate_right(8);
29 state[c] = state[c].wrapping_add(state[d]);
30 state[b] = (state[b] ^ state[c]).rotate_right(7);
31}
32
33#[inline(always)]
34fn round(state: &mut [u32; 16], m: &[u32; 16]) {
35 // Columns.
36 g(state, 0, 4, 8, 12, m[0], m[1]);
37 g(state, 1, 5, 9, 13, m[2], m[3]);
38 g(state, 2, 6, 10, 14, m[4], m[5]);
39 g(state, 3, 7, 11, 15, m[6], m[7]);
40 // Diagonals.
41 g(state, 0, 5, 10, 15, m[8], m[9]);
42 g(state, 1, 6, 11, 12, m[10], m[11]);
43 g(state, 2, 7, 8, 13, m[12], m[13]);
44 g(state, 3, 4, 9, 14, m[14], m[15]);
45}
46
47fn permute(m: &mut [u32; 16]) {
48 let mut permuted = [0; 16];
49 for i in 0..16 {
50 permuted[i] = m[MSG_PERMUTATION[i]];
51 }
52 *m = permuted;
53}
54
55fn compress(cv: &[u32; 8], block_words: &[u32; 16], counter: u64, block_len: u32, flags: u32) -> [u32; 16] {
56 let mut state = [
57 cv[0], cv[1], cv[2], cv[3], cv[4], cv[5], cv[6], cv[7],
58 IV[0], IV[1], IV[2], IV[3],
59 counter as u32, (counter >> 32) as u32, block_len, flags,
60 ];
61 let mut block = *block_words;
62 for r in 0..7 {
63 round(&mut state, &block);
64 if r < 6 {
65 permute(&mut block);
66 }
67 }
68 for i in 0..8 {
69 state[i] ^= state[i + 8];
70 state[i + 8] ^= cv[i];
71 }
72 state
73}
74
75fn first_8_words(w: [u32; 16]) -> [u32; 8] {
76 let mut out = [0; 8];
77 out.copy_from_slice(&w[..8]);
78 out
79}
80
81fn words_from_le_bytes(bytes: &[u8; BLOCK_LEN]) -> [u32; 16] {
82 let mut words = [0; 16];
83 for (word, chunk) in words.iter_mut().zip(bytes.chunks_exact(4)) {
84 *word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
85 }
86 words
87}
88
89struct Output {
90 input_cv: [u32; 8],
91 block_words: [u32; 16],
92 counter: u64,
93 block_len: u32,
94 flags: u32,
95}
96
97impl Output {
98 fn chaining_value(&self) -> [u32; 8] {
99 first_8_words(compress(&self.input_cv, &self.block_words, self.counter, self.block_len, self.flags))
100 }
101
102 fn root_hash(&self) -> [u8; OUT_LEN] {
103 let words = compress(&self.input_cv, &self.block_words, 0, self.block_len, self.flags | ROOT);
104 let mut out = [0u8; OUT_LEN];
105 for (i, word) in words[..8].iter().enumerate() {
106 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_le_bytes());
107 }
108 out
109 }
110}
111
112struct ChunkState {
113 cv: [u32; 8],
114 chunk_counter: u64,
115 block: [u8; BLOCK_LEN],
116 block_len: usize,
117 blocks_compressed: usize,
118}
119
120impl ChunkState {
121 fn new(chunk_counter: u64) -> Self {
122 ChunkState { cv: IV, chunk_counter, block: [0; BLOCK_LEN], block_len: 0, blocks_compressed: 0 }
123 }
124
125 fn len(&self) -> usize {
126 BLOCK_LEN * self.blocks_compressed + self.block_len
127 }
128
129 fn start_flag(&self) -> u32 {
130 if self.blocks_compressed == 0 { CHUNK_START } else { 0 }
131 }
132
133 fn update(&mut self, mut input: &[u8]) {
134 while !input.is_empty() {
135 // A full block is only compressed once more input arrives, so the last block of the
136 // chunk is still here when output() needs it.
137 if self.block_len == BLOCK_LEN {
138 let words = words_from_le_bytes(&self.block);
139 self.cv = first_8_words(compress(&self.cv, &words, self.chunk_counter, BLOCK_LEN as u32, self.start_flag()));
140 self.blocks_compressed += 1;
141 self.block = [0; BLOCK_LEN];
142 self.block_len = 0;
143 }
144 let take = (BLOCK_LEN - self.block_len).min(input.len());
145 self.block[self.block_len..self.block_len + take].copy_from_slice(&input[..take]);
146 self.block_len += take;
147 input = &input[take..];
148 }
149 }
150
151 fn output(&self) -> Output {
152 Output {
153 input_cv: self.cv,
154 block_words: words_from_le_bytes(&self.block),
155 counter: self.chunk_counter,
156 block_len: self.block_len as u32,
157 flags: self.start_flag() | CHUNK_END,
158 }
159 }
160}
161
162fn parent_output(left: [u32; 8], right: [u32; 8]) -> Output {
163 let mut block_words = [0; 16];
164 block_words[..8].copy_from_slice(&left);
165 block_words[8..].copy_from_slice(&right);
166 Output { input_cv: IV, block_words, counter: 0, block_len: BLOCK_LEN as u32, flags: PARENT }
167}
168
169/// Incremental BLAKE3 hasher.
170pub struct Hasher {
171 chunk: ChunkState,
172 cv_stack: [[u32; 8]; 54], // enough for 2^54 chunks
173 cv_stack_len: usize,
174}
175
176impl Default for Hasher {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182impl Hasher {
183 pub fn new() -> Self {
184 Hasher { chunk: ChunkState::new(0), cv_stack: [[0; 8]; 54], cv_stack_len: 0 }
185 }
186
187 fn push(&mut self, cv: [u32; 8]) {
188 self.cv_stack[self.cv_stack_len] = cv;
189 self.cv_stack_len += 1;
190 }
191
192 fn pop(&mut self) -> [u32; 8] {
193 self.cv_stack_len -= 1;
194 self.cv_stack[self.cv_stack_len]
195 }
196
197 // Merge completed subtrees: one merge per trailing zero bit of the chunk count.
198 fn add_chunk_cv(&mut self, mut cv: [u32; 8], mut total_chunks: u64) {
199 while total_chunks & 1 == 0 {
200 cv = parent_output(self.pop(), cv).chaining_value();
201 total_chunks >>= 1;
202 }
203 self.push(cv);
204 }
205
206 pub fn update(&mut self, mut input: &[u8]) -> &mut Self {
207 while !input.is_empty() {
208 if self.chunk.len() == CHUNK_LEN {
209 let cv = self.chunk.output().chaining_value();
210 let total = self.chunk.chunk_counter + 1;
211 self.add_chunk_cv(cv, total);
212 self.chunk = ChunkState::new(total);
213 }
214 let take = (CHUNK_LEN - self.chunk.len()).min(input.len());
215 self.chunk.update(&input[..take]);
216 input = &input[take..];
217 }
218 self
219 }
220
221 pub fn finalize(&self) -> [u8; OUT_LEN] {
222 let mut output = self.chunk.output();
223 let mut i = self.cv_stack_len;
224 while i > 0 {
225 i -= 1;
226 output = parent_output(self.cv_stack[i], output.chaining_value());
227 }
228 output.root_hash()
229 }
230}
231
232/// The BLAKE3 hash of `input`.
233pub fn hash(input: &[u8]) -> [u8; OUT_LEN] {
234 let mut h = Hasher::new();
235 h.update(input);
236 h.finalize()
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::hex;
243
244 // From the official test_vectors.json: input is bytes 0, 1, 2, … 250, 0, 1, … of each length.
245 const VECTORS: &[(usize, &str)] = &[
246 (0, "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"),
247 (1, "2d3adedff11b61f14c886e35afa036736dcd87a74d27b5c1510225d0f592e213"),
248 (63, "e9bc37a594daad83be9470df7f7b3798297c3d834ce80ba85d6e207627b7db7b"),
249 (64, "4eed7141ea4a5cd4b788606bd23f46e212af9cacebacdc7d1f4c6dc7f2511b98"),
250 (65, "de1e5fa0be70df6d2be8fffd0e99ceaa8eb6e8c93a63f2d8d1c30ecb6b263dee"),
251 (1023, "10108970eeda3eb932baac1428c7a2163b0e924c9a9e25b35bba72b28f70bd11"),
252 (1024, "42214739f095a406f3fc83deb889744ac00df831c10daa55189b5d121c855af7"),
253 (1025, "d00278ae47eb27b34faecf67b4fe263f82d5412916c1ffd97c8cb7fb814b8444"),
254 (2048, "e776b6028c7cd22a4d0ba182a8bf62205d2ef576467e838ed6f2529b85fba24a"),
255 (2049, "5f4d72f40d7a5f82b15ca2b2e44b1de3c2ef86c426c95c1af0b6879522563030"),
256 (3072, "b98cb0ff3623be03326b373de6b9095218513e64f1ee2edd2525c7ad1e5cffd2"),
257 (3073, "7124b49501012f81cc7f11ca069ec9226cecb8a2c850cfe644e327d22d3e1cd3"),
258 (4096, "015094013f57a5277b59d8475c0501042c0b642e531b0a1c8f58d2163229e969"),
259 (4097, "9b4052b38f1c5fc8b1f9ff7ac7b27cd242487b3d890d15c96a1c25b8aa0fb995"),
260 (8192, "aae792484c8efe4f19e2ca7d371d8c467ffb10748d8a5a1ae579948f718a2a63"),
261 (8193, "bab6c09cb8ce8cf459261398d2e7aef35700bf488116ceb94a36d0f5f1b7bc3b"),
262 (16384, "f875d6646de28985646f34ee13be9a576fd515f76b5b0a26bb324735041ddde4"),
263 (31744, "62b6960e1a44bcc1eb1a611a8d6235b6b4b78f32e7abc4fb4c6cdcce94895c47"),
264 (102400, "bc3e3d41a1146b069abffad3c0d44860cf664390afce4d9661f7902e7943e085"),
265 ];
266
267 fn input(len: usize) -> Vec<u8> {
268 (0..len).map(|i| (i % 251) as u8).collect()
269 }
270
271 #[test]
272 fn official_vectors() {
273 for &(len, expected) in VECTORS {
274 assert_eq!(hex::encode(&hash(&input(len))), expected, "length {len}");
275 }
276 }
277
278 #[test]
279 fn incremental_matches_one_shot() {
280 let data = input(102400);
281 for split in [1, 63, 64, 65, 1023, 1024, 1025, 4097, 50000] {
282 let mut h = Hasher::new();
283 for piece in data.chunks(split) {
284 h.update(piece);
285 }
286 assert_eq!(h.finalize(), hash(&data), "pieces of {split}");
287 }
288 }
289}
src/cli.rsadded+690 −0
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}
src/diff.rsadded+181 −0
1//! Line diffs (Myers' O(ND) algorithm) and unified output.
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum Op {
5 Same,
6 Del,
7 Add,
8}
9
10/// The shortest edit script from `a` to `b`, as (op, line) pairs in order.
11pub fn diff<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<(Op, &'a str)> {
12 // Trim the common prefix and suffix first; most diffs are small edits in large files.
13 let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
14 let suf = a[pre..].iter().rev().zip(b[pre..].iter().rev()).take_while(|(x, y)| x == y).count();
15 let (am, bm) = (&a[pre..a.len() - suf], &b[pre..b.len() - suf]);
16
17 let mut out: Vec<(Op, &str)> = a[..pre].iter().map(|l| (Op::Same, *l)).collect();
18 out.extend(myers(am, bm));
19 out.extend(a[a.len() - suf..].iter().map(|l| (Op::Same, *l)));
20 out
21}
22
23fn myers<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<(Op, &'a str)> {
24 let (n, m) = (a.len() as isize, b.len() as isize);
25 if n == 0 {
26 return b.iter().map(|l| (Op::Add, *l)).collect();
27 }
28 if m == 0 {
29 return a.iter().map(|l| (Op::Del, *l)).collect();
30 }
31 let max = (n + m) as usize;
32 let offset = max as isize;
33 let mut v = vec![0isize; 2 * max + 2];
34 let mut trace: Vec<Vec<isize>> = Vec::new();
35 'outer: for d in 0..=max as isize {
36 trace.push(v.clone());
37 let mut k = -d;
38 while k <= d {
39 let idx = (k + offset) as usize;
40 let mut x = if k == -d || (k != d && v[idx - 1] < v[idx + 1]) { v[idx + 1] } else { v[idx - 1] + 1 };
41 let mut y = x - k;
42 while x < n && y < m && a[x as usize] == b[y as usize] {
43 x += 1;
44 y += 1;
45 }
46 v[idx] = x;
47 if x >= n && y >= m {
48 break 'outer;
49 }
50 k += 2;
51 }
52 }
53 // Walk back through the saved frontiers to recover the path.
54 let mut out = Vec::new();
55 let (mut x, mut y) = (n, m);
56 for d in (0..trace.len() as isize).rev() {
57 let v = &trace[d as usize];
58 let k = x - y;
59 let idx = (k + offset) as usize;
60 let prev_k = if k == -d || (k != d && v[idx - 1] < v[idx + 1]) { k + 1 } else { k - 1 };
61 let prev_x = v[(prev_k + offset) as usize];
62 let prev_y = prev_x - prev_k;
63 while x > prev_x && y > prev_y {
64 out.push((Op::Same, a[(x - 1) as usize]));
65 x -= 1;
66 y -= 1;
67 }
68 if d > 0 {
69 if x == prev_x {
70 out.push((Op::Add, b[(y - 1) as usize]));
71 } else {
72 out.push((Op::Del, a[(x - 1) as usize]));
73 }
74 }
75 x = prev_x;
76 y = prev_y;
77 }
78 out.reverse();
79 out
80}
81
82/// Split text into lines, keeping track of whether the last one ended with a newline.
83pub fn lines(text: &str) -> Vec<&str> {
84 if text.is_empty() {
85 return Vec::new();
86 }
87 text.strip_suffix('\n').unwrap_or(text).split('\n').collect()
88}
89
90/// Unified diff hunks with `context` lines around each change. Empty if equal.
91pub fn unified(a: &str, b: &str, context: usize) -> String {
92 let (al, bl) = (lines(a), lines(b));
93 let ops = diff(&al, &bl);
94 if ops.iter().all(|(op, _)| *op == Op::Same) {
95 return String::new();
96 }
97 // Line numbers before each op.
98 let mut pos = Vec::with_capacity(ops.len());
99 let (mut i, mut j) = (1usize, 1usize);
100 for (op, _) in &ops {
101 pos.push((i, j));
102 match op {
103 Op::Same => {
104 i += 1;
105 j += 1;
106 }
107 Op::Del => i += 1,
108 Op::Add => j += 1,
109 }
110 }
111 let changed: Vec<usize> = ops.iter().enumerate().filter(|(_, (op, _))| *op != Op::Same).map(|(k, _)| k).collect();
112 let mut out = String::new();
113 let mut k = 0;
114 while k < changed.len() {
115 let start = changed[k].saturating_sub(context);
116 let mut end = changed[k];
117 while k + 1 < changed.len() && changed[k + 1] <= end + 2 * context + 1 {
118 k += 1;
119 end = changed[k];
120 }
121 let end = (end + context + 1).min(ops.len());
122 let (a0, b0) = pos[start];
123 let a_len = ops[start..end].iter().filter(|(op, _)| *op != Op::Add).count();
124 let b_len = ops[start..end].iter().filter(|(op, _)| *op != Op::Del).count();
125 out.push_str(&format!("@@ -{},{} +{},{} @@\n", if a_len == 0 { a0 - 1 } else { a0 }, a_len, if b_len == 0 { b0 - 1 } else { b0 }, b_len));
126 for (op, line) in &ops[start..end] {
127 out.push(match op {
128 Op::Same => ' ',
129 Op::Del => '-',
130 Op::Add => '+',
131 });
132 out.push_str(line);
133 out.push('\n');
134 }
135 k += 1;
136 }
137 out
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 fn apply(a: &[&str], ops: &[(Op, &str)]) -> (Vec<String>, Vec<String>) {
145 let from: Vec<String> = ops.iter().filter(|(o, _)| *o != Op::Add).map(|(_, l)| l.to_string()).collect();
146 let to: Vec<String> = ops.iter().filter(|(o, _)| *o != Op::Del).map(|(_, l)| l.to_string()).collect();
147 let _ = a;
148 (from, to)
149 }
150
151 #[test]
152 fn edit_scripts_reproduce_both_sides() {
153 let cases: &[(&str, &str)] = &[
154 ("", ""),
155 ("a\nb\nc\n", "a\nb\nc\n"),
156 ("", "a\nb\n"),
157 ("a\nb\n", ""),
158 ("a\nb\nc\nd\n", "a\nx\nc\nd\ne\n"),
159 ("a\nb\nc\na\nb\nb\na\n", "c\nb\na\nb\na\nc\n"),
160 ];
161 for (a, b) in cases {
162 let (al, bl) = (lines(a), lines(b));
163 let ops = diff(&al, &bl);
164 let (from, to) = apply(&al, &ops);
165 assert_eq!(from, al, "{a:?} -> {b:?}");
166 assert_eq!(to, bl, "{a:?} -> {b:?}");
167 }
168 // The classic example has a shortest edit script of length 5.
169 let (al, bl) = (lines("a\nb\nc\na\nb\nb\na\n"), lines("c\nb\na\nb\na\nc\n"));
170 assert_eq!(diff(&al, &bl).iter().filter(|(o, _)| *o != Op::Same).count(), 5);
171 }
172
173 #[test]
174 fn unified_output() {
175 let a = "1\n2\n3\n4\n5\n6\n7\n8\n9\n";
176 let b = "1\n2\n3\nfour\n5\n6\n7\n8\n9\n";
177 assert_eq!(unified(a, b, 1), "@@ -3,3 +3,3 @@\n 3\n-4\n+four\n 5\n");
178 assert_eq!(unified(a, a, 3), "");
179 assert_eq!(unified("", "new\n", 3), "@@ -0,0 +1,1 @@\n+new\n");
180 }
181}
src/hex.rsadded+37 −0
1//! Lowercase hex, for object ids and tokens.
2
3const DIGITS: &[u8; 16] = b"0123456789abcdef";
4
5pub fn encode(bytes: &[u8]) -> String {
6 let mut s = String::with_capacity(bytes.len() * 2);
7 for b in bytes {
8 s.push(DIGITS[(b >> 4) as usize] as char);
9 s.push(DIGITS[(b & 0xf) as usize] as char);
10 }
11 s
12}
13
14pub fn decode(s: &str) -> Option<Vec<u8>> {
15 if s.len() % 2 != 0 {
16 return None;
17 }
18 let nibble = |c: u8| match c {
19 b'0'..=b'9' => Some(c - b'0'),
20 b'a'..=b'f' => Some(c - b'a' + 10),
21 _ => None,
22 };
23 s.as_bytes().chunks(2).map(|p| Some(nibble(p[0])? << 4 | nibble(p[1])?)).collect()
24}
25
26#[cfg(test)]
27mod tests {
28 #[test]
29 fn round_trip() {
30 let bytes = [0u8, 1, 0x7f, 0x80, 0xff];
31 assert_eq!(super::encode(&bytes), "00017f80ff");
32 assert_eq!(super::decode("00017f80ff").unwrap(), bytes);
33 assert!(super::decode("0g").is_none());
34 assert!(super::decode("abc").is_none());
35 assert!(super::decode("AB").is_none());
36 }
37}
src/ignore.rsadded+127 −0
1//! `.groveignore`: which paths in a working directory aren't part of the project.
2//!
3//! One pattern per line; `#` starts a comment. Like the familiar ignore files:
4//! - `name` matches that name at any depth; `dir/name` (or `/name`) is anchored at the root
5//! - a trailing `/` matches directories only
6//! - `*` matches within one path segment, `?` one character, `**` any number of segments
7//! - `!pattern` re-includes something an earlier pattern excluded; the last match wins
8//!
9//! `.grove/` (the working directory's own state) is always ignored.
10
11#[derive(Debug)]
12struct Pattern {
13 glob: Vec<u8>,
14 anchored: bool,
15 dir_only: bool,
16 negate: bool,
17}
18
19#[derive(Debug, Default)]
20pub struct Ignore {
21 patterns: Vec<Pattern>,
22}
23
24impl Ignore {
25 pub fn parse(text: &str) -> Ignore {
26 let mut patterns = Vec::new();
27 for line in text.lines() {
28 let line = line.trim_end();
29 if line.is_empty() || line.starts_with('#') {
30 continue;
31 }
32 let (negate, line) = match line.strip_prefix('!') {
33 Some(rest) => (true, rest),
34 None => (false, line),
35 };
36 let (dir_only, line) = match line.strip_suffix('/') {
37 Some(rest) => (true, rest),
38 None => (false, line),
39 };
40 let anchored = line.contains('/');
41 let line = line.strip_prefix('/').unwrap_or(line);
42 if line.is_empty() {
43 continue;
44 }
45 patterns.push(Pattern { glob: line.as_bytes().to_vec(), anchored, dir_only, negate });
46 }
47 Ignore { patterns }
48 }
49
50 /// Is `path` (relative, `/`-separated) ignored? `is_dir` says whether it's a directory.
51 pub fn ignored(&self, path: &str, is_dir: bool) -> bool {
52 if path == ".grove" || path.starts_with(".grove/") {
53 return true;
54 }
55 let name = path.rsplit('/').next().unwrap_or(path);
56 let mut result = false;
57 for p in &self.patterns {
58 if p.dir_only && !is_dir {
59 continue;
60 }
61 let subject = if p.anchored { path } else { name };
62 if glob(&p.glob, subject.as_bytes()) {
63 result = !p.negate;
64 }
65 }
66 result
67 }
68}
69
70/// Glob match where `*` and `?` stay within a segment and `**` spans segments.
71fn glob(p: &[u8], s: &[u8]) -> bool {
72 if p.is_empty() {
73 return s.is_empty();
74 }
75 if p.starts_with(b"**") {
76 let rest = p[2..].strip_prefix(b"/").unwrap_or(&p[2..]);
77 // `**` matches zero or more whole segments.
78 if glob(rest, s) {
79 return true;
80 }
81 for (i, &c) in s.iter().enumerate() {
82 if c == b'/' && glob(rest, &s[i + 1..]) {
83 return true;
84 }
85 }
86 return rest.is_empty();
87 }
88 match p[0] {
89 b'*' => {
90 for i in 0..=s.len() {
91 if glob(&p[1..], &s[i..]) {
92 return true;
93 }
94 if i < s.len() && s[i] == b'/' {
95 break;
96 }
97 }
98 false
99 }
100 b'?' => !s.is_empty() && s[0] != b'/' && glob(&p[1..], &s[1..]),
101 c => !s.is_empty() && s[0] == c && glob(&p[1..], &s[1..]),
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn patterns() {
111 let ig = Ignore::parse("# build\ntarget/\n*.log\n/root-only\ndocs/*.tmp\n**/cache\n!keep.log\n");
112 assert!(ig.ignored("target", true));
113 assert!(ig.ignored("sub/target", true));
114 assert!(!ig.ignored("target", false), "dir-only pattern");
115 assert!(ig.ignored("a.log", false));
116 assert!(ig.ignored("deep/b.log", false));
117 assert!(!ig.ignored("keep.log", false), "negation");
118 assert!(ig.ignored("root-only", false));
119 assert!(!ig.ignored("sub/root-only", false), "anchored");
120 assert!(ig.ignored("docs/x.tmp", false));
121 assert!(!ig.ignored("docs/sub/x.tmp", false), "* stays in one segment");
122 assert!(ig.ignored("cache", true));
123 assert!(ig.ignored("a/b/cache", true));
124 assert!(ig.ignored(".grove", true));
125 assert!(!ig.ignored("src/main.rs", false));
126 }
127}
src/json.rsadded+436 −0
1//! JSON (RFC 8259): log record payloads, and later the API.
2//!
3//! Objects keep their key order, so encoding is deterministic. Integers are kept exact as i64;
4//! other numbers are f64.
5
6use std::fmt::Write as _;
7
8#[derive(Clone, Debug, PartialEq)]
9pub enum Value {
10 Null,
11 Bool(bool),
12 Int(i64),
13 Float(f64),
14 Str(String),
15 Array(Vec<Value>),
16 Object(Vec<(String, Value)>),
17}
18
19impl Value {
20 pub fn get(&self, key: &str) -> Option<&Value> {
21 match self {
22 Value::Object(fields) => fields.iter().find(|(k, _)| k == key).map(|(_, v)| v),
23 _ => None,
24 }
25 }
26 pub fn as_str(&self) -> Option<&str> {
27 match self {
28 Value::Str(s) => Some(s),
29 _ => None,
30 }
31 }
32 pub fn as_i64(&self) -> Option<i64> {
33 match self {
34 Value::Int(n) => Some(*n),
35 _ => None,
36 }
37 }
38 pub fn as_array(&self) -> Option<&[Value]> {
39 match self {
40 Value::Array(a) => Some(a),
41 _ => None,
42 }
43 }
44 pub fn is_null(&self) -> bool {
45 matches!(self, Value::Null)
46 }
47
48 pub fn encode(&self) -> String {
49 let mut out = String::new();
50 self.write(&mut out);
51 out
52 }
53
54 fn write(&self, out: &mut String) {
55 match self {
56 Value::Null => out.push_str("null"),
57 Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
58 Value::Int(n) => {
59 let _ = write!(out, "{n}");
60 }
61 Value::Float(f) => {
62 if f.is_finite() {
63 let _ = write!(out, "{f:?}");
64 } else {
65 out.push_str("null"); // JSON has no NaN or infinity
66 }
67 }
68 Value::Str(s) => write_str(s, out),
69 Value::Array(items) => {
70 out.push('[');
71 for (i, v) in items.iter().enumerate() {
72 if i > 0 {
73 out.push(',');
74 }
75 v.write(out);
76 }
77 out.push(']');
78 }
79 Value::Object(fields) => {
80 out.push('{');
81 for (i, (k, v)) in fields.iter().enumerate() {
82 if i > 0 {
83 out.push(',');
84 }
85 write_str(k, out);
86 out.push(':');
87 v.write(out);
88 }
89 out.push('}');
90 }
91 }
92 }
93}
94
95fn write_str(s: &str, out: &mut String) {
96 out.push('"');
97 for c in s.chars() {
98 match c {
99 '"' => out.push_str("\\\""),
100 '\\' => out.push_str("\\\\"),
101 '\n' => out.push_str("\\n"),
102 '\r' => out.push_str("\\r"),
103 '\t' => out.push_str("\\t"),
104 c if (c as u32) < 0x20 => {
105 let _ = write!(out, "\\u{:04x}", c as u32);
106 }
107 c => out.push(c),
108 }
109 }
110 out.push('"');
111}
112
113/// Build an object: `obj([("a", 1.into()), ("b", "x".into())])`.
114pub fn obj<const N: usize>(fields: [(&str, Value); N]) -> Value {
115 Value::Object(fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
116}
117
118impl From<&str> for Value {
119 fn from(s: &str) -> Self {
120 Value::Str(s.to_string())
121 }
122}
123impl From<String> for Value {
124 fn from(s: String) -> Self {
125 Value::Str(s)
126 }
127}
128impl From<i64> for Value {
129 fn from(n: i64) -> Self {
130 Value::Int(n)
131 }
132}
133impl From<u64> for Value {
134 fn from(n: u64) -> Self {
135 Value::Int(n as i64)
136 }
137}
138impl From<bool> for Value {
139 fn from(b: bool) -> Self {
140 Value::Bool(b)
141 }
142}
143impl<T: Into<Value>> From<Option<T>> for Value {
144 fn from(o: Option<T>) -> Self {
145 o.map_or(Value::Null, Into::into)
146 }
147}
148impl From<Vec<Value>> for Value {
149 fn from(v: Vec<Value>) -> Self {
150 Value::Array(v)
151 }
152}
153
154#[derive(Debug, PartialEq)]
155pub struct ParseError {
156 pub at: usize,
157 pub msg: &'static str,
158}
159
160impl std::fmt::Display for ParseError {
161 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
162 write!(f, "invalid JSON at byte {}: {}", self.at, self.msg)
163 }
164}
165
166/// Parse a complete JSON document. Trailing content other than whitespace is an error.
167pub fn parse(src: &str) -> Result<Value, ParseError> {
168 let mut p = Parser { s: src.as_bytes(), i: 0, depth: 0 };
169 p.ws();
170 let v = p.value()?;
171 p.ws();
172 if p.i != p.s.len() {
173 return Err(p.err("trailing characters"));
174 }
175 Ok(v)
176}
177
178const MAX_DEPTH: usize = 128;
179
180struct Parser<'a> {
181 s: &'a [u8],
182 i: usize,
183 depth: usize,
184}
185
186impl Parser<'_> {
187 fn err(&self, msg: &'static str) -> ParseError {
188 ParseError { at: self.i, msg }
189 }
190 fn ws(&mut self) {
191 while let Some(b' ' | b'\n' | b'\r' | b'\t') = self.s.get(self.i) {
192 self.i += 1;
193 }
194 }
195 fn eat(&mut self, lit: &[u8]) -> bool {
196 if self.s[self.i..].starts_with(lit) {
197 self.i += lit.len();
198 true
199 } else {
200 false
201 }
202 }
203
204 fn value(&mut self) -> Result<Value, ParseError> {
205 match self.s.get(self.i) {
206 None => Err(self.err("unexpected end")),
207 Some(b'n') if self.eat(b"null") => Ok(Value::Null),
208 Some(b't') if self.eat(b"true") => Ok(Value::Bool(true)),
209 Some(b'f') if self.eat(b"false") => Ok(Value::Bool(false)),
210 Some(b'"') => Ok(Value::Str(self.string()?)),
211 Some(b'[') => self.array(),
212 Some(b'{') => self.object(),
213 Some(b'-' | b'0'..=b'9') => self.number(),
214 _ => Err(self.err("unexpected character")),
215 }
216 }
217
218 fn nest(&mut self) -> Result<(), ParseError> {
219 self.depth += 1;
220 if self.depth > MAX_DEPTH {
221 return Err(self.err("nested too deeply"));
222 }
223 Ok(())
224 }
225
226 fn array(&mut self) -> Result<Value, ParseError> {
227 self.nest()?;
228 self.i += 1;
229 let mut items = Vec::new();
230 self.ws();
231 if self.eat(b"]") {
232 self.depth -= 1;
233 return Ok(Value::Array(items));
234 }
235 loop {
236 self.ws();
237 items.push(self.value()?);
238 self.ws();
239 if self.eat(b",") {
240 continue;
241 }
242 if self.eat(b"]") {
243 self.depth -= 1;
244 return Ok(Value::Array(items));
245 }
246 return Err(self.err("expected , or ]"));
247 }
248 }
249
250 fn object(&mut self) -> Result<Value, ParseError> {
251 self.nest()?;
252 self.i += 1;
253 let mut fields: Vec<(String, Value)> = Vec::new();
254 self.ws();
255 if self.eat(b"}") {
256 self.depth -= 1;
257 return Ok(Value::Object(fields));
258 }
259 loop {
260 self.ws();
261 if self.s.get(self.i) != Some(&b'"') {
262 return Err(self.err("expected a key"));
263 }
264 let key = self.string()?;
265 if fields.iter().any(|(k, _)| *k == key) {
266 return Err(self.err("duplicate key"));
267 }
268 self.ws();
269 if !self.eat(b":") {
270 return Err(self.err("expected :"));
271 }
272 self.ws();
273 let v = self.value()?;
274 fields.push((key, v));
275 self.ws();
276 if self.eat(b",") {
277 continue;
278 }
279 if self.eat(b"}") {
280 self.depth -= 1;
281 return Ok(Value::Object(fields));
282 }
283 return Err(self.err("expected , or }"));
284 }
285 }
286
287 fn number(&mut self) -> Result<Value, ParseError> {
288 let start = self.i;
289 let digits = |p: &mut Self| {
290 let s = p.i;
291 while let Some(b'0'..=b'9') = p.s.get(p.i) {
292 p.i += 1;
293 }
294 p.i - s
295 };
296 self.eat(b"-");
297 match self.s.get(self.i) {
298 Some(b'0') => self.i += 1,
299 Some(b'1'..=b'9') => {
300 digits(self);
301 }
302 _ => return Err(self.err("expected a digit")),
303 }
304 let mut float = false;
305 if self.eat(b".") {
306 float = true;
307 if digits(self) == 0 {
308 return Err(self.err("expected a digit after ."));
309 }
310 }
311 if let Some(b'e' | b'E') = self.s.get(self.i) {
312 float = true;
313 self.i += 1;
314 if let Some(b'+' | b'-') = self.s.get(self.i) {
315 self.i += 1;
316 }
317 if digits(self) == 0 {
318 return Err(self.err("expected a digit in exponent"));
319 }
320 }
321 let text = std::str::from_utf8(&self.s[start..self.i]).expect("ascii");
322 if !float {
323 if let Ok(n) = text.parse::<i64>() {
324 return Ok(Value::Int(n));
325 }
326 }
327 text.parse::<f64>().map(Value::Float).map_err(|_| self.err("bad number"))
328 }
329
330 fn string(&mut self) -> Result<String, ParseError> {
331 self.i += 1; // opening quote
332 let mut out = String::new();
333 loop {
334 let start = self.i;
335 while let Some(&b) = self.s.get(self.i) {
336 if b == b'"' || b == b'\\' || b < 0x20 {
337 break;
338 }
339 self.i += 1;
340 }
341 // The input is a &str, so any run between ASCII delimiters is valid UTF-8.
342 out.push_str(std::str::from_utf8(&self.s[start..self.i]).map_err(|_| self.err("bad UTF-8"))?);
343 match self.s.get(self.i) {
344 None => return Err(self.err("unterminated string")),
345 Some(b'"') => {
346 self.i += 1;
347 return Ok(out);
348 }
349 Some(b'\\') => {
350 self.i += 1;
351 let c = match self.s.get(self.i) {
352 Some(b'"') => '"',
353 Some(b'\\') => '\\',
354 Some(b'/') => '/',
355 Some(b'b') => '\u{8}',
356 Some(b'f') => '\u{c}',
357 Some(b'n') => '\n',
358 Some(b'r') => '\r',
359 Some(b't') => '\t',
360 Some(b'u') => {
361 self.i += 1;
362 let hi = self.hex4()?;
363 let code = if (0xD800..0xDC00).contains(&hi) {
364 if !self.eat(b"\\u") {
365 return Err(self.err("lone surrogate"));
366 }
367 let lo = self.hex4()?;
368 if !(0xDC00..0xE000).contains(&lo) {
369 return Err(self.err("bad surrogate pair"));
370 }
371 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)
372 } else if (0xDC00..0xE000).contains(&hi) {
373 return Err(self.err("lone surrogate"));
374 } else {
375 hi
376 };
377 out.push(char::from_u32(code).ok_or_else(|| self.err("bad escape"))?);
378 continue;
379 }
380 _ => return Err(self.err("bad escape")),
381 };
382 self.i += 1;
383 out.push(c);
384 }
385 Some(_) => return Err(self.err("control character in string")),
386 }
387 }
388 }
389
390 fn hex4(&mut self) -> Result<u32, ParseError> {
391 let h = self.s.get(self.i..self.i + 4).ok_or_else(|| self.err("short \\u escape"))?;
392 let s = std::str::from_utf8(h).map_err(|_| self.err("bad \\u escape"))?;
393 let n = u32::from_str_radix(s, 16).map_err(|_| self.err("bad \\u escape"))?;
394 self.i += 4;
395 Ok(n)
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn round_trips() {
405 let docs = [
406 r#"null"#,
407 r#"[true,false,0,-12,9007199254740993,1.5,"a"]"#,
408 r#"{"b":1,"a":{"nested":[]},"s":"quote \" backslash \\ newline \n tab \t"}"#,
409 r#""unicode: é 中文 🌱""#,
410 ];
411 for d in docs {
412 let v = parse(d).unwrap();
413 assert_eq!(parse(&v.encode()).unwrap(), v, "{d}");
414 }
415 // Key order and exact integers survive.
416 assert_eq!(parse(r#"{"z":1,"a":9007199254740993}"#).unwrap().encode(), r#"{"z":1,"a":9007199254740993}"#);
417 }
418
419 #[test]
420 fn escapes() {
421 assert_eq!(parse(r#""\u00e9\ud83c\udf31\/""#).unwrap(), Value::Str("é🌱/".into()));
422 assert_eq!(Value::Str("\u{1}".into()).encode(), r#""\u0001""#);
423 }
424
425 #[test]
426 fn rejects_invalid() {
427 for bad in [
428 "", "[", "[1,]", "{\"a\":1,}", "01", "1.", "-", "\"\\x\"", "\"\\ud800\"", "\"a\nb\"",
429 "{\"a\":1,\"a\":2}", "nul", "[1] 2", "{a:1}",
430 ] {
431 assert!(parse(bad).is_err(), "should reject {bad:?}");
432 }
433 let deep = "[".repeat(200) + &"]".repeat(200);
434 assert!(parse(&deep).is_err());
435 }
436}
src/lib.rsadded+17 −0
1//! grove: where agents build software for people. See VISION.md and docs/model.md.
2
3pub mod blake3;
4pub mod cli;
5pub mod diff;
6pub mod hex;
7pub mod ignore;
8pub mod json;
9pub mod log;
10pub mod model;
11pub mod objects;
12pub mod random;
13pub mod store;
14pub mod workdir;
15
16#[cfg(test)]
17mod testdir;
src/log.rsadded+336 −0
1//! The append-only log: every fact grove knows, in order.
2//!
3//! File layout: an 8-byte header `grovelg1`, then records. Each record is
4//!
5//! ```text
6//! length: u32 little-endian (payload bytes)
7//! length check: 4 bytes (first 4 bytes of BLAKE3(length))
8//! payload check: 8 bytes (first 8 bytes of BLAKE3(payload))
9//! payload
10//! ```
11//!
12//! An append is acknowledged only once flushed to disk. A crash mid-append can leave a torn
13//! last record: a prefix of it, or (on some filesystems) zeros where its bytes should be.
14//! Opening the log recognises exactly those cases and cuts the file back to the last whole
15//! record. Anything else that fails a check is damage, and opening refuses rather than guess;
16//! the separate length check means a damaged length can't pass for a torn tail and take the
17//! records after it with it. While open, the log holds an exclusive lock on its file.
18
19use crate::blake3;
20use std::fs::{File, OpenOptions, TryLockError};
21use std::io::{self, Read, Seek, SeekFrom, Write};
22use std::path::Path;
23
24const HEADER: &[u8; 8] = b"grovelg1";
25const FRAME: usize = 4 + 4 + 8;
26/// Largest record grove will write or read: 64 MiB.
27pub const MAX_RECORD: usize = 64 << 20;
28
29pub struct Log {
30 file: File,
31 len: u64,
32 count: u64,
33}
34
35#[derive(Debug)]
36pub enum OpenError {
37 Io(io::Error),
38 Locked,
39 NotALog,
40 Damaged { offset: u64, reason: &'static str },
41}
42
43impl std::fmt::Display for OpenError {
44 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
45 match self {
46 OpenError::Io(e) => write!(f, "{e}"),
47 OpenError::Locked => write!(f, "another grove process has this store open"),
48 OpenError::NotALog => write!(f, "not a grove log"),
49 OpenError::Damaged { offset, reason } => write!(f, "log damaged at byte {offset}: {reason}"),
50 }
51 }
52}
53
54impl From<io::Error> for OpenError {
55 fn from(e: io::Error) -> Self {
56 OpenError::Io(e)
57 }
58}
59
60/// What opening found, besides the records.
61#[derive(Debug, Default, PartialEq)]
62pub struct Recovery {
63 /// Bytes cut from the end because the last record was torn.
64 pub truncated: u64,
65}
66
67fn check(payload: &[u8]) -> [u8; 8] {
68 let h = blake3::hash(payload);
69 let mut c = [0; 8];
70 c.copy_from_slice(&h[..8]);
71 c
72}
73
74fn len_check(len: [u8; 4]) -> [u8; 4] {
75 let h = blake3::hash(&len);
76 [h[0], h[1], h[2], h[3]]
77}
78
79/// True if a crash could have left these bytes at the end of the file: nothing but zeros.
80fn zeros(bytes: &[u8]) -> bool {
81 bytes.iter().all(|&b| b == 0)
82}
83
84impl Log {
85 /// Open (creating if needed) and read every record.
86 pub fn open(path: &Path) -> Result<(Log, Vec<Vec<u8>>, Recovery), OpenError> {
87 let mut file = OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?;
88 match file.try_lock() {
89 Ok(()) => {}
90 Err(TryLockError::WouldBlock) => return Err(OpenError::Locked),
91 Err(TryLockError::Error(e)) => return Err(e.into()),
92 }
93
94 let mut bytes = Vec::new();
95 file.read_to_end(&mut bytes)?;
96 if bytes.is_empty() {
97 file.write_all(HEADER)?;
98 file.sync_all()?;
99 sync_parent(path)?;
100 return Ok((Log { file, len: HEADER.len() as u64, count: 0 }, Vec::new(), Recovery::default()));
101 }
102 if bytes.len() < HEADER.len() || &bytes[..HEADER.len()] != HEADER {
103 return Err(OpenError::NotALog);
104 }
105
106 let mut records = Vec::new();
107 let mut at = HEADER.len();
108 let mut recovery = Recovery::default();
109 while at < bytes.len() {
110 let rest = &bytes[at..];
111 let damaged = |reason| Err(OpenError::Damaged { offset: at as u64, reason });
112 if rest.len() < FRAME {
113 // A frame cut short: the crash came while writing it.
114 recovery.truncated = rest.len() as u64;
115 break;
116 }
117 let len_bytes: [u8; 4] = rest[..4].try_into().unwrap();
118 if len_check(len_bytes) != rest[4..8] {
119 if zeros(rest) {
120 recovery.truncated = rest.len() as u64;
121 break;
122 }
123 return damaged("record header fails its check");
124 }
125 let len = u32::from_le_bytes(len_bytes) as usize;
126 if len > MAX_RECORD {
127 return damaged("record length out of range");
128 }
129 if rest.len() < FRAME + len {
130 // A sound header with the payload cut short.
131 recovery.truncated = rest.len() as u64;
132 break;
133 }
134 let payload = &rest[FRAME..FRAME + len];
135 if check(payload) != rest[8..FRAME] {
136 // Torn only if this is the last record; anything after it means real damage.
137 if FRAME + len == rest.len() || zeros(&rest[FRAME + len..]) {
138 recovery.truncated = rest.len() as u64;
139 break;
140 }
141 return damaged("record fails its check");
142 }
143 records.push(payload.to_vec());
144 at += FRAME + len;
145 }
146
147 if recovery.truncated > 0 {
148 file.set_len(at as u64)?;
149 file.sync_all()?;
150 }
151 file.seek(SeekFrom::Start(at as u64))?;
152 let count = records.len() as u64;
153 Ok((Log { file, len: at as u64, count }, records, recovery))
154 }
155
156 /// Append one record and flush it to disk. Returns the record's index.
157 pub fn append(&mut self, payload: &[u8]) -> io::Result<u64> {
158 if payload.len() > MAX_RECORD {
159 return Err(io::Error::new(io::ErrorKind::InvalidInput, "record too large"));
160 }
161 let mut frame = Vec::with_capacity(FRAME + payload.len());
162 let len = (payload.len() as u32).to_le_bytes();
163 frame.extend_from_slice(&len);
164 frame.extend_from_slice(&len_check(len));
165 frame.extend_from_slice(&check(payload));
166 frame.extend_from_slice(payload);
167 let result = self.file.write_all(&frame).and_then(|_| self.file.sync_data());
168 if let Err(e) = result {
169 // Leave the file as it was before this append, so the next one starts clean.
170 let _ = self.file.set_len(self.len);
171 let _ = self.file.seek(SeekFrom::Start(self.len));
172 return Err(e);
173 }
174 self.len += frame.len() as u64;
175 self.count += 1;
176 Ok(self.count - 1)
177 }
178
179 pub fn len(&self) -> u64 {
180 self.len
181 }
182
183 pub fn count(&self) -> u64 {
184 self.count
185 }
186
187 pub fn is_empty(&self) -> bool {
188 self.count == 0
189 }
190}
191
192/// Flush a directory entry (a newly created or renamed file) to disk.
193pub fn sync_parent(path: &Path) -> io::Result<()> {
194 match path.parent() {
195 Some(dir) if !dir.as_os_str().is_empty() => File::open(dir)?.sync_all(),
196 _ => Ok(()),
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::testdir::TestDir;
204
205 fn reopen(path: &Path) -> (Log, Vec<Vec<u8>>, Recovery) {
206 Log::open(path).unwrap()
207 }
208
209 #[test]
210 fn append_and_replay() {
211 let dir = TestDir::new("log-replay");
212 let path = dir.path().join("log");
213 {
214 let (mut log, recs, _) = reopen(&path);
215 assert!(recs.is_empty());
216 assert_eq!(log.append(b"one").unwrap(), 0);
217 assert_eq!(log.append(b"").unwrap(), 1);
218 assert_eq!(log.append(&vec![7u8; 100_000]).unwrap(), 2);
219 }
220 let (log, recs, rec) = reopen(&path);
221 assert_eq!(recs, vec![b"one".to_vec(), Vec::new(), vec![7u8; 100_000]]);
222 assert_eq!(log.count(), 3);
223 assert_eq!(rec, Recovery::default());
224 }
225
226 #[test]
227 fn second_open_is_locked_out() {
228 let dir = TestDir::new("log-lock");
229 let path = dir.path().join("log");
230 let _held = reopen(&path);
231 assert!(matches!(Log::open(&path), Err(OpenError::Locked)));
232 }
233
234 #[test]
235 fn torn_tail_is_cut_at_every_byte() {
236 // Simulate a crash at every possible point while writing the third record.
237 let dir = TestDir::new("log-torn");
238 let full = dir.path().join("full");
239 {
240 let (mut log, _, _) = reopen(&full);
241 log.append(b"first").unwrap();
242 log.append(b"second").unwrap();
243 log.append(b"third record").unwrap();
244 }
245 let bytes = std::fs::read(&full).unwrap();
246 let two_records = HEADER.len() + 2 * FRAME + b"first".len() + b"second".len();
247 for cut in two_records..bytes.len() {
248 let path = dir.path().join(format!("cut{cut}"));
249 std::fs::write(&path, &bytes[..cut]).unwrap();
250 let (mut log, recs, rec) = reopen(&path);
251 assert_eq!(recs, vec![b"first".to_vec(), b"second".to_vec()], "cut at {cut}");
252 assert_eq!(rec.truncated, (cut - two_records) as u64);
253 // The log is usable again afterwards.
254 log.append(b"after").unwrap();
255 drop(log);
256 assert_eq!(reopen(&path).1.len(), 3);
257 }
258 }
259
260 #[test]
261 fn garbage_in_last_record_is_cut() {
262 let dir = TestDir::new("log-garbage-tail");
263 let path = dir.path().join("log");
264 {
265 let (mut log, _, _) = reopen(&path);
266 log.append(b"keep").unwrap();
267 log.append(b"lose").unwrap();
268 }
269 let mut bytes = std::fs::read(&path).unwrap();
270 let n = bytes.len();
271 bytes[n - 1] ^= 0xff; // the tail record's payload no longer matches its check
272 std::fs::write(&path, &bytes).unwrap();
273 let (_, recs, rec) = reopen(&path);
274 assert_eq!(recs, vec![b"keep".to_vec()]);
275 assert!(rec.truncated > 0);
276 }
277
278 #[test]
279 fn damage_before_the_tail_is_refused() {
280 let dir = TestDir::new("log-damage");
281 let path = dir.path().join("log");
282 {
283 let (mut log, _, _) = reopen(&path);
284 log.append(b"first").unwrap();
285 log.append(b"second").unwrap();
286 }
287 let mut bytes = std::fs::read(&path).unwrap();
288 bytes[HEADER.len() + FRAME] ^= 0xff; // inside the first payload
289 std::fs::write(&path, &bytes).unwrap();
290 assert!(matches!(Log::open(&path), Err(OpenError::Damaged { .. })));
291 // And nothing was cut.
292 assert_eq!(std::fs::read(&path).unwrap(), bytes);
293 }
294
295 #[test]
296 fn damaged_length_does_not_eat_later_records() {
297 let dir = TestDir::new("log-bad-length");
298 let path = dir.path().join("log");
299 {
300 let (mut log, _, _) = reopen(&path);
301 log.append(b"first").unwrap();
302 log.append(b"second").unwrap();
303 log.append(b"third").unwrap();
304 }
305 let mut bytes = std::fs::read(&path).unwrap();
306 bytes[HEADER.len() + 1] = 0x40; // first record now claims to be 16 MiB long
307 std::fs::write(&path, &bytes).unwrap();
308 assert!(matches!(Log::open(&path), Err(OpenError::Damaged { .. })));
309 assert_eq!(std::fs::read(&path).unwrap(), bytes, "nothing cut");
310 }
311
312 #[test]
313 fn zero_filled_tail_is_cut() {
314 // Some filesystems extend the file before the data lands; a crash leaves zeros.
315 let dir = TestDir::new("log-zeros");
316 let path = dir.path().join("log");
317 {
318 let (mut log, _, _) = reopen(&path);
319 log.append(b"kept").unwrap();
320 }
321 let mut bytes = std::fs::read(&path).unwrap();
322 bytes.extend_from_slice(&[0u8; 300]);
323 std::fs::write(&path, &bytes).unwrap();
324 let (_, recs, rec) = reopen(&path);
325 assert_eq!(recs, vec![b"kept".to_vec()]);
326 assert_eq!(rec.truncated, 300);
327 }
328
329 #[test]
330 fn not_a_log() {
331 let dir = TestDir::new("log-foreign");
332 let path = dir.path().join("log");
333 std::fs::write(&path, b"hello world").unwrap();
334 assert!(matches!(Log::open(&path), Err(OpenError::NotALog)));
335 }
336}
src/main.rsadded+7 −0
1fn main() {
2 let argv: Vec<String> = std::env::args().collect();
3 if let Err(msg) = grove::cli::run(argv) {
4 eprintln!("grove: {msg}");
5 std::process::exit(1);
6 }
7}
src/model.rsadded+481 −0
1//! grove's state: what the log's records add up to.
2//!
3//! Every record is a JSON object `{"t": type, "at": ms, "by": actor, …}`. `apply` folds one
4//! record into the state. Replaying the log from the start rebuilds the state exactly, so
5//! `apply` must be deterministic and must accept only records the store would have written:
6//! a record that doesn't fit means the log is damaged, and the store refuses to open.
7//!
8//! `apply` is all or nothing: it checks a record completely before changing anything, so the
9//! store can use it to validate a write before appending it.
10
11use crate::hex;
12use crate::json::{obj, Value};
13use crate::objects::Id;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct Actor {
17 pub person: String,
18 /// The agent acting for the person, if it wasn't the person themselves.
19 pub agent: Option<String>,
20}
21
22impl Actor {
23 pub fn person(name: &str) -> Actor {
24 Actor { person: name.into(), agent: None }
25 }
26 pub fn agent(person: &str, agent: &str) -> Actor {
27 Actor { person: person.into(), agent: Some(agent.into()) }
28 }
29 pub fn to_json(&self) -> Value {
30 obj([("person", self.person.clone().into()), ("agent", self.agent.clone().into())])
31 }
32 fn from_json(v: &Value) -> Result<Actor, String> {
33 Ok(Actor {
34 person: str_field(v, "person")?,
35 agent: match v.get("agent") {
36 None | Some(Value::Null) => None,
37 Some(a) => Some(a.as_str().ok_or("agent must be a string")?.to_string()),
38 },
39 })
40 }
41}
42
43impl std::fmt::Display for Actor {
44 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
45 match &self.agent {
46 Some(a) => write!(f, "{a} for {}", self.person),
47 None => write!(f, "{}", self.person),
48 }
49 }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ChangeState {
54 Idea,
55 Draft,
56 Proposed,
57 Landed,
58 Declined,
59 Withdrawn,
60}
61
62impl ChangeState {
63 pub fn name(self) -> &'static str {
64 match self {
65 ChangeState::Idea => "idea",
66 ChangeState::Draft => "draft",
67 ChangeState::Proposed => "proposed",
68 ChangeState::Landed => "landed",
69 ChangeState::Declined => "declined",
70 ChangeState::Withdrawn => "withdrawn",
71 }
72 }
73 pub fn parse(s: &str) -> Option<ChangeState> {
74 Some(match s {
75 "idea" => ChangeState::Idea,
76 "draft" => ChangeState::Draft,
77 "proposed" => ChangeState::Proposed,
78 "landed" => ChangeState::Landed,
79 "declined" => ChangeState::Declined,
80 "withdrawn" => ChangeState::Withdrawn,
81 _ => return None,
82 })
83 }
84 pub fn is_open(self) -> bool {
85 matches!(self, ChangeState::Idea | ChangeState::Draft | ChangeState::Proposed)
86 }
87}
88
89#[derive(Clone, Debug)]
90pub struct Person {
91 pub username: String,
92 pub display_name: String,
93 pub at: i64,
94}
95
96#[derive(Clone, Debug)]
97pub struct Agent {
98 pub id: String,
99 pub person: String,
100 pub name: String,
101 pub revoked: bool,
102 pub at: i64,
103}
104
105#[derive(Clone, Debug)]
106pub struct Point {
107 pub n: u64,
108 pub snapshot: Id,
109 pub message: String,
110 pub author: Actor,
111 pub landed_by: Actor,
112 pub change: Option<u64>,
113 pub step: Option<String>,
114 pub at: i64,
115}
116
117#[derive(Clone, Debug, PartialEq)]
118pub struct Step {
119 /// Stable within a change, across revisions.
120 pub id: String,
121 pub snapshot: Id,
122 pub message: String,
123}
124
125#[derive(Clone, Debug)]
126pub struct Revision {
127 pub number: u32,
128 pub base: u64,
129 pub steps: Vec<Step>,
130 pub note: Option<String>,
131 pub by: Actor,
132 pub at: i64,
133}
134
135#[derive(Clone, Debug)]
136pub struct Message {
137 pub id: u32,
138 pub body: String,
139 pub by: Actor,
140 pub at: i64,
141}
142
143#[derive(Clone, Debug)]
144pub struct Evidence {
145 pub revision: u32,
146 pub title: String,
147 pub media_type: String,
148 pub blob: Option<Id>,
149 pub url: Option<String>,
150 pub by: Actor,
151 pub at: i64,
152}
153
154/// Something that happened to a change, for its timeline.
155#[derive(Clone, Debug)]
156pub enum Event {
157 Opened,
158 Revised(u32),
159 State(ChangeState),
160 Said(u32),
161 Attached(usize),
162 Landed { revision: u32, points: (u64, u64) },
163}
164
165#[derive(Clone, Debug)]
166pub struct Change {
167 pub number: u64,
168 pub intent: String,
169 pub proposer: Actor,
170 pub state: ChangeState,
171 pub revisions: Vec<Revision>,
172 pub messages: Vec<Message>,
173 pub evidence: Vec<Evidence>,
174 pub timeline: Vec<(i64, Actor, Event)>,
175 pub at: i64,
176}
177
178impl Change {
179 pub fn latest(&self) -> Option<&Revision> {
180 self.revisions.last()
181 }
182}
183
184#[derive(Clone, Debug)]
185pub struct Project {
186 pub owner: String,
187 pub name: String,
188 pub public: bool,
189 pub points: Vec<Point>,
190 pub changes: Vec<Change>,
191 pub at: i64,
192}
193
194impl Project {
195 pub fn tip(&self) -> u64 {
196 self.points.len() as u64
197 }
198 pub fn point(&self, n: u64) -> Option<&Point> {
199 if n == 0 { None } else { self.points.get(n as usize - 1) }
200 }
201 pub fn change(&self, number: u64) -> Option<&Change> {
202 if number == 0 { None } else { self.changes.get(number as usize - 1) }
203 }
204 fn change_mut(&mut self, number: u64) -> Option<&mut Change> {
205 if number == 0 { None } else { self.changes.get_mut(number as usize - 1) }
206 }
207}
208
209#[derive(Default)]
210pub struct State {
211 pub people: Vec<Person>,
212 pub agents: Vec<Agent>,
213 pub projects: Vec<Project>,
214 pub records: u64,
215}
216
217// ── Reading fields ─────────────────────────────────────────
218
219fn str_field(v: &Value, key: &str) -> Result<String, String> {
220 v.get(key).and_then(Value::as_str).map(str::to_string).ok_or_else(|| format!("missing {key}"))
221}
222fn opt_str(v: &Value, key: &str) -> Result<Option<String>, String> {
223 match v.get(key) {
224 None | Some(Value::Null) => Ok(None),
225 Some(Value::Str(s)) => Ok(Some(s.clone())),
226 _ => Err(format!("{key} must be a string")),
227 }
228}
229fn int_field(v: &Value, key: &str) -> Result<i64, String> {
230 v.get(key).and_then(Value::as_i64).ok_or_else(|| format!("missing {key}"))
231}
232fn uint(v: &Value, key: &str) -> Result<u64, String> {
233 let n = int_field(v, key)?;
234 u64::try_from(n).map_err(|_| format!("{key} must not be negative"))
235}
236pub fn id_to_json(id: &Id) -> Value {
237 Value::Str(hex::encode(id))
238}
239fn id_value(v: &Value) -> Result<Id, String> {
240 let s = v.as_str().ok_or("an id must be a string")?;
241 let bytes = hex::decode(s).ok_or("an id must be hex")?;
242 bytes.try_into().map_err(|_| "an id must be 32 bytes".to_string())
243}
244fn id_field(v: &Value, key: &str) -> Result<Id, String> {
245 id_value(v.get(key).ok_or_else(|| format!("missing {key}"))?).map_err(|e| format!("{key}: {e}"))
246}
247fn opt_id(v: &Value, key: &str) -> Result<Option<Id>, String> {
248 match v.get(key) {
249 None | Some(Value::Null) => Ok(None),
250 _ => id_field(v, key).map(Some),
251 }
252}
253
254pub fn step_to_json(s: &Step) -> Value {
255 obj([("id", s.id.clone().into()), ("snapshot", id_to_json(&s.snapshot)), ("message", s.message.clone().into())])
256}
257fn step_from_json(v: &Value) -> Result<Step, String> {
258 Ok(Step { id: str_field(v, "id")?, snapshot: id_field(v, "snapshot")?, message: str_field(v, "message")? })
259}
260
261// ── Applying records ───────────────────────────────────────
262
263impl State {
264 pub fn person(&self, username: &str) -> Option<&Person> {
265 self.people.iter().find(|p| p.username == username)
266 }
267 pub fn agent(&self, id: &str) -> Option<&Agent> {
268 self.agents.iter().find(|a| a.id == id)
269 }
270 pub fn project(&self, owner: &str, name: &str) -> Option<&Project> {
271 self.projects.iter().find(|p| p.owner == owner && p.name == name)
272 }
273 fn project_mut(&mut self, owner: &str, name: &str) -> Result<&mut Project, String> {
274 self.projects.iter_mut().find(|p| p.owner == owner && p.name == name).ok_or_else(|| format!("no project {owner}/{name}"))
275 }
276
277 /// Fold one record into the state.
278 pub fn apply(&mut self, rec: &Value) -> Result<(), String> {
279 let t = str_field(rec, "t")?;
280 let at = int_field(rec, "at")?;
281 let by = Actor::from_json(rec.get("by").ok_or("missing by")?)?;
282 if t != "person" && self.person(&by.person).is_none() {
283 return Err(format!("unknown person {}", by.person));
284 }
285 if let Some(agent) = &by.agent {
286 match self.agent(agent) {
287 Some(a) if a.person == by.person && !a.revoked => {}
288 _ => return Err(format!("agent {agent} can't act for {}", by.person)),
289 }
290 }
291 match t.as_str() {
292 "person" => {
293 let username = str_field(rec, "username")?;
294 if username != by.person {
295 return Err("a person record is made by that person".into());
296 }
297 if self.person(&username).is_some() {
298 return Err(format!("person {username} exists"));
299 }
300 self.people.push(Person { display_name: str_field(rec, "display_name")?, username, at });
301 }
302 "agent" => {
303 let id = str_field(rec, "id")?;
304 if self.agent(&id).is_some() {
305 return Err(format!("agent {id} exists"));
306 }
307 self.agents.push(Agent { id, person: by.person.clone(), name: str_field(rec, "name")?, revoked: false, at });
308 }
309 "agent.revoke" => {
310 let id = str_field(rec, "id")?;
311 let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?;
312 a.revoked = true;
313 }
314 "project" => {
315 let name = str_field(rec, "name")?;
316 if self.project(&by.person, &name).is_some() {
317 return Err(format!("project {}/{name} exists", by.person));
318 }
319 let public = matches!(rec.get("public"), Some(Value::Bool(true)));
320 self.projects.push(Project { owner: by.person.clone(), name, public, points: Vec::new(), changes: Vec::new(), at });
321 }
322 "point" => {
323 // A point recorded directly (the first snapshot of a project, or imported history).
324 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
325 if by.person != p.owner {
326 return Err("only the owner records points".into());
327 }
328 let n = uint(rec, "n")?;
329 if n != p.tip() + 1 {
330 return Err(format!("point {n} doesn't follow {}", p.tip()));
331 }
332 let author = match rec.get("author") {
333 Some(a) => Actor::from_json(a)?,
334 None => by.clone(),
335 };
336 p.points.push(Point { n, snapshot: id_field(rec, "snapshot")?, message: str_field(rec, "message")?, author, landed_by: by, change: None, step: None, at });
337 }
338 "change" => {
339 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
340 let number = uint(rec, "number")?;
341 if number != p.changes.len() as u64 + 1 {
342 return Err(format!("change {number} out of sequence"));
343 }
344 let state = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
345 if !matches!(state, ChangeState::Idea | ChangeState::Draft) {
346 return Err("a change opens as an idea or a draft".into());
347 }
348 p.changes.push(Change {
349 number,
350 intent: str_field(rec, "intent")?,
351 proposer: by.clone(),
352 state,
353 revisions: Vec::new(),
354 messages: Vec::new(),
355 evidence: Vec::new(),
356 timeline: vec![(at, by, Event::Opened)],
357 at,
358 });
359 }
360 "revision" => {
361 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
362 let tip = p.tip();
363 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
364 if !c.state.is_open() {
365 return Err("change is closed".into());
366 }
367 let number = uint(rec, "number")? as u32;
368 if number as usize != c.revisions.len() + 1 {
369 return Err("revision out of sequence".into());
370 }
371 let base = uint(rec, "base")?;
372 if base == 0 || base > tip {
373 return Err(format!("base {base} isn't a point"));
374 }
375 let steps = rec.get("steps").and_then(Value::as_array).ok_or("missing steps")?;
376 let steps = steps.iter().map(step_from_json).collect::<Result<Vec<_>, _>>()?;
377 if steps.is_empty() {
378 return Err("a revision needs at least one step".into());
379 }
380 c.revisions.push(Revision { number, base, steps, note: opt_str(rec, "note")?, by: by.clone(), at });
381 if c.state == ChangeState::Idea {
382 c.state = ChangeState::Draft;
383 }
384 c.timeline.push((at, by, Event::Revised(number)));
385 }
386 "state" => {
387 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
388 let owner = p.owner.clone();
389 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
390 let to = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
391 check_transition(c, to, &by, &owner)?;
392 c.state = to;
393 c.timeline.push((at, by, Event::State(to)));
394 }
395 "land" => {
396 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
397 if by.person != p.owner || by.agent.is_some() {
398 return Err("only the owner, in person, lands".into());
399 }
400 let number = uint(rec, "change")?;
401 let revision = uint(rec, "revision")? as u32;
402 let first = uint(rec, "first")?;
403 if first != p.tip() + 1 {
404 return Err("landing doesn't follow the tip".into());
405 }
406 let c = p.change(number).ok_or("no such change")?;
407 if c.state != ChangeState::Proposed {
408 return Err("only proposed changes land".into());
409 }
410 let rev = c.revisions.get((revision as usize).wrapping_sub(1)).ok_or("no such revision")?.clone();
411 let snapshots = rec.get("snapshots").and_then(Value::as_array).ok_or("missing snapshots")?;
412 let snapshots = snapshots.iter().map(id_value).collect::<Result<Vec<_>, _>>()?;
413 if snapshots.len() != rev.steps.len() {
414 return Err("one snapshot per step".into());
415 }
416 // Everything is checked; from here on nothing can fail.
417 for (i, (step, snapshot)) in rev.steps.iter().zip(snapshots).enumerate() {
418 p.points.push(Point {
419 n: first + i as u64,
420 snapshot,
421 message: step.message.clone(),
422 author: rev.by.clone(),
423 landed_by: by.clone(),
424 change: Some(number),
425 step: Some(step.id.clone()),
426 at,
427 });
428 }
429 let last = p.tip();
430 let c = p.change_mut(number).unwrap();
431 c.state = ChangeState::Landed;
432 c.timeline.push((at, by, Event::Landed { revision, points: (first, last) }));
433 }
434 "say" => {
435 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
436 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
437 let id = c.messages.len() as u32 + 1;
438 c.messages.push(Message { id, body: str_field(rec, "body")?, by: by.clone(), at });
439 c.timeline.push((at, by, Event::Said(id)));
440 }
441 "evidence" => {
442 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
443 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
444 let revision = uint(rec, "revision")? as u32;
445 if revision == 0 || revision as usize > c.revisions.len() {
446 return Err("no such revision".into());
447 }
448 let blob = opt_id(rec, "blob")?;
449 let url = opt_str(rec, "url")?;
450 if blob.is_none() == url.is_none() {
451 return Err("evidence is a blob or a url".into());
452 }
453 c.evidence.push(Evidence { revision, title: str_field(rec, "title")?, media_type: str_field(rec, "type")?, blob, url, by: by.clone(), at });
454 let i = c.evidence.len() - 1;
455 c.timeline.push((at, by, Event::Attached(i)));
456 }
457 other => return Err(format!("unknown record type {other}")),
458 }
459 self.records += 1;
460 Ok(())
461 }
462}
463
464/// Who may move a change between which states.
465fn check_transition(c: &Change, to: ChangeState, by: &Actor, owner: &str) -> Result<(), String> {
466 use ChangeState::*;
467 let is_owner = by.person == owner;
468 // Decisions are the owner's own: an agent acting for them can't make them.
469 let owner_in_person = is_owner && by.agent.is_none();
470 let is_proposer = by.person == c.proposer.person;
471 match (c.state, to) {
472 (Draft, Proposed) if is_proposer || is_owner => Ok(()),
473 (Proposed, Draft) if is_proposer || is_owner => Ok(()),
474 (Idea | Draft | Proposed, Declined) if owner_in_person => Ok(()),
475 (Idea | Draft | Proposed, Withdrawn) if is_proposer => Ok(()),
476 (Declined | Withdrawn, Draft) if (is_proposer || is_owner) && !c.revisions.is_empty() => Ok(()),
477 (Declined | Withdrawn, Idea) if (is_proposer || is_owner) && c.revisions.is_empty() => Ok(()),
478 (from, to) if from == to => Err(format!("already {}", to.name())),
479 (from, to) => Err(format!("{} can't move a change from {} to {}", by, from.name(), to.name())),
480 }
481}
src/objects.rsadded+308 −0
1//! Objects: file contents (blobs) and directories (trees), stored by the hash of their bytes.
2//!
3//! An object's file holds exactly the bytes its id is the hash of: a type prefix
4//! (`grove blob\0` or `grove tree\0`) and the content. So any object can be checked by hashing
5//! its file, and a blob and a tree can never share an id.
6//!
7//! Writing: temporary file, flush, rename into place, flush the directory. An object either
8//! exists completely or not at all, and is never rewritten.
9
10use crate::blake3::Hasher;
11use crate::hex;
12use crate::log::sync_parent;
13use std::fs::{self, File};
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16
17pub type Id = [u8; 32];
18
19const BLOB: &[u8] = b"grove blob\0";
20const TREE: &[u8] = b"grove tree\0";
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Kind {
24 File,
25 Exec,
26 Link,
27 Dir,
28}
29
30impl Kind {
31 fn byte(self) -> u8 {
32 match self {
33 Kind::File => b'f',
34 Kind::Exec => b'x',
35 Kind::Link => b'l',
36 Kind::Dir => b'd',
37 }
38 }
39 fn from_byte(b: u8) -> Option<Kind> {
40 Some(match b {
41 b'f' => Kind::File,
42 b'x' => Kind::Exec,
43 b'l' => Kind::Link,
44 b'd' => Kind::Dir,
45 _ => return None,
46 })
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct Entry {
52 pub name: String,
53 pub kind: Kind,
54 pub id: Id,
55}
56
57#[derive(Debug)]
58pub enum Error {
59 Io(io::Error),
60 Missing(Id),
61 Corrupt(Id),
62 BadName(String),
63}
64
65impl std::fmt::Display for Error {
66 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
67 match self {
68 Error::Io(e) => write!(f, "{e}"),
69 Error::Missing(id) => write!(f, "object {} is missing", hex::encode(id)),
70 Error::Corrupt(id) => write!(f, "object {} is corrupt", hex::encode(id)),
71 Error::BadName(n) => write!(f, "invalid file name {n:?}"),
72 }
73 }
74}
75
76impl From<io::Error> for Error {
77 fn from(e: io::Error) -> Self {
78 Error::Io(e)
79 }
80}
81
82pub type Result<T> = std::result::Result<T, Error>;
83
84/// Names must be usable as one path component everywhere.
85pub fn valid_name(name: &str) -> bool {
86 !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\0')
87}
88
89fn id_of(prefix: &[u8], content: &[u8]) -> Id {
90 let mut h = Hasher::new();
91 h.update(prefix).update(content);
92 h.finalize()
93}
94
95pub fn blob_id(content: &[u8]) -> Id {
96 id_of(BLOB, content)
97}
98
99/// Canonical tree bytes: entries sorted by name, each `kind, name length (u32 LE), name, id`.
100pub fn encode_tree(entries: &[Entry]) -> Result<Vec<u8>> {
101 let mut sorted: Vec<&Entry> = entries.iter().collect();
102 sorted.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
103 let mut out = Vec::new();
104 for (i, e) in sorted.iter().enumerate() {
105 if !valid_name(&e.name) {
106 return Err(Error::BadName(e.name.clone()));
107 }
108 if i > 0 && sorted[i - 1].name == e.name {
109 return Err(Error::BadName(format!("{} (twice)", e.name)));
110 }
111 out.push(e.kind.byte());
112 out.extend_from_slice(&(e.name.len() as u32).to_le_bytes());
113 out.extend_from_slice(e.name.as_bytes());
114 out.extend_from_slice(&e.id);
115 }
116 Ok(out)
117}
118
119fn decode_tree(id: &Id, mut bytes: &[u8]) -> Result<Vec<Entry>> {
120 let corrupt = || Error::Corrupt(*id);
121 let mut entries = Vec::new();
122 while !bytes.is_empty() {
123 let kind = Kind::from_byte(bytes[0]).ok_or_else(corrupt)?;
124 let len = u32::from_le_bytes(bytes.get(1..5).ok_or_else(corrupt)?.try_into().unwrap()) as usize;
125 let name = std::str::from_utf8(bytes.get(5..5 + len).ok_or_else(corrupt)?).map_err(|_| corrupt())?;
126 let id_bytes = bytes.get(5 + len..5 + len + 32).ok_or_else(corrupt)?;
127 entries.push(Entry { name: name.to_string(), kind, id: id_bytes.try_into().unwrap() });
128 bytes = &bytes[5 + len + 32..];
129 }
130 Ok(entries)
131}
132
133pub struct Objects {
134 root: PathBuf,
135}
136
137impl Objects {
138 pub fn open(root: &Path) -> Result<Objects> {
139 fs::create_dir_all(root.join("tmp"))?;
140 Ok(Objects { root: root.to_path_buf() })
141 }
142
143 fn path(&self, id: &Id) -> PathBuf {
144 let h = hex::encode(id);
145 self.root.join(&h[..2]).join(&h[2..])
146 }
147
148 pub fn has(&self, id: &Id) -> bool {
149 self.path(id).is_file()
150 }
151
152 fn put(&self, prefix: &[u8], content: &[u8]) -> Result<Id> {
153 let id = id_of(prefix, content);
154 let path = self.path(&id);
155 if path.is_file() {
156 return Ok(id);
157 }
158 fs::create_dir_all(path.parent().unwrap())?;
159 let tmp = self.root.join("tmp").join(format!("{}-{}", hex::encode(&id[..8]), crate::random::hex(8)));
160 {
161 let mut f = File::create(&tmp)?;
162 f.write_all(prefix)?;
163 f.write_all(content)?;
164 f.sync_all()?;
165 }
166 fs::rename(&tmp, &path)?;
167 sync_parent(&path)?;
168 Ok(id)
169 }
170
171 /// Read an object's content, checking that it hashes to its id.
172 fn get(&self, prefix: &[u8], id: &Id) -> Result<Vec<u8>> {
173 let bytes = match fs::read(self.path(id)) {
174 Ok(b) => b,
175 Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(Error::Missing(*id)),
176 Err(e) => return Err(e.into()),
177 };
178 if !bytes.starts_with(prefix) || crate::blake3::hash(&bytes) != *id {
179 return Err(Error::Corrupt(*id));
180 }
181 Ok(bytes[prefix.len()..].to_vec())
182 }
183
184 pub fn put_blob(&self, content: &[u8]) -> Result<Id> {
185 self.put(BLOB, content)
186 }
187
188 pub fn get_blob(&self, id: &Id) -> Result<Vec<u8>> {
189 self.get(BLOB, id)
190 }
191
192 pub fn put_tree(&self, entries: &[Entry]) -> Result<Id> {
193 self.put(TREE, &encode_tree(entries)?)
194 }
195
196 /// Entries sorted by name.
197 pub fn get_tree(&self, id: &Id) -> Result<Vec<Entry>> {
198 let bytes = self.get(TREE, id)?;
199 decode_tree(id, &bytes)
200 }
201
202 /// The id of an empty tree, stored.
203 pub fn empty_tree(&self) -> Result<Id> {
204 self.put_tree(&[])
205 }
206
207 /// Every file under a tree, as (path, kind, blob id), sorted by path.
208 pub fn flatten(&self, tree: &Id) -> Result<Vec<(String, Kind, Id)>> {
209 let mut out = Vec::new();
210 self.flatten_into(tree, "", &mut out)?;
211 Ok(out)
212 }
213
214 fn flatten_into(&self, tree: &Id, prefix: &str, out: &mut Vec<(String, Kind, Id)>) -> Result<()> {
215 for e in self.get_tree(tree)? {
216 let path = format!("{prefix}{}", e.name);
217 if e.kind == Kind::Dir {
218 self.flatten_into(&e.id, &format!("{path}/"), out)?;
219 } else {
220 out.push((path, e.kind, e.id));
221 }
222 }
223 Ok(())
224 }
225
226 /// Confirm every object under a tree exists and matches its id.
227 pub fn verify_tree(&self, tree: &Id) -> Result<usize> {
228 let mut count = 1;
229 for e in self.get_tree(tree)? {
230 count += if e.kind == Kind::Dir { self.verify_tree(&e.id)? } else { self.get_blob(&e.id).map(|_| 1)? };
231 }
232 Ok(count)
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use crate::testdir::TestDir;
240
241 #[test]
242 fn blobs_round_trip_and_dedupe() {
243 let dir = TestDir::new("objects-blobs");
244 let objects = Objects::open(dir.path()).unwrap();
245 let a = objects.put_blob(b"hello").unwrap();
246 assert_eq!(objects.put_blob(b"hello").unwrap(), a);
247 assert_ne!(objects.put_blob(b"hello!").unwrap(), a);
248 assert_eq!(objects.get_blob(&a).unwrap(), b"hello");
249 assert_eq!(a, blob_id(b"hello"));
250 assert!(objects.has(&a));
251 }
252
253 #[test]
254 fn trees_are_canonical() {
255 let dir = TestDir::new("objects-trees");
256 let objects = Objects::open(dir.path()).unwrap();
257 let f = objects.put_blob(b"x").unwrap();
258 let e = |name: &str| Entry { name: name.into(), kind: Kind::File, id: f };
259 let t1 = objects.put_tree(&[e("b"), e("a")]).unwrap();
260 let t2 = objects.put_tree(&[e("a"), e("b")]).unwrap();
261 assert_eq!(t1, t2, "order of input doesn't matter");
262 let entries = objects.get_tree(&t1).unwrap();
263 assert_eq!(entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["a", "b"]);
264 // A tree and a blob with identical bytes still differ.
265 assert_ne!(objects.empty_tree().unwrap(), objects.put_blob(b"").unwrap());
266 }
267
268 #[test]
269 fn rejects_bad_names() {
270 let dir = TestDir::new("objects-names");
271 let objects = Objects::open(dir.path()).unwrap();
272 let f = objects.put_blob(b"x").unwrap();
273 for bad in ["", ".", "..", "a/b", "nul\0"] {
274 let e = Entry { name: bad.into(), kind: Kind::File, id: f };
275 assert!(matches!(objects.put_tree(&[e]), Err(Error::BadName(_))), "{bad:?}");
276 }
277 let e = Entry { name: "same".into(), kind: Kind::File, id: f };
278 assert!(objects.put_tree(&[e.clone(), e]).is_err());
279 }
280
281 #[test]
282 fn corruption_and_absence_are_detected() {
283 let dir = TestDir::new("objects-corrupt");
284 let objects = Objects::open(dir.path()).unwrap();
285 let id = objects.put_blob(b"precious").unwrap();
286 std::fs::write(objects.path(&id), b"grove blob\0tampered").unwrap();
287 assert!(matches!(objects.get_blob(&id), Err(Error::Corrupt(_))));
288 assert!(matches!(objects.get_blob(&blob_id(b"never stored")), Err(Error::Missing(_))));
289 }
290
291 #[test]
292 fn flatten_and_verify() {
293 let dir = TestDir::new("objects-flatten");
294 let objects = Objects::open(dir.path()).unwrap();
295 let a = objects.put_blob(b"a").unwrap();
296 let b = objects.put_blob(b"b").unwrap();
297 let sub = objects.put_tree(&[Entry { name: "inner.txt".into(), kind: Kind::File, id: b }]).unwrap();
298 let root = objects
299 .put_tree(&[
300 Entry { name: "run.sh".into(), kind: Kind::Exec, id: a },
301 Entry { name: "dir".into(), kind: Kind::Dir, id: sub },
302 ])
303 .unwrap();
304 let files = objects.flatten(&root).unwrap();
305 assert_eq!(files, vec![("dir/inner.txt".into(), Kind::File, b), ("run.sh".into(), Kind::Exec, a)]);
306 assert_eq!(objects.verify_tree(&root).unwrap(), 4);
307 }
308}
src/random.rsadded+16 −0
1//! Randomness from the operating system, for ids and tokens.
2
3use std::io::Read;
4
5pub fn bytes(n: usize) -> Vec<u8> {
6 let mut buf = vec![0u8; n];
7 std::fs::File::open("/dev/urandom")
8 .and_then(|mut f| f.read_exact(&mut buf))
9 .expect("the operating system's random source must be readable");
10 buf
11}
12
13/// `n` random bytes as hex.
14pub fn hex(n: usize) -> String {
15 crate::hex::encode(&bytes(n))
16}
src/store.rsadded+442 −0
1//! The store: objects, the log, and the state the log adds up to.
2//!
3//! A write is: check the referenced objects exist, apply the record to the in-memory state
4//! (which validates it and changes nothing if it's invalid), then append it to the log. If
5//! the append fails, the state is rebuilt from the log on disk, so memory never claims
6//! something the disk doesn't hold.
7
8use crate::json::{self, Value};
9use crate::log::{Log, OpenError, Recovery};
10use crate::model::{id_to_json, step_to_json, Actor, ChangeState, Project, State, Step};
11use crate::objects::{self, Id, Objects};
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15#[derive(Debug)]
16pub enum Error {
17 Open(OpenError),
18 Objects(objects::Error),
19 Io(std::io::Error),
20 /// The log holds a record that doesn't fit the state before it.
21 Damaged { record: u64, reason: String },
22 /// The request isn't allowed or doesn't make sense.
23 Refused(String),
24 /// Landing needs the change rebuilt on the current tip first.
25 Behind { base: u64, tip: u64 },
26}
27
28impl std::fmt::Display for Error {
29 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
30 match self {
31 Error::Open(e) => write!(f, "{e}"),
32 Error::Objects(e) => write!(f, "{e}"),
33 Error::Io(e) => write!(f, "{e}"),
34 Error::Damaged { record, reason } => write!(f, "log record {record} is damaged: {reason}"),
35 Error::Refused(r) => write!(f, "{r}"),
36 Error::Behind { base, tip } => write!(f, "built on point {base}, but main is at {tip}; rebuild on the tip first"),
37 }
38 }
39}
40
41impl From<OpenError> for Error {
42 fn from(e: OpenError) -> Self {
43 Error::Open(e)
44 }
45}
46impl From<objects::Error> for Error {
47 fn from(e: objects::Error) -> Self {
48 Error::Objects(e)
49 }
50}
51impl From<std::io::Error> for Error {
52 fn from(e: std::io::Error) -> Self {
53 Error::Io(e)
54 }
55}
56
57pub type Result<T> = std::result::Result<T, Error>;
58
59fn refused<T>(msg: impl Into<String>) -> Result<T> {
60 Err(Error::Refused(msg.into()))
61}
62
63/// What a piece of evidence holds.
64pub enum Content<'a> {
65 Bytes(&'a [u8]),
66 Url(&'a str),
67}
68
69pub fn now_ms() -> i64 {
70 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
71}
72
73pub struct Store {
74 dir: PathBuf,
75 log: Log,
76 pub objects: Objects,
77 state: State,
78 pub recovery: Recovery,
79}
80
81fn replay(records: &[Vec<u8>]) -> Result<State> {
82 let mut state = State::default();
83 for (i, bytes) in records.iter().enumerate() {
84 let damaged = |reason: String| Error::Damaged { record: i as u64, reason };
85 let text = std::str::from_utf8(bytes).map_err(|_| damaged("not UTF-8".into()))?;
86 let rec = json::parse(text).map_err(|e| damaged(e.to_string()))?;
87 state.apply(&rec).map_err(damaged)?;
88 }
89 Ok(state)
90}
91
92impl Store {
93 pub fn open(dir: &Path) -> Result<Store> {
94 std::fs::create_dir_all(dir)?;
95 let objects = Objects::open(&dir.join("objects"))?;
96 let (log, records, recovery) = Log::open(&dir.join("log"))?;
97 let state = replay(&records)?;
98 Ok(Store { dir: dir.to_path_buf(), log, objects, state, recovery })
99 }
100
101 pub fn dir(&self) -> &Path {
102 &self.dir
103 }
104
105 pub fn state(&self) -> &State {
106 &self.state
107 }
108
109 /// Validate, apply, append. `rec` gets its time and actor here.
110 fn write(&mut self, by: &Actor, mut rec: Vec<(String, Value)>) -> Result<()> {
111 rec.insert(1, ("at".into(), now_ms().into()));
112 rec.insert(2, ("by".into(), by.to_json()));
113 let rec = Value::Object(rec);
114 self.state.apply(&rec).map_err(Error::Refused)?;
115 if let Err(e) = self.log.append(rec.encode().as_bytes()) {
116 // The state now holds a record the log doesn't. Rebuild it from the disk.
117 let (log, records, _) = Log::open(&self.dir.join("log")).map_err(|_| Error::Io(e))?;
118 self.log = log;
119 self.state = replay(&records)?;
120 return Err(Error::Refused("couldn't write to the log".into()));
121 }
122 Ok(())
123 }
124
125 fn rec(t: &str, fields: Vec<(&str, Value)>) -> Vec<(String, Value)> {
126 let mut out = vec![("t".to_string(), Value::from(t))];
127 out.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
128 out
129 }
130
131 fn require_tree(&self, id: &Id) -> Result<()> {
132 if !self.objects.has(id) {
133 return refused(format!("snapshot {} isn't stored", crate::hex::encode(id)));
134 }
135 Ok(())
136 }
137
138 pub fn project(&self, owner: &str, name: &str) -> Result<&Project> {
139 self.state.project(owner, name).ok_or_else(|| Error::Refused(format!("no project {owner}/{name}")))
140 }
141
142 // ── People and agents ──────────────────────────────────
143
144 pub fn add_person(&mut self, username: &str, display_name: &str) -> Result<()> {
145 if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') || username.is_empty() {
146 return refused("usernames are letters, digits, - and _");
147 }
148 self.write(&Actor::person(username), Self::rec("person", vec![("username", username.into()), ("display_name", display_name.into())]))
149 }
150
151 /// Register an agent acting for `person`. Returns its id.
152 pub fn add_agent(&mut self, person: &str, name: &str) -> Result<String> {
153 let id = crate::random::hex(6);
154 self.write(&Actor::person(person), Self::rec("agent", vec![("id", id.clone().into()), ("name", name.into())]))?;
155 Ok(id)
156 }
157
158 pub fn revoke_agent(&mut self, person: &str, id: &str) -> Result<()> {
159 self.write(&Actor::person(person), Self::rec("agent.revoke", vec![("id", id.into())]))
160 }
161
162 // ── Projects and main ──────────────────────────────────
163
164 pub fn create_project(&mut self, by: &Actor, name: &str, public: bool) -> Result<()> {
165 if !objects::valid_name(name) || name.contains(char::is_whitespace) {
166 return refused(format!("{name:?} can't be a project name"));
167 }
168 self.write(by, Self::rec("project", vec![("name", name.into()), ("public", public.into())]))
169 }
170
171 /// Record a point directly on main: a project's first snapshot, or imported history.
172 pub fn record_point(&mut self, by: &Actor, owner: &str, name: &str, snapshot: &Id, message: &str, author: Option<&Actor>) -> Result<u64> {
173 self.require_tree(snapshot)?;
174 let n = self.project(owner, name)?.tip() + 1;
175 let mut fields = vec![
176 ("owner", owner.into()),
177 ("project", name.into()),
178 ("n", n.into()),
179 ("snapshot", id_to_json(snapshot)),
180 ("message", message.into()),
181 ];
182 if let Some(a) = author {
183 fields.push(("author", a.to_json()));
184 }
185 self.write(by, Self::rec("point", fields))?;
186 Ok(n)
187 }
188
189 // ── Changes ────────────────────────────────────────────
190
191 /// Open a change as an idea: an intent, no code yet. Its first revision makes it a draft.
192 pub fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> Result<u64> {
193 let p = self.project(owner, name)?;
194 if !p.public && by.person != p.owner {
195 return refused(format!("{owner}/{name} is private"));
196 }
197 let number = p.changes.len() as u64 + 1;
198 self.write(by, Self::rec("change", vec![("owner", owner.into()), ("project", name.into()), ("number", number.into()), ("intent", intent.into()), ("state", "idea".into())]))?;
199 Ok(number)
200 }
201
202 /// Add a revision: the whole stack of steps, built on main point `base`.
203 pub fn revise(&mut self, by: &Actor, owner: &str, name: &str, change: u64, base: u64, steps: &[Step], note: Option<&str>) -> Result<u32> {
204 for s in steps {
205 self.require_tree(&s.snapshot)?;
206 }
207 let p = self.project(owner, name)?;
208 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
209 if by.person != c.proposer.person && by.person != p.owner {
210 return refused("only the proposer or the owner can revise a change");
211 }
212 let number = c.revisions.len() as u32 + 1;
213 let steps: Vec<Value> = steps.iter().map(step_to_json).collect();
214 self.write(
215 by,
216 Self::rec("revision", vec![
217 ("owner", owner.into()),
218 ("project", name.into()),
219 ("change", change.into()),
220 ("number", (number as u64).into()),
221 ("base", base.into()),
222 ("steps", steps.into()),
223 ("note", note.map(str::to_string).into()),
224 ]),
225 )?;
226 Ok(number)
227 }
228
229 pub fn set_state(&mut self, by: &Actor, owner: &str, name: &str, change: u64, to: ChangeState) -> Result<()> {
230 if to == ChangeState::Landed {
231 return refused("changes land through land()");
232 }
233 self.write(by, Self::rec("state", vec![("owner", owner.into()), ("project", name.into()), ("change", change.into()), ("state", to.name().into())]))
234 }
235
236 /// Land a change's latest revision. Its steps become the next points on main, all at once.
237 pub fn land(&mut self, by: &Actor, owner: &str, name: &str, change: u64) -> Result<(u64, u64)> {
238 let p = self.project(owner, name)?;
239 if by.person != p.owner || by.agent.is_some() {
240 return refused("only the owner, in person, lands changes");
241 }
242 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
243 let rev = c.latest().ok_or_else(|| Error::Refused(format!("#{change} has no revisions")))?;
244 if rev.base != p.tip() {
245 return Err(Error::Behind { base: rev.base, tip: p.tip() });
246 }
247 let first = p.tip() + 1;
248 let count = rev.steps.len() as u64;
249 let snapshots: Vec<Value> = rev.steps.iter().map(|s| id_to_json(&s.snapshot)).collect();
250 let revision = rev.number as u64;
251 self.write(
252 by,
253 Self::rec("land", vec![
254 ("owner", owner.into()),
255 ("project", name.into()),
256 ("change", change.into()),
257 ("revision", revision.into()),
258 ("first", first.into()),
259 ("snapshots", snapshots.into()),
260 ]),
261 )?;
262 Ok((first, first + count - 1))
263 }
264
265 pub fn say(&mut self, by: &Actor, owner: &str, name: &str, change: u64, body: &str) -> Result<()> {
266 self.write(by, Self::rec("say", vec![("owner", owner.into()), ("project", name.into()), ("change", change.into()), ("body", body.into())]))
267 }
268
269 /// Attach evidence to a revision: stored bytes of a media type, or a URL.
270 pub fn attach(&mut self, by: &Actor, owner: &str, name: &str, change: u64, revision: u32, title: &str, media_type: &str, content: Content) -> Result<()> {
271 let (blob, url) = match content {
272 Content::Bytes(bytes) => (Value::from(crate::hex::encode(&self.objects.put_blob(bytes)?)), Value::Null),
273 Content::Url(url) => (Value::Null, Value::from(url)),
274 };
275 self.write(
276 by,
277 Self::rec("evidence", vec![
278 ("owner", owner.into()),
279 ("project", name.into()),
280 ("change", change.into()),
281 ("revision", (revision as u64).into()),
282 ("title", title.into()),
283 ("type", media_type.into()),
284 ("blob", blob),
285 ("url", url),
286 ]),
287 )
288 }
289
290 /// Check that every object the state refers to exists and matches its id.
291 pub fn verify(&self) -> Result<usize> {
292 let mut count = 0;
293 let mut seen = std::collections::HashSet::new();
294 let mut check_tree = |id: &Id, count: &mut usize| -> Result<()> {
295 if seen.insert(*id) {
296 *count += self.objects.verify_tree(id)?;
297 }
298 Ok(())
299 };
300 for p in &self.state.projects {
301 for pt in &p.points {
302 check_tree(&pt.snapshot, &mut count)?;
303 }
304 for c in &p.changes {
305 for r in &c.revisions {
306 for s in &r.steps {
307 check_tree(&s.snapshot, &mut count)?;
308 }
309 }
310 for e in &c.evidence {
311 if let Some(b) = &e.blob {
312 self.objects.get_blob(b)?;
313 count += 1;
314 }
315 }
316 }
317 }
318 Ok(count)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::objects::{Entry, Kind};
326 use crate::testdir::TestDir;
327
328 fn tree(store: &Store, files: &[(&str, &str)]) -> Id {
329 let entries: Vec<Entry> = files
330 .iter()
331 .map(|(n, c)| Entry { name: n.to_string(), kind: Kind::File, id: store.objects.put_blob(c.as_bytes()).unwrap() })
332 .collect();
333 store.objects.put_tree(&entries).unwrap()
334 }
335
336 fn setup(dir: &TestDir) -> (Store, Actor, String) {
337 let mut store = Store::open(dir.path()).unwrap();
338 store.add_person("anton", "Anton").unwrap();
339 let agent = store.add_agent("anton", "claude").unwrap();
340 let me = Actor::agent("anton", &agent);
341 store.create_project(&Actor::person("anton"), "grove", true).unwrap();
342 let t = tree(&store, &[("README", "hello")]);
343 store.record_point(&Actor::person("anton"), "anton", "grove", &t, "Start", None).unwrap();
344 (store, me, agent)
345 }
346
347 #[test]
348 fn change_lifecycle_and_replay() {
349 let dir = TestDir::new("store-lifecycle");
350 {
351 let (mut store, me, _) = setup(&dir);
352 let owner = Actor::person("anton");
353 let c = store.open_change(&me, "anton", "grove", "Say more in the README").unwrap();
354 assert_eq!(store.project("anton", "grove").unwrap().change(c).unwrap().state, ChangeState::Idea);
355
356 let t1 = tree(&store, &[("README", "hello there")]);
357 let t2 = tree(&store, &[("README", "hello there"), ("NOTES", "x")]);
358 let steps = vec![
359 Step { id: "s1".into(), snapshot: t1, message: "Longer greeting".into() },
360 Step { id: "s2".into(), snapshot: t2, message: "Add notes".into() },
361 ];
362 assert_eq!(store.revise(&me, "anton", "grove", c, 1, &steps, None).unwrap(), 1);
363 store.set_state(&me, "anton", "grove", c, ChangeState::Proposed).unwrap();
364 store.say(&owner, "anton", "grove", c, "Looks good").unwrap();
365 store.attach(&me, "anton", "grove", c, 1, "notes", "text/plain", Content::Bytes(b"checked by hand")).unwrap();
366
367 // Only the owner lands.
368 assert!(matches!(store.land(&me, "anton", "grove", c), Err(Error::Refused(_))));
369 assert_eq!(store.land(&owner, "anton", "grove", c).unwrap(), (2, 3));
370 let p = store.project("anton", "grove").unwrap();
371 assert_eq!(p.tip(), 3);
372 assert_eq!(p.point(3).unwrap().snapshot, t2);
373 assert_eq!(p.change(c).unwrap().state, ChangeState::Landed);
374 assert_eq!(store.verify().unwrap() > 0, true);
375 }
376 // Everything comes back from the log.
377 let store = Store::open(dir.path()).unwrap();
378 let p = store.project("anton", "grove").unwrap();
379 assert_eq!(p.tip(), 3);
380 let c = p.change(1).unwrap();
381 assert_eq!(c.state, ChangeState::Landed);
382 assert_eq!(c.messages[0].body, "Looks good");
383 assert_eq!(c.evidence.len(), 1);
384 assert_eq!(p.point(2).unwrap().message, "Longer greeting");
385 assert!(p.point(2).unwrap().author.agent.is_some());
386 }
387
388 #[test]
389 fn landing_behind_the_tip_is_refused() {
390 let dir = TestDir::new("store-behind");
391 let (mut store, me, _) = setup(&dir);
392 let owner = Actor::person("anton");
393 let t = tree(&store, &[("README", "a")]);
394 let step = |id: &str| vec![Step { id: id.into(), snapshot: t, message: "m".into() }];
395 let a = store.open_change(&me, "anton", "grove", "a").unwrap();
396 let b = store.open_change(&me, "anton", "grove", "b").unwrap();
397 store.revise(&me, "anton", "grove", a, 1, &step("x"), None).unwrap();
398 store.revise(&me, "anton", "grove", b, 1, &step("y"), None).unwrap();
399 store.set_state(&me, "anton", "grove", a, ChangeState::Proposed).unwrap();
400 store.set_state(&me, "anton", "grove", b, ChangeState::Proposed).unwrap();
401 store.land(&owner, "anton", "grove", a).unwrap();
402 assert!(matches!(store.land(&owner, "anton", "grove", b), Err(Error::Behind { base: 1, tip: 2 })));
403 // Nothing changed for b.
404 assert_eq!(store.project("anton", "grove").unwrap().change(b).unwrap().state, ChangeState::Proposed);
405 }
406
407 #[test]
408 fn refusals_leave_no_trace() {
409 let dir = TestDir::new("store-refusals");
410 let (mut store, me, agent) = setup(&dir);
411 let before = store.state().records;
412 // Unstored snapshot, unknown project, bad transition, revoked agent.
413 let ghost = crate::objects::blob_id(b"not stored");
414 assert!(store.record_point(&Actor::person("anton"), "anton", "grove", &ghost, "x", None).is_err());
415 assert!(store.open_change(&me, "anton", "nope", "x").is_err());
416 let c = store.open_change(&me, "anton", "grove", "idea").unwrap();
417 assert!(store.set_state(&me, "anton", "grove", c, ChangeState::Proposed).is_err(), "an idea can't be proposed");
418 store.revoke_agent("anton", &agent).unwrap();
419 assert!(store.say(&me, "anton", "grove", c, "still here?").is_err());
420 assert_eq!(store.state().records, before + 2, "only the change and the revocation were written");
421 drop(store);
422 assert_eq!(Store::open(dir.path()).unwrap().state().records, before + 2);
423 }
424
425 #[test]
426 fn strangers_and_private_projects() {
427 let dir = TestDir::new("store-strangers");
428 let (mut store, _, _) = setup(&dir);
429 store.add_person("visitor", "Visitor").unwrap();
430 let visitor = Actor::person("visitor");
431 // Anyone can propose to a public project, but not land.
432 let c = store.open_change(&visitor, "anton", "grove", "An idea from outside").unwrap();
433 let t = tree(&store, &[("README", "outside")]);
434 store.revise(&visitor, "anton", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap();
435 store.set_state(&visitor, "anton", "grove", c, ChangeState::Proposed).unwrap();
436 assert!(store.land(&visitor, "anton", "grove", c).is_err());
437 store.set_state(&Actor::person("anton"), "anton", "grove", c, ChangeState::Declined).unwrap();
438 // Private projects take changes only from the owner.
439 store.create_project(&Actor::person("anton"), "secret", false).unwrap();
440 assert!(store.open_change(&visitor, "anton", "secret", "let me in").is_err());
441 }
442}
src/testdir.rsadded+28 −0
1//! A fresh directory per test, removed afterwards.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6static NEXT: AtomicU64 = AtomicU64::new(0);
7
8pub struct TestDir(PathBuf);
9
10impl TestDir {
11 pub fn new(name: &str) -> TestDir {
12 let n = NEXT.fetch_add(1, Ordering::Relaxed);
13 let path = std::env::temp_dir().join(format!("grove-test-{}-{name}-{n}", std::process::id()));
14 let _ = std::fs::remove_dir_all(&path);
15 std::fs::create_dir_all(&path).unwrap();
16 TestDir(path)
17 }
18
19 pub fn path(&self) -> &Path {
20 &self.0
21 }
22}
23
24impl Drop for TestDir {
25 fn drop(&mut self) {
26 let _ = std::fs::remove_dir_all(&self.0);
27 }
28}
src/workdir.rsadded+233 −0
1//! Working directories: turning a folder into a snapshot and a snapshot back into a folder.
2
3use crate::ignore::Ignore;
4use crate::objects::{self, Entry, Id, Kind, Objects};
5use std::collections::BTreeMap;
6use std::fs;
7use std::io;
8use std::os::unix::fs::PermissionsExt;
9use std::path::Path;
10
11#[derive(Debug)]
12pub enum Error {
13 Io(io::Error),
14 Objects(objects::Error),
15 /// A file name grove can't store (not UTF-8, or reserved).
16 BadName(String),
17}
18
19impl std::fmt::Display for Error {
20 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
21 match self {
22 Error::Io(e) => write!(f, "{e}"),
23 Error::Objects(e) => write!(f, "{e}"),
24 Error::BadName(n) => write!(f, "can't store a file named {n}"),
25 }
26 }
27}
28
29impl From<io::Error> for Error {
30 fn from(e: io::Error) -> Self {
31 Error::Io(e)
32 }
33}
34impl From<objects::Error> for Error {
35 fn from(e: objects::Error) -> Self {
36 Error::Objects(e)
37 }
38}
39
40pub type Result<T> = std::result::Result<T, Error>;
41
42pub fn load_ignore(root: &Path) -> Ignore {
43 Ignore::parse(&fs::read_to_string(root.join(".groveignore")).unwrap_or_default())
44}
45
46/// Store everything in `root` that isn't ignored; return the snapshot (root tree) id.
47pub fn snapshot(objects: &Objects, root: &Path) -> Result<Id> {
48 let ignore = load_ignore(root);
49 Ok(snapshot_dir(objects, root, "", &ignore)?.unwrap_or(objects.empty_tree()?))
50}
51
52/// None for a directory with nothing to store (empty directories aren't kept).
53fn snapshot_dir(objects: &Objects, dir: &Path, rel: &str, ignore: &Ignore) -> Result<Option<Id>> {
54 let mut entries = Vec::new();
55 let mut names: Vec<_> = fs::read_dir(dir)?.collect::<io::Result<Vec<_>>>()?;
56 names.sort_by_key(|e| e.file_name());
57 for e in names {
58 let name = e.file_name().into_string().map_err(|n| Error::BadName(format!("{n:?}")))?;
59 if !objects::valid_name(&name) {
60 return Err(Error::BadName(name));
61 }
62 let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
63 let meta = fs::symlink_metadata(e.path())?;
64 let ft = meta.file_type();
65 if ignore.ignored(&path, ft.is_dir()) {
66 continue;
67 }
68 if ft.is_dir() {
69 if let Some(id) = snapshot_dir(objects, &e.path(), &path, ignore)? {
70 entries.push(Entry { name, kind: Kind::Dir, id });
71 }
72 } else if ft.is_symlink() {
73 let target = fs::read_link(e.path())?;
74 let target = target.to_str().ok_or_else(|| Error::BadName(format!("link target of {path}")))?;
75 entries.push(Entry { name, kind: Kind::Link, id: objects.put_blob(target.as_bytes())? });
76 } else if ft.is_file() {
77 let kind = if meta.permissions().mode() & 0o111 != 0 { Kind::Exec } else { Kind::File };
78 entries.push(Entry { name, kind, id: objects.put_blob(&fs::read(e.path())?)? });
79 }
80 // Sockets, devices and pipes aren't project content.
81 }
82 if entries.is_empty() {
83 return Ok(None);
84 }
85 Ok(Some(objects.put_tree(&entries)?))
86}
87
88/// Write a snapshot into `root`. Files that were in `previous` but aren't in `tree` are
89/// removed; anything grove never tracked is left alone.
90pub fn materialize(objects: &Objects, tree: &Id, root: &Path, previous: Option<&Id>) -> Result<()> {
91 let want: BTreeMap<String, (Kind, Id)> = objects.flatten(tree)?.into_iter().map(|(p, k, i)| (p, (k, i))).collect();
92 if let Some(prev) = previous {
93 for (path, _, _) in objects.flatten(prev)? {
94 if !want.contains_key(&path) {
95 let full = root.join(&path);
96 match fs::remove_file(&full) {
97 Ok(()) => {}
98 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
99 Err(e) => return Err(e.into()),
100 }
101 remove_empty_parents(root, &full);
102 }
103 }
104 }
105 for (path, (kind, id)) in &want {
106 let full = root.join(path);
107 if let Some(dir) = full.parent() {
108 fs::create_dir_all(dir)?;
109 }
110 let content = objects.get_blob(id)?;
111 // Replace whatever is there (a file, or a link of a different kind).
112 if fs::symlink_metadata(&full).is_ok() {
113 fs::remove_file(&full)?;
114 }
115 match kind {
116 Kind::Link => {
117 let target = String::from_utf8(content).map_err(|_| Error::BadName(path.clone()))?;
118 std::os::unix::fs::symlink(target, &full)?;
119 }
120 Kind::File | Kind::Exec => {
121 fs::write(&full, &content)?;
122 let mode = if *kind == Kind::Exec { 0o755 } else { 0o644 };
123 fs::set_permissions(&full, fs::Permissions::from_mode(mode))?;
124 }
125 Kind::Dir => unreachable!("flatten yields files only"),
126 }
127 }
128 Ok(())
129}
130
131fn remove_empty_parents(root: &Path, file: &Path) {
132 let mut dir = file.parent();
133 while let Some(d) = dir {
134 if d == root || fs::remove_dir(d).is_err() {
135 break;
136 }
137 dir = d.parent();
138 }
139}
140
141#[derive(Debug, PartialEq, Eq, Clone, Copy)]
142pub enum FileChange {
143 Added,
144 Removed,
145 Modified,
146}
147
148/// Paths that differ between two snapshots, sorted. A kind change (file to exec) counts as modified.
149pub fn compare(objects: &Objects, a: &Id, b: &Id) -> Result<Vec<(String, FileChange)>> {
150 if a == b {
151 return Ok(Vec::new());
152 }
153 let a: BTreeMap<_, _> = objects.flatten(a)?.into_iter().map(|(p, k, i)| (p, (k, i))).collect();
154 let b: BTreeMap<_, _> = objects.flatten(b)?.into_iter().map(|(p, k, i)| (p, (k, i))).collect();
155 let mut out = Vec::new();
156 for (path, old) in &a {
157 match b.get(path) {
158 None => out.push((path.clone(), FileChange::Removed)),
159 Some(new) if new != old => out.push((path.clone(), FileChange::Modified)),
160 _ => {}
161 }
162 }
163 for path in b.keys() {
164 if !a.contains_key(path) {
165 out.push((path.clone(), FileChange::Added));
166 }
167 }
168 out.sort_by(|x, y| x.0.cmp(&y.0));
169 Ok(out)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::testdir::TestDir;
176
177 #[test]
178 fn snapshot_and_materialize_round_trip() {
179 let src = TestDir::new("wd-src");
180 let store = TestDir::new("wd-store");
181 let objects = Objects::open(store.path()).unwrap();
182 let root = src.path();
183 fs::create_dir_all(root.join("src/deep")).unwrap();
184 fs::create_dir_all(root.join("target")).unwrap();
185 fs::create_dir_all(root.join("empty")).unwrap();
186 fs::write(root.join("README"), "hi").unwrap();
187 fs::write(root.join("src/deep/a.rs"), "fn a() {}").unwrap();
188 fs::write(root.join("run.sh"), "#!/bin/sh").unwrap();
189 fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
190 std::os::unix::fs::symlink("README", root.join("link")).unwrap();
191 fs::write(root.join("target/out.bin"), "built").unwrap();
192 fs::write(root.join(".groveignore"), "target/\n").unwrap();
193
194 let snap = snapshot(&objects, root).unwrap();
195 let files: Vec<String> = objects.flatten(&snap).unwrap().into_iter().map(|(p, _, _)| p).collect();
196 assert_eq!(files, [".groveignore", "README", "link", "run.sh", "src/deep/a.rs"]);
197
198 let dst = TestDir::new("wd-dst");
199 materialize(&objects, &snap, dst.path(), None).unwrap();
200 assert_eq!(snapshot(&objects, dst.path()).unwrap(), snap, "identical round trip");
201 assert_eq!(fs::read_link(dst.path().join("link")).unwrap().to_str(), Some("README"));
202 assert!(fs::metadata(dst.path().join("run.sh")).unwrap().permissions().mode() & 0o111 != 0);
203 }
204
205 #[test]
206 fn moving_between_snapshots() {
207 let store = TestDir::new("wd-move-store");
208 let objects = Objects::open(store.path()).unwrap();
209 let wd = TestDir::new("wd-move");
210 let root = wd.path();
211 fs::create_dir_all(root.join("a/b")).unwrap();
212 fs::write(root.join("a/b/old.txt"), "old").unwrap();
213 fs::write(root.join("keep.txt"), "1").unwrap();
214 let first = snapshot(&objects, root).unwrap();
215
216 fs::remove_dir_all(root.join("a")).unwrap();
217 fs::write(root.join("keep.txt"), "2").unwrap();
218 fs::write(root.join("new.txt"), "new").unwrap();
219 let second = snapshot(&objects, root).unwrap();
220 assert_eq!(
221 compare(&objects, &first, &second).unwrap(),
222 vec![("a/b/old.txt".into(), FileChange::Removed), ("keep.txt".into(), FileChange::Modified), ("new.txt".into(), FileChange::Added)]
223 );
224
225 // Going back restores the old files and removes the new one, leaving untracked files.
226 fs::write(root.join("untracked.tmp"), "mine").unwrap();
227 materialize(&objects, &first, root, Some(&second)).unwrap();
228 assert!(root.join("a/b/old.txt").exists());
229 assert!(!root.join("new.txt").exists());
230 assert_eq!(fs::read_to_string(root.join("keep.txt")).unwrap(), "1");
231 assert!(root.join("untracked.tmp").exists());
232 }
233}