1use 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
62pub struct RemoteCreate {
74 repo_path: PathBuf,
75 name: String,
76 url: String,
77}
78
79impl RemoteCreate {
80 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 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
129pub struct RemoteDelete {
141 repo_path: PathBuf,
142 name: String,
143}
144
145impl RemoteDelete {
146 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 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
187pub struct RemoteRename {
199 repo_path: PathBuf,
200 old_name: String,
201 new_name: String,
202}
203
204impl RemoteRename {
205 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 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
259pub struct RemoteSetUrl {
271 repo_path: PathBuf,
272 name: String,
273 url: String,
274}
275
276impl RemoteSetUrl {
277 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 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
326pub struct RemoteList {
338 repo_path: PathBuf,
339}
340
341impl RemoteList {
342 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
344 Self {
345 repo_path: repo_path.into(),
346 }
347 }
348
349 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
389pub struct RemoteLookup {
401 repo_path: PathBuf,
402 name: String,
403}
404
405impl RemoteLookup {
406 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 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}