Skip to main content

track/services/
worktree_service.rs

1use crate::db::row_mapping::parse_datetime;
2use crate::db::Database;
3use crate::models::{RepoLink, Worktree};
4use crate::utils::{Result, TrackError};
5use chrono::Utc;
6use rusqlite::{params, OptionalExtension};
7use std::path::Path;
8use std::process::Command;
9
10pub struct WorktreeService<'a> {
11    db: &'a Database,
12}
13
14impl<'a> WorktreeService<'a> {
15    pub fn new(db: &'a Database) -> Self {
16        Self { db }
17    }
18
19    pub fn add_worktree(
20        &self,
21        task_id: i64,
22        repo_path: &str,
23        branch: Option<&str>,
24        ticket_id: Option<&str>,
25        todo_id: Option<i64>,
26        is_base: bool,
27    ) -> Result<Worktree> {
28        // Verify it's a JJ repository
29        if !self.is_jj_repository(repo_path)? {
30            return Err(TrackError::NotJjRepository(repo_path.to_string()));
31        }
32
33        // Fetch todo_index if todo_id is present
34        let todo_index = if let Some(t_id) = todo_id {
35            let conn = self.db.get_connection();
36            let idx: i64 = conn
37                .query_row(
38                    "SELECT task_index FROM todos WHERE id = ?1",
39                    params![t_id],
40                    |row| row.get(0),
41                )
42                .map_err(|e| match e {
43                    rusqlite::Error::QueryReturnedNoRows => TrackError::TodoNotFound(t_id),
44                    _ => TrackError::Database(e),
45                })?;
46            Some(idx)
47        } else {
48            None
49        };
50
51        // Determine bookmark name
52        let branch_name = self.determine_branch_name(branch, ticket_id, task_id, todo_index)?;
53
54        // Check if bookmark already exists
55        if self.bookmark_exists(repo_path, &branch_name)? {
56            return Err(TrackError::BookmarkExists(branch_name));
57        }
58
59        // Determine workspace path
60        let worktree_path = self.determine_worktree_path(repo_path, &branch_name)?;
61
62        let task_bookmark = self.task_bookmark_name(task_id, ticket_id);
63        let base_revset = if is_base { "@" } else { task_bookmark.as_str() };
64
65        // Create workspace and bookmark
66        self.create_jj_workspace(repo_path, &worktree_path, &branch_name, base_revset)?;
67
68        let worktree = self.insert_worktree_record(
69            task_id,
70            &worktree_path,
71            &branch_name,
72            repo_path,
73            todo_id,
74            is_base,
75        )?;
76
77        Ok(worktree)
78    }
79
80    fn insert_worktree_record(
81        &self,
82        task_id: i64,
83        worktree_path: &str,
84        branch_name: &str,
85        repo_path: &str,
86        todo_id: Option<i64>,
87        is_base: bool,
88    ) -> Result<Worktree> {
89        let now = Utc::now().to_rfc3339();
90        let conn = self.db.get_connection();
91
92        conn.execute(
93            "INSERT INTO worktrees (task_id, path, branch, base_repo, status, created_at, todo_id, is_base) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
94            params![task_id, worktree_path, branch_name, repo_path, "active", now, todo_id, is_base as i32],
95        )?;
96
97        let worktree_id = conn.last_insert_rowid();
98        self.db.increment_rev("worktrees")?;
99        self.get_worktree(worktree_id)
100    }
101
102    pub fn add_existing_worktree(
103        &self,
104        task_id: i64,
105        repo_path: &str,
106        branch: &str,
107        todo_id: Option<i64>,
108        is_base: bool,
109        worktree_path: Option<&str>,
110    ) -> Result<Worktree> {
111        if !self.is_jj_repository(repo_path)? {
112            return Err(TrackError::NotJjRepository(repo_path.to_string()));
113        }
114
115        let resolved_path = if let Some(path) = worktree_path {
116            path.to_string()
117        } else {
118            self.determine_worktree_path(repo_path, branch)?
119        };
120
121        self.create_jj_workspace_for_existing_bookmark(repo_path, &resolved_path, branch)?;
122
123        self.insert_worktree_record(task_id, &resolved_path, branch, repo_path, todo_id, is_base)
124    }
125
126    pub fn recreate_worktree(&self, worktree: &Worktree, force: bool) -> Result<Worktree> {
127        let repo_path = worktree.base_repo.as_ref().ok_or_else(|| {
128            TrackError::Other("Worktree has no base repository reference".to_string())
129        })?;
130
131        if Path::new(&worktree.path).exists() {
132            if self.has_uncommitted_changes(&worktree.path)? && !force {
133                return Err(TrackError::Other(format!(
134                    "Worktree {} has uncommitted changes. Use --force to recreate.",
135                    worktree.path
136                )));
137            }
138
139            self.remove_jj_workspace(repo_path, &worktree.path)?;
140        }
141
142        let conn = self.db.get_connection();
143        conn.execute("DELETE FROM worktrees WHERE id = ?1", params![worktree.id])?;
144        self.db.increment_rev("worktrees")?;
145
146        self.add_existing_worktree(
147            worktree.task_id,
148            repo_path,
149            &worktree.branch,
150            worktree.todo_id,
151            worktree.is_base,
152            Some(&worktree.path),
153        )
154    }
155
156    pub fn get_worktree(&self, worktree_id: i64) -> Result<Worktree> {
157        let conn = self.db.get_connection();
158        let mut stmt = conn.prepare(
159            "SELECT id, task_id, path, branch, base_repo, status, created_at, todo_id, is_base FROM worktrees WHERE id = ?1"
160        )?;
161
162        let worktree = stmt
163            .query_row(params![worktree_id], |row| {
164                let is_base: i32 = row.get(8).unwrap_or(0);
165                Ok(Worktree {
166                    id: row.get(0)?,
167                    task_id: row.get(1)?,
168                    path: row.get(2)?,
169                    branch: row.get(3)?,
170                    base_repo: row.get(4)?,
171                    status: row.get(5)?,
172                    created_at: parse_datetime(row.get::<_, String>(6)?)?,
173                    todo_id: row.get(7)?,
174                    is_base: is_base != 0,
175                })
176            })
177            .map_err(|_| TrackError::WorktreeNotFound(worktree_id))?;
178
179        Ok(worktree)
180    }
181
182    pub fn list_worktrees(&self, task_id: i64) -> Result<Vec<Worktree>> {
183        let conn = self.db.get_connection();
184        let mut stmt = conn.prepare(
185            "SELECT id, task_id, path, branch, base_repo, status, created_at, todo_id, is_base FROM worktrees WHERE task_id = ?1 ORDER BY created_at ASC"
186        )?;
187
188        let worktrees = stmt
189            .query_map(params![task_id], |row| {
190                let is_base: i32 = row.get(8).unwrap_or(0);
191                Ok(Worktree {
192                    id: row.get(0)?,
193                    task_id: row.get(1)?,
194                    path: row.get(2)?,
195                    branch: row.get(3)?,
196                    base_repo: row.get(4)?,
197                    status: row.get(5)?,
198                    created_at: parse_datetime(row.get::<_, String>(6)?)?,
199                    todo_id: row.get(7)?,
200                    is_base: is_base != 0,
201                })
202            })?
203            .collect::<std::result::Result<Vec<_>, _>>()?;
204
205        Ok(worktrees)
206    }
207
208    pub fn list_repo_links(&self, worktree_id: i64) -> Result<Vec<RepoLink>> {
209        let conn = self.db.get_connection();
210        let mut stmt = conn.prepare(
211            "SELECT id, worktree_id, url, kind, created_at FROM repo_links WHERE worktree_id = ?1 ORDER BY created_at ASC"
212        )?;
213
214        let repo_links = stmt
215            .query_map(params![worktree_id], |row| {
216                Ok(RepoLink {
217                    id: row.get(0)?,
218                    worktree_id: row.get(1)?,
219                    url: row.get(2)?,
220                    kind: row.get(3)?,
221                    created_at: parse_datetime(row.get::<_, String>(4)?)?,
222                })
223            })?
224            .collect::<std::result::Result<Vec<_>, _>>()?;
225
226        Ok(repo_links)
227    }
228
229    pub fn remove_worktree(&self, worktree_id: i64, keep_files: bool) -> Result<()> {
230        let worktree = self.get_worktree(worktree_id)?;
231
232        if !keep_files {
233            // Remove JJ workspace
234            if let Some(base_repo) = &worktree.base_repo {
235                self.remove_jj_workspace(base_repo, &worktree.path)?;
236            }
237        }
238
239        // Remove from database
240        let conn = self.db.get_connection();
241        conn.execute("DELETE FROM worktrees WHERE id = ?1", params![worktree_id])?;
242
243        self.db.increment_rev("worktrees")?;
244        Ok(())
245    }
246
247    fn is_jj_repository(&self, path: &str) -> Result<bool> {
248        let jj_dir = Path::new(path).join(".jj");
249        Ok(jj_dir.exists())
250    }
251
252    pub fn bookmark_exists_in_repo(&self, repo_path: &str, bookmark: &str) -> Result<bool> {
253        self.bookmark_exists(repo_path, bookmark)
254    }
255
256    fn bookmark_exists(&self, repo_path: &str, bookmark: &str) -> Result<bool> {
257        if !self.is_jj_repository(repo_path)? {
258            return Err(TrackError::NotJjRepository(repo_path.to_string()));
259        }
260
261        let output = Command::new("jj")
262            .current_dir(repo_path)
263            .args(["-R", repo_path, "bookmark", "list", bookmark])
264            .output()?;
265
266        if !output.status.success() {
267            return Ok(false);
268        }
269
270        let stdout = String::from_utf8_lossy(&output.stdout);
271        Ok(stdout
272            .lines()
273            .any(|line| line.trim_start().starts_with(&format!("{}:", bookmark))))
274    }
275
276    fn determine_branch_name(
277        &self,
278        branch: Option<&str>,
279        ticket_id: Option<&str>,
280        task_id: i64,
281        todo_index: Option<i64>,
282    ) -> Result<String> {
283        match (branch, ticket_id, todo_index) {
284            // If branch is explicitly specified, use it (with ticket prefix if available)
285            (Some(b), Some(t), _) => Ok(format!("{}/{}", t, b)),
286            (Some(b), None, _) => Ok(b.to_string()),
287
288            // If todo_index is present
289            (None, Some(t), Some(todo)) => Ok(format!("{}-todo-{}", t, todo)),
290            (None, None, Some(todo)) => Ok(format!("task-{}-todo-{}", task_id, todo)),
291
292            // Base worktree (no todo_index)
293            (None, Some(t), None) => Ok(format!("task/{}", t)),
294            (None, None, None) => {
295                let timestamp = Utc::now().timestamp();
296                Ok(format!("task-{}-{}", task_id, timestamp))
297            }
298        }
299    }
300
301    pub fn task_bookmark_name(&self, task_id: i64, ticket_id: Option<&str>) -> String {
302        if let Some(ticket) = ticket_id {
303            format!("task/{}", ticket)
304        } else {
305            format!("task/task-{}", task_id)
306        }
307    }
308
309    fn determine_worktree_path(&self, repo_path: &str, branch: &str) -> Result<String> {
310        let sanitized_branch = branch.replace(['/', '\\'], "_");
311        let repo_path = Path::new(repo_path);
312        let worktree_path = repo_path.join(sanitized_branch);
313
314        Ok(worktree_path.to_string_lossy().to_string())
315    }
316
317    fn create_jj_workspace(
318        &self,
319        repo_path: &str,
320        worktree_path: &str,
321        bookmark: &str,
322        base_revset: &str,
323    ) -> Result<()> {
324        let output = Command::new("jj")
325            .current_dir(repo_path)
326            .args([
327                "-R",
328                repo_path,
329                "workspace",
330                "add",
331                worktree_path,
332                "-r",
333                base_revset,
334            ])
335            .output()?;
336
337        if !output.status.success() {
338            let error = String::from_utf8_lossy(&output.stderr);
339            return Err(TrackError::Jj(error.to_string()));
340        }
341
342        let bookmark_output = Command::new("jj")
343            .current_dir(worktree_path)
344            .args([
345                "-R",
346                worktree_path,
347                "bookmark",
348                "create",
349                bookmark,
350                "-r",
351                "@",
352            ])
353            .output()?;
354
355        if !bookmark_output.status.success() {
356            let error = String::from_utf8_lossy(&bookmark_output.stderr);
357            return Err(TrackError::Jj(error.to_string()));
358        }
359
360        Ok(())
361    }
362
363    fn create_jj_workspace_for_existing_bookmark(
364        &self,
365        repo_path: &str,
366        worktree_path: &str,
367        bookmark: &str,
368    ) -> Result<()> {
369        let output = Command::new("jj")
370            .current_dir(repo_path)
371            .args(["-R", repo_path, "workspace", "add", worktree_path])
372            .output()?;
373
374        if !output.status.success() {
375            let error = String::from_utf8_lossy(&output.stderr);
376            return Err(TrackError::Jj(error.to_string()));
377        }
378
379        let edit_output = Command::new("jj")
380            .current_dir(worktree_path)
381            .args(["-R", worktree_path, "edit", bookmark])
382            .output()?;
383
384        if !edit_output.status.success() {
385            let error = String::from_utf8_lossy(&edit_output.stderr);
386            return Err(TrackError::Jj(error.to_string()));
387        }
388
389        Ok(())
390    }
391
392    fn remove_jj_workspace(&self, repo_path: &str, worktree_path: &str) -> Result<()> {
393        let workspace_name = Path::new(worktree_path)
394            .file_name()
395            .and_then(|name| name.to_str())
396            .ok_or_else(|| TrackError::Other("Failed to determine workspace name".to_string()))?;
397
398        let output = Command::new("jj")
399            .current_dir(repo_path)
400            .args(["-R", repo_path, "workspace", "forget", workspace_name])
401            .output()?;
402
403        if !output.status.success() {
404            let error = String::from_utf8_lossy(&output.stderr);
405            return Err(TrackError::Jj(error.to_string()));
406        }
407
408        if Path::new(worktree_path).exists() {
409            std::fs::remove_dir_all(worktree_path)?;
410        }
411
412        Ok(())
413    }
414
415    /// Calculates the expected bookmark name for a TODO item.
416    /// This allows clients to know the bookmark name even before the workspace is created.
417    pub fn get_todo_branch_name(
418        &self,
419        task_id: i64,
420        ticket_id: Option<&str>,
421        todo_index: i64,
422    ) -> Result<String> {
423        self.determine_branch_name(None, ticket_id, task_id, Some(todo_index))
424    }
425
426    fn get_task_ticket_id(&self, task_id: i64) -> Result<Option<String>> {
427        let conn = self.db.get_connection();
428        let mut stmt = conn.prepare("SELECT ticket_id FROM tasks WHERE id = ?1")?;
429        let ticket_id = stmt
430            .query_row(params![task_id], |row| row.get::<_, Option<String>>(0))
431            .optional()?;
432        Ok(ticket_id.flatten())
433    }
434
435    pub fn complete_worktree_for_todo(&self, todo_id: i64) -> Result<Option<String>> {
436        let wt = match self.get_worktree_by_todo(todo_id)? {
437            Some(wt) => wt,
438            None => return Ok(None),
439        };
440
441        // Try to find a base worktree in the database
442        let merge_target_path = if let Some(base_wt) = self.get_base_worktree(wt.task_id)? {
443            // Use the registered base worktree
444            base_wt.path
445        } else {
446            // Fall back to the base repository path (main repository directory)
447            // The TODO worktree's base_repo field points to the main repository
448            wt.base_repo.clone().ok_or_else(|| {
449                TrackError::Other("TODO workspace has no base repository reference".to_string())
450            })?
451        };
452
453        if self.has_uncommitted_changes(&wt.path)? {
454            return Err(TrackError::Other(format!(
455                "Workspace {} has uncommitted changes. Please clean or snapshot them.",
456                wt.path
457            )));
458        }
459
460        let ticket_id = self.get_task_ticket_id(wt.task_id)?;
461        let task_bookmark = self.task_bookmark_name(wt.task_id, ticket_id.as_deref());
462
463        self.integrate_todo_bookmark(&merge_target_path, &wt.branch, &task_bookmark)?;
464        self.remove_worktree(wt.id, false)?;
465
466        Ok(Some(wt.branch))
467    }
468
469    fn get_worktree_by_todo(&self, todo_id: i64) -> Result<Option<Worktree>> {
470        let conn = self.db.get_connection();
471        let mut stmt = conn.prepare(
472            "SELECT id, task_id, path, branch, base_repo, status, created_at, todo_id, is_base FROM worktrees WHERE todo_id = ?1"
473        )?;
474
475        let result = stmt
476            .query_row(params![todo_id], |row| {
477                let is_base: i32 = row.get(8).unwrap_or(0);
478                Ok(Worktree {
479                    id: row.get(0)?,
480                    task_id: row.get(1)?,
481                    path: row.get(2)?,
482                    branch: row.get(3)?,
483                    base_repo: row.get(4)?,
484                    status: row.get(5)?,
485                    created_at: parse_datetime(row.get::<_, String>(6)?)?,
486                    todo_id: row.get(7)?,
487                    is_base: is_base != 0,
488                })
489            })
490            .optional()?;
491
492        Ok(result)
493    }
494
495    fn get_base_worktree(&self, task_id: i64) -> Result<Option<Worktree>> {
496        let conn = self.db.get_connection();
497        let mut stmt = conn.prepare(
498            "SELECT id, task_id, path, branch, base_repo, status, created_at, todo_id, is_base FROM worktrees WHERE task_id = ?1 AND is_base = 1"
499        )?;
500
501        let result = stmt
502            .query_row(params![task_id], |row| {
503                let is_base: i32 = row.get(8).unwrap_or(0);
504                Ok(Worktree {
505                    id: row.get(0)?,
506                    task_id: row.get(1)?,
507                    path: row.get(2)?,
508                    branch: row.get(3)?,
509                    base_repo: row.get(4)?,
510                    status: row.get(5)?,
511                    created_at: parse_datetime(row.get::<_, String>(6)?)?,
512                    todo_id: row.get(7)?,
513                    is_base: is_base != 0,
514                })
515            })
516            .optional()?;
517
518        Ok(result)
519    }
520
521    pub fn has_uncommitted_changes(&self, path: &str) -> Result<bool> {
522        let output = Command::new("jj")
523            .current_dir(path)
524            .args(["-R", path, "diff", "--summary"])
525            .output()?;
526
527        Ok(!output.stdout.is_empty())
528    }
529
530    fn integrate_todo_bookmark(
531        &self,
532        target_path: &str,
533        todo_bookmark: &str,
534        task_bookmark: &str,
535    ) -> Result<()> {
536        let rebase_output = Command::new("jj")
537            .current_dir(target_path)
538            .args([
539                "-R",
540                target_path,
541                "rebase",
542                "-r",
543                todo_bookmark,
544                "-d",
545                task_bookmark,
546            ])
547            .output()?;
548
549        if !rebase_output.status.success() {
550            let error = String::from_utf8_lossy(&rebase_output.stderr);
551            return Err(TrackError::Jj(format!("Rebase failed: {}", error)));
552        }
553
554        let move_output = Command::new("jj")
555            .current_dir(target_path)
556            .args([
557                "-R",
558                target_path,
559                "bookmark",
560                "move",
561                task_bookmark,
562                "-t",
563                todo_bookmark,
564            ])
565            .output()?;
566
567        if !move_output.status.success() {
568            let error = String::from_utf8_lossy(&move_output.stderr);
569            return Err(TrackError::Jj(format!("Bookmark move failed: {}", error)));
570        }
571
572        let edit_output = Command::new("jj")
573            .current_dir(target_path)
574            .args(["-R", target_path, "edit", task_bookmark])
575            .output()?;
576
577        if !edit_output.status.success() {
578            let error = String::from_utf8_lossy(&edit_output.stderr);
579            return Err(TrackError::Jj(format!(
580                "Workspace update failed: {}",
581                error
582            )));
583        }
584
585        Ok(())
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::db::Database;
593    use std::process::Command;
594
595    fn setup_db() -> Database {
596        Database::new_in_memory().unwrap()
597    }
598
599    fn jj_available() -> bool {
600        Command::new("jj")
601            .arg("--version")
602            .output()
603            .map(|output| output.status.success())
604            .unwrap_or(false)
605    }
606
607    fn require_jj() -> bool {
608        if !jj_available() {
609            eprintln!("Skipping test: jj binary not available");
610            return false;
611        }
612        true
613    }
614
615    fn init_jj_repo(path: &str) {
616        let output = Command::new("jj")
617            .args(["git", "init", path])
618            .output()
619            .expect("failed to run jj git init");
620        assert!(
621            output.status.success(),
622            "jj git init failed: {}",
623            String::from_utf8_lossy(&output.stderr)
624        );
625    }
626
627    fn describe_change(path: &str, message: &str) {
628        let output = Command::new("jj")
629            .args(["-R", path, "describe", "-m", message])
630            .output()
631            .expect("failed to run jj describe");
632        assert!(
633            output.status.success(),
634            "jj describe failed: {}",
635            String::from_utf8_lossy(&output.stderr)
636        );
637    }
638
639    fn new_change(path: &str) {
640        let output = Command::new("jj")
641            .args(["-R", path, "new"])
642            .output()
643            .expect("failed to run jj new");
644        assert!(
645            output.status.success(),
646            "jj new failed: {}",
647            String::from_utf8_lossy(&output.stderr)
648        );
649    }
650
651    fn create_bookmark(path: &str, name: &str) {
652        let output = Command::new("jj")
653            .args(["-R", path, "bookmark", "create", name, "-r", "@"])
654            .output()
655            .expect("failed to run jj bookmark create");
656        assert!(
657            output.status.success(),
658            "jj bookmark create failed: {}",
659            String::from_utf8_lossy(&output.stderr)
660        );
661    }
662
663    #[test]
664    fn test_determine_branch_name_with_explicit_branch_and_ticket() {
665        let db = setup_db();
666        let service = WorktreeService::new(&db);
667
668        let result = service
669            .determine_branch_name(Some("feature-x"), Some("PROJ-123"), 1, None)
670            .unwrap();
671        assert_eq!(result, "PROJ-123/feature-x");
672    }
673
674    #[test]
675    fn test_determine_branch_name_with_explicit_branch_only() {
676        let db = setup_db();
677        let service = WorktreeService::new(&db);
678
679        let result = service
680            .determine_branch_name(Some("feature-y"), None, 1, None)
681            .unwrap();
682        assert_eq!(result, "feature-y");
683    }
684
685    #[test]
686    fn test_determine_branch_name_with_ticket_and_todo() {
687        let db = setup_db();
688        let service = WorktreeService::new(&db);
689
690        let result = service
691            .determine_branch_name(None, Some("PROJ-456"), 1, Some(5))
692            .unwrap();
693        assert_eq!(result, "PROJ-456-todo-5");
694    }
695
696    #[test]
697    fn test_determine_branch_name_with_todo_only() {
698        let db = setup_db();
699        let service = WorktreeService::new(&db);
700
701        let result = service
702            .determine_branch_name(None, None, 2, Some(7))
703            .unwrap();
704        assert_eq!(result, "task-2-todo-7");
705    }
706
707    #[test]
708    fn test_determine_branch_name_base_with_ticket() {
709        let db = setup_db();
710        let service = WorktreeService::new(&db);
711
712        let result = service
713            .determine_branch_name(None, Some("PROJ-789"), 3, None)
714            .unwrap();
715        assert_eq!(result, "task/PROJ-789");
716    }
717
718    #[test]
719    fn test_determine_branch_name_base_without_ticket() {
720        let db = setup_db();
721        let service = WorktreeService::new(&db);
722
723        let result = service.determine_branch_name(None, None, 4, None).unwrap();
724        // Should contain "task-4-" followed by timestamp
725        assert!(result.starts_with("task-4-"));
726    }
727
728    #[test]
729    fn test_has_uncommitted_changes() {
730        use std::fs::File;
731        if !require_jj() {
732            return;
733        }
734        // Setup temp JJ repo
735        let temp_dir = tempfile::tempdir().unwrap();
736        let path = temp_dir.path().to_str().unwrap();
737
738        init_jj_repo(path);
739
740        let db = setup_db();
741        let service = WorktreeService::new(&db);
742
743        // No changes initially
744        assert!(!service.has_uncommitted_changes(path).unwrap());
745
746        // Create a file (untracked)
747        File::create(temp_dir.path().join("test.txt")).unwrap();
748        assert!(service.has_uncommitted_changes(path).unwrap());
749
750        // Record the change and move to a clean working copy
751        describe_change(path, "init");
752        new_change(path);
753        assert!(!service.has_uncommitted_changes(path).unwrap());
754
755        // Modify
756        std::fs::write(temp_dir.path().join("test.txt"), "mod").unwrap();
757        assert!(service.has_uncommitted_changes(path).unwrap());
758    }
759
760    #[test]
761    fn test_determine_worktree_path() {
762        let db = setup_db();
763        let service = WorktreeService::new(&db);
764
765        // Test with simple path
766        let repo_path = if cfg!(windows) {
767            "C:\\path\\to\\repo"
768        } else {
769            "/path/to/repo"
770        };
771        let branch = "feature/test";
772
773        let result = service.determine_worktree_path(repo_path, branch).unwrap();
774
775        let expected = Path::new(repo_path)
776            .join("feature_test")
777            .to_string_lossy()
778            .to_string();
779        assert_eq!(result, expected);
780    }
781
782    #[test]
783    fn test_add_worktree_and_get() {
784        use crate::services::TaskService;
785        use std::fs;
786        if !require_jj() {
787            return;
788        }
789
790        let db = setup_db();
791        let task_service = TaskService::new(&db);
792        let service = WorktreeService::new(&db);
793
794        // Create a task
795        let task = task_service
796            .create_task("Test Task", None, Some("PROJ-100"), None)
797            .unwrap();
798
799        // Create a temporary JJ repository
800        let temp_dir = tempfile::tempdir().unwrap();
801        let repo_path = temp_dir.path().to_str().unwrap();
802
803        init_jj_repo(repo_path);
804
805        // Create initial change
806        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
807        describe_change(repo_path, "Initial commit");
808
809        // Add base worktree
810        let worktree = service
811            .add_worktree(task.id, repo_path, None, Some("PROJ-100"), None, true)
812            .unwrap();
813
814        assert_eq!(worktree.task_id, task.id);
815        assert_eq!(worktree.branch, "task/PROJ-100");
816        assert!(worktree.is_base);
817
818        // Get worktree by ID
819        let retrieved = service.get_worktree(worktree.id).unwrap();
820        assert_eq!(retrieved.id, worktree.id);
821        assert_eq!(retrieved.branch, "task/PROJ-100");
822    }
823
824    #[test]
825    fn test_list_worktrees() {
826        use crate::services::{TaskService, TodoService};
827        use std::fs;
828        if !require_jj() {
829            return;
830        }
831
832        let db = setup_db();
833        let task_service = TaskService::new(&db);
834        let todo_service = TodoService::new(&db);
835        let service = WorktreeService::new(&db);
836
837        // Create a task
838        let task = task_service
839            .create_task("Test Task", None, Some("PROJ-200"), None)
840            .unwrap();
841
842        // Create a TODO
843        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
844
845        // Create a temporary JJ repository
846        let temp_dir = tempfile::tempdir().unwrap();
847        let repo_path = temp_dir.path().to_str().unwrap();
848
849        init_jj_repo(repo_path);
850        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
851        describe_change(repo_path, "Initial commit");
852
853        // Add base worktree
854        service
855            .add_worktree(task.id, repo_path, None, Some("PROJ-200"), None, true)
856            .unwrap();
857
858        // Add TODO worktree
859        service
860            .add_worktree(
861                task.id,
862                repo_path,
863                None,
864                Some("PROJ-200"),
865                Some(todo.id),
866                false,
867            )
868            .unwrap();
869
870        // List worktrees
871        let worktrees = service.list_worktrees(task.id).unwrap();
872        assert_eq!(worktrees.len(), 2);
873
874        // Verify base worktree
875        let base = worktrees.iter().find(|w| w.is_base).unwrap();
876        assert_eq!(base.branch, "task/PROJ-200");
877
878        // Verify TODO worktree
879        let todo_wt = worktrees.iter().find(|w| !w.is_base).unwrap();
880        assert_eq!(todo_wt.branch, "PROJ-200-todo-1");
881        assert_eq!(todo_wt.todo_id, Some(todo.id));
882    }
883
884    #[test]
885    fn test_remove_worktree() {
886        use crate::services::TaskService;
887        use std::fs;
888        if !require_jj() {
889            return;
890        }
891
892        let db = setup_db();
893        let task_service = TaskService::new(&db);
894        let service = WorktreeService::new(&db);
895
896        let task = task_service
897            .create_task("Test Task", None, Some("PROJ-300"), None)
898            .unwrap();
899
900        let temp_dir = tempfile::tempdir().unwrap();
901        let repo_path = temp_dir.path().to_str().unwrap();
902
903        init_jj_repo(repo_path);
904        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
905        describe_change(repo_path, "Initial commit");
906
907        let worktree = service
908            .add_worktree(task.id, repo_path, None, Some("PROJ-300"), None, true)
909            .unwrap();
910
911        // Verify worktree exists
912        let worktrees_before = service.list_worktrees(task.id).unwrap();
913        assert_eq!(worktrees_before.len(), 1);
914
915        // Remove worktree
916        service.remove_worktree(worktree.id, false).unwrap();
917
918        // Verify worktree is removed from DB
919        let worktrees_after = service.list_worktrees(task.id).unwrap();
920        assert_eq!(worktrees_after.len(), 0);
921
922        // Verify get_worktree returns error
923        let result = service.get_worktree(worktree.id);
924        assert!(result.is_err());
925    }
926
927    #[test]
928    fn test_get_base_worktree() {
929        use crate::services::{TaskService, TodoService};
930        use std::fs;
931        if !require_jj() {
932            return;
933        }
934
935        let db = setup_db();
936        let task_service = TaskService::new(&db);
937        let todo_service = TodoService::new(&db);
938        let service = WorktreeService::new(&db);
939
940        let task = task_service
941            .create_task("Test Task", None, Some("PROJ-400"), None)
942            .unwrap();
943
944        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
945
946        let temp_dir = tempfile::tempdir().unwrap();
947        let repo_path = temp_dir.path().to_str().unwrap();
948
949        init_jj_repo(repo_path);
950        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
951        describe_change(repo_path, "Initial commit");
952
953        // Add base worktree
954        let base_wt = service
955            .add_worktree(task.id, repo_path, None, Some("PROJ-400"), None, true)
956            .unwrap();
957
958        // Add TODO worktree
959        service
960            .add_worktree(
961                task.id,
962                repo_path,
963                None,
964                Some("PROJ-400"),
965                Some(todo.id),
966                false,
967            )
968            .unwrap();
969
970        // Get base worktree
971        let retrieved_base = service.get_base_worktree(task.id).unwrap();
972        assert!(retrieved_base.is_some());
973        let base = retrieved_base.unwrap();
974        assert_eq!(base.id, base_wt.id);
975        assert!(base.is_base);
976    }
977
978    #[test]
979    fn test_get_worktree_by_todo() {
980        use crate::services::{TaskService, TodoService};
981        use std::fs;
982        if !require_jj() {
983            return;
984        }
985
986        let db = setup_db();
987        let task_service = TaskService::new(&db);
988        let todo_service = TodoService::new(&db);
989        let service = WorktreeService::new(&db);
990
991        let task = task_service
992            .create_task("Test Task", None, Some("PROJ-500"), None)
993            .unwrap();
994
995        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
996
997        let temp_dir = tempfile::tempdir().unwrap();
998        let repo_path = temp_dir.path().to_str().unwrap();
999
1000        init_jj_repo(repo_path);
1001        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
1002        describe_change(repo_path, "Initial commit");
1003        create_bookmark(repo_path, "task/PROJ-500");
1004
1005        // Add TODO worktree
1006        let todo_wt = service
1007            .add_worktree(
1008                task.id,
1009                repo_path,
1010                None,
1011                Some("PROJ-500"),
1012                Some(todo.id),
1013                false,
1014            )
1015            .unwrap();
1016
1017        // Get worktree by TODO
1018        let retrieved = service.get_worktree_by_todo(todo.id).unwrap();
1019        assert!(retrieved.is_some());
1020        let wt = retrieved.unwrap();
1021        assert_eq!(wt.id, todo_wt.id);
1022        assert_eq!(wt.todo_id, Some(todo.id));
1023    }
1024
1025    #[test]
1026    fn test_get_worktree_by_todo_not_found() {
1027        use crate::services::{TaskService, TodoService};
1028
1029        let db = setup_db();
1030        let task_service = TaskService::new(&db);
1031        let todo_service = TodoService::new(&db);
1032        let service = WorktreeService::new(&db);
1033
1034        let task = task_service
1035            .create_task("Test Task", None, Some("PROJ-501"), None)
1036            .unwrap();
1037
1038        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
1039
1040        // Don't create any worktree for this TODO
1041        // Try to get worktree by TODO
1042        let retrieved = service.get_worktree_by_todo(todo.id).unwrap();
1043        assert!(retrieved.is_none());
1044    }
1045
1046    #[test]
1047    fn test_get_worktree_by_todo_is_base_field() {
1048        use crate::services::{TaskService, TodoService};
1049        use std::fs;
1050        if !require_jj() {
1051            return;
1052        }
1053
1054        let db = setup_db();
1055        let task_service = TaskService::new(&db);
1056        let todo_service = TodoService::new(&db);
1057        let service = WorktreeService::new(&db);
1058
1059        let task = task_service
1060            .create_task("Test Task", None, Some("PROJ-502"), None)
1061            .unwrap();
1062
1063        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
1064
1065        let temp_dir = tempfile::tempdir().unwrap();
1066        let repo_path = temp_dir.path().to_str().unwrap();
1067
1068        init_jj_repo(repo_path);
1069        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
1070        describe_change(repo_path, "Initial commit");
1071        create_bookmark(repo_path, "task/PROJ-502");
1072
1073        // Add TODO worktree (not base)
1074        service
1075            .add_worktree(
1076                task.id,
1077                repo_path,
1078                None,
1079                Some("PROJ-502"),
1080                Some(todo.id),
1081                false, // is_base = false
1082            )
1083            .unwrap();
1084
1085        // Get worktree by TODO and verify is_base is false
1086        let retrieved = service.get_worktree_by_todo(todo.id).unwrap();
1087        assert!(retrieved.is_some());
1088        let wt = retrieved.unwrap();
1089        assert_eq!(wt.is_base, false);
1090    }
1091
1092    #[test]
1093    fn test_add_worktree_with_invalid_todo_id() {
1094        use crate::utils::TrackError;
1095        if !require_jj() {
1096            return;
1097        }
1098        let db = setup_db();
1099        let service = WorktreeService::new(&db);
1100
1101        let temp_dir = tempfile::tempdir().unwrap();
1102        let repo_path = temp_dir.path().to_str().unwrap();
1103        init_jj_repo(repo_path);
1104
1105        let result = service.add_worktree(
1106            1, // task_id
1107            repo_path,
1108            None,
1109            None,
1110            Some(999), // Invalid TODO ID
1111            false,
1112        );
1113
1114        match result {
1115            Err(TrackError::TodoNotFound(id)) => assert_eq!(id, 999),
1116            _ => panic!("Expected TodoNotFound error, got {:?}", result),
1117        }
1118    }
1119
1120    #[test]
1121    fn test_add_worktree_validation() {
1122        use crate::utils::TrackError;
1123        let db = setup_db();
1124        let service = WorktreeService::new(&db);
1125
1126        // 1. Invalid JJ Repo
1127        let temp_dir = tempfile::tempdir().unwrap();
1128        let path = temp_dir.path().to_str().unwrap();
1129        // Don't init jj
1130
1131        let result = service.add_worktree(1, path, Some("b"), None, None, false);
1132        assert!(matches!(result, Err(TrackError::NotJjRepository(_))));
1133
1134        // 2. Bookmark Exists
1135        if !require_jj() {
1136            return;
1137        }
1138        let temp_dir2 = tempfile::tempdir().unwrap();
1139        let repo_path = temp_dir2.path().to_str().unwrap();
1140        init_jj_repo(repo_path);
1141        std::fs::write(std::path::Path::new(repo_path).join("README.md"), "init").unwrap();
1142        describe_change(repo_path, "init");
1143        create_bookmark(repo_path, "existing-branch");
1144
1145        let result = service.add_worktree(1, repo_path, Some("existing-branch"), None, None, false);
1146        // Should detect bookmark exists
1147        assert!(matches!(result, Err(TrackError::BookmarkExists(_))));
1148    }
1149}