Skip to main content

git_ledger/
lib.rs

1//! Git-native record storage.
2//!
3//! Each record is its own ref. The ref points to a commit whose tree holds the
4//! record's fields as blobs. Updates create new commits, providing full history.
5
6use git2::{Error, Oid, Repository};
7
8/// A single record in the ledger.
9#[derive(Debug, Clone)]
10pub struct LedgerEntry {
11    /// The record's identifier (e.g. `1`, `abc123`).
12    pub id: String,
13    /// The full ref name (e.g. `refs/issues/1`).
14    pub ref_: String,
15    /// The commit OID backing this version of the record.
16    pub commit: Oid,
17    /// The record's fields as `(name, value)` pairs.
18    pub fields: Vec<(String, Vec<u8>)>,
19}
20
21/// Strategy for generating record IDs.
22pub enum IdStrategy<'a> {
23    /// Scan existing refs and use max + 1.
24    Sequential,
25    /// Hash caller-supplied bytes using git's object hash.
26    ContentAddressed(&'a [u8]),
27    /// Use the caller's string directly.
28    CallerProvided(&'a str),
29    /// Name the record's ref after the OID of the commit that `create` writes.
30    CommitOid,
31}
32
33/// A mutation to apply to a record's fields.
34pub enum Mutation<'a> {
35    /// Upsert a field.
36    Set(&'a str, &'a [u8]),
37    /// Delete a field.
38    Delete(&'a str),
39}
40
41/// Core ledger operations.
42pub trait Ledger {
43    /// Create a new record under `ref_prefix`.
44    fn create(
45        &self,
46        ref_prefix: &str,
47        strategy: &IdStrategy<'_>,
48        fields: &[(&str, &[u8])],
49        message: &str,
50    ) -> Result<LedgerEntry, Error>;
51
52    /// Read an existing record by its full ref name.
53    fn read(&self, ref_name: &str) -> Result<LedgerEntry, Error>;
54
55    /// Update an existing record by applying mutations.
56    fn update(
57        &self,
58        ref_name: &str,
59        mutations: &[Mutation<'_>],
60        message: &str,
61    ) -> Result<LedgerEntry, Error>;
62
63    /// List all record IDs under a ref prefix.
64    fn list(&self, ref_prefix: &str) -> Result<Vec<String>, Error>;
65
66    /// Return the commit history for a record.
67    fn history(&self, ref_name: &str) -> Result<Vec<Oid>, Error>;
68}
69
70// ---------------------------------------------------------------------------
71// Helpers
72// ---------------------------------------------------------------------------
73
74/// Recursively insert a blob at an arbitrary depth inside a tree builder.
75fn insert_nested(
76    repo: &Repository,
77    builder: &mut git2::TreeBuilder<'_>,
78    components: &[&str],
79    blob_oid: Oid,
80) -> Result<(), Error> {
81    match components {
82        [leaf] => {
83            builder.insert(leaf, blob_oid, 0o100644)?;
84        }
85        [head, rest @ ..] => {
86            let mut sub_builder = if let Some(existing) = builder.get(head)? {
87                let existing_tree = repo.find_tree(existing.id())?;
88                repo.treebuilder(Some(&existing_tree))?
89            } else {
90                repo.treebuilder(None)?
91            };
92            insert_nested(repo, &mut sub_builder, rest, blob_oid)?;
93            let sub_tree = sub_builder.write()?;
94            builder.insert(head, sub_tree, 0o040000)?;
95        }
96        [] => {}
97    }
98    Ok(())
99}
100
101/// Recursively remove a blob at an arbitrary depth inside a tree builder.
102/// Returns `true` if the subtree at this level is now empty and should be pruned.
103fn remove_nested(
104    repo: &Repository,
105    builder: &mut git2::TreeBuilder<'_>,
106    components: &[&str],
107) -> Result<bool, Error> {
108    match components {
109        [leaf] => {
110            let _ = builder.remove(leaf);
111        }
112        [head, rest @ ..] => {
113            let existing_tree_id = builder
114                .get(head)?
115                .filter(|e| e.kind() == Some(git2::ObjectType::Tree))
116                .map(|e| e.id());
117            if let Some(tree_id) = existing_tree_id {
118                let et = repo.find_tree(tree_id)?;
119                let mut sub_builder = repo.treebuilder(Some(&et))?;
120                let empty = remove_nested(repo, &mut sub_builder, rest)?;
121                if empty {
122                    let _ = builder.remove(head);
123                } else {
124                    let sub_tree = sub_builder.write()?;
125                    builder.insert(head, sub_tree, 0o040000)?;
126                }
127            }
128        }
129        [] => {}
130    }
131    Ok(builder.is_empty())
132}
133
134/// Build a tree from a list of field name/value pairs.
135fn build_fields_tree(repo: &Repository, fields: &[(&str, &[u8])]) -> Result<Oid, Error> {
136    let mut builder = repo.treebuilder(None)?;
137    for (name, value) in fields {
138        let blob_oid = repo.blob(value)?;
139        let components: Vec<&str> = name.split('/').collect();
140        insert_nested(repo, &mut builder, &components, blob_oid)?;
141    }
142    builder.write()
143}
144
145/// Read all fields from a tree (recursively for subdirectories).
146fn read_fields(
147    repo: &Repository,
148    tree: &git2::Tree<'_>,
149    prefix: &str,
150) -> Result<Vec<(String, Vec<u8>)>, Error> {
151    let mut fields = Vec::new();
152    for entry in tree.iter() {
153        let name = entry.name().unwrap_or("").to_string();
154        let path = if prefix.is_empty() {
155            name.clone()
156        } else {
157            format!("{}/{}", prefix, name)
158        };
159        match entry.kind() {
160            Some(git2::ObjectType::Blob) => {
161                let blob = repo.find_blob(entry.id())?;
162                fields.push((path, blob.content().to_vec()));
163            }
164            Some(git2::ObjectType::Tree) => {
165                let subtree = repo.find_tree(entry.id())?;
166                fields.extend(read_fields(repo, &subtree, &path)?);
167            }
168            _ => {}
169        }
170    }
171    Ok(fields)
172}
173
174/// Extract the ID portion from a full ref name given a prefix.
175fn id_from_ref(ref_name: &str, ref_prefix: &str) -> String {
176    let prefix = if ref_prefix.ends_with('/') {
177        ref_prefix.to_string()
178    } else {
179        format!("{}/", ref_prefix)
180    };
181    ref_name
182        .strip_prefix(&prefix)
183        .unwrap_or(ref_name)
184        .to_string()
185}
186
187/// Generate the next sequential ID by scanning existing refs.
188fn next_sequential_id(repo: &Repository, ref_prefix: &str) -> Result<u64, Error> {
189    let pattern = if ref_prefix.ends_with('/') {
190        format!("{}*", ref_prefix)
191    } else {
192        format!("{}/*", ref_prefix)
193    };
194    let refs = repo.references_glob(&pattern)?;
195    let mut max_id: u64 = 0;
196    for reference in refs {
197        let reference = reference?;
198        if let Some(name) = reference.name() {
199            let id_str = id_from_ref(name, ref_prefix);
200            if let Ok(n) = id_str.parse::<u64>() {
201                max_id = max_id.max(n);
202            }
203        }
204    }
205    Ok(max_id + 1)
206}
207
208// ---------------------------------------------------------------------------
209// Implementation
210// ---------------------------------------------------------------------------
211
212impl Ledger for Repository {
213    fn create(
214        &self,
215        ref_prefix: &str,
216        strategy: &IdStrategy<'_>,
217        fields: &[(&str, &[u8])],
218        message: &str,
219    ) -> Result<LedgerEntry, Error> {
220        let tree_oid = build_fields_tree(self, fields)?;
221        let tree = self.find_tree(tree_oid)?;
222        let sig = self.signature()?;
223
224        if let IdStrategy::CommitOid = strategy {
225            // Write the commit first (no ref), then name the ref after its OID.
226            let commit_oid = self.commit(None, &sig, &sig, message, &tree, &[])?;
227            let ref_name = if ref_prefix.ends_with('/') {
228                format!("{}{}", ref_prefix, commit_oid)
229            } else {
230                format!("{}/{}", ref_prefix, commit_oid)
231            };
232            self.reference(&ref_name, commit_oid, false, message)?;
233            let fields = read_fields(self, &tree, "")?;
234            return Ok(LedgerEntry {
235                id: commit_oid.to_string(),
236                ref_: ref_name,
237                commit: commit_oid,
238                fields,
239            });
240        }
241
242        let id = match strategy {
243            IdStrategy::Sequential => {
244                let next = next_sequential_id(self, ref_prefix)?;
245                next.to_string()
246            }
247            IdStrategy::ContentAddressed(bytes) => {
248                let oid = self.blob(bytes)?;
249                oid.to_string()
250            }
251            IdStrategy::CallerProvided(s) => s.to_string(),
252            IdStrategy::CommitOid => unreachable!(),
253        };
254
255        let ref_name = if ref_prefix.ends_with('/') {
256            format!("{}{}", ref_prefix, id)
257        } else {
258            format!("{}/{}", ref_prefix, id)
259        };
260
261        // Check the ref doesn't already exist
262        if self.find_reference(&ref_name).is_ok() {
263            return Err(Error::from_str(&format!(
264                "record already exists: {}",
265                ref_name
266            )));
267        }
268
269        let commit_oid = self.commit(
270            Some(&ref_name),
271            &sig,
272            &sig,
273            message,
274            &tree,
275            &[], // no parents for first commit
276        )?;
277
278        let fields = read_fields(self, &tree, "")?;
279        let id = ref_name.rsplit('/').next().unwrap_or(&ref_name).to_string();
280
281        Ok(LedgerEntry {
282            id,
283            ref_: ref_name,
284            commit: commit_oid,
285            fields,
286        })
287    }
288
289    fn read(&self, ref_name: &str) -> Result<LedgerEntry, Error> {
290        let reference = self.find_reference(ref_name)?;
291        let commit = reference.peel_to_commit()?;
292        let tree = commit.tree()?;
293        let fields = read_fields(self, &tree, "")?;
294
295        // Extract ID from ref name — take the last component
296        let id = ref_name.rsplit('/').next().unwrap_or(ref_name).to_string();
297
298        Ok(LedgerEntry {
299            id,
300            ref_: ref_name.to_string(),
301            commit: commit.id(),
302            fields,
303        })
304    }
305
306    fn update(
307        &self,
308        ref_name: &str,
309        mutations: &[Mutation<'_>],
310        message: &str,
311    ) -> Result<LedgerEntry, Error> {
312        let reference = self.find_reference(ref_name)?;
313        let parent_commit = reference.peel_to_commit()?;
314        let existing_tree = parent_commit.tree()?;
315
316        let mut builder = self.treebuilder(Some(&existing_tree))?;
317
318        for mutation in mutations {
319            match mutation {
320                Mutation::Set(name, value) => {
321                    let blob_oid = self.blob(value)?;
322                    let components: Vec<&str> = name.split('/').collect();
323                    insert_nested(self, &mut builder, &components, blob_oid)?;
324                }
325                Mutation::Delete(name) => {
326                    let components: Vec<&str> = name.split('/').collect();
327                    remove_nested(self, &mut builder, &components)?;
328                }
329            }
330        }
331
332        let tree_oid = builder.write()?;
333        let tree = self.find_tree(tree_oid)?;
334        let sig = self.signature()?;
335
336        let commit_oid = self.commit(
337            Some(ref_name),
338            &sig,
339            &sig,
340            message,
341            &tree,
342            &[&parent_commit],
343        )?;
344
345        let fields = read_fields(self, &tree, "")?;
346        let id = ref_name.rsplit('/').next().unwrap_or(ref_name).to_string();
347
348        Ok(LedgerEntry {
349            id,
350            ref_: ref_name.to_string(),
351            commit: commit_oid,
352            fields,
353        })
354    }
355
356    fn list(&self, ref_prefix: &str) -> Result<Vec<String>, Error> {
357        let pattern = if ref_prefix.ends_with('/') {
358            format!("{}*", ref_prefix)
359        } else {
360            format!("{}/*", ref_prefix)
361        };
362        let refs = self.references_glob(&pattern)?;
363        let mut ids = Vec::new();
364        for reference in refs {
365            let reference = reference?;
366            if let Some(name) = reference.name() {
367                ids.push(id_from_ref(name, ref_prefix));
368            }
369        }
370        ids.sort();
371        Ok(ids)
372    }
373
374    fn history(&self, ref_name: &str) -> Result<Vec<Oid>, Error> {
375        let reference = self.find_reference(ref_name)?;
376        let commit = reference.peel_to_commit()?;
377
378        let mut oids = Vec::new();
379        let mut current = Some(commit);
380        while let Some(c) = current {
381            oids.push(c.id());
382            current = c.parent(0).ok();
383        }
384        Ok(oids)
385    }
386}
387
388#[cfg(test)]
389mod tests;