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) -> 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}