1use 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
57pub struct RemoteCreate {
69 repo_path: PathBuf,
70 name: String,
71 url: String,
72}
73
74impl RemoteCreate {
75 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 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
119pub struct RemoteDelete {
131 repo_path: PathBuf,
132 name: String,
133}
134
135impl RemoteDelete {
136 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 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
177pub struct RemoteRename {
189 repo_path: PathBuf,
190 old_name: String,
191 new_name: String,
192}
193
194impl RemoteRename {
195 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 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
249pub struct RemoteSetUrl {
261 repo_path: PathBuf,
262 name: String,
263 url: String,
264}
265
266impl RemoteSetUrl {
267 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 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
311pub struct RemoteList {
323 repo_path: PathBuf,
324}
325
326impl RemoteList {
327 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
329 Self {
330 repo_path: repo_path.into(),
331 }
332 }
333
334 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
374pub struct RemoteLookup {
386 repo_path: PathBuf,
387 name: String,
388}
389
390impl RemoteLookup {
391 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 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}