Skip to main content

mlua_swarm/blueprint/store/
git2_store.rs

1//! `Git2BlueprintStore` — the git2-rs backend, one repo per id.
2//!
3//! Internally holds a `Git2BlobStore` (a type-erased per-id repo
4//! manager). This file itself only owns the Blueprint-specific bits:
5//! commit-message format, canonical YAML, and version computation. On
6//! disk the layout is `<root>/<id>/.git/` per-id bare repos, chosen so
7//! that GC, backup, and migration units line up cleanly.
8
9use super::git2_blob_store::{extract_msg_field, Git2BlobStore, HeadCommit};
10use super::types::*;
11use super::{blueprint_version, canonical_yaml, BlueprintStore};
12use crate::blueprint::Blueprint;
13use async_trait::async_trait;
14use std::path::Path;
15
16/// Git2-backed `BlueprintStore` — one bare repo per `BlueprintId` under
17/// `<root>/<id>/.git/`, delegating the mechanical blob/commit work to a
18/// shared `Git2BlobStore`.
19pub struct Git2BlueprintStore {
20    backend: Git2BlobStore,
21}
22
23impl Git2BlueprintStore {
24    /// Open (or create) the store rooted at `root`. Per-id repositories
25    /// are created lazily on first write/read.
26    pub fn open_or_init(root: impl AsRef<Path>) -> Result<Self, BlueprintStoreError> {
27        Ok(Self {
28            backend: Git2BlobStore::open_or_init(root, "blueprint.yaml")?,
29        })
30    }
31
32    /// The root directory this store was opened with.
33    pub fn root(&self) -> &Path {
34        self.backend.root()
35    }
36}
37
38fn build_commit_msg(
39    id: &BlueprintId,
40    version: BlueprintVersion,
41    metadata: &CommitMetadata,
42) -> String {
43    format!(
44        "blueprint update [{id}]\n\n\
45         blueprint_content_hash: {hash}\n\
46         patch_content_hash:     {patch}\n\
47         epoch_blueprint_id:     {epoch_id}\n\
48         epoch_start_version:    {epoch_v}\n\
49         epoch_started_at_ms:    {epoch_ts}\n\
50         rationale:              {rationale}\n",
51        id = id,
52        hash = version,
53        patch = metadata.patch_hash,
54        epoch_id = metadata.epoch_id.blueprint_id,
55        epoch_v = metadata.epoch_id.start_version,
56        epoch_ts = metadata.epoch_id.started_at_ms,
57        rationale = metadata.rationale,
58    )
59}
60
61fn head_to_traced(
62    id: &BlueprintId,
63    head: HeadCommit,
64) -> Result<Traced<Blueprint>, BlueprintStoreError> {
65    let bp: Blueprint = serde_yaml::from_str(&head.yaml)?;
66    let version = parse_version(&head.commit_msg)
67        .ok_or_else(|| BlueprintStoreError::Other("version not found in commit msg".to_string()))?;
68    let _ = id;
69    let trace = Trace::new(
70        TraceOrigin::Git {
71            commit_hash: head.commit_hash_hex,
72        },
73        version,
74        head.ts_ms,
75    );
76    Ok(Traced::new(bp, trace))
77}
78
79fn parse_version(msg: &str) -> Option<BlueprintVersion> {
80    extract_msg_field(msg, "blueprint_content_hash")
81        .and_then(|hex| ContentHash::from_hex(&hex).ok())
82        .map(BlueprintVersion)
83}
84
85#[async_trait]
86impl BlueprintStore for Git2BlueprintStore {
87    fn name(&self) -> &str {
88        "git2"
89    }
90
91    async fn read_head(&self, id: &BlueprintId) -> Result<Traced<Blueprint>, BlueprintStoreError> {
92        if self.backend.is_archived(id.as_str())? {
93            return Err(BlueprintStoreError::Archived(id.clone()));
94        }
95        let head = self.backend.read_head(id.as_str(), id.clone())?;
96        head_to_traced(id, head)
97    }
98
99    async fn write_new(
100        &self,
101        id: &BlueprintId,
102        new_bp: &Blueprint,
103        parents: &[BlueprintVersion],
104        metadata: CommitMetadata,
105    ) -> Result<BlueprintVersion, BlueprintStoreError> {
106        if self.backend.is_archived(id.as_str())? {
107            return Err(BlueprintStoreError::Archived(id.clone()));
108        }
109        let yaml = canonical_yaml(new_bp)?;
110        let version = blueprint_version(new_bp)?;
111        let msg = build_commit_msg(id, version, &metadata);
112        self.backend
113            .try_write_blob_commit(id.as_str(), &yaml, &msg)?;
114        let _ = parents;
115        Ok(version)
116    }
117
118    async fn read_version(
119        &self,
120        id: &BlueprintId,
121        version: BlueprintVersion,
122    ) -> Result<Traced<Blueprint>, BlueprintStoreError> {
123        let match_line = format!("blueprint_content_hash: {}", version.to_hex());
124        let head = self
125            .backend
126            .find_commit_by_msg(id.as_str(), &match_line, id.clone())
127            .map_err(|e| match e {
128                BlueprintStoreError::Other(_) => BlueprintStoreError::NotFound {
129                    id: id.clone(),
130                    version,
131                },
132                other => other,
133            })?;
134        head_to_traced(id, head)
135    }
136
137    async fn history(
138        &self,
139        id: &BlueprintId,
140        limit: usize,
141    ) -> Result<Vec<BlueprintVersion>, BlueprintStoreError> {
142        let msgs = self.backend.history_msgs(id.as_str(), limit)?;
143        Ok(msgs.iter().filter_map(|m| parse_version(m)).collect())
144    }
145
146    async fn read_commit_rationale(
147        &self,
148        id: &BlueprintId,
149        version: BlueprintVersion,
150    ) -> Result<Option<String>, BlueprintStoreError> {
151        let match_line = format!("blueprint_content_hash: {}", version.to_hex());
152        match self
153            .backend
154            .find_commit_by_msg(id.as_str(), &match_line, id.clone())
155        {
156            Ok(head) => Ok(extract_msg_field(&head.commit_msg, "rationale")),
157            Err(BlueprintStoreError::HeadEmpty(_)) | Err(BlueprintStoreError::Other(_)) => Ok(None),
158            Err(e) => Err(e),
159        }
160    }
161
162    async fn list_ids(&self) -> Result<Vec<BlueprintId>, BlueprintStoreError> {
163        Ok(self
164            .backend
165            .list_ids(false)?
166            .into_iter()
167            .map(BlueprintId::new)
168            .collect())
169    }
170
171    async fn archive_id(&self, id: &BlueprintId) -> Result<(), BlueprintStoreError> {
172        let ts_ms = std::time::SystemTime::now()
173            .duration_since(std::time::UNIX_EPOCH)
174            .map(|d| d.as_millis() as i64)
175            .unwrap_or(0);
176        self.backend.write_archive_marker(id.as_str(), true, ts_ms)
177    }
178
179    async fn unarchive_id(&self, id: &BlueprintId) -> Result<(), BlueprintStoreError> {
180        let ts_ms = std::time::SystemTime::now()
181            .duration_since(std::time::UNIX_EPOCH)
182            .map(|d| d.as_millis() as i64)
183            .unwrap_or(0);
184        self.backend.write_archive_marker(id.as_str(), false, ts_ms)
185    }
186
187    async fn is_archived(&self, id: &BlueprintId) -> Result<bool, BlueprintStoreError> {
188        self.backend.is_archived(id.as_str())
189    }
190}