tsk/context/
git_operations.rs

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    /// Check if a branch has commits that are not in the base branch
34    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    /// Delete a branch
42    async fn delete_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), String>;
43
44    /// Get the current commit SHA
45    async fn get_current_commit(&self, repo_path: &Path) -> Result<String, String>;
46
47    /// Create a branch from a specific commit
48    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    /// Get list of tracked files in the repository
56    async fn get_tracked_files(&self, repo_path: &Path) -> Result<Vec<PathBuf>, String>;
57
58    /// Get list of untracked files that are not ignored
59    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(&current_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                // Get the branch reference
335                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                // Get the base branch reference
345                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 they point to the same commit, there are no unique commits
355                if branch_oid == base_oid {
356                    return Ok(false);
357                }
358
359                // Check if the branch commit is reachable from the base branch
360                // If it is, then there are no unique commits in the branch
361                match repo.graph_descendant_of(base_oid, branch_oid) {
362                    Ok(true) => Ok(false), // branch is behind base, no unique commits
363                    Ok(false) => Ok(true), // branch has commits not in base
364                    Err(_) => {
365                        // If we can't determine the relationship, assume there are commits
366                        // This is safer than assuming there aren't
367                        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                // Force update the working directory to match the commit
449                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                    // Check if file is untracked (not in index)
517                    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            // Initialize a real git repository
819            let repo = git2::Repository::init(repo_path).unwrap();
820
821            // Test get_status on empty repo
822            let status = git_ops.get_status(repo_path).await.unwrap();
823            assert_eq!(status, "");
824
825            // Create a test file
826            std::fs::write(repo_path.join("test.txt"), "Hello, world!").unwrap();
827
828            // Test get_status with untracked file
829            let status = git_ops.get_status(repo_path).await.unwrap();
830            assert!(status.contains("?? test.txt"));
831
832            // Test add_all
833            git_ops.add_all(repo_path).await.unwrap();
834
835            // Test get_status after add
836            let status = git_ops.get_status(repo_path).await.unwrap();
837            assert!(status.contains("A test.txt"));
838
839            // Configure git user for commit
840            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            // Test commit
845            git_ops.commit(repo_path, "Initial commit").await.unwrap();
846
847            // Test get_status after commit
848            let status = git_ops.get_status(repo_path).await.unwrap();
849            assert_eq!(status, "");
850
851            // Test create_branch
852            git_ops
853                .create_branch(repo_path, "test-branch")
854                .await
855                .unwrap();
856
857            // Verify we're on the new branch
858            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            // Initialize a git repository
870            git2::Repository::init(repo_path).unwrap();
871
872            // Test add_remote
873            git_ops
874                .add_remote(repo_path, "origin", "https://github.com/test/repo.git")
875                .await
876                .unwrap();
877
878            // Test adding the same remote again (should not error)
879            git_ops
880                .add_remote(repo_path, "origin", "https://github.com/test/repo.git")
881                .await
882                .unwrap();
883
884            // Test remove_remote
885            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            // Test is_git_repository
894            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            // Test get_status
901            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            // Test create_branch
906            let result = mock
907                .create_branch(Path::new("/test"), "feature-branch")
908                .await;
909            assert!(result.is_ok());
910
911            // Verify the call was recorded
912            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            // Simulate a commit flow
928            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            // Add all changes
933            let result = mock.add_all(repo_path).await;
934            assert!(result.is_ok());
935
936            // Commit
937            let result = mock.commit(repo_path, "Test commit message").await;
938            assert!(result.is_ok());
939
940            // Verify calls were recorded
941            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            // Add remote
960            let result = mock.add_remote(repo_path, remote_name, remote_url).await;
961            assert!(result.is_ok());
962
963            // Fetch branch
964            let result = mock.fetch_branch(repo_path, remote_name, branch_name).await;
965            assert!(result.is_ok());
966
967            // Remove remote
968            let result = mock.remove_remote(repo_path, remote_name).await;
969            assert!(result.is_ok());
970
971            // Verify calls
972            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            // Initialize a real git repository
994            let repo = git2::Repository::init(repo_path).unwrap();
995
996            // Configure git user for commit
997            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            // Create and commit a file
1002            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            // Get the current commit
1007            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); // SHA should be 40 characters
1010
1011            // Verify it's the same as what git2 reports
1012            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            // Initialize a real git repository
1024            let repo = git2::Repository::init(repo_path).unwrap();
1025
1026            // Configure git user for commit
1027            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            // Create first commit
1032            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            // Get the first commit SHA
1037            let first_commit_sha = git_ops.get_current_commit(repo_path).await.unwrap();
1038
1039            // Create second commit
1040            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            // Create a branch from the first commit
1045            git_ops
1046                .create_branch_from_commit(repo_path, "feature-from-first", &first_commit_sha)
1047                .await
1048                .unwrap();
1049
1050            // Verify we're on the new branch
1051            let head = repo.head().unwrap();
1052            let branch_name = head.shorthand().unwrap();
1053            assert_eq!(branch_name, "feature-from-first");
1054
1055            // Verify the branch is at the first commit
1056            let current_commit = head.peel_to_commit().unwrap();
1057            assert_eq!(current_commit.id().to_string(), first_commit_sha);
1058
1059            // Verify the second file doesn't exist in the working directory
1060            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            // Test get_current_commit
1070            let commit_sha = mock.get_current_commit(Path::new("/test")).await.unwrap();
1071            assert_eq!(commit_sha, "abc123def456789012345678901234567890abcd");
1072
1073            // Verify the call was recorded
1074            let calls = mock.get_get_current_commit_calls();
1075            assert_eq!(calls.len(), 1);
1076            assert_eq!(calls[0], "/test");
1077
1078            // Test with error
1079            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            // Initialize a real git repository
1091            let repo = git2::Repository::init(repo_path).unwrap();
1092
1093            // Configure git user for commit
1094            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            // Create and add some tracked files
1099            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            // Create untracked files
1104            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            // Create a .gitignore file
1114            std::fs::write(repo_path.join(".gitignore"), "ignored.txt\n").unwrap();
1115
1116            // Create an ignored file
1117            std::fs::write(repo_path.join("ignored.txt"), "ignored content").unwrap();
1118
1119            // Get untracked files
1120            let untracked_files = git_ops.get_untracked_files(repo_path).await.unwrap();
1121
1122            // Should include untracked files but not ignored ones
1123            assert!(untracked_files.contains(&PathBuf::from("untracked1.txt")));
1124            assert!(untracked_files.contains(&PathBuf::from("untracked2.txt")));
1125            // Git reports the directory, not individual files within it
1126            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            // Test create_branch_from_commit
1138            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            // Verify the call was recorded
1148            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}