Skip to main content

ironflow_ops_git/
remote.rs

1//! Remote operations.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use git2::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 RemoteCreateOutput {
16    pub name: String,
17    pub url: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RemoteDeleteOutput {
22    pub name: String,
23    pub deleted: bool,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RemoteRenameOutput {
28    pub old_name: String,
29    pub new_name: String,
30    pub problems: Vec<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RemoteSetUrlOutput {
35    pub name: String,
36    pub url: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RemoteEntry {
41    pub name: String,
42    pub url: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct RemoteListOutput {
47    pub remotes: Vec<RemoteEntry>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct RemoteLookupOutput {
52    pub name: String,
53    pub url: String,
54    pub pushurl: Option<String>,
55}
56
57/// Create a new remote.
58///
59/// # Examples
60///
61/// ```no_run
62/// use ironflow_ops_git::remote::RemoteCreate;
63/// use ironflow_core::operation::Operation;
64///
65/// let op = RemoteCreate::new("/path/to/repo", "origin", "https://example.com/repo.git");
66/// assert_eq!(op.kind(), "git");
67/// ```
68pub struct RemoteCreate {
69    repo_path: PathBuf,
70    name: String,
71    url: String,
72}
73
74impl RemoteCreate {
75    /// Create a new remote-create operation.
76    pub fn new(
77        repo_path: impl Into<PathBuf>,
78        name: impl Into<String>,
79        url: impl Into<String>,
80    ) -> Self {
81        Self {
82            repo_path: repo_path.into(),
83            name: name.into(),
84            url: url.into(),
85        }
86    }
87
88    /// Execute and return a typed result.
89    pub async fn run(&self, _ctx: &OperationContext) -> Result<RemoteCreateOutput, OperationError> {
90        let repo_path = self.repo_path.clone();
91        let name = self.name.clone();
92        let url = self.url.clone();
93        blocking(move || {
94            let repo = Repository::open(&repo_path)?;
95            repo.remote(&name, &url)?;
96            Ok(RemoteCreateOutput { name, url })
97        })
98        .await
99    }
100}
101
102#[async_trait]
103impl Operation for RemoteCreate {
104    fn kind(&self) -> &str {
105        "git"
106    }
107    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
108        to_value(&self.run(ctx).await?)
109    }
110    fn input(&self) -> Option<Value> {
111        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name, "url": self.url }))
112    }
113}
114
115impl TypedOperation for RemoteCreate {
116    type Output = RemoteCreateOutput;
117}
118
119/// Delete a remote.
120///
121/// # Examples
122///
123/// ```no_run
124/// use ironflow_ops_git::remote::RemoteDelete;
125/// use ironflow_core::operation::Operation;
126///
127/// let op = RemoteDelete::new("/path/to/repo", "origin");
128/// assert_eq!(op.kind(), "git");
129/// ```
130pub struct RemoteDelete {
131    repo_path: PathBuf,
132    name: String,
133}
134
135impl RemoteDelete {
136    /// Create a new remote-delete operation.
137    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
138        Self {
139            repo_path: repo_path.into(),
140            name: name.into(),
141        }
142    }
143
144    /// Execute and return a typed result.
145    pub async fn run(&self, _ctx: &OperationContext) -> Result<RemoteDeleteOutput, OperationError> {
146        let repo_path = self.repo_path.clone();
147        let name = self.name.clone();
148        blocking(move || {
149            let repo = Repository::open(&repo_path)?;
150            repo.remote_delete(&name)?;
151            Ok(RemoteDeleteOutput {
152                name,
153                deleted: true,
154            })
155        })
156        .await
157    }
158}
159
160#[async_trait]
161impl Operation for RemoteDelete {
162    fn kind(&self) -> &str {
163        "git"
164    }
165    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
166        to_value(&self.run(ctx).await?)
167    }
168    fn input(&self) -> Option<Value> {
169        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
170    }
171}
172
173impl TypedOperation for RemoteDelete {
174    type Output = RemoteDeleteOutput;
175}
176
177/// Rename a remote.
178///
179/// # Examples
180///
181/// ```no_run
182/// use ironflow_ops_git::remote::RemoteRename;
183/// use ironflow_core::operation::Operation;
184///
185/// let op = RemoteRename::new("/path/to/repo", "origin", "upstream");
186/// assert_eq!(op.kind(), "git");
187/// ```
188pub struct RemoteRename {
189    repo_path: PathBuf,
190    old_name: String,
191    new_name: String,
192}
193
194impl RemoteRename {
195    /// Create a new remote-rename operation.
196    pub fn new(
197        repo_path: impl Into<PathBuf>,
198        old_name: impl Into<String>,
199        new_name: impl Into<String>,
200    ) -> Self {
201        Self {
202            repo_path: repo_path.into(),
203            old_name: old_name.into(),
204            new_name: new_name.into(),
205        }
206    }
207
208    /// Execute and return a typed result.
209    pub async fn run(&self, _ctx: &OperationContext) -> Result<RemoteRenameOutput, OperationError> {
210        let repo_path = self.repo_path.clone();
211        let old = self.old_name.clone();
212        let new = self.new_name.clone();
213        blocking(move || {
214            let repo = Repository::open(&repo_path)?;
215            let problems = repo.remote_rename(&old, &new)?;
216            let issues: Vec<String> = problems
217                .iter()
218                .filter_map(|s| s.map(String::from))
219                .collect();
220            Ok(RemoteRenameOutput {
221                old_name: old,
222                new_name: new,
223                problems: issues,
224            })
225        })
226        .await
227    }
228}
229
230#[async_trait]
231impl Operation for RemoteRename {
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 RemoteRename {
246    type Output = RemoteRenameOutput;
247}
248
249/// Set the URL of a remote.
250///
251/// # Examples
252///
253/// ```no_run
254/// use ironflow_ops_git::remote::RemoteSetUrl;
255/// use ironflow_core::operation::Operation;
256///
257/// let op = RemoteSetUrl::new("/path/to/repo", "origin", "https://new-url.com/repo.git");
258/// assert_eq!(op.kind(), "git");
259/// ```
260pub struct RemoteSetUrl {
261    repo_path: PathBuf,
262    name: String,
263    url: String,
264}
265
266impl RemoteSetUrl {
267    /// Create a new set-url operation.
268    pub fn new(
269        repo_path: impl Into<PathBuf>,
270        name: impl Into<String>,
271        url: impl Into<String>,
272    ) -> Self {
273        Self {
274            repo_path: repo_path.into(),
275            name: name.into(),
276            url: url.into(),
277        }
278    }
279
280    /// Execute and return a typed result.
281    pub async fn run(&self, _ctx: &OperationContext) -> Result<RemoteSetUrlOutput, OperationError> {
282        let repo_path = self.repo_path.clone();
283        let name = self.name.clone();
284        let url = self.url.clone();
285        blocking(move || {
286            let repo = Repository::open(&repo_path)?;
287            repo.remote_set_url(&name, &url)?;
288            Ok(RemoteSetUrlOutput { name, url })
289        })
290        .await
291    }
292}
293
294#[async_trait]
295impl Operation for RemoteSetUrl {
296    fn kind(&self) -> &str {
297        "git"
298    }
299    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
300        to_value(&self.run(ctx).await?)
301    }
302    fn input(&self) -> Option<Value> {
303        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name, "url": self.url }))
304    }
305}
306
307impl TypedOperation for RemoteSetUrl {
308    type Output = RemoteSetUrlOutput;
309}
310
311/// List all remotes.
312///
313/// # Examples
314///
315/// ```no_run
316/// use ironflow_ops_git::remote::RemoteList;
317/// use ironflow_core::operation::Operation;
318///
319/// let op = RemoteList::new("/path/to/repo");
320/// assert_eq!(op.kind(), "git");
321/// ```
322pub struct RemoteList {
323    repo_path: PathBuf,
324}
325
326impl RemoteList {
327    /// Create a new remote-list operation.
328    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
329        Self {
330            repo_path: repo_path.into(),
331        }
332    }
333
334    /// Execute and return a typed result.
335    pub async fn run(&self, _ctx: &OperationContext) -> Result<RemoteListOutput, OperationError> {
336        let repo_path = self.repo_path.clone();
337        blocking(move || {
338            let repo = Repository::open(&repo_path)?;
339            let remotes = repo.remotes()?;
340            let list: Vec<RemoteEntry> = remotes
341                .iter()
342                .filter_map(|name| {
343                    let name = name?;
344                    let remote = repo.find_remote(name).ok()?;
345                    Some(RemoteEntry {
346                        name: name.to_string(),
347                        url: remote.url().unwrap_or("").to_string(),
348                    })
349                })
350                .collect();
351            Ok(RemoteListOutput { remotes: list })
352        })
353        .await
354    }
355}
356
357#[async_trait]
358impl Operation for RemoteList {
359    fn kind(&self) -> &str {
360        "git"
361    }
362    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
363        to_value(&self.run(ctx).await?)
364    }
365    fn input(&self) -> Option<Value> {
366        Some(serde_json::json!({ "repo_path": self.repo_path }))
367    }
368}
369
370impl TypedOperation for RemoteList {
371    type Output = RemoteListOutput;
372}
373
374/// Look up a remote by name.
375///
376/// # Examples
377///
378/// ```no_run
379/// use ironflow_ops_git::remote::RemoteLookup;
380/// use ironflow_core::operation::Operation;
381///
382/// let op = RemoteLookup::new("/path/to/repo", "origin");
383/// assert_eq!(op.kind(), "git");
384/// ```
385pub struct RemoteLookup {
386    repo_path: PathBuf,
387    name: String,
388}
389
390impl RemoteLookup {
391    /// Create a new remote-lookup operation.
392    pub fn new(repo_path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
393        Self {
394            repo_path: repo_path.into(),
395            name: name.into(),
396        }
397    }
398
399    /// Execute and return a typed result.
400    pub async fn run(&self, _ctx: &OperationContext) -> Result<RemoteLookupOutput, OperationError> {
401        let repo_path = self.repo_path.clone();
402        let name = self.name.clone();
403        blocking(move || {
404            let repo = Repository::open(&repo_path)?;
405            let remote = repo.find_remote(&name)?;
406            Ok(RemoteLookupOutput {
407                name,
408                url: remote.url().unwrap_or("").to_string(),
409                pushurl: remote.pushurl().map(String::from),
410            })
411        })
412        .await
413    }
414}
415
416#[async_trait]
417impl Operation for RemoteLookup {
418    fn kind(&self) -> &str {
419        "git"
420    }
421    async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
422        to_value(&self.run(ctx).await?)
423    }
424    fn input(&self) -> Option<Value> {
425        Some(serde_json::json!({ "repo_path": self.repo_path, "name": self.name }))
426    }
427}
428
429impl TypedOperation for RemoteLookup {
430    type Output = RemoteLookupOutput;
431}
432
433#[cfg(test)]
434mod tests {
435    use git2::Repository;
436    use ironflow_core::operation::Operation;
437
438    use super::*;
439    use crate::test_helpers::ctx;
440
441    #[tokio::test]
442    async fn create_and_lookup() {
443        let tmp = tempfile::tempdir().unwrap();
444        Repository::init(tmp.path()).unwrap();
445        RemoteCreate::new(tmp.path(), "origin", "https://example.com/r.git")
446            .run(&ctx())
447            .await
448            .unwrap();
449        let result = RemoteLookup::new(tmp.path(), "origin")
450            .run(&ctx())
451            .await
452            .unwrap();
453        assert_eq!(result.name, "origin");
454        assert_eq!(result.url, "https://example.com/r.git");
455        assert!(result.pushurl.is_none());
456    }
457
458    #[tokio::test]
459    async fn list_remotes() {
460        let tmp = tempfile::tempdir().unwrap();
461        Repository::init(tmp.path()).unwrap();
462        RemoteCreate::new(tmp.path(), "origin", "https://a.com")
463            .run(&ctx())
464            .await
465            .unwrap();
466        RemoteCreate::new(tmp.path(), "upstream", "https://b.com")
467            .run(&ctx())
468            .await
469            .unwrap();
470        let result = RemoteList::new(tmp.path()).run(&ctx()).await.unwrap();
471        assert_eq!(result.remotes.len(), 2);
472        let names: Vec<&str> = result.remotes.iter().map(|r| r.name.as_str()).collect();
473        assert!(names.contains(&"origin"));
474        assert!(names.contains(&"upstream"));
475    }
476
477    #[tokio::test]
478    async fn rename_remote() {
479        let tmp = tempfile::tempdir().unwrap();
480        Repository::init(tmp.path()).unwrap();
481        RemoteCreate::new(tmp.path(), "old", "https://a.com")
482            .run(&ctx())
483            .await
484            .unwrap();
485        let result = RemoteRename::new(tmp.path(), "old", "new")
486            .run(&ctx())
487            .await
488            .unwrap();
489        assert_eq!(result.old_name, "old");
490        assert_eq!(result.new_name, "new");
491        assert!(
492            RemoteLookup::new(tmp.path(), "new")
493                .run(&ctx())
494                .await
495                .is_ok()
496        );
497    }
498
499    #[tokio::test]
500    async fn set_url() {
501        let tmp = tempfile::tempdir().unwrap();
502        Repository::init(tmp.path()).unwrap();
503        RemoteCreate::new(tmp.path(), "origin", "https://old.com")
504            .run(&ctx())
505            .await
506            .unwrap();
507        RemoteSetUrl::new(tmp.path(), "origin", "https://new.com")
508            .run(&ctx())
509            .await
510            .unwrap();
511        let result = RemoteLookup::new(tmp.path(), "origin")
512            .run(&ctx())
513            .await
514            .unwrap();
515        assert_eq!(result.url, "https://new.com");
516    }
517
518    #[tokio::test]
519    async fn delete_remote() {
520        let tmp = tempfile::tempdir().unwrap();
521        Repository::init(tmp.path()).unwrap();
522        RemoteCreate::new(tmp.path(), "origin", "https://a.com")
523            .run(&ctx())
524            .await
525            .unwrap();
526        let result = RemoteDelete::new(tmp.path(), "origin")
527            .run(&ctx())
528            .await
529            .unwrap();
530        assert!(result.deleted);
531        assert!(
532            RemoteLookup::new(tmp.path(), "origin")
533                .run(&ctx())
534                .await
535                .is_err()
536        );
537    }
538
539    #[tokio::test]
540    async fn lookup_missing_remote_fails() {
541        let tmp = tempfile::tempdir().unwrap();
542        Repository::init(tmp.path()).unwrap();
543        assert!(
544            RemoteLookup::new(tmp.path(), "nope")
545                .run(&ctx())
546                .await
547                .is_err()
548        );
549    }
550
551    #[tokio::test]
552    async fn execute_serializes_correctly() {
553        let tmp = tempfile::tempdir().unwrap();
554        Repository::init(tmp.path()).unwrap();
555        let value = RemoteList::new(tmp.path()).execute(&ctx()).await.unwrap();
556        assert!(value["remotes"].as_array().unwrap().is_empty());
557    }
558}