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 if !self.is_jj_repository(repo_path)? {
30 return Err(TrackError::NotJjRepository(repo_path.to_string()));
31 }
32
33 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 let branch_name = self.determine_branch_name(branch, ticket_id, task_id, todo_index)?;
53
54 if self.bookmark_exists(repo_path, &branch_name)? {
56 return Err(TrackError::BookmarkExists(branch_name));
57 }
58
59 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 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 if let Some(base_repo) = &worktree.base_repo {
235 self.remove_jj_workspace(base_repo, &worktree.path)?;
236 }
237 }
238
239 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 (Some(b), Some(t), _) => Ok(format!("{}/{}", t, b)),
286 (Some(b), None, _) => Ok(b.to_string()),
287
288 (None, Some(t), Some(todo)) => Ok(format!("{}-todo-{}", t, todo)),
290 (None, None, Some(todo)) => Ok(format!("task-{}-todo-{}", task_id, todo)),
291
292 (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 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 let merge_target_path = if let Some(base_wt) = self.get_base_worktree(wt.task_id)? {
443 base_wt.path
445 } else {
446 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 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 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 assert!(!service.has_uncommitted_changes(path).unwrap());
745
746 File::create(temp_dir.path().join("test.txt")).unwrap();
748 assert!(service.has_uncommitted_changes(path).unwrap());
749
750 describe_change(path, "init");
752 new_change(path);
753 assert!(!service.has_uncommitted_changes(path).unwrap());
754
755 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 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 let task = task_service
796 .create_task("Test Task", None, Some("PROJ-100"), None)
797 .unwrap();
798
799 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 fs::write(temp_dir.path().join("README.md"), "# Test").unwrap();
807 describe_change(repo_path, "Initial commit");
808
809 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 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 let task = task_service
839 .create_task("Test Task", None, Some("PROJ-200"), None)
840 .unwrap();
841
842 let todo = todo_service.add_todo(task.id, "Test TODO", true).unwrap();
844
845 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 service
855 .add_worktree(task.id, repo_path, None, Some("PROJ-200"), None, true)
856 .unwrap();
857
858 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 let worktrees = service.list_worktrees(task.id).unwrap();
872 assert_eq!(worktrees.len(), 2);
873
874 let base = worktrees.iter().find(|w| w.is_base).unwrap();
876 assert_eq!(base.branch, "task/PROJ-200");
877
878 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 let worktrees_before = service.list_worktrees(task.id).unwrap();
913 assert_eq!(worktrees_before.len(), 1);
914
915 service.remove_worktree(worktree.id, false).unwrap();
917
918 let worktrees_after = service.list_worktrees(task.id).unwrap();
920 assert_eq!(worktrees_after.len(), 0);
921
922 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 let base_wt = service
955 .add_worktree(task.id, repo_path, None, Some("PROJ-400"), None, true)
956 .unwrap();
957
958 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 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 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 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 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 service
1075 .add_worktree(
1076 task.id,
1077 repo_path,
1078 None,
1079 Some("PROJ-502"),
1080 Some(todo.id),
1081 false, )
1083 .unwrap();
1084
1085 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, repo_path,
1108 None,
1109 None,
1110 Some(999), 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 let temp_dir = tempfile::tempdir().unwrap();
1128 let path = temp_dir.path().to_str().unwrap();
1129 let result = service.add_worktree(1, path, Some("b"), None, None, false);
1132 assert!(matches!(result, Err(TrackError::NotJjRepository(_))));
1133
1134 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 assert!(matches!(result, Err(TrackError::BookmarkExists(_))));
1148 }
1149}