Skip to main content

ironflow_ops_git/
remote.rs

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