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