tsk/
git.rs

1use crate::context::file_system::FileSystemOperations;
2use crate::context::git_operations::GitOperations;
3use crate::storage::XdgDirectories;
4use chrono::{DateTime, Local};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8pub struct RepoManager {
9    xdg_directories: Arc<XdgDirectories>,
10    file_system: Arc<dyn FileSystemOperations>,
11    git_operations: Arc<dyn GitOperations>,
12}
13
14impl RepoManager {
15    pub fn new(
16        xdg_directories: Arc<XdgDirectories>,
17        file_system: Arc<dyn FileSystemOperations>,
18        git_operations: Arc<dyn GitOperations>,
19    ) -> Self {
20        Self {
21            xdg_directories,
22            file_system,
23            git_operations,
24        }
25    }
26
27    /// Copy repository for a task using the task ID and repository root
28    /// Copies git-tracked files, untracked files (not ignored), the .git directory, and the .tsk directory
29    /// This captures the complete state of the repository as shown by `git status`
30    /// Returns the path to the copied repository and the branch name
31    pub async fn copy_repo(
32        &self,
33        task_id: &str,
34        repo_root: &Path,
35        source_commit: Option<&str>,
36    ) -> Result<(PathBuf, String), String> {
37        // Use the task ID directly for the directory name
38        let task_dir_name = task_id;
39        let branch_name = format!("tsk/{task_id}");
40
41        // Create the task directory structure in centralized location
42        let repo_hash = crate::storage::get_repo_hash(repo_root);
43        let task_dir = self.xdg_directories.task_dir(task_dir_name, &repo_hash);
44        let repo_path = task_dir.join("repo");
45
46        // Create directories if they don't exist
47        self.file_system
48            .create_dir(&task_dir)
49            .await
50            .map_err(|e| format!("Failed to create task directory: {e}"))?;
51
52        // Check if we're in a git repository
53        if !self.git_operations.is_git_repository().await? {
54            return Err("Not in a git repository".to_string());
55        }
56
57        // Use the provided repository root
58        let current_dir = repo_root.to_path_buf();
59
60        // Get list of tracked files from git
61        let tracked_files = self.git_operations.get_tracked_files(&current_dir).await?;
62
63        // Get list of untracked files that are not ignored
64        let untracked_files = self
65            .git_operations
66            .get_untracked_files(&current_dir)
67            .await?;
68
69        // Copy .git directory first
70        let git_src = current_dir.join(".git");
71        let git_dst = repo_path.join(".git");
72        if self
73            .file_system
74            .exists(&git_src)
75            .await
76            .map_err(|e| format!("Failed to check if .git exists: {e}"))?
77        {
78            self.copy_directory(&git_src, &git_dst).await?;
79        }
80
81        // Copy all tracked files
82        for file_path in tracked_files {
83            let src_path = current_dir.join(&file_path);
84            let dst_path = repo_path.join(&file_path);
85
86            // Create parent directory if it doesn't exist
87            if let Some(parent) = dst_path.parent() {
88                self.file_system
89                    .create_dir(parent)
90                    .await
91                    .map_err(|e| format!("Failed to create parent directory: {e}"))?;
92            }
93
94            // Copy the file
95            self.file_system
96                .copy_file(&src_path, &dst_path)
97                .await
98                .map_err(|e| {
99                    format!("Failed to copy tracked file {}: {}", file_path.display(), e)
100                })?;
101        }
102
103        // Copy all untracked files (not ignored)
104        for file_path in untracked_files {
105            // Remove trailing slash if present (git adds it for directories)
106            let file_path_str = file_path.to_string_lossy();
107            let file_path_clean = if let Some(stripped) = file_path_str.strip_suffix('/') {
108                PathBuf::from(stripped)
109            } else {
110                file_path.clone()
111            };
112
113            let src_path = current_dir.join(&file_path_clean);
114            let dst_path = repo_path.join(&file_path_clean);
115
116            // Check if this is a directory
117            if self.file_system.read_dir(&src_path).await.is_ok() {
118                // It's a directory, copy it recursively
119                self.copy_directory(&src_path, &dst_path).await?;
120            } else {
121                // It's a file
122                // Create parent directory if it doesn't exist
123                if let Some(parent) = dst_path.parent() {
124                    self.file_system
125                        .create_dir(parent)
126                        .await
127                        .map_err(|e| format!("Failed to create parent directory: {e}"))?;
128                }
129
130                // Copy the file
131                match self.file_system.copy_file(&src_path, &dst_path).await {
132                    Ok(_) => {}
133                    Err(e) => {
134                        // If the file doesn't exist in src, it might be because git reported
135                        // a directory with a trailing slash. Skip it.
136                        if !e.to_string().contains("Source file not found") {
137                            return Err(format!(
138                                "Failed to copy untracked file {}: {}",
139                                file_path.display(),
140                                e
141                            ));
142                        }
143                    }
144                }
145            }
146        }
147
148        // Copy .tsk directory if it exists (for project-specific Docker configurations)
149        let tsk_src = current_dir.join(".tsk");
150        let tsk_dst = repo_path.join(".tsk");
151        if self
152            .file_system
153            .exists(&tsk_src)
154            .await
155            .map_err(|e| format!("Failed to check if .tsk exists: {e}"))?
156        {
157            self.copy_directory(&tsk_src, &tsk_dst).await?;
158        }
159
160        // Create a new branch in the copied repository
161        match source_commit {
162            Some(commit_sha) => {
163                // Create branch from specific commit
164                self.git_operations
165                    .create_branch_from_commit(&repo_path, &branch_name, commit_sha)
166                    .await?;
167                println!("Created branch from commit: {commit_sha}");
168            }
169            None => {
170                // Create branch from HEAD (existing behavior)
171                self.git_operations
172                    .create_branch(&repo_path, &branch_name)
173                    .await?;
174            }
175        }
176
177        println!("Created repository copy at: {}", repo_path.display());
178        println!("Branch: {branch_name}");
179        Ok((repo_path, branch_name))
180    }
181
182    /// Copy directory recursively
183    #[allow(clippy::only_used_in_recursion)]
184    async fn copy_directory(&self, src: &Path, dst: &Path) -> Result<(), String> {
185        self.file_system
186            .create_dir(dst)
187            .await
188            .map_err(|e| format!("Failed to create destination directory: {e}"))?;
189
190        let entries = self
191            .file_system
192            .read_dir(src)
193            .await
194            .map_err(|e| format!("Failed to read directory: {e}"))?;
195
196        for path in entries {
197            let file_name = path
198                .file_name()
199                .ok_or_else(|| "Invalid file name".to_string())?;
200
201            let dst_path = dst.join(file_name);
202
203            // Check if it's a directory by trying to read it as one
204            if self.file_system.read_dir(&path).await.is_ok() {
205                Box::pin(self.copy_directory(&path, &dst_path)).await?;
206            } else {
207                self.file_system
208                    .copy_file(&path, &dst_path)
209                    .await
210                    .map_err(|e| format!("Failed to copy file {}: {}", path.display(), e))?;
211            }
212        }
213
214        Ok(())
215    }
216
217    /// Commit any uncommitted changes in the repository
218    pub async fn commit_changes(&self, repo_path: &Path, message: &str) -> Result<(), String> {
219        // Check if there are any changes to commit
220        let status_output = self.git_operations.get_status(repo_path).await?;
221
222        if status_output.trim().is_empty() {
223            println!("No changes to commit");
224            return Ok(());
225        }
226
227        // Add all changes
228        self.git_operations.add_all(repo_path).await?;
229
230        // Commit changes
231        self.git_operations.commit(repo_path, message).await?;
232
233        println!("Committed changes: {message}");
234        Ok(())
235    }
236
237    /// Fetch changes from the copied repository back to the main repository
238    /// Returns false if no changes were fetched (branch has no new commits)
239    pub async fn fetch_changes(
240        &self,
241        repo_path: &Path,
242        branch_name: &str,
243        repo_root: &Path,
244    ) -> Result<bool, String> {
245        let repo_path_str = repo_path
246            .to_str()
247            .ok_or_else(|| "Invalid repo path".to_string())?;
248
249        // Use the provided repository root
250        let main_repo = repo_root.to_path_buf();
251
252        // Add the copied repository as a remote in the main repository
253        let now: DateTime<Local> = Local::now();
254        let remote_name = format!("tsk-temp-{}", now.format("%Y-%m-%d-%H%M%S"));
255
256        self.git_operations
257            .add_remote(&main_repo, &remote_name, repo_path_str)
258            .await?;
259
260        // Fetch the specific branch from the remote
261        match self
262            .git_operations
263            .fetch_branch(&main_repo, &remote_name, branch_name)
264            .await
265        {
266            Ok(_) => {
267                // Remove the temporary remote
268                self.git_operations
269                    .remove_remote(&main_repo, &remote_name)
270                    .await?;
271            }
272            Err(e) => {
273                // Remove the temporary remote before returning error
274                let _ = self
275                    .git_operations
276                    .remove_remote(&main_repo, &remote_name)
277                    .await;
278                return Err(e);
279            }
280        }
281
282        // Now check if the fetched branch has any commits not in main
283        let has_commits = self
284            .git_operations
285            .has_commits_not_in_base(&main_repo, branch_name, "main")
286            .await?;
287
288        if !has_commits {
289            println!("No new commits in branch {branch_name} - deleting branch");
290            // Delete the branch from the main repository since it has no new commits
291            if let Err(e) = self
292                .git_operations
293                .delete_branch(&main_repo, branch_name)
294                .await
295            {
296                eprintln!("Warning: Failed to delete branch {branch_name}: {e}");
297            }
298            return Ok(false);
299        }
300
301        println!("Fetched changes from copied repository");
302        Ok(true)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::context::git_operations::tests::MockGitOperations;
310    use std::collections::HashMap;
311    use tempfile::TempDir;
312
313    fn create_test_xdg_directories(temp_dir: &TempDir) -> Arc<XdgDirectories> {
314        unsafe {
315            std::env::set_var("XDG_DATA_HOME", temp_dir.path().join("data"));
316        }
317        unsafe {
318            std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path().join("runtime"));
319        }
320        let xdg = XdgDirectories::new().unwrap();
321        xdg.ensure_directories().unwrap();
322        Arc::new(xdg)
323    }
324
325    #[tokio::test]
326    async fn test_copy_repo_not_in_git_repo() {
327        let temp_dir = TempDir::new().expect("Failed to create temp dir");
328        let xdg_directories = create_test_xdg_directories(&temp_dir);
329
330        // Create mock git operations
331        let mock_git_ops = Arc::new(MockGitOperations::new());
332        mock_git_ops.set_is_repo_result(Ok(false));
333
334        use crate::context::file_system::tests::MockFileSystem;
335        let fs = Arc::new(MockFileSystem::new());
336
337        let manager = RepoManager::new(xdg_directories, fs, mock_git_ops.clone());
338
339        let repo_root = temp_dir.path();
340        let result = manager
341            .copy_repo("2024-01-01-1200-generic-test-task", repo_root, None)
342            .await;
343
344        assert!(result.is_err());
345        assert_eq!(result.unwrap_err(), "Not in a git repository");
346    }
347
348    #[tokio::test]
349    async fn test_commit_changes_no_changes() {
350        let temp_dir = TempDir::new().expect("Failed to create temp dir");
351        let repo_path = temp_dir.path();
352
353        // Create mock git operations
354        let mock_git_ops = Arc::new(MockGitOperations::new());
355        mock_git_ops.set_get_status_result(Ok("".to_string()));
356
357        use crate::context::file_system::tests::MockFileSystem;
358        let fs = Arc::new(MockFileSystem::new());
359
360        let xdg_directories = create_test_xdg_directories(&temp_dir);
361        let manager = RepoManager::new(xdg_directories, fs, mock_git_ops);
362
363        let result = manager.commit_changes(repo_path, "Test commit").await;
364
365        assert!(result.is_ok(), "Error: {result:?}");
366    }
367
368    #[tokio::test]
369    async fn test_commit_changes_with_changes() {
370        let temp_dir = TempDir::new().expect("Failed to create temp dir");
371        let repo_path = temp_dir.path();
372
373        // Create mock git operations
374        let mock_git_ops = Arc::new(MockGitOperations::new());
375        mock_git_ops.set_get_status_result(Ok("M file.txt\n".to_string()));
376
377        use crate::context::file_system::tests::MockFileSystem;
378        let fs = Arc::new(MockFileSystem::new());
379
380        let xdg_directories = create_test_xdg_directories(&temp_dir);
381        let manager = RepoManager::new(xdg_directories, fs, mock_git_ops);
382
383        let result = manager.commit_changes(repo_path, "Test commit").await;
384
385        assert!(result.is_ok(), "Error: {result:?}");
386    }
387
388    #[tokio::test]
389    async fn test_fetch_changes_no_commits() {
390        let temp_dir = TempDir::new().expect("Failed to create temp dir");
391        let repo_path = temp_dir.path();
392
393        // Create mock git operations
394        let mock_git_ops = Arc::new(MockGitOperations::new());
395        mock_git_ops.set_has_commits_not_in_base_result(Ok(false));
396
397        use crate::context::file_system::tests::MockFileSystem;
398        let fs = Arc::new(MockFileSystem::new());
399
400        // Create XDG directories for test
401        unsafe {
402            std::env::set_var("XDG_DATA_HOME", temp_dir.path().join("data"));
403        }
404        unsafe {
405            std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path().join("runtime"));
406        }
407        let xdg = Arc::new(crate::storage::XdgDirectories::new().unwrap());
408
409        let manager = RepoManager::new(xdg, fs, mock_git_ops.clone());
410
411        let repo_root = temp_dir.path();
412        let result = manager
413            .fetch_changes(repo_path, "tsk/test-branch", repo_root)
414            .await;
415
416        assert!(result.is_ok(), "Error: {result:?}");
417        assert_eq!(result.unwrap(), false);
418
419        // Verify that delete_branch was called
420        let delete_calls = mock_git_ops.get_delete_branch_calls();
421        assert_eq!(delete_calls.len(), 1);
422        assert_eq!(delete_calls[0].1, "tsk/test-branch");
423    }
424
425    #[tokio::test]
426    async fn test_fetch_changes_with_commits() {
427        let temp_dir = TempDir::new().expect("Failed to create temp dir");
428        let repo_path = temp_dir.path();
429
430        // Create mock git operations
431        let mock_git_ops = Arc::new(MockGitOperations::new());
432        mock_git_ops.set_has_commits_not_in_base_result(Ok(true));
433
434        use crate::context::file_system::tests::MockFileSystem;
435        let fs = Arc::new(MockFileSystem::new());
436
437        // Create XDG directories for test
438        unsafe {
439            std::env::set_var("XDG_DATA_HOME", temp_dir.path().join("data"));
440        }
441        unsafe {
442            std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path().join("runtime"));
443        }
444        let xdg = Arc::new(crate::storage::XdgDirectories::new().unwrap());
445
446        let manager = RepoManager::new(xdg, fs, mock_git_ops.clone());
447
448        let repo_root = temp_dir.path();
449        let result = manager
450            .fetch_changes(repo_path, "tsk/test-branch", repo_root)
451            .await;
452
453        assert!(result.is_ok(), "Error: {result:?}");
454        assert_eq!(result.unwrap(), true);
455
456        // Verify that delete_branch was NOT called
457        let delete_calls = mock_git_ops.get_delete_branch_calls();
458        assert_eq!(delete_calls.len(), 0);
459    }
460
461    #[tokio::test]
462    async fn test_copy_repo_with_source_commit() {
463        let temp_dir = TempDir::new().expect("Failed to create temp dir");
464        let xdg_directories = create_test_xdg_directories(&temp_dir);
465
466        // Create mock git operations
467        let mock_git_ops = Arc::new(MockGitOperations::new());
468        mock_git_ops.set_is_repo_result(Ok(true));
469        mock_git_ops.set_get_tracked_files_result(Ok(vec![
470            PathBuf::from("src/main.rs"),
471            PathBuf::from("Cargo.toml"),
472        ]));
473
474        use crate::context::file_system::tests::MockFileSystem;
475        let fs = Arc::new(MockFileSystem::new());
476        // Add mock files with absolute paths
477        let mut files = HashMap::new();
478        files.insert(temp_dir.path().join(".git"), "dir".to_string());
479        files.insert(
480            temp_dir.path().join("src/main.rs"),
481            "file content".to_string(),
482        );
483        files.insert(temp_dir.path().join("Cargo.toml"), "[package]".to_string());
484        fs.set_files(files);
485
486        let manager = RepoManager::new(xdg_directories, fs, mock_git_ops.clone());
487
488        let repo_root = temp_dir.path();
489        let source_commit = "abc123def456789012345678901234567890abcd";
490        let result = manager
491            .copy_repo(
492                "2024-01-01-1200-generic-test-task",
493                repo_root,
494                Some(source_commit),
495            )
496            .await;
497
498        assert!(result.is_ok(), "Error: {result:?}");
499        let (_, branch_name) = result.unwrap();
500        assert_eq!(branch_name, "tsk/2024-01-01-1200-generic-test-task");
501
502        // Verify create_branch_from_commit was called
503        let create_from_commit_calls = mock_git_ops.get_create_branch_from_commit_calls();
504        assert_eq!(create_from_commit_calls.len(), 1);
505        assert_eq!(
506            create_from_commit_calls[0].1,
507            "tsk/2024-01-01-1200-generic-test-task"
508        );
509        assert_eq!(create_from_commit_calls[0].2, source_commit);
510
511        // Verify regular create_branch was NOT called
512        let create_branch_calls = mock_git_ops.get_create_branch_calls();
513        assert_eq!(create_branch_calls.len(), 0);
514    }
515
516    #[tokio::test]
517    async fn test_copy_repo_without_source_commit() {
518        let temp_dir = TempDir::new().expect("Failed to create temp dir");
519        let xdg_directories = create_test_xdg_directories(&temp_dir);
520
521        // Create mock git operations
522        let mock_git_ops = Arc::new(MockGitOperations::new());
523        mock_git_ops.set_is_repo_result(Ok(true));
524        mock_git_ops.set_get_tracked_files_result(Ok(vec![PathBuf::from("README.md")]));
525
526        use crate::context::file_system::tests::MockFileSystem;
527        let fs = Arc::new(MockFileSystem::new());
528        // Add mock files with absolute paths
529        let mut files = HashMap::new();
530        files.insert(temp_dir.path().join(".git"), "dir".to_string());
531        files.insert(temp_dir.path().join("README.md"), "# README".to_string());
532        fs.set_files(files);
533
534        let manager = RepoManager::new(xdg_directories, fs, mock_git_ops.clone());
535
536        let repo_root = temp_dir.path();
537        let result = manager
538            .copy_repo("2024-01-01-1200-generic-test-task", repo_root, None)
539            .await;
540
541        assert!(result.is_ok(), "Error: {result:?}");
542        let (_, branch_name) = result.unwrap();
543        assert_eq!(branch_name, "tsk/2024-01-01-1200-generic-test-task");
544
545        // Verify regular create_branch was called
546        let create_branch_calls = mock_git_ops.get_create_branch_calls();
547        assert_eq!(create_branch_calls.len(), 1);
548        assert_eq!(
549            create_branch_calls[0].1,
550            "tsk/2024-01-01-1200-generic-test-task"
551        );
552
553        // Verify create_branch_from_commit was NOT called
554        let create_from_commit_calls = mock_git_ops.get_create_branch_from_commit_calls();
555        assert_eq!(create_from_commit_calls.len(), 0);
556    }
557
558    #[tokio::test]
559    async fn test_copy_repo_separates_tracked_and_untracked_files() {
560        let temp_dir = TempDir::new().expect("Failed to create temp dir");
561        let xdg_directories = create_test_xdg_directories(&temp_dir);
562
563        // Create mock git operations
564        let mock_git_ops = Arc::new(MockGitOperations::new());
565        mock_git_ops.set_is_repo_result(Ok(true));
566        // Only track specific files
567        mock_git_ops.set_get_tracked_files_result(Ok(vec![
568            PathBuf::from("src/main.rs"),
569            PathBuf::from("Cargo.toml"),
570        ]));
571        // Return untracked files, but not ignored ones (like target/)
572        mock_git_ops.set_get_untracked_files_result(Ok(vec![PathBuf::from("build.log")]));
573
574        use crate::context::file_system::tests::MockFileSystem;
575        let fs = Arc::new(MockFileSystem::new());
576        // Add mock files including untracked build artifacts with absolute paths
577        let mut files = HashMap::new();
578        files.insert(temp_dir.path().join(".git"), "dir".to_string());
579        files.insert(
580            temp_dir.path().join("src/main.rs"),
581            "fn main() {}".to_string(),
582        );
583        files.insert(temp_dir.path().join("Cargo.toml"), "[package]".to_string());
584        files.insert(
585            temp_dir.path().join("target/debug/app"),
586            "binary".to_string(),
587        ); // ignored
588        files.insert(temp_dir.path().join("build.log"), "log content".to_string()); // untracked
589        fs.set_files(files);
590
591        let manager = RepoManager::new(xdg_directories.clone(), fs.clone(), mock_git_ops.clone());
592
593        let repo_root = temp_dir.path();
594        let result = manager
595            .copy_repo("2024-01-01-1200-generic-test-task", repo_root, None)
596            .await;
597
598        assert!(result.is_ok(), "Error: {result:?}");
599        let (repo_path, _) = result.unwrap();
600
601        // Verify tracked and untracked (non-ignored) files were copied
602        let copied_files = fs.get_files();
603        let repo_path_str = repo_path.to_string_lossy();
604
605        // Check that tracked files exist in destination
606        assert!(copied_files.contains_key(&format!("{repo_path_str}/src/main.rs")));
607        assert!(copied_files.contains_key(&format!("{repo_path_str}/Cargo.toml")));
608
609        // Check that untracked non-ignored file was copied
610        assert!(copied_files.contains_key(&format!("{repo_path_str}/build.log")));
611
612        // Check that ignored file was NOT copied
613        assert!(!copied_files.contains_key(&format!("{repo_path_str}/target/debug/app")));
614
615        // Check that .git directory was copied
616        let copied_dirs = fs.get_dirs();
617        assert!(
618            copied_dirs
619                .iter()
620                .any(|d| d == &format!("{repo_path_str}/.git"))
621        );
622    }
623
624    #[tokio::test]
625    async fn test_copy_repo_includes_untracked_files() {
626        let temp_dir = TempDir::new().expect("Failed to create temp dir");
627        let xdg_directories = create_test_xdg_directories(&temp_dir);
628
629        // Create mock git operations
630        let mock_git_ops = Arc::new(MockGitOperations::new());
631        mock_git_ops.set_is_repo_result(Ok(true));
632        mock_git_ops.set_get_tracked_files_result(Ok(vec![
633            PathBuf::from("src/main.rs"),
634            PathBuf::from("Cargo.toml"),
635        ]));
636        mock_git_ops.set_get_untracked_files_result(Ok(vec![
637            PathBuf::from("notes.txt"),
638            PathBuf::from("test_output.log"),
639            PathBuf::from("debug/"), // Git reports directories with trailing slash
640        ]));
641
642        use crate::context::file_system::tests::MockFileSystem;
643        let fs = Arc::new(MockFileSystem::new());
644        // Add mock files including both tracked and untracked files
645        let mut files = HashMap::new();
646        files.insert(temp_dir.path().join(".git"), "dir".to_string());
647        files.insert(temp_dir.path().join("src"), "dir".to_string());
648        files.insert(temp_dir.path().join("debug"), "dir".to_string());
649        files.insert(
650            temp_dir.path().join("src/main.rs"),
651            "fn main() {}".to_string(),
652        );
653        files.insert(temp_dir.path().join("Cargo.toml"), "[package]".to_string());
654        files.insert(temp_dir.path().join("notes.txt"), "Some notes".to_string());
655        files.insert(
656            temp_dir.path().join("test_output.log"),
657            "test output".to_string(),
658        );
659        files.insert(
660            temp_dir.path().join("debug/temp.txt"),
661            "temporary debug file".to_string(),
662        );
663        // This file is ignored and should not be returned by get_untracked_files
664        files.insert(
665            temp_dir.path().join("target/debug/app"),
666            "binary".to_string(),
667        );
668        fs.set_files(files);
669
670        let manager = RepoManager::new(xdg_directories.clone(), fs.clone(), mock_git_ops.clone());
671
672        let repo_root = temp_dir.path();
673        let result = manager
674            .copy_repo("2024-01-01-1200-generic-test-task", repo_root, None)
675            .await;
676
677        assert!(result.is_ok(), "Error: {result:?}");
678        let (repo_path, _) = result.unwrap();
679
680        // Verify both tracked and untracked files were copied
681        let copied_files = fs.get_files();
682        let repo_path_str = repo_path.to_string_lossy();
683
684        // Check that tracked files exist in destination
685        assert!(copied_files.contains_key(&format!("{repo_path_str}/src/main.rs")));
686        assert!(copied_files.contains_key(&format!("{repo_path_str}/Cargo.toml")));
687
688        // Check that untracked files were also copied
689        assert!(copied_files.contains_key(&format!("{repo_path_str}/notes.txt")));
690        assert!(copied_files.contains_key(&format!("{repo_path_str}/test_output.log")));
691        assert!(copied_files.contains_key(&format!("{repo_path_str}/debug/temp.txt")));
692
693        // Check that ignored file was NOT copied (it wasn't in the untracked files list)
694        assert!(!copied_files.contains_key(&format!("{repo_path_str}/target/debug/app")));
695    }
696
697    #[tokio::test]
698    async fn test_copy_repo_includes_tsk_directory() {
699        let temp_dir = TempDir::new().expect("Failed to create temp dir");
700        let xdg_directories = create_test_xdg_directories(&temp_dir);
701
702        // Create mock git operations
703        let mock_git_ops = Arc::new(MockGitOperations::new());
704        mock_git_ops.set_is_repo_result(Ok(true));
705        mock_git_ops.set_get_tracked_files_result(Ok(vec![
706            PathBuf::from("src/main.rs"),
707            PathBuf::from("Cargo.toml"),
708        ]));
709
710        use crate::context::file_system::tests::MockFileSystem;
711        let fs = Arc::new(MockFileSystem::new());
712        // Add mock files including .tsk directory
713        let mut files = HashMap::new();
714        files.insert(temp_dir.path().join(".git"), "dir".to_string());
715        files.insert(temp_dir.path().join(".tsk"), "dir".to_string());
716        files.insert(
717            temp_dir
718                .path()
719                .join(".tsk/dockerfiles/project/test-project/Dockerfile"),
720            "FROM ubuntu:22.04".to_string(),
721        );
722        files.insert(
723            temp_dir.path().join("src/main.rs"),
724            "fn main() {}".to_string(),
725        );
726        files.insert(temp_dir.path().join("Cargo.toml"), "[package]".to_string());
727        fs.set_files(files);
728
729        let manager = RepoManager::new(xdg_directories.clone(), fs.clone(), mock_git_ops.clone());
730
731        let repo_root = temp_dir.path();
732        let result = manager
733            .copy_repo("2024-01-01-1200-generic-test-task", repo_root, None)
734            .await;
735
736        assert!(result.is_ok(), "Error: {result:?}");
737        let (repo_path, _) = result.unwrap();
738
739        // Verify .tsk directory and its contents were copied
740        let copied_files = fs.get_files();
741        let copied_dirs = fs.get_dirs();
742        let repo_path_str = repo_path.to_string_lossy();
743
744        // Check that .tsk directory was copied
745        assert!(
746            copied_dirs
747                .iter()
748                .any(|d| d == &format!("{}/.tsk", repo_path_str))
749        );
750
751        // Check that .tsk contents were copied (directories and files)
752        assert!(
753            copied_dirs.iter().any(|d| d.contains(".tsk"))
754                || copied_files.keys().any(|f| f.contains(".tsk/dockerfiles"))
755        );
756
757        // Check that the copy operation was called for .tsk directory
758        // (The actual file copying might not show up in our mock due to the recursive copy)
759        let copy_directory_exists = copied_dirs.iter().any(|d| d.contains(".tsk"))
760            || fs.get_files().keys().any(|k| k.contains(".tsk"));
761        assert!(
762            copy_directory_exists,
763            "Expected .tsk directory or its contents to be copied"
764        );
765    }
766}