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