1use crate::db::Database;
2use crate::models::{jj_slug, Task, TaskStatus};
3use crate::services::{LegacyWorktreeCleanupOutcome, TaskService, TodoService, WorktreeService};
4use crate::utils::Result;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct LegacyWorktreeTaskReport {
9 pub task_id: i64,
10 pub task_name: String,
11 pub jj_slug: String,
12 pub flagged_todos: usize,
13 pub legacy_worktrees: usize,
14 pub worktrees_removed: usize,
15 pub worktrees_skipped_dirty: Vec<String>,
16 pub legacy_worktree_paths: Vec<String>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MigrateLegacyWorktreesOutcome {
22 pub dry_run: bool,
23 pub tasks: Vec<LegacyWorktreeTaskReport>,
24 pub todos_cleared: usize,
25 pub worktrees_removed: usize,
26}
27
28pub struct MigrateLegacyWorktreesUseCase<'a> {
30 db: &'a Database,
31}
32
33impl<'a> MigrateLegacyWorktreesUseCase<'a> {
34 pub fn new(db: &'a Database) -> Self {
35 Self { db }
36 }
37
38 pub fn execute(
39 &self,
40 task_id: Option<i64>,
41 dry_run: bool,
42 force: bool,
43 ) -> Result<MigrateLegacyWorktreesOutcome> {
44 let task_service = TaskService::new(self.db);
45 let todo_service = TodoService::new(self.db);
46 let worktree_service = WorktreeService::new(self.db);
47
48 let tasks: Vec<Task> = match task_id {
49 Some(id) => vec![task_service.get_task(id)?],
50 None => task_service
51 .list_tasks(true)?
52 .into_iter()
53 .filter(|task| task.status == TaskStatus::Active)
54 .collect(),
55 };
56
57 let mut reports = Vec::new();
58 let mut todos_cleared = 0;
59 let mut worktrees_removed = 0;
60
61 for task in tasks {
62 let todos = todo_service.list_todos(task.id)?;
63 let flagged = todos.iter().filter(|todo| todo.worktree_requested).count();
64 let legacy_worktrees = worktree_service.list_legacy_worktrees(task.id)?;
65 if flagged == 0 && legacy_worktrees.is_empty() {
66 continue;
67 }
68
69 let slug = jj_slug(&task);
70 let legacy_paths: Vec<String> = legacy_worktrees
71 .iter()
72 .map(|worktree| worktree.path.clone())
73 .collect();
74
75 let cleanup = if dry_run {
76 LegacyWorktreeCleanupOutcome::default()
77 } else {
78 if flagged > 0 {
79 todos_cleared += todo_service.clear_legacy_worktree_flags(Some(task.id))?;
80 }
81 worktree_service.cleanup_legacy_worktrees(task.id, force)?
82 };
83
84 worktrees_removed += cleanup.removed.len();
85
86 reports.push(LegacyWorktreeTaskReport {
87 task_id: task.id,
88 task_name: task.name.clone(),
89 jj_slug: slug,
90 flagged_todos: flagged,
91 legacy_worktrees: legacy_paths.len(),
92 worktrees_removed: if dry_run { 0 } else { cleanup.removed.len() },
93 worktrees_skipped_dirty: cleanup.skipped_dirty,
94 legacy_worktree_paths: legacy_paths,
95 });
96 }
97
98 if dry_run {
99 todos_cleared = reports.iter().map(|report| report.flagged_todos).sum();
100 worktrees_removed = reports.iter().map(|report| report.legacy_worktrees).sum();
101 }
102
103 Ok(MigrateLegacyWorktreesOutcome {
104 dry_run,
105 tasks: reports,
106 todos_cleared,
107 worktrees_removed,
108 })
109 }
110
111 pub fn resolve_task_id(&self, task_ref: Option<&str>) -> Result<Option<i64>> {
112 let task_service = TaskService::new(self.db);
113 match task_ref {
114 Some(reference) => Ok(Some(task_service.resolve_task_id(reference)?)),
115 None => Ok(None),
116 }
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::models::TodoAddOptions;
124 use crate::services::WorktreeService;
125 use chrono::Utc;
126
127 #[test]
128 fn migrate_clears_legacy_flags() {
129 let db = Database::new_in_memory().unwrap();
130 let task_service = TaskService::new(&db);
131 let todo_service = TodoService::new(&db);
132 let task = task_service
133 .create_task("Legacy", None, None, None)
134 .unwrap();
135 todo_service
136 .add_todo(
137 task.id,
138 "Old style",
139 TodoAddOptions::from_flags(true, false),
140 )
141 .unwrap();
142
143 let outcome = MigrateLegacyWorktreesUseCase::new(&db)
144 .execute(Some(task.id), false, false)
145 .unwrap();
146
147 assert_eq!(outcome.todos_cleared, 1);
148 let todos = todo_service.list_todos(task.id).unwrap();
149 assert!(!todos[0].worktree_requested);
150 assert!(todos[0].requires_workspace);
151 }
152
153 #[test]
154 fn migrate_dry_run_does_not_clear() {
155 let db = Database::new_in_memory().unwrap();
156 let task_service = TaskService::new(&db);
157 let todo_service = TodoService::new(&db);
158 let task = task_service
159 .create_task("Legacy", None, None, None)
160 .unwrap();
161 todo_service
162 .add_todo(
163 task.id,
164 "Old style",
165 TodoAddOptions::from_flags(true, false),
166 )
167 .unwrap();
168
169 let outcome = MigrateLegacyWorktreesUseCase::new(&db)
170 .execute(Some(task.id), true, false)
171 .unwrap();
172
173 assert_eq!(outcome.todos_cleared, 1);
174 let todos = todo_service.list_todos(task.id).unwrap();
175 assert!(todos[0].worktree_requested);
176 }
177
178 #[test]
179 fn migrate_removes_orphan_worktree_records() {
180 let db = Database::new_in_memory().unwrap();
181 let task_service = TaskService::new(&db);
182 let todo_service = TodoService::new(&db);
183 let worktree_service = WorktreeService::new(&db);
184
185 let task = task_service
186 .create_task("Orphan WT", None, None, None)
187 .unwrap();
188 let todo = todo_service
189 .add_todo(task.id, "Done legacy", TodoAddOptions::default())
190 .unwrap();
191 todo_service.mark_done(todo.id).unwrap();
192
193 let now = Utc::now().to_rfc3339();
194 db.get_connection()
195 .execute(
196 "INSERT INTO worktrees (task_id, path, branch, base_repo, status, created_at, todo_id, is_base) VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?6, 0)",
197 rusqlite::params![task.id, "/gone/workspace", "task-1-todo-1", "/repo", now, todo.id],
198 )
199 .unwrap();
200
201 let outcome = MigrateLegacyWorktreesUseCase::new(&db)
202 .execute(Some(task.id), false, false)
203 .unwrap();
204
205 assert_eq!(outcome.worktrees_removed, 1);
206 assert!(worktree_service.list_worktrees(task.id).unwrap().is_empty());
207 }
208
209 #[test]
210 fn migrate_dry_run_reports_orphan_worktrees() {
211 let db = Database::new_in_memory().unwrap();
212 let task_service = TaskService::new(&db);
213 let task = task_service
214 .create_task("Orphan WT", None, None, None)
215 .unwrap();
216
217 let now = Utc::now().to_rfc3339();
218 db.get_connection()
219 .execute(
220 "INSERT INTO worktrees (task_id, path, branch, base_repo, status, created_at, todo_id, is_base) VALUES (?1, '/gone/base', 'task/task-1', '/repo', 'active', ?2, NULL, 1)",
221 rusqlite::params![task.id, now],
222 )
223 .unwrap();
224
225 let outcome = MigrateLegacyWorktreesUseCase::new(&db)
226 .execute(Some(task.id), true, false)
227 .unwrap();
228
229 assert_eq!(outcome.tasks.len(), 1);
230 assert_eq!(outcome.worktrees_removed, 1);
231 assert_eq!(outcome.tasks[0].legacy_worktree_paths, vec!["/gone/base"]);
232 }
233}