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