Skip to main content

ironflow_ops_git/
refs.rs

1//! Reference operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::{Oid, ReferenceType, 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
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RefCreateOutput {
16    pub name: String,
17    pub target: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RefDeleteOutput {
22    pub name: String,
23    pub deleted: bool,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RefRenameOutput {
28    pub old_name: String,
29    pub new_name: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct RefLookupOutput {
34    pub name: String,
35    pub target: Option<String>,
36    pub symbolic: bool,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RefNameToIdOutput {
41    pub name: String,
42    pub oid: String,
43}
44
45/// Create a new reference.
46///
47/// # Examples
48///
49/// ```no_run
50/// use ironflow_ops_git::refs::RefCreate;
51/// use ironflow_core::operation::Operation;
52///
53/// let op = RefCreate::new("/path/to/repo", "refs/heads/new-branch", "abc123", "create branch");
54/// assert_eq!(op.kind(), "git");
55/// ```
56pub struct RefCreate {
57    repo_path: PathBuf,
58    name: String,
59    target: String,
60    log_message: String,
61}
62
63impl RefCreate {
64    /// Create a new reference-create operation.
65    pub fn new(
66        repo_path: impl Into<PathBuf>,
67        name: impl Into<String>,
68        target: impl Into<String>,
69        log_message: impl Into<String>,
70    ) -> Self {
71        Self {
72            repo_path: repo_path.into(),
73            name: name.into(),
74            target: target.into(),
75            log_message: log_message.into(),
76        }
77    }
78
79    /// Execute and return a typed result.
80    pub async fn run(&self, _ctx: &OperationContext) -> Result<RefCreateOutput, OperationError> {
81        let repo_path = self.repo_path.clone();
82        let name = self.name.clone();
83        let target = self.target.clone();
84        let msg = self.log_message.clone();
85        blocking(move || {
86            let repo = Repository::open(&repo_path)?;
87            let oid = Oid::from_str(&target)?;
88            repo.reference(&name, oid, false, &msg)?;
89            Ok(RefCreateOutput { name, target })
90        })
91        .await
92    }
93}
94
95#[async_trait]
96impl Operation for RefCreate {
97    fn kind(&self) -> &str {
98        "git"
99    }
100    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
101        to_value(&self.run(ctx).await?)
102    }
103    fn input(&self) -> Option<Value> {
104        Some(
105            serde_json::json!({ "repo_path": self.repo_path, "name": self.name, "target": self.target }),
106        )
107    }
108}
109
110impl TypedOperation for RefCreate {
111    type Output = RefCreateOutput;
112}
113
114/// Delete a reference.
115///
116/// # Examples
117///
118/// ```no_run
119/// use ironflow_ops_git::refs::RefDelete;
120/// use ironflow_core::operation::Operation;
121///
122/// let op = RefDelete::new("/path/to/repo", "refs/heads/old-branch");
123/// assert_eq!(op.kind(), "git");
124/// ```
125pub struct RefDelete {
126    repo_path: PathBuf,
127    name: String,
128}
129
130impl RefDelete {
131    /// Create a new reference-delete operation.
132    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
133        Self {
134            repo_path: repo_path.into(),
135            name: name.into(),
136        }
137    }
138
139    /// Execute and return a typed result.
140    pub async fn run(&self, _ctx: &OperationContext) -> Result<RefDeleteOutput, OperationError> {
141        let repo_path = self.repo_path.clone();
142        let name = self.name.clone();
143        blocking(move || {
144            let repo = Repository::open(&repo_path)?;
145            let mut reference = repo.find_reference(&name)?;
146            reference.delete()?;
147            Ok(RefDeleteOutput {
148                name,
149                deleted: true,
150            })
151        })
152        .await
153    }
154}
155
156#[async_trait]
157impl Operation for RefDelete {
158    fn kind(&self) -> &str {
159        "git"
160    }
161    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
162        to_value(&self.run(ctx).await?)
163    }
164    fn input(&self) -> Option<Value> {
165        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
166    }
167}
168
169impl TypedOperation for RefDelete {
170    type Output = RefDeleteOutput;
171}
172
173/// Rename a reference.
174///
175/// # Examples
176///
177/// ```no_run
178/// use ironflow_ops_git::refs::RefRename;
179/// use ironflow_core::operation::Operation;
180///
181/// let op = RefRename::new("/path/to/repo", "refs/heads/old", "refs/heads/new", "rename", false);
182/// assert_eq!(op.kind(), "git");
183/// ```
184pub struct RefRename {
185    repo_path: PathBuf,
186    old_name: String,
187    new_name: String,
188    log_message: String,
189    force: bool,
190}
191
192impl RefRename {
193    /// Create a new reference-rename operation.
194    pub fn new(
195        repo_path: impl Into<PathBuf>,
196        old_name: impl Into<String>,
197        new_name: impl Into<String>,
198        log_message: impl Into<String>,
199        force: bool,
200    ) -> Self {
201        Self {
202            repo_path: repo_path.into(),
203            old_name: old_name.into(),
204            new_name: new_name.into(),
205            log_message: log_message.into(),
206            force,
207        }
208    }
209
210    /// Execute and return a typed result.
211    pub async fn run(&self, _ctx: &OperationContext) -> Result<RefRenameOutput, OperationError> {
212        let repo_path = self.repo_path.clone();
213        let old = self.old_name.clone();
214        let new = self.new_name.clone();
215        let msg = self.log_message.clone();
216        let force = self.force;
217        blocking(move || {
218            let repo = Repository::open(&repo_path)?;
219            let mut reference = repo.find_reference(&old)?;
220            reference.rename(&new, force, &msg)?;
221            Ok(RefRenameOutput {
222                old_name: old,
223                new_name: new,
224            })
225        })
226        .await
227    }
228}
229
230#[async_trait]
231impl Operation for RefRename {
232    fn kind(&self) -> &str {
233        "git"
234    }
235    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
236        to_value(&self.run(ctx).await?)
237    }
238    fn input(&self) -> Option<Value> {
239        Some(
240            serde_json::json!({ "repo_path": self.repo_path, "old_name": self.old_name, "new_name": self.new_name }),
241        )
242    }
243}
244
245impl TypedOperation for RefRename {
246    type Output = RefRenameOutput;
247}
248
249/// Look up a reference by name.
250///
251/// # Examples
252///
253/// ```no_run
254/// use ironflow_ops_git::refs::RefLookup;
255/// use ironflow_core::operation::Operation;
256///
257/// let op = RefLookup::new("/path/to/repo", "refs/heads/main");
258/// assert_eq!(op.kind(), "git");
259/// ```
260pub struct RefLookup {
261    repo_path: PathBuf,
262    name: String,
263}
264
265impl RefLookup {
266    /// Create a new reference-lookup operation.
267    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
268        Self {
269            repo_path: repo_path.into(),
270            name: name.into(),
271        }
272    }
273
274    /// Execute and return a typed result.
275    pub async fn run(&self, _ctx: &OperationContext) -> Result<RefLookupOutput, OperationError> {
276        let repo_path = self.repo_path.clone();
277        let name = self.name.clone();
278        blocking(move || {
279            let repo = Repository::open(&repo_path)?;
280            let reference = repo.find_reference(&name)?;
281            let target = reference.target().map(|o| o.to_string());
282            let is_symbolic = reference.kind() == Some(ReferenceType::Symbolic);
283            Ok(RefLookupOutput {
284                name,
285                target,
286                symbolic: is_symbolic,
287            })
288        })
289        .await
290    }
291}
292
293#[async_trait]
294impl Operation for RefLookup {
295    fn kind(&self) -> &str {
296        "git"
297    }
298    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
299        to_value(&self.run(ctx).await?)
300    }
301    fn input(&self) -> Option<Value> {
302        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
303    }
304}
305
306impl TypedOperation for RefLookup {
307    type Output = RefLookupOutput;
308}
309
310/// Resolve a reference name to an OID.
311///
312/// # Examples
313///
314/// ```no_run
315/// use ironflow_ops_git::refs::RefNameToId;
316/// use ironflow_core::operation::Operation;
317///
318/// let op = RefNameToId::new("/path/to/repo", "HEAD");
319/// assert_eq!(op.kind(), "git");
320/// ```
321pub struct RefNameToId {
322    repo_path: PathBuf,
323    name: String,
324}
325
326impl RefNameToId {
327    /// Create a new name-to-id operation.
328    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
329        Self {
330            repo_path: repo_path.into(),
331            name: name.into(),
332        }
333    }
334
335    /// Execute and return a typed result.
336    pub async fn run(&self, _ctx: &OperationContext) -> Result<RefNameToIdOutput, OperationError> {
337        let repo_path = self.repo_path.clone();
338        let name = self.name.clone();
339        blocking(move || {
340            let repo = Repository::open(&repo_path)?;
341            let oid = repo.refname_to_id(&name)?;
342            Ok(RefNameToIdOutput {
343                name,
344                oid: oid.to_string(),
345            })
346        })
347        .await
348    }
349}
350
351#[async_trait]
352impl Operation for RefNameToId {
353    fn kind(&self) -> &str {
354        "git"
355    }
356    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
357        to_value(&self.run(ctx).await?)
358    }
359    fn input(&self) -> Option<Value> {
360        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
361    }
362}
363
364impl TypedOperation for RefNameToId {
365    type Output = RefNameToIdOutput;
366}
367
368#[cfg(test)]
369mod tests {
370    use ironflow_core::operation::Operation;
371
372    use super::*;
373    use crate::test_helpers::{ctx, init_repo};
374
375    #[tokio::test]
376    async fn create_and_lookup() {
377        let tmp = tempfile::tempdir().unwrap();
378        let oid = init_repo(tmp.path()).to_string();
379        RefCreate::new(tmp.path(), "refs/heads/test-ref", &oid, "create")
380            .run(&ctx())
381            .await
382            .unwrap();
383        let result = RefLookup::new(tmp.path(), "refs/heads/test-ref")
384            .run(&ctx())
385            .await
386            .unwrap();
387        assert_eq!(result.name, "refs/heads/test-ref");
388        assert_eq!(result.target.as_deref(), Some(oid.as_str()));
389        assert!(!result.symbolic);
390    }
391
392    #[tokio::test]
393    async fn name_to_id() {
394        let tmp = tempfile::tempdir().unwrap();
395        let oid = init_repo(tmp.path()).to_string();
396        let result = RefNameToId::new(tmp.path(), "HEAD")
397            .run(&ctx())
398            .await
399            .unwrap();
400        assert_eq!(result.oid, oid);
401    }
402
403    #[tokio::test]
404    async fn rename_ref() {
405        let tmp = tempfile::tempdir().unwrap();
406        let oid = init_repo(tmp.path()).to_string();
407        RefCreate::new(tmp.path(), "refs/heads/old-ref", &oid, "c")
408            .run(&ctx())
409            .await
410            .unwrap();
411        let result = RefRename::new(
412            tmp.path(),
413            "refs/heads/old-ref",
414            "refs/heads/new-ref",
415            "rename",
416            false,
417        )
418        .run(&ctx())
419        .await
420        .unwrap();
421        assert_eq!(result.old_name, "refs/heads/old-ref");
422        assert_eq!(result.new_name, "refs/heads/new-ref");
423        assert!(
424            RefLookup::new(tmp.path(), "refs/heads/new-ref")
425                .run(&ctx())
426                .await
427                .is_ok()
428        );
429    }
430
431    #[tokio::test]
432    async fn delete_ref() {
433        let tmp = tempfile::tempdir().unwrap();
434        let oid = init_repo(tmp.path()).to_string();
435        RefCreate::new(tmp.path(), "refs/heads/to-delete", &oid, "c")
436            .run(&ctx())
437            .await
438            .unwrap();
439        let result = RefDelete::new(tmp.path(), "refs/heads/to-delete")
440            .run(&ctx())
441            .await
442            .unwrap();
443        assert!(result.deleted);
444        assert!(
445            RefLookup::new(tmp.path(), "refs/heads/to-delete")
446                .run(&ctx())
447                .await
448                .is_err()
449        );
450    }
451
452    #[tokio::test]
453    async fn lookup_missing_ref_fails() {
454        let tmp = tempfile::tempdir().unwrap();
455        init_repo(tmp.path());
456        assert!(
457            RefLookup::new(tmp.path(), "refs/heads/nope")
458                .run(&ctx())
459                .await
460                .is_err()
461        );
462    }
463
464    #[tokio::test]
465    async fn execute_serializes_correctly() {
466        let tmp = tempfile::tempdir().unwrap();
467        let oid = init_repo(tmp.path()).to_string();
468        let value = RefNameToId::new(tmp.path(), "HEAD")
469            .execute(&ctx())
470            .await
471            .unwrap();
472        assert_eq!(value["oid"], oid);
473    }
474}