Skip to main content

track/use_cases/
migrate_legacy_worktrees.rs

1use crate::db::Database;
2use crate::models::{jj_slug, Task, TaskStatus};
3use crate::services::{LegacyWorktreeCleanupOutcome, TaskService, TodoService, WorktreeService};
4use crate::utils::Result;
5
6/// Per-task summary for legacy worktree migration.
7#[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/// Result of scanning or applying legacy worktree migration.
20#[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
28/// Clears legacy `worktree_requested` flags and removes track-managed worktree records.
29pub 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}