Skip to main content

track/use_cases/
sync_task.rs

1use crate::db::Database;
2use crate::models::{jj_slug, Task, TodoStatus, VcsMode};
3use crate::services::{git_worktree, RepoService, TaskService, TodoService, WorktreeService};
4use crate::utils::{Result, TrackError};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8/// Per-repository result from a sync run.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum RepoSyncOutcome {
11    Missing,
12    BookmarkCreated {
13        base_ref: String,
14        edit_ok: bool,
15    },
16    BookmarkExists {
17        edit_ok: bool,
18    },
19    BookmarkCreateFailed {
20        base_ref: String,
21        detail: String,
22    },
23    WorktreeCreated {
24        base_ref: String,
25        workspace_path: String,
26    },
27    WorktreeExists {
28        workspace_path: String,
29    },
30    WorktreeCreateFailed {
31        base_ref: String,
32        detail: String,
33    },
34}
35
36/// A TODO workspace created during sync.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WorkspaceCreated {
39    pub todo_index: i64,
40    pub todo_content: String,
41    pub repo_path: String,
42    pub workspace_path: String,
43    pub branch: String,
44}
45
46/// A workspace creation failure during sync (non-fatal).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct WorkspaceCreateError {
49    pub todo_index: i64,
50    pub repo_path: String,
51    pub detail: String,
52}
53
54/// Result of syncing the current task's VCS workspaces.
55#[derive(Debug, Clone)]
56pub struct SyncTaskOutcome {
57    pub vcs_mode: VcsMode,
58    pub task: Task,
59    pub task_bookmark: String,
60    pub repos: Vec<(String, RepoSyncOutcome)>,
61    pub workspaces_created: Vec<WorkspaceCreated>,
62    pub workspace_errors: Vec<WorkspaceCreateError>,
63}
64
65/// Syncs task bookmarks/worktrees across registered repos and creates pending TODO workspaces.
66pub struct SyncTaskUseCase<'a> {
67    db: &'a Database,
68}
69
70impl<'a> SyncTaskUseCase<'a> {
71    pub fn new(db: &'a Database) -> Self {
72        Self { db }
73    }
74
75    pub fn execute(&self, task_id: i64, legacy: bool) -> Result<SyncTaskOutcome> {
76        let vcs_mode = self.db.get_vcs_mode()?;
77        let task_service = TaskService::new(self.db);
78        let task = task_service.get_task(task_id)?;
79        let repo_service = RepoService::new(self.db);
80        let repos = repo_service.list_repos(task_id)?;
81
82        if repos.is_empty() {
83            return Err(TrackError::NoRepositoriesRegistered);
84        }
85
86        let slug = jj_slug(&task);
87        let worktree_service = WorktreeService::new(self.db);
88        let existing_worktrees = worktree_service.list_worktrees(task_id)?;
89
90        if vcs_mode == VcsMode::Jj && !legacy {
91            let todo_service = TodoService::new(self.db);
92            let todos = todo_service.list_todos(task_id)?;
93            if !crate::models::legacy_worktree_pending(&todos) {
94                return Err(TrackError::SyncUseJjTask { slug });
95            }
96        }
97
98        let task_bookmark = match vcs_mode {
99            VcsMode::Jj => {
100                let worktree_service = WorktreeService::new(self.db);
101                worktree_service.task_bookmark_name(task.id, task.ticket_id.as_deref())
102            }
103            VcsMode::Git => git_worktree::git_branch_name(&slug),
104        };
105
106        let worktree_service = WorktreeService::new(self.db);
107
108        let mut repo_outcomes = Vec::new();
109
110        for repo in &repos {
111            let outcome = match vcs_mode {
112                VcsMode::Jj => {
113                    self.sync_repo_jj(&worktree_service, repo, &task_bookmark, &existing_worktrees)?
114                }
115                VcsMode::Git => self.sync_repo_git(repo, &slug)?,
116            };
117            repo_outcomes.push((repo.repo_path.clone(), outcome));
118        }
119
120        let mut workspaces_created = Vec::new();
121        let mut workspace_errors = Vec::new();
122
123        if vcs_mode == VcsMode::Jj {
124            let todo_service = TodoService::new(self.db);
125            let todos = todo_service.list_todos(task_id)?;
126
127            for todo in todos {
128                if todo.worktree_requested && todo.status != TodoStatus::Done {
129                    let worktrees = worktree_service.list_worktrees(task_id)?;
130                    let exists = worktrees.iter().any(|wt| wt.todo_id == Some(todo.id));
131                    if exists {
132                        continue;
133                    }
134
135                    for repo in &repos {
136                        match worktree_service.add_worktree(
137                            task_id,
138                            &repo.repo_path,
139                            None,
140                            task.ticket_id.as_deref(),
141                            Some(todo.id),
142                            false,
143                        ) {
144                            Ok(wt) => workspaces_created.push(WorkspaceCreated {
145                                todo_index: todo.task_index,
146                                todo_content: todo.content.clone(),
147                                repo_path: repo.repo_path.clone(),
148                                workspace_path: wt.path,
149                                branch: wt.branch,
150                            }),
151                            Err(err) => workspace_errors.push(WorkspaceCreateError {
152                                todo_index: todo.task_index,
153                                repo_path: repo.repo_path.clone(),
154                                detail: err.to_string(),
155                            }),
156                        }
157                    }
158                }
159            }
160        }
161
162        Ok(SyncTaskOutcome {
163            vcs_mode,
164            task,
165            task_bookmark,
166            repos: repo_outcomes,
167            workspaces_created,
168            workspace_errors,
169        })
170    }
171
172    fn sync_repo_jj(
173        &self,
174        worktree_service: &WorktreeService<'_>,
175        repo: &crate::models::TaskRepo,
176        task_bookmark: &str,
177        existing_worktrees: &[crate::models::Worktree],
178    ) -> Result<RepoSyncOutcome> {
179        if !Path::new(&repo.repo_path).exists() {
180            return Ok(RepoSyncOutcome::Missing);
181        }
182
183        let status_output = Command::new("jj")
184            .current_dir(&repo.repo_path)
185            .args(["-R", &repo.repo_path, "diff", "--summary"])
186            .output()?;
187
188        if !status_output.status.success() {
189            return Err(TrackError::FailedRepoStatusCheck(repo.repo_path.clone()));
190        }
191
192        if Self::base_workspace_has_changes(
193            &repo.repo_path,
194            &status_output.stdout,
195            existing_worktrees,
196        )? {
197            return Err(TrackError::RepoHasPendingChanges(repo.repo_path.clone()));
198        }
199
200        let bookmark_exists =
201            worktree_service.bookmark_exists_in_repo(&repo.repo_path, task_bookmark)?;
202
203        if !bookmark_exists {
204            let base_ref = repo
205                .base_branch
206                .clone()
207                .or_else(|| repo.base_commit_hash.clone())
208                .unwrap_or_else(|| "@".to_string());
209
210            let create_result = Command::new("jj")
211                .args([
212                    "-R",
213                    &repo.repo_path,
214                    "bookmark",
215                    "create",
216                    task_bookmark,
217                    "-r",
218                    &base_ref,
219                ])
220                .output()?;
221
222            if create_result.status.success() {
223                let edit_ok = try_edit_workspace(&repo.repo_path, task_bookmark);
224                return Ok(RepoSyncOutcome::BookmarkCreated { base_ref, edit_ok });
225            }
226
227            let detail = String::from_utf8_lossy(&create_result.stderr)
228                .trim()
229                .to_string();
230            return Ok(RepoSyncOutcome::BookmarkCreateFailed { base_ref, detail });
231        }
232
233        let edit_ok = try_edit_workspace(&repo.repo_path, task_bookmark);
234        Ok(RepoSyncOutcome::BookmarkExists { edit_ok })
235    }
236
237    fn sync_repo_git(&self, repo: &crate::models::TaskRepo, slug: &str) -> Result<RepoSyncOutcome> {
238        if !Path::new(&repo.repo_path).exists() {
239            return Ok(RepoSyncOutcome::Missing);
240        }
241
242        if !git_worktree::is_git_repository(&repo.repo_path) {
243            return Err(TrackError::NotGitRepository(repo.repo_path.clone()));
244        }
245
246        let worktree_path = git_worktree::git_worktree_path(&repo.repo_path, slug);
247        if git_worktree::git_worktree_exists(&worktree_path) {
248            return Ok(RepoSyncOutcome::WorktreeExists {
249                workspace_path: worktree_path,
250            });
251        }
252
253        if git_worktree::base_repo_has_changes(&repo.repo_path, slug)? {
254            return Err(TrackError::RepoHasPendingChanges(repo.repo_path.clone()));
255        }
256
257        let base_ref = repo
258            .base_branch
259            .clone()
260            .or_else(|| repo.base_commit_hash.clone())
261            .unwrap_or_else(|| "HEAD".to_string());
262
263        match git_worktree::create_git_worktree(&repo.repo_path, slug, &base_ref) {
264            Ok(path) => Ok(RepoSyncOutcome::WorktreeCreated {
265                base_ref,
266                workspace_path: path,
267            }),
268            Err(err) => Ok(RepoSyncOutcome::WorktreeCreateFailed {
269                base_ref,
270                detail: err.to_string(),
271            }),
272        }
273    }
274
275    fn base_workspace_has_changes(
276        repo_path: &str,
277        status_stdout: &[u8],
278        existing_worktrees: &[crate::models::Worktree],
279    ) -> Result<bool> {
280        let repo_root = Path::new(repo_path)
281            .canonicalize()
282            .map_err(|e| TrackError::PathResolutionFailed(e.to_string()))?;
283        let repo_worktrees: Vec<PathBuf> = existing_worktrees
284            .iter()
285            .filter(|wt| wt.base_repo.as_deref() == Some(repo_path))
286            .filter_map(|wt| Path::new(&wt.path).canonicalize().ok())
287            .filter_map(|wt_path| wt_path.strip_prefix(&repo_root).ok().map(PathBuf::from))
288            .collect();
289
290        let status_stdout = String::from_utf8_lossy(status_stdout);
291        for line in status_stdout.lines() {
292            let path = line.split_whitespace().last().unwrap_or("").trim();
293            if path.is_empty() {
294                continue;
295            }
296
297            let path = Path::new(path);
298            let resolved = Self::resolve_diff_path(&repo_root, path);
299            let is_worktree = resolved
300                .as_ref()
301                .and_then(|resolved| resolved.strip_prefix(&repo_root).ok())
302                .is_some_and(|relative| {
303                    repo_worktrees
304                        .iter()
305                        .any(|worktree_path| relative.starts_with(worktree_path))
306                });
307
308            if !is_worktree {
309                return Ok(true);
310            }
311        }
312
313        Ok(false)
314    }
315
316    fn resolve_diff_path(repo_root: &Path, path: &Path) -> Option<PathBuf> {
317        if path.is_absolute() {
318            return path.canonicalize().ok();
319        }
320
321        repo_root
322            .join(path)
323            .canonicalize()
324            .ok()
325            .or_else(|| std::env::current_dir().ok()?.join(path).canonicalize().ok())
326    }
327}
328
329fn try_edit_workspace(repo_path: &str, task_bookmark: &str) -> bool {
330    Command::new("jj")
331        .args(["-R", repo_path, "edit", task_bookmark])
332        .status()
333        .map(|status| status.success())
334        .unwrap_or(false)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::db::Database;
341    use crate::services::TodoService;
342
343    #[test]
344    fn sync_requires_registered_repos() {
345        let db = Database::new_in_memory().unwrap();
346        let task_service = TaskService::new(&db);
347        let task = task_service
348            .create_task("Sync task", None, None, None)
349            .unwrap();
350
351        let result = SyncTaskUseCase::new(&db).execute(task.id, false);
352        assert!(matches!(result, Err(TrackError::NoRepositoriesRegistered)));
353    }
354
355    #[test]
356    fn sync_rejects_jj_mode_without_legacy_or_worktree_todos() {
357        let db = Database::new_in_memory().unwrap();
358        let task_service = TaskService::new(&db);
359        let task = task_service
360            .create_task("Modern", None, Some("MOD-1"), None)
361            .unwrap();
362        let todo_service = TodoService::new(&db);
363        todo_service.add_todo(task.id, "Implement", false).unwrap();
364
365        db.get_connection()
366            .execute(
367                "INSERT INTO task_repos (task_id, task_index, repo_path, created_at) VALUES (?1, 1, '/repo', datetime('now'))",
368                rusqlite::params![task.id],
369            )
370            .unwrap();
371
372        let result = SyncTaskUseCase::new(&db).execute(task.id, false);
373        assert!(matches!(result, Err(TrackError::SyncUseJjTask { .. })));
374    }
375}