Skip to main content

ironflow_ops_git/
tag.rs

1//! Tag operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{Repository, Signature};
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
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct TagLightweightOutput {
16    pub name: String,
17    pub target: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct TagAnnotatedOutput {
22    pub name: String,
23    pub oid: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TagDeleteOutput {
28    pub name: String,
29    pub oid: String,
30    pub deleted: bool,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TagListOutput {
35    pub tags: Vec<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub pattern: Option<String>,
38}
39
40/// Create a lightweight tag.
41///
42/// # Examples
43///
44/// ```no_run
45/// use ironflow_ops_git::tag::TagCreateLightweight;
46/// use ironflow_core::operation::Operation;
47///
48/// let op = TagCreateLightweight::new("/path/to/repo", "v1.0.0");
49/// assert_eq!(op.kind(), "git");
50/// ```
51pub struct TagCreateLightweight {
52    repo_path: PathBuf,
53    name: String,
54}
55
56impl TagCreateLightweight {
57    /// Create a new lightweight-tag operation.
58    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
59        Self {
60            repo_path: repo_path.into(),
61            name: name.into(),
62        }
63    }
64
65    /// Execute and return a typed result.
66    pub async fn run(
67        &self,
68        _ctx: &OperationContext,
69    ) -> Result<TagLightweightOutput, OperationError> {
70        let repo_path = self.repo_path.clone();
71        let name = self.name.clone();
72        blocking(move || {
73            let repo = Repository::open(&repo_path)?;
74            let head = repo.head()?.peel_to_commit()?;
75            let obj = head.as_object();
76            repo.tag_lightweight(&name, obj, false)?;
77            Ok(TagLightweightOutput {
78                name,
79                target: head.id().to_string(),
80            })
81        })
82        .await
83    }
84}
85
86#[async_trait]
87impl Operation for TagCreateLightweight {
88    fn kind(&self) -> &str {
89        "git"
90    }
91    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
92        to_value(&self.run(ctx).await?)
93    }
94    fn input(&self) -> Option<Value> {
95        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
96    }
97}
98
99impl TypedOperation for TagCreateLightweight {
100    type Output = TagLightweightOutput;
101}
102
103/// Create an annotated tag.
104///
105/// # Examples
106///
107/// ```no_run
108/// use ironflow_ops_git::tag::TagCreateAnnotated;
109/// use ironflow_core::operation::Operation;
110///
111/// let op = TagCreateAnnotated::new("/path/to/repo", "v1.0.0", "Release 1.0", "Alice", "alice@example.com");
112/// assert_eq!(op.kind(), "git");
113/// ```
114pub struct TagCreateAnnotated {
115    repo_path: PathBuf,
116    name: String,
117    message: String,
118    tagger_name: String,
119    tagger_email: String,
120}
121
122impl TagCreateAnnotated {
123    /// Create a new annotated-tag operation.
124    pub fn new(
125        repo_path: impl Into<PathBuf>,
126        name: impl Into<String>,
127        message: impl Into<String>,
128        tagger_name: impl Into<String>,
129        tagger_email: impl Into<String>,
130    ) -> Self {
131        Self {
132            repo_path: repo_path.into(),
133            name: name.into(),
134            message: message.into(),
135            tagger_name: tagger_name.into(),
136            tagger_email: tagger_email.into(),
137        }
138    }
139
140    /// Execute and return a typed result.
141    pub async fn run(&self, _ctx: &OperationContext) -> Result<TagAnnotatedOutput, OperationError> {
142        let repo_path = self.repo_path.clone();
143        let name = self.name.clone();
144        let message = self.message.clone();
145        let tagger_name = self.tagger_name.clone();
146        let tagger_email = self.tagger_email.clone();
147        blocking(move || {
148            let repo = Repository::open(&repo_path)?;
149            let head = repo.head()?.peel_to_commit()?;
150            let obj = head.as_object();
151            let sig = Signature::now(&tagger_name, &tagger_email)?;
152            let oid = repo.tag(&name, obj, &sig, &message, false)?;
153            Ok(TagAnnotatedOutput {
154                name,
155                oid: oid.to_string(),
156            })
157        })
158        .await
159    }
160}
161
162#[async_trait]
163impl Operation for TagCreateAnnotated {
164    fn kind(&self) -> &str {
165        "git"
166    }
167    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
168        to_value(&self.run(ctx).await?)
169    }
170    fn input(&self) -> Option<Value> {
171        Some(
172            serde_json::json!({ "repo_path": self.repo_path, "name": self.name, "message": self.message }),
173        )
174    }
175}
176
177impl TypedOperation for TagCreateAnnotated {
178    type Output = TagAnnotatedOutput;
179}
180
181/// Delete a tag.
182///
183/// # Examples
184///
185/// ```no_run
186/// use ironflow_ops_git::tag::TagDelete;
187/// use ironflow_core::operation::Operation;
188///
189/// let op = TagDelete::new("/path/to/repo", "v1.0.0");
190/// assert_eq!(op.kind(), "git");
191/// ```
192pub struct TagDelete {
193    repo_path: PathBuf,
194    name: String,
195}
196
197impl TagDelete {
198    /// Create a new tag-delete operation.
199    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
200        Self {
201            repo_path: repo_path.into(),
202            name: name.into(),
203        }
204    }
205
206    /// Execute and return a typed result.
207    pub async fn run(&self, _ctx: &OperationContext) -> Result<TagDeleteOutput, OperationError> {
208        let repo_path = self.repo_path.clone();
209        let name = self.name.clone();
210        blocking(move || {
211            let repo = Repository::open(&repo_path)?;
212            let refname = format!("refs/tags/{name}");
213            let oid = repo.refname_to_id(&refname)?;
214            repo.tag_delete(&name)?;
215            Ok(TagDeleteOutput {
216                name,
217                oid: oid.to_string(),
218                deleted: true,
219            })
220        })
221        .await
222    }
223}
224
225#[async_trait]
226impl Operation for TagDelete {
227    fn kind(&self) -> &str {
228        "git"
229    }
230    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
231        to_value(&self.run(ctx).await?)
232    }
233    fn input(&self) -> Option<Value> {
234        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
235    }
236}
237
238impl TypedOperation for TagDelete {
239    type Output = TagDeleteOutput;
240}
241
242/// List all tags.
243///
244/// # Examples
245///
246/// ```no_run
247/// use ironflow_ops_git::tag::TagList;
248/// use ironflow_core::operation::Operation;
249///
250/// let op = TagList::new("/path/to/repo");
251/// assert_eq!(op.kind(), "git");
252/// ```
253pub struct TagList {
254    repo_path: PathBuf,
255}
256
257impl TagList {
258    /// Create a new tag-list operation.
259    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
260        Self {
261            repo_path: repo_path.into(),
262        }
263    }
264
265    /// Execute and return a typed result.
266    pub async fn run(&self, _ctx: &OperationContext) -> Result<TagListOutput, OperationError> {
267        let repo_path = self.repo_path.clone();
268        blocking(move || {
269            let repo = Repository::open(&repo_path)?;
270            let tags = repo.tag_names(None)?;
271            let list: Vec<String> = tags.iter().filter_map(|t| t.map(String::from)).collect();
272            Ok(TagListOutput {
273                tags: list,
274                pattern: None,
275            })
276        })
277        .await
278    }
279}
280
281#[async_trait]
282impl Operation for TagList {
283    fn kind(&self) -> &str {
284        "git"
285    }
286    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
287        to_value(&self.run(ctx).await?)
288    }
289    fn input(&self) -> Option<Value> {
290        Some(serde_json::json!({ "repo_path": self.repo_path }))
291    }
292}
293
294impl TypedOperation for TagList {
295    type Output = TagListOutput;
296}
297
298/// List tags matching a glob pattern.
299///
300/// # Examples
301///
302/// ```no_run
303/// use ironflow_ops_git::tag::TagListMatch;
304/// use ironflow_core::operation::Operation;
305///
306/// let op = TagListMatch::new("/path/to/repo", "v1.*");
307/// assert_eq!(op.kind(), "git");
308/// ```
309pub struct TagListMatch {
310    repo_path: PathBuf,
311    pattern: String,
312}
313
314impl TagListMatch {
315    /// Create a new tag-list-match operation.
316    pub fn new(repo_path: impl Into<PathBuf>, pattern: impl Into<String>) -> Self {
317        Self {
318            repo_path: repo_path.into(),
319            pattern: pattern.into(),
320        }
321    }
322
323    /// Execute and return a typed result.
324    pub async fn run(&self, _ctx: &OperationContext) -> Result<TagListOutput, OperationError> {
325        let repo_path = self.repo_path.clone();
326        let pattern = self.pattern.clone();
327        blocking(move || {
328            let repo = Repository::open(&repo_path)?;
329            let tags = repo.tag_names(Some(&pattern))?;
330            let list: Vec<String> = tags.iter().filter_map(|t| t.map(String::from)).collect();
331            Ok(TagListOutput {
332                tags: list,
333                pattern: Some(pattern),
334            })
335        })
336        .await
337    }
338}
339
340#[async_trait]
341impl Operation for TagListMatch {
342    fn kind(&self) -> &str {
343        "git"
344    }
345    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
346        to_value(&self.run(ctx).await?)
347    }
348    fn input(&self) -> Option<Value> {
349        Some(serde_json::json!({ "repo_path": self.repo_path, "pattern": self.pattern }))
350    }
351}
352
353impl TypedOperation for TagListMatch {
354    type Output = TagListOutput;
355}
356
357#[cfg(test)]
358mod tests {
359    use ironflow_core::operation::Operation;
360
361    use super::*;
362    use crate::test_helpers::{ctx, init_repo};
363
364    #[tokio::test]
365    async fn annotated_tag_create_and_list() {
366        let tmp = tempfile::tempdir().unwrap();
367        init_repo(tmp.path());
368        let result = TagCreateAnnotated::new(tmp.path(), "v2.0", "Release", "A", "a@t.com")
369            .run(&ctx())
370            .await
371            .unwrap();
372        assert_eq!(result.name, "v2.0");
373        assert!(!result.oid.is_empty());
374        let list = TagList::new(tmp.path()).run(&ctx()).await.unwrap();
375        assert!(list.tags.contains(&"v2.0".to_string()));
376    }
377
378    #[tokio::test]
379    async fn delete_tag() {
380        let tmp = tempfile::tempdir().unwrap();
381        init_repo(tmp.path());
382        TagCreateLightweight::new(tmp.path(), "v1.0")
383            .run(&ctx())
384            .await
385            .unwrap();
386        let result = TagDelete::new(tmp.path(), "v1.0")
387            .run(&ctx())
388            .await
389            .unwrap();
390        assert!(result.deleted);
391        let list = TagList::new(tmp.path()).run(&ctx()).await.unwrap();
392        assert!(!list.tags.contains(&"v1.0".to_string()));
393    }
394
395    #[tokio::test]
396    async fn list_match_filters() {
397        let tmp = tempfile::tempdir().unwrap();
398        init_repo(tmp.path());
399        TagCreateLightweight::new(tmp.path(), "v1.0")
400            .run(&ctx())
401            .await
402            .unwrap();
403        TagCreateLightweight::new(tmp.path(), "release-1")
404            .run(&ctx())
405            .await
406            .unwrap();
407        let result = TagListMatch::new(tmp.path(), "v*")
408            .run(&ctx())
409            .await
410            .unwrap();
411        assert!(result.tags.contains(&"v1.0".to_string()));
412        assert!(!result.tags.contains(&"release-1".to_string()));
413        assert_eq!(result.pattern.as_deref(), Some("v*"));
414    }
415
416    #[tokio::test]
417    async fn delete_nonexistent_tag_fails() {
418        let tmp = tempfile::tempdir().unwrap();
419        init_repo(tmp.path());
420        assert!(
421            TagDelete::new(tmp.path(), "nope")
422                .run(&ctx())
423                .await
424                .is_err()
425        );
426    }
427
428    #[tokio::test]
429    async fn execute_serializes_correctly() {
430        let tmp = tempfile::tempdir().unwrap();
431        init_repo(tmp.path());
432        let value = TagList::new(tmp.path()).execute(&ctx()).await.unwrap();
433        assert!(value["tags"].is_array());
434    }
435}