Skip to main content

ironflow_ops_git/
object.rs

1//! Object operations (blob, tree, find).
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{ObjectType, Oid, Repository};
7use ironflow_core::error::OperationError;
8use ironflow_core::operation::{Operation, OperationContext, TypedOperation};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::helpers::{blocking, to_value};
13
14fn object_type_label(kind: Option<ObjectType>) -> &'static str {
15    match kind {
16        Some(ObjectType::Commit) => "commit",
17        Some(ObjectType::Tree) => "tree",
18        Some(ObjectType::Blob) => "blob",
19        Some(ObjectType::Tag) => "tag",
20        _ => "unknown",
21    }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BlobCreateOutput {
26    pub oid: String,
27    pub size: usize,
28}
29
30/// A tree entry.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct TreeEntryOutput {
33    pub name: String,
34    pub oid: String,
35    pub kind: String,
36    pub filemode: i32,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct TreeLookupOutput {
41    pub oid: String,
42    pub entries: Vec<TreeEntryOutput>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct FindObjectOutput {
47    pub oid: String,
48    #[serde(rename = "type")]
49    pub object_type: String,
50}
51
52/// Create a blob from content.
53///
54/// # Examples
55///
56/// ```no_run
57/// use ironflow_ops_git::object::BlobCreate;
58/// use ironflow_core::operation::Operation;
59///
60/// let op = BlobCreate::new("/path/to/repo", b"hello world".to_vec());
61/// assert_eq!(op.kind(), "git");
62/// ```
63pub struct BlobCreate {
64    repo_path: PathBuf,
65    content: Vec<u8>,
66}
67
68impl BlobCreate {
69    /// Create a new blob-create operation.
70    pub fn new(repo_path: impl Into<PathBuf>, content: Vec<u8>) -> Self {
71        Self {
72            repo_path: repo_path.into(),
73            content,
74        }
75    }
76
77    /// Execute and return a typed result.
78    pub async fn run(&self, _ctx: &OperationContext) -> Result<BlobCreateOutput, OperationError> {
79        let repo_path = self.repo_path.clone();
80        let content = self.content.clone();
81        blocking(move || {
82            let repo = Repository::open(&repo_path)?;
83            let oid = repo.blob(&content)?;
84            Ok(BlobCreateOutput {
85                oid: oid.to_string(),
86                size: content.len(),
87            })
88        })
89        .await
90    }
91}
92
93#[async_trait]
94impl Operation for BlobCreate {
95    fn kind(&self) -> &str {
96        "git"
97    }
98    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
99        to_value(&self.run(ctx).await?)
100    }
101    fn input(&self) -> Option<Value> {
102        Some(serde_json::json!({ "repo_path": self.repo_path, "size": self.content.len() }))
103    }
104}
105
106impl TypedOperation for BlobCreate {
107    type Output = BlobCreateOutput;
108}
109
110/// Look up a tree by OID and list its entries.
111///
112/// # Examples
113///
114/// ```no_run
115/// use ironflow_ops_git::object::TreeLookup;
116/// use ironflow_core::operation::Operation;
117///
118/// let op = TreeLookup::new("/path/to/repo", "abc123");
119/// assert_eq!(op.kind(), "git");
120/// ```
121pub struct TreeLookup {
122    repo_path: PathBuf,
123    oid: String,
124}
125
126impl TreeLookup {
127    /// Create a new tree-lookup operation.
128    pub fn new(repo_path: impl Into<PathBuf>, oid: impl Into<String>) -> Self {
129        Self {
130            repo_path: repo_path.into(),
131            oid: oid.into(),
132        }
133    }
134
135    /// Execute and return a typed result.
136    pub async fn run(&self, _ctx: &OperationContext) -> Result<TreeLookupOutput, OperationError> {
137        let repo_path = self.repo_path.clone();
138        let oid_str = self.oid.clone();
139        blocking(move || {
140            let repo = Repository::open(&repo_path)?;
141            let oid = Oid::from_str(&oid_str)?;
142            let tree = repo.find_tree(oid)?;
143            let entries = tree
144                .iter()
145                .map(|entry| TreeEntryOutput {
146                    name: entry.name().unwrap_or("").to_string(),
147                    oid: entry.id().to_string(),
148                    kind: object_type_label(entry.kind()).to_string(),
149                    filemode: entry.filemode(),
150                })
151                .collect();
152            Ok(TreeLookupOutput {
153                oid: oid_str,
154                entries,
155            })
156        })
157        .await
158    }
159}
160
161#[async_trait]
162impl Operation for TreeLookup {
163    fn kind(&self) -> &str {
164        "git"
165    }
166    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
167        to_value(&self.run(ctx).await?)
168    }
169    fn input(&self) -> Option<Value> {
170        Some(serde_json::json!({ "repo_path": self.repo_path, "oid": self.oid }))
171    }
172}
173
174impl TypedOperation for TreeLookup {
175    type Output = TreeLookupOutput;
176}
177
178/// Find an object by OID.
179///
180/// # Examples
181///
182/// ```no_run
183/// use ironflow_ops_git::object::FindObject;
184/// use ironflow_core::operation::Operation;
185///
186/// let op = FindObject::new("/path/to/repo", "abc123");
187/// assert_eq!(op.kind(), "git");
188/// ```
189pub struct FindObject {
190    repo_path: PathBuf,
191    oid: String,
192}
193
194impl FindObject {
195    /// Create a new find-object operation.
196    pub fn new(repo_path: impl Into<PathBuf>, oid: impl Into<String>) -> Self {
197        Self {
198            repo_path: repo_path.into(),
199            oid: oid.into(),
200        }
201    }
202
203    /// Execute and return a typed result.
204    pub async fn run(&self, _ctx: &OperationContext) -> Result<FindObjectOutput, OperationError> {
205        let repo_path = self.repo_path.clone();
206        let oid_str = self.oid.clone();
207        blocking(move || {
208            let repo = Repository::open(&repo_path)?;
209            let oid = Oid::from_str(&oid_str)?;
210            let obj = repo.find_object(oid, None)?;
211            Ok(FindObjectOutput {
212                oid: oid_str,
213                object_type: object_type_label(obj.kind()).to_string(),
214            })
215        })
216        .await
217    }
218}
219
220#[async_trait]
221impl Operation for FindObject {
222    fn kind(&self) -> &str {
223        "git"
224    }
225    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
226        to_value(&self.run(ctx).await?)
227    }
228    fn input(&self) -> Option<Value> {
229        Some(serde_json::json!({ "repo_path": self.repo_path, "oid": self.oid }))
230    }
231}
232
233impl TypedOperation for FindObject {
234    type Output = FindObjectOutput;
235}
236
237#[cfg(test)]
238mod tests {
239    use git2::Repository;
240    use ironflow_core::operation::Operation;
241
242    use super::*;
243    use crate::test_helpers::{ctx, init_repo};
244
245    #[tokio::test]
246    async fn blob_create_returns_oid_and_size() {
247        let tmp = tempfile::tempdir().unwrap();
248        init_repo(tmp.path());
249        let result = BlobCreate::new(tmp.path(), b"hello".to_vec())
250            .run(&ctx())
251            .await
252            .unwrap();
253        assert!(!result.oid.is_empty());
254        assert_eq!(result.size, 5);
255    }
256
257    #[tokio::test]
258    async fn blob_create_empty() {
259        let tmp = tempfile::tempdir().unwrap();
260        init_repo(tmp.path());
261        let result = BlobCreate::new(tmp.path(), vec![])
262            .run(&ctx())
263            .await
264            .unwrap();
265        assert_eq!(result.size, 0);
266    }
267
268    #[tokio::test]
269    async fn tree_lookup_lists_entries() {
270        let tmp = tempfile::tempdir().unwrap();
271        init_repo(tmp.path());
272        let repo = Repository::open(tmp.path()).unwrap();
273        let head = repo.head().unwrap().peel_to_commit().unwrap();
274        let tree_oid = head.tree().unwrap().id().to_string();
275        let result = TreeLookup::new(tmp.path(), &tree_oid)
276            .run(&ctx())
277            .await
278            .unwrap();
279        assert_eq!(result.oid, tree_oid);
280        assert!(result.entries.iter().any(|e| e.name == "file.txt"));
281    }
282
283    #[tokio::test]
284    async fn find_object_returns_type() {
285        let tmp = tempfile::tempdir().unwrap();
286        init_repo(tmp.path());
287        let repo = Repository::open(tmp.path()).unwrap();
288        let commit_oid = repo
289            .head()
290            .unwrap()
291            .peel_to_commit()
292            .unwrap()
293            .id()
294            .to_string();
295        let result = FindObject::new(tmp.path(), &commit_oid)
296            .run(&ctx())
297            .await
298            .unwrap();
299        assert_eq!(result.object_type, "commit");
300    }
301
302    #[tokio::test]
303    async fn find_object_invalid_oid_fails() {
304        let tmp = tempfile::tempdir().unwrap();
305        init_repo(tmp.path());
306        assert!(
307            FindObject::new(tmp.path(), "0000000000000000000000000000000000000000")
308                .run(&ctx())
309                .await
310                .is_err()
311        );
312    }
313
314    #[tokio::test]
315    async fn execute_serializes_correctly() {
316        let tmp = tempfile::tempdir().unwrap();
317        init_repo(tmp.path());
318        let value = BlobCreate::new(tmp.path(), b"data".to_vec())
319            .execute(&ctx())
320            .await
321            .unwrap();
322        assert_eq!(value["size"], 4);
323        assert!(value["oid"].is_string());
324    }
325}