1use async_trait::async_trait;
2use git2::{Repository, RepositoryOpenFlags};
3use std::path::{Path, PathBuf};
4
5#[async_trait]
6pub trait GitOperations: Send + Sync {
7 async fn is_git_repository(&self) -> Result<bool, String>;
8
9 async fn create_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), String>;
10
11 async fn get_status(&self, repo_path: &Path) -> Result<String, String>;
12
13 async fn add_all(&self, repo_path: &Path) -> Result<(), String>;
14
15 async fn commit(&self, repo_path: &Path, message: &str) -> Result<(), String>;
16
17 async fn add_remote(
18 &self,
19 repo_path: &Path,
20 remote_name: &str,
21 url: &str,
22 ) -> Result<(), String>;
23
24 async fn fetch_branch(
25 &self,
26 repo_path: &Path,
27 remote_name: &str,
28 branch_name: &str,
29 ) -> Result<(), String>;
30
31 async fn remove_remote(&self, repo_path: &Path, remote_name: &str) -> Result<(), String>;
32
33 async fn has_commits_not_in_base(
35 &self,
36 repo_path: &Path,
37 branch_name: &str,
38 base_branch: &str,
39 ) -> Result<bool, String>;
40
41 async fn delete_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), String>;
43
44 async fn get_current_commit(&self, repo_path: &Path) -> Result<String, String>;
46
47 async fn create_branch_from_commit(
49 &self,
50 repo_path: &Path,
51 branch_name: &str,
52 commit_sha: &str,
53 ) -> Result<(), String>;
54
55 async fn get_tracked_files(&self, repo_path: &Path) -> Result<Vec<PathBuf>, String>;
57
58 async fn get_untracked_files(&self, repo_path: &Path) -> Result<Vec<PathBuf>, String>;
60}
61
62pub struct DefaultGitOperations;
63
64impl DefaultGitOperations {}
65
66#[async_trait]
67impl GitOperations for DefaultGitOperations {
68 async fn is_git_repository(&self) -> Result<bool, String> {
69 let current_dir =
70 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
71
72 match Repository::open_ext(¤t_dir, RepositoryOpenFlags::empty(), &[] as &[&Path]) {
73 Ok(_) => Ok(true),
74 Err(_) => Ok(false),
75 }
76 }
77
78 async fn create_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), String> {
79 tokio::task::spawn_blocking({
80 let repo_path = repo_path.to_owned();
81 let branch_name = branch_name.to_owned();
82 move || -> Result<(), String> {
83 let repo = Repository::open(&repo_path)
84 .map_err(|e| format!("Failed to open repository: {e}"))?;
85
86 let head = repo
87 .head()
88 .map_err(|e| format!("Failed to get HEAD: {e}"))?;
89
90 let commit = head
91 .peel_to_commit()
92 .map_err(|e| format!("Failed to get commit from HEAD: {e}"))?;
93
94 repo.branch(&branch_name, &commit, false)
95 .map_err(|e| format!("Failed to create branch: {e}"))?;
96
97 repo.set_head(&format!("refs/heads/{branch_name}"))
98 .map_err(|e| format!("Failed to checkout branch: {e}"))?;
99
100 repo.checkout_head(None)
101 .map_err(|e| format!("Failed to update working directory: {e}"))?;
102
103 Ok(())
104 }
105 })
106 .await
107 .map_err(|e| format!("Task join error: {e}"))?
108 }
109
110 async fn get_status(&self, repo_path: &Path) -> Result<String, String> {
111 tokio::task::spawn_blocking({
112 let repo_path = repo_path.to_owned();
113 move || -> Result<String, String> {
114 let repo = Repository::open(&repo_path)
115 .map_err(|e| format!("Failed to open repository: {e}"))?;
116
117 let statuses = repo
118 .statuses(None)
119 .map_err(|e| format!("Failed to get repository status: {e}"))?;
120
121 let mut result = String::new();
122
123 for entry in statuses.iter() {
124 let status = entry.status();
125 if let Some(path) = entry.path() {
126 let status_char = if status.is_wt_new() {
127 "??"
128 } else if status.contains(git2::Status::INDEX_NEW) {
129 "A"
130 } else if status.contains(git2::Status::INDEX_MODIFIED)
131 || status.contains(git2::Status::WT_MODIFIED)
132 {
133 "M"
134 } else if status.contains(git2::Status::INDEX_DELETED)
135 || status.contains(git2::Status::WT_DELETED)
136 {
137 "D"
138 } else if status.contains(git2::Status::INDEX_RENAMED)
139 || status.contains(git2::Status::WT_RENAMED)
140 {
141 "R"
142 } else if status.contains(git2::Status::CONFLICTED) {
143 "C"
144 } else {
145 continue;
146 };
147
148 result.push_str(&format!("{status_char} {path}\n"));
149 }
150 }
151
152 Ok(result)
153 }
154 })
155 .await
156 .map_err(|e| format!("Task join error: {e}"))?
157 }
158
159 async fn add_all(&self, repo_path: &Path) -> Result<(), String> {
160 tokio::task::spawn_blocking({
161 let repo_path = repo_path.to_owned();
162 move || -> Result<(), String> {
163 let repo = Repository::open(&repo_path)
164 .map_err(|e| format!("Failed to open repository: {e}"))?;
165
166 let mut index = repo
167 .index()
168 .map_err(|e| format!("Failed to get repository index: {e}"))?;
169
170 index
171 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
172 .map_err(|e| format!("Failed to add files to index: {e}"))?;
173
174 index
175 .write()
176 .map_err(|e| format!("Failed to write index: {e}"))?;
177
178 Ok(())
179 }
180 })
181 .await
182 .map_err(|e| format!("Task join error: {e}"))?
183 }
184
185 async fn commit(&self, repo_path: &Path, message: &str) -> Result<(), String> {
186 tokio::task::spawn_blocking({
187 let repo_path = repo_path.to_owned();
188 let message = message.to_owned();
189 move || -> Result<(), String> {
190 let repo = Repository::open(&repo_path)
191 .map_err(|e| format!("Failed to open repository: {e}"))?;
192
193 let mut index = repo
194 .index()
195 .map_err(|e| format!("Failed to get repository index: {e}"))?;
196
197 let tree_id = index
198 .write_tree()
199 .map_err(|e| format!("Failed to write tree: {e}"))?;
200
201 let tree = repo
202 .find_tree(tree_id)
203 .map_err(|e| format!("Failed to find tree: {e}"))?;
204
205 let signature = repo
206 .signature()
207 .map_err(|e| format!("Failed to get signature: {e}"))?;
208
209 let parent_commit = match repo.head() {
210 Ok(head) => Some(
211 head.peel_to_commit()
212 .map_err(|e| format!("Failed to get parent commit: {e}"))?,
213 ),
214 Err(_) => None,
215 };
216
217 let parents = if let Some(ref parent) = parent_commit {
218 vec![parent]
219 } else {
220 vec![]
221 };
222
223 repo.commit(
224 Some("HEAD"),
225 &signature,
226 &signature,
227 &message,
228 &tree,
229 &parents,
230 )
231 .map_err(|e| format!("Failed to create commit: {e}"))?;
232
233 Ok(())
234 }
235 })
236 .await
237 .map_err(|e| format!("Task join error: {e}"))?
238 }
239
240 async fn add_remote(
241 &self,
242 repo_path: &Path,
243 remote_name: &str,
244 url: &str,
245 ) -> Result<(), String> {
246 tokio::task::spawn_blocking({
247 let repo_path = repo_path.to_owned();
248 let remote_name = remote_name.to_owned();
249 let url = url.to_owned();
250 move || -> Result<(), String> {
251 let repo = Repository::open(&repo_path)
252 .map_err(|e| format!("Failed to open repository: {e}"))?;
253
254 let result = repo.remote(&remote_name, &url);
255 match result {
256 Ok(_) => Ok(()),
257 Err(e) => {
258 if e.code() == git2::ErrorCode::Exists {
259 Ok(())
260 } else {
261 Err(format!("Failed to add remote: {e}"))
262 }
263 }
264 }
265 }
266 })
267 .await
268 .map_err(|e| format!("Task join error: {e}"))?
269 }
270
271 async fn fetch_branch(
272 &self,
273 repo_path: &Path,
274 remote_name: &str,
275 branch_name: &str,
276 ) -> Result<(), String> {
277 tokio::task::spawn_blocking({
278 let repo_path = repo_path.to_owned();
279 let remote_name = remote_name.to_owned();
280 let branch_name = branch_name.to_owned();
281 move || -> Result<(), String> {
282 let repo = Repository::open(&repo_path)
283 .map_err(|e| format!("Failed to open repository: {e}"))?;
284
285 let mut remote = repo
286 .find_remote(&remote_name)
287 .map_err(|e| format!("Failed to find remote: {e}"))?;
288
289 let refspec = format!("refs/heads/{branch_name}:refs/heads/{branch_name}");
290
291 remote
292 .fetch(&[&refspec], None, None)
293 .map_err(|e| format!("Failed to fetch changes: {e}"))?;
294
295 Ok(())
296 }
297 })
298 .await
299 .map_err(|e| format!("Task join error: {e}"))?
300 }
301
302 async fn remove_remote(&self, repo_path: &Path, remote_name: &str) -> Result<(), String> {
303 tokio::task::spawn_blocking({
304 let repo_path = repo_path.to_owned();
305 let remote_name = remote_name.to_owned();
306 move || -> Result<(), String> {
307 let repo = Repository::open(&repo_path)
308 .map_err(|e| format!("Failed to open repository: {e}"))?;
309
310 repo.remote_delete(&remote_name)
311 .map_err(|e| format!("Failed to remove temporary remote: {e}"))?;
312
313 Ok(())
314 }
315 })
316 .await
317 .map_err(|e| format!("Task join error: {e}"))?
318 }
319
320 async fn has_commits_not_in_base(
321 &self,
322 repo_path: &Path,
323 branch_name: &str,
324 base_branch: &str,
325 ) -> Result<bool, String> {
326 tokio::task::spawn_blocking({
327 let repo_path = repo_path.to_owned();
328 let branch_name = branch_name.to_owned();
329 let base_branch = base_branch.to_owned();
330 move || -> Result<bool, String> {
331 let repo = Repository::open(&repo_path)
332 .map_err(|e| format!("Failed to open repository: {e}"))?;
333
334 let branch_ref = format!("refs/heads/{branch_name}");
336 let branch = repo
337 .find_reference(&branch_ref)
338 .map_err(|e| format!("Failed to find branch {branch_name}: {e}"))?;
339
340 let branch_oid = branch
341 .target()
342 .ok_or_else(|| format!("Branch {branch_name} has no target"))?;
343
344 let base_ref = format!("refs/heads/{base_branch}");
346 let base = repo
347 .find_reference(&base_ref)
348 .map_err(|e| format!("Failed to find base branch {base_branch}: {e}"))?;
349
350 let base_oid = base
351 .target()
352 .ok_or_else(|| format!("Base branch {base_branch} has no target"))?;
353
354 if branch_oid == base_oid {
356 return Ok(false);
357 }
358
359 match repo.graph_descendant_of(base_oid, branch_oid) {
362 Ok(true) => Ok(false), Ok(false) => Ok(true), Err(_) => {
365 Ok(true)
368 }
369 }
370 }
371 })
372 .await
373 .map_err(|e| format!("Task join error: {e}"))?
374 }
375
376 async fn delete_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), String> {
377 tokio::task::spawn_blocking({
378 let repo_path = repo_path.to_owned();
379 let branch_name = branch_name.to_owned();
380 move || -> Result<(), String> {
381 let repo = Repository::open(&repo_path)
382 .map_err(|e| format!("Failed to open repository: {e}"))?;
383
384 let mut branch = repo
385 .find_branch(&branch_name, git2::BranchType::Local)
386 .map_err(|e| format!("Failed to find branch {branch_name}: {e}"))?;
387
388 branch
389 .delete()
390 .map_err(|e| format!("Failed to delete branch {branch_name}: {e}"))?;
391
392 Ok(())
393 }
394 })
395 .await
396 .map_err(|e| format!("Task join error: {e}"))?
397 }
398
399 async fn get_current_commit(&self, repo_path: &Path) -> Result<String, String> {
400 tokio::task::spawn_blocking({
401 let repo_path = repo_path.to_owned();
402 move || -> Result<String, String> {
403 let repo = Repository::open(&repo_path)
404 .map_err(|e| format!("Failed to open repository: {e}"))?;
405
406 let head = repo
407 .head()
408 .map_err(|e| format!("Failed to get HEAD: {e}"))?;
409
410 let commit = head
411 .peel_to_commit()
412 .map_err(|e| format!("Failed to get commit from HEAD: {e}"))?;
413
414 Ok(commit.id().to_string())
415 }
416 })
417 .await
418 .map_err(|e| format!("Task join error: {e}"))?
419 }
420
421 async fn create_branch_from_commit(
422 &self,
423 repo_path: &Path,
424 branch_name: &str,
425 commit_sha: &str,
426 ) -> Result<(), String> {
427 tokio::task::spawn_blocking({
428 let repo_path = repo_path.to_owned();
429 let branch_name = branch_name.to_owned();
430 let commit_sha = commit_sha.to_owned();
431 move || -> Result<(), String> {
432 let repo = Repository::open(&repo_path)
433 .map_err(|e| format!("Failed to open repository: {e}"))?;
434
435 let oid = git2::Oid::from_str(&commit_sha)
436 .map_err(|e| format!("Invalid commit SHA: {e}"))?;
437
438 let commit = repo
439 .find_commit(oid)
440 .map_err(|e| format!("Failed to find commit {commit_sha}: {e}"))?;
441
442 repo.branch(&branch_name, &commit, false)
443 .map_err(|e| format!("Failed to create branch: {e}"))?;
444
445 repo.set_head(&format!("refs/heads/{branch_name}"))
446 .map_err(|e| format!("Failed to checkout branch: {e}"))?;
447
448 let mut checkout_opts = git2::build::CheckoutBuilder::new();
450 checkout_opts.force();
451 repo.checkout_head(Some(&mut checkout_opts))
452 .map_err(|e| format!("Failed to update working directory: {e}"))?;
453
454 Ok(())
455 }
456 })
457 .await
458 .map_err(|e| format!("Task join error: {e}"))?
459 }
460
461 async fn get_tracked_files(&self, repo_path: &Path) -> Result<Vec<PathBuf>, String> {
462 tokio::task::spawn_blocking({
463 let repo_path = repo_path.to_owned();
464 move || -> Result<Vec<PathBuf>, String> {
465 let repo = Repository::open(&repo_path)
466 .map_err(|e| format!("Failed to open repository: {e}"))?;
467
468 let head = repo
469 .head()
470 .map_err(|e| format!("Failed to get HEAD: {e}"))?;
471
472 let tree = head
473 .peel_to_tree()
474 .map_err(|e| format!("Failed to get tree from HEAD: {e}"))?;
475
476 let mut tracked_files = Vec::new();
477
478 tree.walk(git2::TreeWalkMode::PreOrder, |path, entry| {
479 if entry.kind() == Some(git2::ObjectType::Blob) {
480 let file_path = if path.is_empty() {
481 PathBuf::from(entry.name().unwrap_or(""))
482 } else {
483 PathBuf::from(path).join(entry.name().unwrap_or(""))
484 };
485 tracked_files.push(file_path);
486 }
487 git2::TreeWalkResult::Ok
488 })
489 .map_err(|e| format!("Failed to walk tree: {e}"))?;
490
491 Ok(tracked_files)
492 }
493 })
494 .await
495 .map_err(|e| format!("Task join error: {e}"))?
496 }
497
498 async fn get_untracked_files(&self, repo_path: &Path) -> Result<Vec<PathBuf>, String> {
499 tokio::task::spawn_blocking({
500 let repo_path = repo_path.to_owned();
501 move || -> Result<Vec<PathBuf>, String> {
502 let repo = Repository::open(&repo_path)
503 .map_err(|e| format!("Failed to open repository: {e}"))?;
504
505 let mut opts = git2::StatusOptions::new();
506 opts.include_untracked(true).include_ignored(false);
507
508 let statuses = repo
509 .statuses(Some(&mut opts))
510 .map_err(|e| format!("Failed to get repository status: {e}"))?;
511
512 let mut untracked_files = Vec::new();
513
514 for entry in statuses.iter() {
515 let status = entry.status();
516 if status.is_wt_new() {
518 if let Some(path) = entry.path() {
519 untracked_files.push(PathBuf::from(path));
520 }
521 }
522 }
523
524 Ok(untracked_files)
525 }
526 })
527 .await
528 .map_err(|e| format!("Task join error: {e}"))?
529 }
530}
531
532#[cfg(test)]
533pub(crate) mod tests {
534 use super::*;
535 use std::sync::{Arc, Mutex};
536
537 #[derive(Clone)]
538 pub struct MockGitOperations {
539 is_repo_result: Arc<Mutex<Result<bool, String>>>,
540 create_branch_calls: Arc<Mutex<Vec<(String, String)>>>,
541 create_branch_result: Arc<Mutex<Result<(), String>>>,
542 get_status_calls: Arc<Mutex<Vec<String>>>,
543 get_status_result: Arc<Mutex<Result<String, String>>>,
544 add_all_calls: Arc<Mutex<Vec<String>>>,
545 add_all_result: Arc<Mutex<Result<(), String>>>,
546 commit_calls: Arc<Mutex<Vec<(String, String)>>>,
547 commit_result: Arc<Mutex<Result<(), String>>>,
548 add_remote_calls: Arc<Mutex<Vec<(String, String, String)>>>,
549 add_remote_result: Arc<Mutex<Result<(), String>>>,
550 fetch_branch_calls: Arc<Mutex<Vec<(String, String, String)>>>,
551 fetch_branch_result: Arc<Mutex<Result<(), String>>>,
552 remove_remote_calls: Arc<Mutex<Vec<(String, String)>>>,
553 remove_remote_result: Arc<Mutex<Result<(), String>>>,
554 has_commits_not_in_base_calls: Arc<Mutex<Vec<(String, String, String)>>>,
555 has_commits_not_in_base_result: Arc<Mutex<Result<bool, String>>>,
556 delete_branch_calls: Arc<Mutex<Vec<(String, String)>>>,
557 delete_branch_result: Arc<Mutex<Result<(), String>>>,
558 get_current_commit_calls: Arc<Mutex<Vec<String>>>,
559 get_current_commit_result: Arc<Mutex<Result<String, String>>>,
560 create_branch_from_commit_calls: Arc<Mutex<Vec<(String, String, String)>>>,
561 create_branch_from_commit_result: Arc<Mutex<Result<(), String>>>,
562 get_tracked_files_calls: Arc<Mutex<Vec<String>>>,
563 get_tracked_files_result: Arc<Mutex<Result<Vec<PathBuf>, String>>>,
564 get_untracked_files_calls: Arc<Mutex<Vec<String>>>,
565 get_untracked_files_result: Arc<Mutex<Result<Vec<PathBuf>, String>>>,
566 }
567
568 impl MockGitOperations {
569 pub fn new() -> Self {
570 Self {
571 is_repo_result: Arc::new(Mutex::new(Ok(true))),
572 create_branch_calls: Arc::new(Mutex::new(Vec::new())),
573 create_branch_result: Arc::new(Mutex::new(Ok(()))),
574 get_status_calls: Arc::new(Mutex::new(Vec::new())),
575 get_status_result: Arc::new(Mutex::new(Ok("".to_string()))),
576 add_all_calls: Arc::new(Mutex::new(Vec::new())),
577 add_all_result: Arc::new(Mutex::new(Ok(()))),
578 commit_calls: Arc::new(Mutex::new(Vec::new())),
579 commit_result: Arc::new(Mutex::new(Ok(()))),
580 add_remote_calls: Arc::new(Mutex::new(Vec::new())),
581 add_remote_result: Arc::new(Mutex::new(Ok(()))),
582 fetch_branch_calls: Arc::new(Mutex::new(Vec::new())),
583 fetch_branch_result: Arc::new(Mutex::new(Ok(()))),
584 remove_remote_calls: Arc::new(Mutex::new(Vec::new())),
585 remove_remote_result: Arc::new(Mutex::new(Ok(()))),
586 has_commits_not_in_base_calls: Arc::new(Mutex::new(Vec::new())),
587 has_commits_not_in_base_result: Arc::new(Mutex::new(Ok(true))),
588 delete_branch_calls: Arc::new(Mutex::new(Vec::new())),
589 delete_branch_result: Arc::new(Mutex::new(Ok(()))),
590 get_current_commit_calls: Arc::new(Mutex::new(Vec::new())),
591 get_current_commit_result: Arc::new(Mutex::new(Ok(
592 "abc123def456789012345678901234567890abcd".to_string(),
593 ))),
594 create_branch_from_commit_calls: Arc::new(Mutex::new(Vec::new())),
595 create_branch_from_commit_result: Arc::new(Mutex::new(Ok(()))),
596 get_tracked_files_calls: Arc::new(Mutex::new(Vec::new())),
597 get_tracked_files_result: Arc::new(Mutex::new(Ok(vec![]))),
598 get_untracked_files_calls: Arc::new(Mutex::new(Vec::new())),
599 get_untracked_files_result: Arc::new(Mutex::new(Ok(vec![]))),
600 }
601 }
602
603 pub fn set_is_repo_result(&self, result: Result<bool, String>) {
604 *self.is_repo_result.lock().unwrap() = result;
605 }
606
607 pub fn set_get_status_result(&self, result: Result<String, String>) {
608 *self.get_status_result.lock().unwrap() = result;
609 }
610
611 pub fn get_create_branch_calls(&self) -> Vec<(String, String)> {
612 self.create_branch_calls.lock().unwrap().clone()
613 }
614
615 pub fn get_get_status_calls(&self) -> Vec<String> {
616 self.get_status_calls.lock().unwrap().clone()
617 }
618
619 pub fn get_add_all_calls(&self) -> Vec<String> {
620 self.add_all_calls.lock().unwrap().clone()
621 }
622
623 pub fn get_commit_calls(&self) -> Vec<(String, String)> {
624 self.commit_calls.lock().unwrap().clone()
625 }
626
627 pub fn get_add_remote_calls(&self) -> Vec<(String, String, String)> {
628 self.add_remote_calls.lock().unwrap().clone()
629 }
630
631 pub fn get_fetch_branch_calls(&self) -> Vec<(String, String, String)> {
632 self.fetch_branch_calls.lock().unwrap().clone()
633 }
634
635 pub fn get_remove_remote_calls(&self) -> Vec<(String, String)> {
636 self.remove_remote_calls.lock().unwrap().clone()
637 }
638
639 pub fn set_has_commits_not_in_base_result(&self, result: Result<bool, String>) {
640 *self.has_commits_not_in_base_result.lock().unwrap() = result;
641 }
642
643 pub fn get_delete_branch_calls(&self) -> Vec<(String, String)> {
644 self.delete_branch_calls.lock().unwrap().clone()
645 }
646
647 pub fn set_get_current_commit_result(&self, result: Result<String, String>) {
648 *self.get_current_commit_result.lock().unwrap() = result;
649 }
650
651 pub fn get_get_current_commit_calls(&self) -> Vec<String> {
652 self.get_current_commit_calls.lock().unwrap().clone()
653 }
654
655 pub fn get_create_branch_from_commit_calls(&self) -> Vec<(String, String, String)> {
656 self.create_branch_from_commit_calls.lock().unwrap().clone()
657 }
658
659 pub fn set_get_tracked_files_result(&self, result: Result<Vec<PathBuf>, String>) {
660 *self.get_tracked_files_result.lock().unwrap() = result;
661 }
662
663 pub fn set_get_untracked_files_result(&self, result: Result<Vec<PathBuf>, String>) {
664 *self.get_untracked_files_result.lock().unwrap() = result;
665 }
666 }
667
668 #[async_trait]
669 impl GitOperations for MockGitOperations {
670 async fn is_git_repository(&self) -> Result<bool, String> {
671 self.is_repo_result.lock().unwrap().clone()
672 }
673
674 async fn create_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), String> {
675 self.create_branch_calls.lock().unwrap().push((
676 repo_path.to_string_lossy().to_string(),
677 branch_name.to_string(),
678 ));
679 self.create_branch_result.lock().unwrap().clone()
680 }
681
682 async fn get_status(&self, repo_path: &Path) -> Result<String, String> {
683 self.get_status_calls
684 .lock()
685 .unwrap()
686 .push(repo_path.to_string_lossy().to_string());
687 self.get_status_result.lock().unwrap().clone()
688 }
689
690 async fn add_all(&self, repo_path: &Path) -> Result<(), String> {
691 self.add_all_calls
692 .lock()
693 .unwrap()
694 .push(repo_path.to_string_lossy().to_string());
695 self.add_all_result.lock().unwrap().clone()
696 }
697
698 async fn commit(&self, repo_path: &Path, message: &str) -> Result<(), String> {
699 self.commit_calls
700 .lock()
701 .unwrap()
702 .push((repo_path.to_string_lossy().to_string(), message.to_string()));
703 self.commit_result.lock().unwrap().clone()
704 }
705
706 async fn add_remote(
707 &self,
708 repo_path: &Path,
709 remote_name: &str,
710 url: &str,
711 ) -> Result<(), String> {
712 self.add_remote_calls.lock().unwrap().push((
713 repo_path.to_string_lossy().to_string(),
714 remote_name.to_string(),
715 url.to_string(),
716 ));
717 self.add_remote_result.lock().unwrap().clone()
718 }
719
720 async fn fetch_branch(
721 &self,
722 repo_path: &Path,
723 remote_name: &str,
724 branch_name: &str,
725 ) -> Result<(), String> {
726 self.fetch_branch_calls.lock().unwrap().push((
727 repo_path.to_string_lossy().to_string(),
728 remote_name.to_string(),
729 branch_name.to_string(),
730 ));
731 self.fetch_branch_result.lock().unwrap().clone()
732 }
733
734 async fn remove_remote(&self, repo_path: &Path, remote_name: &str) -> Result<(), String> {
735 self.remove_remote_calls.lock().unwrap().push((
736 repo_path.to_string_lossy().to_string(),
737 remote_name.to_string(),
738 ));
739 self.remove_remote_result.lock().unwrap().clone()
740 }
741
742 async fn has_commits_not_in_base(
743 &self,
744 repo_path: &Path,
745 branch_name: &str,
746 base_branch: &str,
747 ) -> Result<bool, String> {
748 self.has_commits_not_in_base_calls.lock().unwrap().push((
749 repo_path.to_string_lossy().to_string(),
750 branch_name.to_string(),
751 base_branch.to_string(),
752 ));
753 self.has_commits_not_in_base_result.lock().unwrap().clone()
754 }
755
756 async fn delete_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), String> {
757 self.delete_branch_calls.lock().unwrap().push((
758 repo_path.to_string_lossy().to_string(),
759 branch_name.to_string(),
760 ));
761 self.delete_branch_result.lock().unwrap().clone()
762 }
763
764 async fn get_current_commit(&self, repo_path: &Path) -> Result<String, String> {
765 self.get_current_commit_calls
766 .lock()
767 .unwrap()
768 .push(repo_path.to_string_lossy().to_string());
769 self.get_current_commit_result.lock().unwrap().clone()
770 }
771
772 async fn create_branch_from_commit(
773 &self,
774 repo_path: &Path,
775 branch_name: &str,
776 commit_sha: &str,
777 ) -> Result<(), String> {
778 self.create_branch_from_commit_calls.lock().unwrap().push((
779 repo_path.to_string_lossy().to_string(),
780 branch_name.to_string(),
781 commit_sha.to_string(),
782 ));
783 self.create_branch_from_commit_result
784 .lock()
785 .unwrap()
786 .clone()
787 }
788
789 async fn get_tracked_files(&self, repo_path: &Path) -> Result<Vec<PathBuf>, String> {
790 self.get_tracked_files_calls
791 .lock()
792 .unwrap()
793 .push(repo_path.to_string_lossy().to_string());
794 self.get_tracked_files_result.lock().unwrap().clone()
795 }
796
797 async fn get_untracked_files(&self, repo_path: &Path) -> Result<Vec<PathBuf>, String> {
798 self.get_untracked_files_calls
799 .lock()
800 .unwrap()
801 .push(repo_path.to_string_lossy().to_string());
802 self.get_untracked_files_result.lock().unwrap().clone()
803 }
804 }
805
806 #[cfg(test)]
807 mod integration_tests {
808 use super::*;
809 use std::path::Path;
810 use tempfile::TempDir;
811
812 #[tokio::test]
813 async fn test_default_git_operations_with_real_repo() {
814 let git_ops = DefaultGitOperations;
815 let temp_dir = TempDir::new().unwrap();
816 let repo_path = temp_dir.path();
817
818 let repo = git2::Repository::init(repo_path).unwrap();
820
821 let status = git_ops.get_status(repo_path).await.unwrap();
823 assert_eq!(status, "");
824
825 std::fs::write(repo_path.join("test.txt"), "Hello, world!").unwrap();
827
828 let status = git_ops.get_status(repo_path).await.unwrap();
830 assert!(status.contains("?? test.txt"));
831
832 git_ops.add_all(repo_path).await.unwrap();
834
835 let status = git_ops.get_status(repo_path).await.unwrap();
837 assert!(status.contains("A test.txt"));
838
839 let mut config = repo.config().unwrap();
841 config.set_str("user.name", "Test User").unwrap();
842 config.set_str("user.email", "test@example.com").unwrap();
843
844 git_ops.commit(repo_path, "Initial commit").await.unwrap();
846
847 let status = git_ops.get_status(repo_path).await.unwrap();
849 assert_eq!(status, "");
850
851 git_ops
853 .create_branch(repo_path, "test-branch")
854 .await
855 .unwrap();
856
857 let head = repo.head().unwrap();
859 let branch_name = head.shorthand().unwrap();
860 assert_eq!(branch_name, "test-branch");
861 }
862
863 #[tokio::test]
864 async fn test_default_git_operations_remotes() {
865 let git_ops = DefaultGitOperations;
866 let temp_dir = TempDir::new().unwrap();
867 let repo_path = temp_dir.path();
868
869 git2::Repository::init(repo_path).unwrap();
871
872 git_ops
874 .add_remote(repo_path, "origin", "https://github.com/test/repo.git")
875 .await
876 .unwrap();
877
878 git_ops
880 .add_remote(repo_path, "origin", "https://github.com/test/repo.git")
881 .await
882 .unwrap();
883
884 git_ops.remove_remote(repo_path, "origin").await.unwrap();
886 }
887
888 #[tokio::test]
889 async fn test_mock_git_operations() {
890 use super::tests::MockGitOperations;
891 let mock = MockGitOperations::new();
892
893 mock.set_is_repo_result(Ok(true));
895 assert_eq!(mock.is_git_repository().await.unwrap(), true);
896
897 mock.set_is_repo_result(Ok(false));
898 assert_eq!(mock.is_git_repository().await.unwrap(), false);
899
900 mock.set_get_status_result(Ok("M file.txt\n".to_string()));
902 let status = mock.get_status(Path::new("/test")).await.unwrap();
903 assert_eq!(status, "M file.txt\n");
904
905 let result = mock
907 .create_branch(Path::new("/test"), "feature-branch")
908 .await;
909 assert!(result.is_ok());
910
911 let calls = mock.get_create_branch_calls();
913 assert_eq!(calls.len(), 1);
914 assert_eq!(
915 calls[0],
916 ("/test".to_string(), "feature-branch".to_string())
917 );
918 }
919
920 #[tokio::test]
921 async fn test_mock_git_operations_commit_flow() {
922 use super::tests::MockGitOperations;
923 let mock = MockGitOperations::new();
924
925 let repo_path = Path::new("/test/repo");
926
927 mock.set_get_status_result(Ok("M file.txt\n".to_string()));
929 let status = mock.get_status(repo_path).await.unwrap();
930 assert!(!status.is_empty());
931
932 let result = mock.add_all(repo_path).await;
934 assert!(result.is_ok());
935
936 let result = mock.commit(repo_path, "Test commit message").await;
938 assert!(result.is_ok());
939
940 assert_eq!(mock.get_get_status_calls().len(), 1);
942 assert_eq!(mock.get_add_all_calls().len(), 1);
943 assert_eq!(mock.get_commit_calls().len(), 1);
944
945 let commit_calls = mock.get_commit_calls();
946 assert_eq!(commit_calls[0].1, "Test commit message");
947 }
948
949 #[tokio::test]
950 async fn test_mock_git_operations_remote_operations() {
951 use super::tests::MockGitOperations;
952 let mock = MockGitOperations::new();
953
954 let repo_path = Path::new("/test/repo");
955 let remote_name = "origin";
956 let remote_url = "https://github.com/test/repo.git";
957 let branch_name = "main";
958
959 let result = mock.add_remote(repo_path, remote_name, remote_url).await;
961 assert!(result.is_ok());
962
963 let result = mock.fetch_branch(repo_path, remote_name, branch_name).await;
965 assert!(result.is_ok());
966
967 let result = mock.remove_remote(repo_path, remote_name).await;
969 assert!(result.is_ok());
970
971 let add_remote_calls = mock.get_add_remote_calls();
973 assert_eq!(add_remote_calls.len(), 1);
974 assert_eq!(add_remote_calls[0].1, remote_name);
975 assert_eq!(add_remote_calls[0].2, remote_url);
976
977 let fetch_calls = mock.get_fetch_branch_calls();
978 assert_eq!(fetch_calls.len(), 1);
979 assert_eq!(fetch_calls[0].1, remote_name);
980 assert_eq!(fetch_calls[0].2, branch_name);
981
982 let remove_calls = mock.get_remove_remote_calls();
983 assert_eq!(remove_calls.len(), 1);
984 assert_eq!(remove_calls[0].1, remote_name);
985 }
986
987 #[tokio::test]
988 async fn test_get_current_commit() {
989 let git_ops = DefaultGitOperations;
990 let temp_dir = TempDir::new().unwrap();
991 let repo_path = temp_dir.path();
992
993 let repo = git2::Repository::init(repo_path).unwrap();
995
996 let mut config = repo.config().unwrap();
998 config.set_str("user.name", "Test User").unwrap();
999 config.set_str("user.email", "test@example.com").unwrap();
1000
1001 std::fs::write(repo_path.join("test.txt"), "Hello, world!").unwrap();
1003 git_ops.add_all(repo_path).await.unwrap();
1004 git_ops.commit(repo_path, "Initial commit").await.unwrap();
1005
1006 let commit_sha = git_ops.get_current_commit(repo_path).await.unwrap();
1008 assert!(!commit_sha.is_empty());
1009 assert_eq!(commit_sha.len(), 40); let head = repo.head().unwrap();
1013 let head_commit = head.peel_to_commit().unwrap();
1014 assert_eq!(commit_sha, head_commit.id().to_string());
1015 }
1016
1017 #[tokio::test]
1018 async fn test_create_branch_from_commit() {
1019 let git_ops = DefaultGitOperations;
1020 let temp_dir = TempDir::new().unwrap();
1021 let repo_path = temp_dir.path();
1022
1023 let repo = git2::Repository::init(repo_path).unwrap();
1025
1026 let mut config = repo.config().unwrap();
1028 config.set_str("user.name", "Test User").unwrap();
1029 config.set_str("user.email", "test@example.com").unwrap();
1030
1031 std::fs::write(repo_path.join("file1.txt"), "First file").unwrap();
1033 git_ops.add_all(repo_path).await.unwrap();
1034 git_ops.commit(repo_path, "First commit").await.unwrap();
1035
1036 let first_commit_sha = git_ops.get_current_commit(repo_path).await.unwrap();
1038
1039 std::fs::write(repo_path.join("file2.txt"), "Second file").unwrap();
1041 git_ops.add_all(repo_path).await.unwrap();
1042 git_ops.commit(repo_path, "Second commit").await.unwrap();
1043
1044 git_ops
1046 .create_branch_from_commit(repo_path, "feature-from-first", &first_commit_sha)
1047 .await
1048 .unwrap();
1049
1050 let head = repo.head().unwrap();
1052 let branch_name = head.shorthand().unwrap();
1053 assert_eq!(branch_name, "feature-from-first");
1054
1055 let current_commit = head.peel_to_commit().unwrap();
1057 assert_eq!(current_commit.id().to_string(), first_commit_sha);
1058
1059 assert!(!repo_path.join("file2.txt").exists());
1061 assert!(repo_path.join("file1.txt").exists());
1062 }
1063
1064 #[tokio::test]
1065 async fn test_mock_get_current_commit() {
1066 use super::tests::MockGitOperations;
1067 let mock = MockGitOperations::new();
1068
1069 let commit_sha = mock.get_current_commit(Path::new("/test")).await.unwrap();
1071 assert_eq!(commit_sha, "abc123def456789012345678901234567890abcd");
1072
1073 let calls = mock.get_get_current_commit_calls();
1075 assert_eq!(calls.len(), 1);
1076 assert_eq!(calls[0], "/test");
1077
1078 mock.set_get_current_commit_result(Err("Failed to get commit".to_string()));
1080 let result = mock.get_current_commit(Path::new("/test2")).await;
1081 assert!(result.is_err());
1082 }
1083
1084 #[tokio::test]
1085 async fn test_get_untracked_files() {
1086 let git_ops = DefaultGitOperations;
1087 let temp_dir = TempDir::new().unwrap();
1088 let repo_path = temp_dir.path();
1089
1090 let repo = git2::Repository::init(repo_path).unwrap();
1092
1093 let mut config = repo.config().unwrap();
1095 config.set_str("user.name", "Test User").unwrap();
1096 config.set_str("user.email", "test@example.com").unwrap();
1097
1098 std::fs::write(repo_path.join("tracked.txt"), "tracked content").unwrap();
1100 git_ops.add_all(repo_path).await.unwrap();
1101 git_ops.commit(repo_path, "Initial commit").await.unwrap();
1102
1103 std::fs::write(repo_path.join("untracked1.txt"), "untracked content 1").unwrap();
1105 std::fs::write(repo_path.join("untracked2.txt"), "untracked content 2").unwrap();
1106 std::fs::create_dir(repo_path.join("untracked_dir")).unwrap();
1107 std::fs::write(
1108 repo_path.join("untracked_dir/nested.txt"),
1109 "nested untracked content",
1110 )
1111 .unwrap();
1112
1113 std::fs::write(repo_path.join(".gitignore"), "ignored.txt\n").unwrap();
1115
1116 std::fs::write(repo_path.join("ignored.txt"), "ignored content").unwrap();
1118
1119 let untracked_files = git_ops.get_untracked_files(repo_path).await.unwrap();
1121
1122 assert!(untracked_files.contains(&PathBuf::from("untracked1.txt")));
1124 assert!(untracked_files.contains(&PathBuf::from("untracked2.txt")));
1125 assert!(untracked_files.contains(&PathBuf::from("untracked_dir/")));
1127 assert!(untracked_files.contains(&PathBuf::from(".gitignore")));
1128 assert!(!untracked_files.contains(&PathBuf::from("ignored.txt")));
1129 assert!(!untracked_files.contains(&PathBuf::from("tracked.txt")));
1130 }
1131
1132 #[tokio::test]
1133 async fn test_mock_create_branch_from_commit() {
1134 use super::tests::MockGitOperations;
1135 let mock = MockGitOperations::new();
1136
1137 let result = mock
1139 .create_branch_from_commit(
1140 Path::new("/test"),
1141 "feature-branch",
1142 "abc123def456789012345678901234567890abcd",
1143 )
1144 .await;
1145 assert!(result.is_ok());
1146
1147 let calls = mock.get_create_branch_from_commit_calls();
1149 assert_eq!(calls.len(), 1);
1150 assert_eq!(calls[0].0, "/test");
1151 assert_eq!(calls[0].1, "feature-branch");
1152 assert_eq!(calls[0].2, "abc123def456789012345678901234567890abcd");
1153 }
1154 }
1155}