1use 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
40pub struct TagCreateLightweight {
52 repo_path: PathBuf,
53 name: String,
54}
55
56impl TagCreateLightweight {
57 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 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
103pub 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 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 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
181pub struct TagDelete {
193 repo_path: PathBuf,
194 name: String,
195}
196
197impl TagDelete {
198 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 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
242pub struct TagList {
254 repo_path: PathBuf,
255}
256
257impl TagList {
258 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
260 Self {
261 repo_path: repo_path.into(),
262 }
263 }
264
265 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
298pub struct TagListMatch {
310 repo_path: PathBuf,
311 pattern: String,
312}
313
314impl TagListMatch {
315 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 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}