1use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use git2::build::RepoBuilder;
7use git2::{FetchOptions, Repository, RepositoryState};
8use ironflow_core::error::OperationError;
9use ironflow_core::operation::{Operation, OperationContext, TypedOperation};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::helpers::{
14 GitAuth, auth_builders, blocking, blocking_authenticated, credentials_callbacks, redact_url,
15 to_value,
16};
17
18fn repo_state_label(state: RepositoryState) -> &'static str {
19 match state {
20 RepositoryState::Clean => "clean",
21 RepositoryState::Merge => "merge",
22 RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
23 RepositoryState::CherryPickSequence | RepositoryState::CherryPick => "cherrypick",
24 RepositoryState::Bisect => "bisect",
25 RepositoryState::Rebase
26 | RepositoryState::RebaseInteractive
27 | RepositoryState::RebaseMerge => "rebase",
28 RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
29 }
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RepoInitOutput {
35 pub path: PathBuf,
37 pub bare: bool,
39}
40
41pub struct RepoInit {
56 path: PathBuf,
57 bare: bool,
58}
59
60impl RepoInit {
61 pub fn new(path: impl Into<PathBuf>, bare: bool) -> Self {
63 Self {
64 path: path.into(),
65 bare,
66 }
67 }
68
69 pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoInitOutput, OperationError> {
71 let path = self.path.clone();
72 let bare = self.bare;
73 blocking(move || {
74 if bare {
75 Repository::init_bare(&path)?;
76 } else {
77 Repository::init(&path)?;
78 }
79 Ok(RepoInitOutput { path, bare })
80 })
81 .await
82 }
83}
84
85#[async_trait]
86impl Operation for RepoInit {
87 fn kind(&self) -> &str {
88 "git"
89 }
90
91 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
92 to_value(&self.run(ctx).await?)
93 }
94
95 fn input(&self) -> Option<Value> {
96 Some(serde_json::json!({ "path": self.path, "bare": self.bare }))
97 }
98}
99
100impl TypedOperation for RepoInit {
101 type Output = RepoInitOutput;
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct RepoOpenOutput {
107 pub path: PathBuf,
109 pub bare: bool,
111}
112
113pub struct RepoOpen {
127 path: PathBuf,
128}
129
130impl RepoOpen {
131 pub fn new(path: impl Into<PathBuf>) -> Self {
133 Self { path: path.into() }
134 }
135
136 pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoOpenOutput, OperationError> {
138 let path = self.path.clone();
139 blocking(move || {
140 let repo = Repository::open(&path)?;
141 let is_bare = repo.is_bare();
142 let workdir = repo.workdir().map(Path::to_path_buf);
143 Ok(RepoOpenOutput {
144 path: workdir.unwrap_or(path),
145 bare: is_bare,
146 })
147 })
148 .await
149 }
150}
151
152#[async_trait]
153impl Operation for RepoOpen {
154 fn kind(&self) -> &str {
155 "git"
156 }
157
158 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
159 to_value(&self.run(ctx).await?)
160 }
161
162 fn input(&self) -> Option<Value> {
163 Some(serde_json::json!({ "path": self.path }))
164 }
165}
166
167impl TypedOperation for RepoOpen {
168 type Output = RepoOpenOutput;
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct RepoCloneOutput {
174 pub url: String,
176 pub path: PathBuf,
178 pub bare: bool,
180}
181
182pub struct RepoClone {
200 url: String,
201 path: PathBuf,
202 bare: bool,
203 auth: GitAuth,
204}
205
206impl RepoClone {
207 pub fn new(url: impl Into<String>, path: impl Into<PathBuf>) -> Self {
209 Self {
210 url: url.into(),
211 path: path.into(),
212 bare: false,
213 auth: GitAuth::default(),
214 }
215 }
216
217 pub fn bare(mut self, bare: bool) -> Self {
227 self.bare = bare;
228 self
229 }
230
231 pub async fn run(&self, ctx: &OperationContext) -> Result<RepoCloneOutput, OperationError> {
239 let url = self.url.clone();
240 let path = self.path.clone();
241 let bare = self.bare;
242 blocking_authenticated(ctx, &self.auth, move |creds| {
243 let mut fetch_opts = FetchOptions::new();
244 fetch_opts.remote_callbacks(credentials_callbacks(creds));
245 RepoBuilder::new()
246 .bare(bare)
247 .fetch_options(fetch_opts)
248 .clone(&url, &path)?;
249 Ok(RepoCloneOutput {
250 url: redact_url(&url),
251 path,
252 bare,
253 })
254 })
255 .await
256 }
257}
258
259#[async_trait]
260impl Operation for RepoClone {
261 fn kind(&self) -> &str {
262 "git"
263 }
264
265 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
266 to_value(&self.run(ctx).await?)
267 }
268
269 fn input(&self) -> Option<Value> {
270 Some(serde_json::json!({
271 "url": redact_url(&self.url),
272 "path": self.path,
273 "bare": self.bare,
274 }))
275 }
276}
277
278impl TypedOperation for RepoClone {
279 type Output = RepoCloneOutput;
280}
281
282auth_builders!(
283 RepoClone,
284 "repository",
285 "\"https://gitlab.com/group/repo.git\", \"/tmp/repo\""
286);
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct RepoDiscoverOutput {
291 pub path: Option<PathBuf>,
293 pub bare: bool,
295}
296
297pub struct RepoDiscover {
311 start_path: PathBuf,
312}
313
314impl RepoDiscover {
315 pub fn new(start_path: impl Into<PathBuf>) -> Self {
317 Self {
318 start_path: start_path.into(),
319 }
320 }
321
322 pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoDiscoverOutput, OperationError> {
324 let start = self.start_path.clone();
325 blocking(move || {
326 let repo = Repository::discover(&start)?;
327 let workdir = repo.workdir().map(Path::to_path_buf);
328 let is_bare = repo.is_bare();
329 Ok(RepoDiscoverOutput {
330 path: workdir,
331 bare: is_bare,
332 })
333 })
334 .await
335 }
336}
337
338#[async_trait]
339impl Operation for RepoDiscover {
340 fn kind(&self) -> &str {
341 "git"
342 }
343
344 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
345 to_value(&self.run(ctx).await?)
346 }
347
348 fn input(&self) -> Option<Value> {
349 Some(serde_json::json!({ "start_path": self.start_path }))
350 }
351}
352
353impl TypedOperation for RepoDiscover {
354 type Output = RepoDiscoverOutput;
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct RepoStateOutput {
360 pub state: String,
362}
363
364pub struct RepoState {
378 repo_path: PathBuf,
379}
380
381impl RepoState {
382 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
384 Self {
385 repo_path: repo_path.into(),
386 }
387 }
388
389 pub async fn run(&self, _ctx: &OperationContext) -> Result<RepoStateOutput, OperationError> {
391 let path = self.repo_path.clone();
392 blocking(move || {
393 let repo = Repository::open(&path)?;
394 let state = repo_state_label(repo.state()).to_string();
395 Ok(RepoStateOutput { state })
396 })
397 .await
398 }
399}
400
401#[async_trait]
402impl Operation for RepoState {
403 fn kind(&self) -> &str {
404 "git"
405 }
406
407 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
408 to_value(&self.run(ctx).await?)
409 }
410
411 fn input(&self) -> Option<Value> {
412 Some(serde_json::json!({ "repo_path": self.repo_path }))
413 }
414}
415
416impl TypedOperation for RepoState {
417 type Output = RepoStateOutput;
418}
419
420#[cfg(test)]
421mod tests {
422 use std::fs;
423
424 use super::*;
425 use crate::test_helpers::{ctx, init_repo};
426
427 #[tokio::test]
428 async fn init_creates_repo() {
429 let tmp = tempfile::tempdir().unwrap();
430 let target = tmp.path().join("new-repo");
431 let op = RepoInit::new(&target, false);
432 let result = op.run(&ctx()).await.unwrap();
433 assert!(!result.bare);
434 assert!(target.join(".git").exists());
435 }
436
437 #[tokio::test]
438 async fn init_creates_bare_repo() {
439 let tmp = tempfile::tempdir().unwrap();
440 let target = tmp.path().join("bare-repo");
441 let op = RepoInit::new(&target, true);
442 let result = op.run(&ctx()).await.unwrap();
443 assert!(result.bare);
444 assert!(target.join("HEAD").exists());
445 }
446
447 #[tokio::test]
448 async fn clone_local() {
449 let tmp = tempfile::tempdir().unwrap();
450 let origin = tmp.path().join("origin");
451 Repository::init(&origin).unwrap();
452
453 let target = tmp.path().join("clone");
454 let url = origin.to_str().unwrap();
455 let op = RepoClone::new(url, &target);
456 let result = op.run(&ctx()).await.unwrap();
457 assert_eq!(result.path, target);
458 assert!(target.join(".git").exists());
459 }
460
461 #[tokio::test]
462 async fn clone_bare_creates_repo_without_workdir() {
463 let tmp = tempfile::tempdir().unwrap();
464 let origin = tmp.path().join("origin");
465 init_repo(&origin);
466
467 let target = tmp.path().join("clone.git");
468 let result = RepoClone::new(origin.to_str().unwrap(), &target)
469 .bare(true)
470 .run(&ctx())
471 .await
472 .unwrap();
473
474 assert!(result.bare);
475 let repo = Repository::open(&target).unwrap();
476 assert!(repo.is_bare());
477 assert!(target.join("HEAD").exists());
478 assert!(!target.join("file.txt").exists());
479 }
480
481 #[tokio::test]
482 async fn clone_into_non_empty_directory_fails() {
483 let tmp = tempfile::tempdir().unwrap();
484 let origin = tmp.path().join("origin");
485 init_repo(&origin);
486 let target = tmp.path().join("busy");
487 fs::create_dir_all(&target).unwrap();
488 fs::write(target.join("keep.txt"), "x").unwrap();
489
490 let result = RepoClone::new(origin.to_str().unwrap(), &target)
491 .run(&ctx())
492 .await;
493
494 assert!(result.is_err());
495 }
496
497 #[tokio::test]
498 async fn discover_finds_repo() {
499 let tmp = tempfile::tempdir().unwrap();
500 Repository::init(tmp.path()).unwrap();
501 let subdir = tmp.path().join("a").join("b");
502 std::fs::create_dir_all(&subdir).unwrap();
503
504 let op = RepoDiscover::new(&subdir);
505 let result = op.run(&ctx()).await.unwrap();
506 assert!(!result.bare);
507 }
508
509 #[tokio::test]
510 async fn state_on_clean_repo() {
511 let tmp = tempfile::tempdir().unwrap();
512 Repository::init(tmp.path()).unwrap();
513
514 let op = RepoState::new(tmp.path());
515 let result = op.run(&ctx()).await.unwrap();
516 assert_eq!(result.state, "clean");
517 }
518}