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#[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
20pub 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 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 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}