Skip to main content

track/services/
repo_service.rs

1use crate::db::Database;
2use crate::models::TaskRepo;
3use crate::utils::{Result, TrackError};
4use chrono::Utc;
5use rusqlite::{params, OptionalExtension};
6use std::path::{Path, PathBuf};
7
8pub struct RepoService<'a> {
9    db: &'a Database,
10}
11
12impl<'a> RepoService<'a> {
13    pub fn new(db: &'a Database) -> Self {
14        RepoService { db }
15    }
16
17    /// Register a repository to a task
18    pub fn add_repo(
19        &self,
20        task_id: i64,
21        repo_path: &str,
22        base_branch: Option<String>,
23        base_commit_hash: Option<String>,
24    ) -> Result<TaskRepo> {
25        // Resolve to absolute path
26        let abs_path = self.resolve_absolute_path(repo_path)?;
27
28        // Validate it's a JJ repository
29        if !self.is_jj_repository(&abs_path)? {
30            return Err(TrackError::NotJjRepository(abs_path.display().to_string()));
31        }
32
33        let path_str = abs_path.to_string_lossy().to_string();
34        let created_at = Utc::now().to_rfc3339();
35
36        // Use transaction to make duplicate check + SELECT MAX + INSERT atomic
37        self.db.with_transaction(|| {
38            // Check if already registered
39            let existing: Option<i64> = self
40                .db
41                .get_connection()
42                .query_row(
43                    "SELECT id FROM task_repos WHERE task_id = ?1 AND repo_path = ?2",
44                    params![task_id, path_str],
45                    |row| row.get(0),
46                )
47                .optional()?;
48
49            if existing.is_some() {
50                return Err(TrackError::RepoAlreadyRegistered);
51            }
52
53            // Get the next task_index for this task
54            let next_index: i64 = self.db.get_connection().query_row(
55                "SELECT COALESCE(MAX(task_index), 0) + 1 FROM task_repos WHERE task_id = ?1",
56                params![task_id],
57                |row| row.get(0),
58            )?;
59
60            self.db.get_connection().execute(
61                "INSERT INTO task_repos (task_id, task_index, repo_path, base_branch, base_commit_hash, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
62                params![task_id, next_index, path_str, base_branch, base_commit_hash, created_at],
63            )?;
64
65            let id = self.db.get_connection().last_insert_rowid();
66
67            self.db.increment_rev("repos")?;
68            Ok(TaskRepo {
69                id,
70                task_id,
71                task_index: next_index,
72                repo_path: path_str.clone(),
73                base_branch,
74                base_commit_hash,
75                created_at: Utc::now(),
76            })
77        })
78    }
79
80    /// List all repositories for a task
81    pub fn list_repos(&self, task_id: i64) -> Result<Vec<TaskRepo>> {
82        let mut stmt = self.db.get_connection().prepare(
83            "SELECT id, task_id, task_index, repo_path, base_branch, base_commit_hash, created_at FROM task_repos WHERE task_id = ?1 ORDER BY task_index"
84        )?;
85
86        let repos = stmt
87            .query_map(params![task_id], |row| {
88                Ok(TaskRepo {
89                    id: row.get(0)?,
90                    task_id: row.get(1)?,
91                    task_index: row.get(2)?,
92                    repo_path: row.get(3)?,
93                    base_branch: row.get(4)?,
94                    base_commit_hash: row.get(5)?,
95                    created_at: row
96                        .get::<_, String>(6)?
97                        .parse()
98                        .unwrap_or_else(|_| Utc::now()),
99                })
100            })?
101            .collect::<std::result::Result<Vec<_>, _>>()?;
102
103        Ok(repos)
104    }
105
106    /// Remove a repository registration
107    pub fn remove_repo(&self, repo_id: i64) -> Result<()> {
108        let rows_affected = self
109            .db
110            .get_connection()
111            .execute("DELETE FROM task_repos WHERE id = ?1", params![repo_id])?;
112
113        if rows_affected == 0 {
114            return Err(TrackError::TaskRepoNotFound(repo_id));
115        }
116
117        self.db.increment_rev("repos")?;
118        Ok(())
119    }
120
121    /// Resolve path to absolute path
122    fn resolve_absolute_path(&self, path: &str) -> Result<PathBuf> {
123        let path_buf = PathBuf::from(path);
124
125        if path_buf.is_absolute() {
126            Ok(path_buf)
127        } else {
128            std::env::current_dir()?
129                .join(path_buf)
130                .canonicalize()
131                .map_err(|e| TrackError::PathResolutionFailed(e.to_string()))
132        }
133    }
134
135    /// Check if a path is a JJ repository
136    fn is_jj_repository(&self, path: &Path) -> Result<bool> {
137        let jj_dir = path.join(".jj");
138        Ok(jj_dir.exists())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::services::task_service::TaskService;
146
147    fn setup_db() -> Database {
148        Database::new_in_memory().unwrap()
149    }
150
151    #[test]
152    fn test_add_repo_success() {
153        let db = setup_db();
154        let task_service = TaskService::new(&db);
155        let repo_service = RepoService::new(&db);
156
157        // Create a task
158        let task = task_service
159            .create_task("Test Task", None, None, None)
160            .unwrap();
161
162        // Create a temporary JJ repository
163        let temp_dir = std::env::temp_dir().join(format!("test_repo_{}", std::process::id()));
164        std::fs::create_dir_all(&temp_dir).unwrap();
165        std::fs::create_dir(temp_dir.join(".jj")).unwrap();
166
167        // Add the repository
168        let repo = repo_service
169            .add_repo(task.id, temp_dir.to_str().unwrap(), None, None)
170            .unwrap();
171        assert_eq!(repo.task_id, task.id);
172        assert!(repo.repo_path.contains("test_repo"));
173
174        // Cleanup
175        std::fs::remove_dir_all(&temp_dir).ok();
176    }
177
178    #[test]
179    fn test_add_repo_not_git() {
180        let db = setup_db();
181        let task_service = TaskService::new(&db);
182        let repo_service = RepoService::new(&db);
183
184        let task = task_service
185            .create_task("Test Task", None, None, None)
186            .unwrap();
187
188        // Create a temporary directory without .jj
189        let temp_dir = std::env::temp_dir().join(format!("test_not_git_{}", std::process::id()));
190        std::fs::create_dir_all(&temp_dir).unwrap();
191
192        // Try to add the repository
193        let result = repo_service.add_repo(task.id, temp_dir.to_str().unwrap(), None, None);
194        assert!(result.is_err());
195        assert!(result
196            .unwrap_err()
197            .to_string()
198            .contains("not a JJ repository"));
199
200        // Cleanup
201        std::fs::remove_dir_all(&temp_dir).ok();
202    }
203
204    #[test]
205    fn test_add_repo_duplicate() {
206        let db = setup_db();
207        let task_service = TaskService::new(&db);
208        let repo_service = RepoService::new(&db);
209
210        let task = task_service
211            .create_task("Test Task", None, None, None)
212            .unwrap();
213
214        let temp_dir = std::env::temp_dir().join(format!("test_dup_repo_{}", std::process::id()));
215        std::fs::create_dir_all(&temp_dir).unwrap();
216        std::fs::create_dir(temp_dir.join(".jj")).unwrap();
217
218        // Add the repository twice
219        repo_service
220            .add_repo(task.id, temp_dir.to_str().unwrap(), None, None)
221            .unwrap();
222        let result = repo_service.add_repo(task.id, temp_dir.to_str().unwrap(), None, None);
223
224        assert!(result.is_err());
225        assert!(result
226            .unwrap_err()
227            .to_string()
228            .contains("already registered"));
229
230        // Cleanup
231        std::fs::remove_dir_all(&temp_dir).ok();
232    }
233
234    #[test]
235    fn test_list_repos() {
236        let db = setup_db();
237        let task_service = TaskService::new(&db);
238        let repo_service = RepoService::new(&db);
239
240        let task = task_service
241            .create_task("Test Task", None, None, None)
242            .unwrap();
243
244        // Create two temporary JJ repositories
245        let temp_dir1 =
246            std::env::temp_dir().join(format!("test_list_repo1_{}", std::process::id()));
247        let temp_dir2 =
248            std::env::temp_dir().join(format!("test_list_repo2_{}", std::process::id()));
249        std::fs::create_dir_all(&temp_dir1).unwrap();
250        std::fs::create_dir_all(&temp_dir2).unwrap();
251        std::fs::create_dir(temp_dir1.join(".jj")).unwrap();
252        std::fs::create_dir(temp_dir2.join(".jj")).unwrap();
253
254        // Add both repositories
255        repo_service
256            .add_repo(task.id, temp_dir1.to_str().unwrap(), None, None)
257            .unwrap();
258        repo_service
259            .add_repo(task.id, temp_dir2.to_str().unwrap(), None, None)
260            .unwrap();
261
262        // List repositories
263        let repos = repo_service.list_repos(task.id).unwrap();
264        assert_eq!(repos.len(), 2);
265
266        // Cleanup
267        std::fs::remove_dir_all(&temp_dir1).ok();
268        std::fs::remove_dir_all(&temp_dir2).ok();
269    }
270
271    #[test]
272    fn test_remove_repo() {
273        let db = setup_db();
274        let task_service = TaskService::new(&db);
275        let repo_service = RepoService::new(&db);
276
277        let task = task_service
278            .create_task("Test Task", None, None, None)
279            .unwrap();
280
281        let temp_dir =
282            std::env::temp_dir().join(format!("test_remove_repo_{}", std::process::id()));
283        std::fs::create_dir_all(&temp_dir).unwrap();
284        std::fs::create_dir(temp_dir.join(".jj")).unwrap();
285
286        let repo = repo_service
287            .add_repo(task.id, temp_dir.to_str().unwrap(), None, None)
288            .unwrap();
289
290        // Remove the repository
291        repo_service.remove_repo(repo.id).unwrap();
292
293        // Verify it's removed
294        let repos = repo_service.list_repos(task.id).unwrap();
295        assert_eq!(repos.len(), 0);
296    }
297
298    #[test]
299    fn test_add_repo_with_base_info() {
300        let db = setup_db();
301        let task_service = TaskService::new(&db);
302        let repo_service = RepoService::new(&db);
303
304        let task = task_service
305            .create_task("Test Task", None, None, None)
306            .unwrap();
307
308        let temp_dir = std::env::temp_dir().join(format!("test_repo_base_{}", std::process::id()));
309        std::fs::create_dir_all(&temp_dir).unwrap();
310        std::fs::create_dir(temp_dir.join(".jj")).unwrap();
311
312        // Add repository with base branch and commit hash
313        let base_branch = "main".to_string();
314        let base_hash = "abc123def456".to_string();
315
316        let repo = repo_service
317            .add_repo(
318                task.id,
319                temp_dir.to_str().unwrap(),
320                Some(base_branch.clone()),
321                Some(base_hash.clone()),
322            )
323            .unwrap();
324
325        assert_eq!(repo.base_branch, Some(base_branch.clone()));
326        assert_eq!(repo.base_commit_hash, Some(base_hash.clone()));
327
328        // Verify data persists by listing repos
329        let repos = repo_service.list_repos(task.id).unwrap();
330        assert_eq!(repos.len(), 1);
331        assert_eq!(repos[0].base_branch, Some(base_branch));
332        assert_eq!(repos[0].base_commit_hash, Some(base_hash));
333
334        // Cleanup
335        std::fs::remove_dir_all(&temp_dir).ok();
336    }
337}