Skip to main content

track/services/worktree_service/
mod.rs

1mod jj;
2mod naming;
3
4use crate::db::row_mapping::parse_datetime;
5use crate::db::Database;
6use crate::models::{RepoLink, Worktree};
7use crate::utils::{Result, TrackError};
8use chrono::Utc;
9use rusqlite::{params, OptionalExtension};
10use std::path::Path;
11
12/// Result of removing legacy track-managed JJ workspaces for a task.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct LegacyWorktreeCleanupOutcome {
15    pub removed: Vec<(i64, String)>,
16    pub skipped_dirty: Vec<String>,
17    pub errors: Vec<String>,
18}
19
20/// Returns true for track-managed workspaces from the legacy sync / `--worktree` model.
21pub fn is_legacy_worktree(worktree: &Worktree) -> bool {
22    worktree.is_base || worktree.todo_id.is_some()
23}
24
25pub struct WorktreeService<'a> {
26    db: &'a Database,
27}
28
29impl<'a> WorktreeService<'a> {
30    pub fn new(db: &'a Database) -> Self {
31        Self { db }
32    }
33
34    pub fn add_worktree(
35        &self,
36        task_id: i64,
37        repo_path: &str,
38        branch: Option<&str>,
39        ticket_id: Option<&str>,
40        todo_id: Option<i64>,
41        is_base: bool,
42    ) -> Result<Worktree> {
43        if !jj::is_jj_repository(repo_path) {
44            return Err(TrackError::NotJjRepository(repo_path.to_string()));
45        }
46
47        let todo_index = if let Some(t_id) = todo_id {
48            let conn = self.db.get_connection();
49            let idx: i64 = conn
50                .query_row(
51                    "SELECT task_index FROM todos WHERE id = ?1",
52                    params![t_id],
53                    |row| row.get(0),
54                )
55                .map_err(|e| match e {
56                    rusqlite::Error::QueryReturnedNoRows => TrackError::TodoNotFound(t_id),
57                    _ => TrackError::Database(e),
58                })?;
59            Some(idx)
60        } else {
61            None
62        };
63
64        let branch_name = naming::determine_branch_name(branch, ticket_id, task_id, todo_index)?;
65
66        if jj::bookmark_exists(repo_path, &branch_name)? {
67            return Err(TrackError::BookmarkExists(branch_name));
68        }
69
70        let worktree_path = naming::determine_worktree_path(repo_path, &branch_name)?;
71        let task_bookmark = naming::task_bookmark_name(task_id, ticket_id);
72        let base_revset = if is_base { "@" } else { task_bookmark.as_str() };
73
74        jj::create_workspace(repo_path, &worktree_path, &branch_name, base_revset)?;
75
76        self.insert_worktree_record(
77            task_id,
78            &worktree_path,
79            &branch_name,
80            repo_path,
81            todo_id,
82            is_base,
83        )
84    }
85
86    fn insert_worktree_record(
87        &self,
88        task_id: i64,
89        worktree_path: &str,
90        branch_name: &str,
91        repo_path: &str,
92        todo_id: Option<i64>,
93        is_base: bool,
94    ) -> Result<Worktree> {
95        let now = Utc::now().to_rfc3339();
96        let conn = self.db.get_connection();
97
98        conn.execute(
99            "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)",
100            params![task_id, worktree_path, branch_name, repo_path, "active", now, todo_id, is_base as i32],
101        )?;
102
103        let worktree_id = conn.last_insert_rowid();
104        self.db.increment_rev("worktrees")?;
105        self.get_worktree(worktree_id)
106    }
107
108    pub fn add_existing_worktree(
109        &self,
110        task_id: i64,
111        repo_path: &str,
112        branch: &str,
113        todo_id: Option<i64>,
114        is_base: bool,
115        worktree_path: Option<&str>,
116    ) -> Result<Worktree> {
117        if !jj::is_jj_repository(repo_path) {
118            return Err(TrackError::NotJjRepository(repo_path.to_string()));
119        }
120
121        let resolved_path = if let Some(path) = worktree_path {
122            path.to_string()
123        } else {
124            naming::determine_worktree_path(repo_path, branch)?
125        };
126
127        jj::create_workspace_for_existing_bookmark(repo_path, &resolved_path, branch)?;
128
129        self.insert_worktree_record(task_id, &resolved_path, branch, repo_path, todo_id, is_base)
130    }
131
132    pub fn recreate_worktree(&self, worktree: &Worktree, force: bool) -> Result<Worktree> {
133        let repo_path = worktree.base_repo.as_ref().ok_or_else(|| {
134            TrackError::PathResolutionFailed(format!(
135                "worktree #{} has no base repository reference",
136                worktree.id
137            ))
138        })?;
139
140        if Path::new(&worktree.path).exists() {
141            if jj::has_uncommitted_changes(&worktree.path)? && !force {
142                return Err(TrackError::WorkspaceHasUncommittedChanges {
143                    path: worktree.path.clone(),
144                });
145            }
146
147            jj::remove_workspace(repo_path, &worktree.path)?;
148        }
149
150        let conn = self.db.get_connection();
151        conn.execute("DELETE FROM worktrees WHERE id = ?1", params![worktree.id])?;
152        self.db.increment_rev("worktrees")?;
153
154        self.add_existing_worktree(
155            worktree.task_id,
156            repo_path,
157            &worktree.branch,
158            worktree.todo_id,
159            worktree.is_base,
160            Some(&worktree.path),
161        )
162    }
163
164    pub fn get_worktree(&self, worktree_id: i64) -> Result<Worktree> {
165        let conn = self.db.get_connection();
166        let mut stmt = conn.prepare(
167            "SELECT id, task_id, path, branch, base_repo, status, created_at, todo_id, is_base FROM worktrees WHERE id = ?1"
168        )?;
169
170        stmt.query_row(params![worktree_id], map_worktree_row)
171            .map_err(|_| TrackError::WorktreeNotFound(worktree_id))
172    }
173
174    pub fn list_worktrees(&self, task_id: i64) -> Result<Vec<Worktree>> {
175        let conn = self.db.get_connection();
176        let mut stmt = conn.prepare(
177            "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"
178        )?;
179
180        let worktrees = stmt
181            .query_map(params![task_id], map_worktree_row)?
182            .collect::<std::result::Result<Vec<_>, _>>()?;
183
184        Ok(worktrees)
185    }
186
187    pub fn list_repo_links(&self, worktree_id: i64) -> Result<Vec<RepoLink>> {
188        let conn = self.db.get_connection();
189        let mut stmt = conn.prepare(
190            "SELECT id, worktree_id, url, kind, created_at FROM repo_links WHERE worktree_id = ?1 ORDER BY created_at ASC"
191        )?;
192
193        let repo_links = stmt
194            .query_map(params![worktree_id], |row| {
195                Ok(RepoLink {
196                    id: row.get(0)?,
197                    worktree_id: row.get(1)?,
198                    url: row.get(2)?,
199                    kind: row.get(3)?,
200                    created_at: parse_datetime(row.get::<_, String>(4)?)?,
201                })
202            })?
203            .collect::<std::result::Result<Vec<_>, _>>()?;
204
205        Ok(repo_links)
206    }
207
208    pub fn remove_worktree(&self, worktree_id: i64, keep_files: bool) -> Result<()> {
209        let worktree = self.get_worktree(worktree_id)?;
210
211        if !keep_files {
212            if let Some(base_repo) = &worktree.base_repo {
213                if Path::new(&worktree.path).exists() {
214                    jj::remove_workspace(base_repo, &worktree.path)?;
215                }
216            }
217        }
218
219        let conn = self.db.get_connection();
220        conn.execute("DELETE FROM worktrees WHERE id = ?1", params![worktree_id])?;
221        self.db.increment_rev("worktrees")?;
222        Ok(())
223    }
224
225    /// Removes legacy track-managed workspaces (base + per-TODO) for jj-task migration.
226    pub fn cleanup_legacy_worktrees(
227        &self,
228        task_id: i64,
229        force: bool,
230    ) -> Result<LegacyWorktreeCleanupOutcome> {
231        let mut outcome = LegacyWorktreeCleanupOutcome::default();
232        let worktrees: Vec<Worktree> = self
233            .list_worktrees(task_id)?
234            .into_iter()
235            .filter(is_legacy_worktree)
236            .collect();
237
238        for worktree in worktrees {
239            match self.try_remove_legacy_worktree(&worktree, force) {
240                Ok(()) => outcome.removed.push((worktree.id, worktree.path)),
241                Err(TrackError::WorkspaceHasUncommittedChanges { path }) => {
242                    outcome.skipped_dirty.push(path);
243                }
244                Err(err) => outcome.errors.push(err.to_string()),
245            }
246        }
247
248        Ok(outcome)
249    }
250
251    /// Lists legacy worktrees registered for a task.
252    pub fn list_legacy_worktrees(&self, task_id: i64) -> Result<Vec<Worktree>> {
253        Ok(self
254            .list_worktrees(task_id)?
255            .into_iter()
256            .filter(is_legacy_worktree)
257            .collect())
258    }
259
260    fn try_remove_legacy_worktree(&self, worktree: &Worktree, force: bool) -> Result<()> {
261        if !Path::new(&worktree.path).exists() {
262            return self.remove_worktree(worktree.id, true);
263        }
264
265        if jj::has_uncommitted_changes(&worktree.path)? && !force {
266            return Err(TrackError::WorkspaceHasUncommittedChanges {
267                path: worktree.path.clone(),
268            });
269        }
270
271        self.remove_worktree(worktree.id, false)
272    }
273
274    pub fn bookmark_exists_in_repo(&self, repo_path: &str, bookmark: &str) -> Result<bool> {
275        jj::bookmark_exists(repo_path, bookmark)
276    }
277
278    pub fn task_bookmark_name(&self, task_id: i64, ticket_id: Option<&str>) -> String {
279        naming::task_bookmark_name(task_id, ticket_id)
280    }
281
282    pub fn get_todo_branch_name(
283        &self,
284        task_id: i64,
285        ticket_id: Option<&str>,
286        todo_index: i64,
287    ) -> Result<String> {
288        naming::determine_branch_name(None, ticket_id, task_id, Some(todo_index))
289    }
290
291    pub fn complete_worktree_for_todo(&self, todo_id: i64) -> Result<Option<String>> {
292        let wt = match self.get_worktree_by_todo(todo_id)? {
293            Some(wt) => wt,
294            None => return Ok(None),
295        };
296
297        let merge_target_path = if let Some(base_wt) = self.get_base_worktree(wt.task_id)? {
298            base_wt.path
299        } else {
300            wt.base_repo
301                .clone()
302                .ok_or(TrackError::NoWorkspacePathsAvailable)?
303        };
304
305        if jj::has_uncommitted_changes(&wt.path)? {
306            return Err(TrackError::WorkspaceHasUncommittedChanges {
307                path: wt.path.clone(),
308            });
309        }
310
311        let ticket_id = self.get_task_ticket_id(wt.task_id)?;
312        let task_bookmark = naming::task_bookmark_name(wt.task_id, ticket_id.as_deref());
313
314        jj::integrate_todo_bookmark(&merge_target_path, &wt.branch, &task_bookmark)?;
315        self.remove_worktree(wt.id, false)?;
316
317        Ok(Some(wt.branch))
318    }
319
320    pub fn has_uncommitted_changes(&self, path: &str) -> Result<bool> {
321        jj::has_uncommitted_changes(path)
322    }
323
324    fn get_task_ticket_id(&self, task_id: i64) -> Result<Option<String>> {
325        let conn = self.db.get_connection();
326        let mut stmt = conn.prepare("SELECT ticket_id FROM tasks WHERE id = ?1")?;
327        let ticket_id = stmt
328            .query_row(params![task_id], |row| row.get::<_, Option<String>>(0))
329            .optional()?;
330        Ok(ticket_id.flatten())
331    }
332
333    fn get_worktree_by_todo(&self, todo_id: i64) -> Result<Option<Worktree>> {
334        let conn = self.db.get_connection();
335        let mut stmt = conn.prepare(
336            "SELECT id, task_id, path, branch, base_repo, status, created_at, todo_id, is_base FROM worktrees WHERE todo_id = ?1"
337        )?;
338
339        stmt.query_row(params![todo_id], map_worktree_row)
340            .optional()
341            .map_err(TrackError::from)
342    }
343
344    fn get_base_worktree(&self, task_id: i64) -> Result<Option<Worktree>> {
345        let conn = self.db.get_connection();
346        let mut stmt = conn.prepare(
347            "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"
348        )?;
349
350        stmt.query_row(params![task_id], map_worktree_row)
351            .optional()
352            .map_err(TrackError::from)
353    }
354}
355
356fn map_worktree_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Worktree> {
357    let is_base: i32 = row.get(8).unwrap_or(0);
358    Ok(Worktree {
359        id: row.get(0)?,
360        task_id: row.get(1)?,
361        path: row.get(2)?,
362        branch: row.get(3)?,
363        base_repo: row.get(4)?,
364        status: row.get(5)?,
365        created_at: parse_datetime(row.get::<_, String>(6)?)?,
366        todo_id: row.get(7)?,
367        is_base: is_base != 0,
368    })
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::services::TaskService;
375    use std::fs;
376    use std::process::Command;
377
378    fn setup_db() -> Database {
379        Database::new_in_memory().unwrap()
380    }
381
382    fn jj_available() -> bool {
383        Command::new("jj")
384            .arg("--version")
385            .output()
386            .map(|output| output.status.success())
387            .unwrap_or(false)
388    }
389
390    fn require_jj() -> bool {
391        if !jj_available() {
392            eprintln!("Skipping test: jj binary not available");
393            return false;
394        }
395        true
396    }
397
398    fn init_jj_repo(path: &str) {
399        let output = Command::new("jj")
400            .args(["git", "init", path])
401            .output()
402            .expect("failed to run jj git init");
403        assert!(
404            output.status.success(),
405            "jj git init failed: {}",
406            String::from_utf8_lossy(&output.stderr)
407        );
408    }
409
410    fn describe_change(path: &str, message: &str) {
411        let output = Command::new("jj")
412            .args(["-R", path, "describe", "-m", message])
413            .output()
414            .expect("failed to run jj describe");
415        assert!(
416            output.status.success(),
417            "jj describe failed: {}",
418            String::from_utf8_lossy(&output.stderr)
419        );
420    }
421
422    fn create_bookmark(path: &str, name: &str) {
423        let output = Command::new("jj")
424            .args(["-R", path, "bookmark", "create", name, "-r", "@"])
425            .output()
426            .expect("failed to run jj bookmark create");
427        assert!(
428            output.status.success(),
429            "jj bookmark create failed: {}",
430            String::from_utf8_lossy(&output.stderr)
431        );
432    }
433
434    #[test]
435    fn cleanup_removes_db_record_when_path_missing() {
436        let db = setup_db();
437        let task_service = TaskService::new(&db);
438        let service = WorktreeService::new(&db);
439        let task = task_service.create_task("Task", None, None, None).unwrap();
440
441        let now = Utc::now().to_rfc3339();
442        db.get_connection()
443            .execute(
444                "INSERT INTO worktrees (task_id, path, branch, base_repo, status, created_at, todo_id, is_base) VALUES (?1, ?2, ?3, ?4, 'active', ?5, NULL, 1)",
445                rusqlite::params![task.id, "/missing/path", "task/task-1", "/repo", now],
446            )
447            .unwrap();
448
449        let outcome = service.cleanup_legacy_worktrees(task.id, false).unwrap();
450        assert_eq!(outcome.removed.len(), 1);
451        assert!(service.list_worktrees(task.id).unwrap().is_empty());
452    }
453
454    #[test]
455    fn test_add_worktree_and_get() {
456        if !require_jj() {
457            return;
458        }
459
460        let db = setup_db();
461        let task_service = TaskService::new(&db);
462        let service = WorktreeService::new(&db);
463
464        let task = task_service
465            .create_task("Test Task", None, Some("PROJ-100"), None)
466            .unwrap();
467
468        let temp_dir = tempfile::tempdir().unwrap();
469        let repo_path = temp_dir.path().to_str().unwrap();
470        init_jj_repo(repo_path);
471        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
472        describe_change(repo_path, "Initial commit");
473
474        let worktree = service
475            .add_worktree(task.id, repo_path, None, Some("PROJ-100"), None, true)
476            .unwrap();
477
478        assert_eq!(worktree.task_id, task.id);
479        assert_eq!(worktree.branch, "task/PROJ-100");
480        assert!(worktree.is_base);
481
482        let retrieved = service.get_worktree(worktree.id).unwrap();
483        assert_eq!(retrieved.id, worktree.id);
484        assert_eq!(retrieved.branch, "task/PROJ-100");
485    }
486
487    #[test]
488    fn test_list_worktrees() {
489        use crate::services::TodoService;
490        if !require_jj() {
491            return;
492        }
493
494        let db = setup_db();
495        let task_service = TaskService::new(&db);
496        let todo_service = TodoService::new(&db);
497        let service = WorktreeService::new(&db);
498
499        let task = task_service
500            .create_task("Test Task", None, Some("PROJ-200"), None)
501            .unwrap();
502        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
503
504        let temp_dir = tempfile::tempdir().unwrap();
505        let repo_path = temp_dir.path().to_str().unwrap();
506        init_jj_repo(repo_path);
507        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
508        describe_change(repo_path, "Initial commit");
509
510        service
511            .add_worktree(task.id, repo_path, None, Some("PROJ-200"), None, true)
512            .unwrap();
513        service
514            .add_worktree(
515                task.id,
516                repo_path,
517                None,
518                Some("PROJ-200"),
519                Some(todo.id),
520                false,
521            )
522            .unwrap();
523
524        let worktrees = service.list_worktrees(task.id).unwrap();
525        assert_eq!(worktrees.len(), 2);
526        assert!(worktrees.iter().any(|wt| wt.is_base));
527        assert!(worktrees.iter().any(|wt| !wt.is_base));
528    }
529
530    #[test]
531    fn test_remove_worktree() {
532        use crate::services::TodoService;
533        if !require_jj() {
534            return;
535        }
536
537        let db = setup_db();
538        let task_service = TaskService::new(&db);
539        let todo_service = TodoService::new(&db);
540        let service = WorktreeService::new(&db);
541
542        let task = task_service
543            .create_task("Test Task", None, Some("PROJ-300"), None)
544            .unwrap();
545        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
546
547        let temp_dir = tempfile::tempdir().unwrap();
548        let repo_path = temp_dir.path().to_str().unwrap();
549        init_jj_repo(repo_path);
550        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
551        describe_change(repo_path, "Initial commit");
552
553        service
554            .add_worktree(task.id, repo_path, None, Some("PROJ-300"), None, true)
555            .unwrap();
556        let todo_wt = service
557            .add_worktree(
558                task.id,
559                repo_path,
560                None,
561                Some("PROJ-300"),
562                Some(todo.id),
563                false,
564            )
565            .unwrap();
566
567        assert!(Path::new(&todo_wt.path).exists());
568        service.remove_worktree(todo_wt.id, false).unwrap();
569        assert!(!Path::new(&todo_wt.path).exists());
570        assert_eq!(service.list_worktrees(task.id).unwrap().len(), 1);
571    }
572
573    #[test]
574    fn test_get_base_worktree() {
575        if !require_jj() {
576            return;
577        }
578
579        let db = setup_db();
580        let task_service = TaskService::new(&db);
581        let service = WorktreeService::new(&db);
582
583        let task = task_service
584            .create_task("Test Task", None, Some("PROJ-400"), None)
585            .unwrap();
586
587        let temp_dir = tempfile::tempdir().unwrap();
588        let repo_path = temp_dir.path().to_str().unwrap();
589        init_jj_repo(repo_path);
590        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
591        describe_change(repo_path, "Initial commit");
592
593        service
594            .add_worktree(task.id, repo_path, None, Some("PROJ-400"), None, true)
595            .unwrap();
596
597        let base = service.get_base_worktree(task.id).unwrap();
598        assert!(base.is_some());
599        assert!(base.unwrap().is_base);
600    }
601
602    #[test]
603    fn test_get_worktree_by_todo() {
604        use crate::services::TodoService;
605        if !require_jj() {
606            return;
607        }
608
609        let db = setup_db();
610        let task_service = TaskService::new(&db);
611        let todo_service = TodoService::new(&db);
612        let service = WorktreeService::new(&db);
613
614        let task = task_service
615            .create_task("Test Task", None, Some("PROJ-500"), None)
616            .unwrap();
617        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
618
619        let temp_dir = tempfile::tempdir().unwrap();
620        let repo_path = temp_dir.path().to_str().unwrap();
621        init_jj_repo(repo_path);
622        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
623        describe_change(repo_path, "Initial commit");
624
625        service
626            .add_worktree(task.id, repo_path, None, Some("PROJ-500"), None, true)
627            .unwrap();
628        let todo_wt = service
629            .add_worktree(
630                task.id,
631                repo_path,
632                None,
633                Some("PROJ-500"),
634                Some(todo.id),
635                false,
636            )
637            .unwrap();
638
639        let found = service.get_worktree_by_todo(todo.id).unwrap();
640        assert!(found.is_some());
641        assert_eq!(found.unwrap().id, todo_wt.id);
642    }
643
644    #[test]
645    fn test_get_worktree_by_todo_not_found() {
646        let db = setup_db();
647        let service = WorktreeService::new(&db);
648        assert!(service.get_worktree_by_todo(999).unwrap().is_none());
649    }
650
651    #[test]
652    fn test_get_worktree_by_todo_is_base_field() {
653        use crate::services::TodoService;
654        if !require_jj() {
655            return;
656        }
657
658        let db = setup_db();
659        let task_service = TaskService::new(&db);
660        let todo_service = TodoService::new(&db);
661        let service = WorktreeService::new(&db);
662
663        let task = task_service
664            .create_task("Test Task", None, Some("PROJ-600"), None)
665            .unwrap();
666        let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
667
668        let temp_dir = tempfile::tempdir().unwrap();
669        let repo_path = temp_dir.path().to_str().unwrap();
670        init_jj_repo(repo_path);
671        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
672        describe_change(repo_path, "Initial commit");
673
674        service
675            .add_worktree(task.id, repo_path, None, Some("PROJ-600"), None, true)
676            .unwrap();
677        service
678            .add_worktree(
679                task.id,
680                repo_path,
681                None,
682                Some("PROJ-600"),
683                Some(todo.id),
684                false,
685            )
686            .unwrap();
687
688        let todo_wt = service.get_worktree_by_todo(todo.id).unwrap().unwrap();
689        assert!(!todo_wt.is_base);
690    }
691
692    #[test]
693    fn test_add_worktree_with_invalid_todo_id() {
694        if !require_jj() {
695            return;
696        }
697
698        let db = setup_db();
699        let service = WorktreeService::new(&db);
700        let temp_dir = tempfile::tempdir().unwrap();
701        let repo_path = temp_dir.path().to_str().unwrap();
702        init_jj_repo(repo_path);
703
704        let result = service.add_worktree(1, repo_path, None, Some("PROJ-999"), Some(999), false);
705        assert!(matches!(result, Err(TrackError::TodoNotFound(999))));
706    }
707
708    #[test]
709    fn test_add_worktree_validation() {
710        let db = setup_db();
711        let service = WorktreeService::new(&db);
712
713        let path = if cfg!(windows) {
714            "C:\\nonexistent\\path"
715        } else {
716            "/nonexistent/path"
717        };
718        let result = service.add_worktree(1, path, Some("b"), None, None, false);
719        assert!(matches!(result, Err(TrackError::NotJjRepository(_))));
720
721        if !require_jj() {
722            return;
723        }
724
725        let temp_dir = tempfile::tempdir().unwrap();
726        let repo_path = temp_dir.path().to_str().unwrap();
727        init_jj_repo(repo_path);
728        fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
729        describe_change(repo_path, "Initial commit");
730        create_bookmark(repo_path, "existing-branch");
731
732        let result = service.add_worktree(1, repo_path, Some("existing-branch"), None, None, false);
733        assert!(matches!(result, Err(TrackError::BookmarkExists(_))));
734    }
735}