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) -> 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 task_bookmark = match vcs_mode {
88            VcsMode::Jj => {
89                let worktree_service = WorktreeService::new(self.db);
90                worktree_service.task_bookmark_name(task.id, task.ticket_id.as_deref())
91            }
92            VcsMode::Git => git_worktree::git_branch_name(&slug),
93        };
94
95        let worktree_service = WorktreeService::new(self.db);
96        let existing_worktrees = worktree_service.list_worktrees(task_id)?;
97
98        let mut repo_outcomes = Vec::new();
99
100        for repo in &repos {
101            let outcome = match vcs_mode {
102                VcsMode::Jj => {
103                    self.sync_repo_jj(&worktree_service, repo, &task_bookmark, &existing_worktrees)?
104                }
105                VcsMode::Git => self.sync_repo_git(repo, &slug)?,
106            };
107            repo_outcomes.push((repo.repo_path.clone(), outcome));
108        }
109
110        let mut workspaces_created = Vec::new();
111        let mut workspace_errors = Vec::new();
112
113        if vcs_mode == VcsMode::Jj {
114            let todo_service = TodoService::new(self.db);
115            let todos = todo_service.list_todos(task_id)?;
116
117            for todo in todos {
118                if todo.worktree_requested && todo.status != TodoStatus::Done {
119                    let worktrees = worktree_service.list_worktrees(task_id)?;
120                    let exists = worktrees.iter().any(|wt| wt.todo_id == Some(todo.id));
121                    if exists {
122                        continue;
123                    }
124
125                    for repo in &repos {
126                        match worktree_service.add_worktree(
127                            task_id,
128                            &repo.repo_path,
129                            None,
130                            task.ticket_id.as_deref(),
131                            Some(todo.id),
132                            false,
133                        ) {
134                            Ok(wt) => workspaces_created.push(WorkspaceCreated {
135                                todo_index: todo.task_index,
136                                todo_content: todo.content.clone(),
137                                repo_path: repo.repo_path.clone(),
138                                workspace_path: wt.path,
139                                branch: wt.branch,
140                            }),
141                            Err(err) => workspace_errors.push(WorkspaceCreateError {
142                                todo_index: todo.task_index,
143                                repo_path: repo.repo_path.clone(),
144                                detail: err.to_string(),
145                            }),
146                        }
147                    }
148                }
149            }
150        }
151
152        Ok(SyncTaskOutcome {
153            vcs_mode,
154            task,
155            task_bookmark,
156            repos: repo_outcomes,
157            workspaces_created,
158            workspace_errors,
159        })
160    }
161
162    fn sync_repo_jj(
163        &self,
164        worktree_service: &WorktreeService<'_>,
165        repo: &crate::models::TaskRepo,
166        task_bookmark: &str,
167        existing_worktrees: &[crate::models::Worktree],
168    ) -> Result<RepoSyncOutcome> {
169        if !Path::new(&repo.repo_path).exists() {
170            return Ok(RepoSyncOutcome::Missing);
171        }
172
173        let status_output = Command::new("jj")
174            .args(["-R", &repo.repo_path, "diff", "--summary"])
175            .output()?;
176
177        if !status_output.status.success() {
178            return Err(TrackError::FailedRepoStatusCheck(repo.repo_path.clone()));
179        }
180
181        if Self::base_workspace_has_changes(
182            &repo.repo_path,
183            &status_output.stdout,
184            existing_worktrees,
185        )? {
186            return Err(TrackError::RepoHasPendingChanges(repo.repo_path.clone()));
187        }
188
189        let bookmark_exists =
190            worktree_service.bookmark_exists_in_repo(&repo.repo_path, task_bookmark)?;
191
192        if !bookmark_exists {
193            let base_ref = repo
194                .base_branch
195                .clone()
196                .or_else(|| repo.base_commit_hash.clone())
197                .unwrap_or_else(|| "@".to_string());
198
199            let create_result = Command::new("jj")
200                .args([
201                    "-R",
202                    &repo.repo_path,
203                    "bookmark",
204                    "create",
205                    task_bookmark,
206                    "-r",
207                    &base_ref,
208                ])
209                .output()?;
210
211            if create_result.status.success() {
212                let edit_ok = try_edit_workspace(&repo.repo_path, task_bookmark);
213                return Ok(RepoSyncOutcome::BookmarkCreated { base_ref, edit_ok });
214            }
215
216            let detail = String::from_utf8_lossy(&create_result.stderr)
217                .trim()
218                .to_string();
219            return Ok(RepoSyncOutcome::BookmarkCreateFailed { base_ref, detail });
220        }
221
222        let edit_ok = try_edit_workspace(&repo.repo_path, task_bookmark);
223        Ok(RepoSyncOutcome::BookmarkExists { edit_ok })
224    }
225
226    fn sync_repo_git(&self, repo: &crate::models::TaskRepo, slug: &str) -> Result<RepoSyncOutcome> {
227        if !Path::new(&repo.repo_path).exists() {
228            return Ok(RepoSyncOutcome::Missing);
229        }
230
231        if !git_worktree::is_git_repository(&repo.repo_path) {
232            return Err(TrackError::Other(format!(
233                "Not a git repository: {}",
234                repo.repo_path
235            )));
236        }
237
238        let worktree_path = git_worktree::git_worktree_path(&repo.repo_path, slug);
239        if git_worktree::git_worktree_exists(&worktree_path) {
240            return Ok(RepoSyncOutcome::WorktreeExists {
241                workspace_path: worktree_path,
242            });
243        }
244
245        if git_worktree::base_repo_has_changes(&repo.repo_path, slug)? {
246            return Err(TrackError::RepoHasPendingChanges(repo.repo_path.clone()));
247        }
248
249        let base_ref = repo
250            .base_branch
251            .clone()
252            .or_else(|| repo.base_commit_hash.clone())
253            .unwrap_or_else(|| "HEAD".to_string());
254
255        match git_worktree::create_git_worktree(&repo.repo_path, slug, &base_ref) {
256            Ok(path) => Ok(RepoSyncOutcome::WorktreeCreated {
257                base_ref,
258                workspace_path: path,
259            }),
260            Err(err) => Ok(RepoSyncOutcome::WorktreeCreateFailed {
261                base_ref,
262                detail: err.to_string(),
263            }),
264        }
265    }
266
267    fn base_workspace_has_changes(
268        repo_path: &str,
269        status_stdout: &[u8],
270        existing_worktrees: &[crate::models::Worktree],
271    ) -> Result<bool> {
272        let repo_root = Path::new(repo_path)
273            .canonicalize()
274            .map_err(|e| TrackError::Other(format!("Failed to resolve repo path: {}", e)))?;
275        let repo_worktrees: Vec<PathBuf> = existing_worktrees
276            .iter()
277            .filter(|wt| wt.base_repo.as_deref() == Some(repo_path))
278            .filter_map(|wt| Path::new(&wt.path).canonicalize().ok())
279            .filter_map(|wt_path| wt_path.strip_prefix(&repo_root).ok().map(PathBuf::from))
280            .collect();
281
282        let status_stdout = String::from_utf8_lossy(status_stdout);
283        for line in status_stdout.lines() {
284            let path = line.split_whitespace().last().unwrap_or("").trim();
285            if path.is_empty() {
286                continue;
287            }
288
289            let path = Path::new(path);
290            let is_worktree = repo_worktrees
291                .iter()
292                .any(|worktree_path| path.starts_with(worktree_path));
293
294            if !is_worktree {
295                return Ok(true);
296            }
297        }
298
299        Ok(false)
300    }
301}
302
303fn try_edit_workspace(repo_path: &str, task_bookmark: &str) -> bool {
304    Command::new("jj")
305        .args(["-R", repo_path, "edit", task_bookmark])
306        .status()
307        .map(|status| status.success())
308        .unwrap_or(false)
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::db::Database;
315
316    #[test]
317    fn sync_requires_registered_repos() {
318        let db = Database::new_in_memory().unwrap();
319        let task_service = TaskService::new(&db);
320        let task = task_service
321            .create_task("Sync task", None, None, None)
322            .unwrap();
323
324        let result = SyncTaskUseCase::new(&db).execute(task.id);
325        assert!(matches!(result, Err(TrackError::NoRepositoriesRegistered)));
326    }
327}