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