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#[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#[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#[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#[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
65pub 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}