use crate::db::Database;
use crate::models::{jj_slug, Task, TodoStatus, VcsMode};
use crate::services::{git_worktree, RepoService, TaskService, TodoService, WorktreeService};
use crate::utils::{Result, TrackError};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepoSyncOutcome {
Missing,
BookmarkCreated {
base_ref: String,
edit_ok: bool,
},
BookmarkExists {
edit_ok: bool,
},
BookmarkCreateFailed {
base_ref: String,
detail: String,
},
WorktreeCreated {
base_ref: String,
workspace_path: String,
},
WorktreeExists {
workspace_path: String,
},
WorktreeCreateFailed {
base_ref: String,
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreated {
pub todo_index: i64,
pub todo_content: String,
pub repo_path: String,
pub workspace_path: String,
pub branch: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateError {
pub todo_index: i64,
pub repo_path: String,
pub detail: String,
}
#[derive(Debug, Clone)]
pub struct SyncTaskOutcome {
pub vcs_mode: VcsMode,
pub task: Task,
pub task_bookmark: String,
pub repos: Vec<(String, RepoSyncOutcome)>,
pub workspaces_created: Vec<WorkspaceCreated>,
pub workspace_errors: Vec<WorkspaceCreateError>,
}
pub struct SyncTaskUseCase<'a> {
db: &'a Database,
}
impl<'a> SyncTaskUseCase<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
pub fn execute(&self, task_id: i64) -> Result<SyncTaskOutcome> {
let vcs_mode = self.db.get_vcs_mode()?;
let task_service = TaskService::new(self.db);
let task = task_service.get_task(task_id)?;
let repo_service = RepoService::new(self.db);
let repos = repo_service.list_repos(task_id)?;
if repos.is_empty() {
return Err(TrackError::NoRepositoriesRegistered);
}
let slug = jj_slug(&task);
let task_bookmark = match vcs_mode {
VcsMode::Jj => {
let worktree_service = WorktreeService::new(self.db);
worktree_service.task_bookmark_name(task.id, task.ticket_id.as_deref())
}
VcsMode::Git => git_worktree::git_branch_name(&slug),
};
let worktree_service = WorktreeService::new(self.db);
let existing_worktrees = worktree_service.list_worktrees(task_id)?;
let mut repo_outcomes = Vec::new();
for repo in &repos {
let outcome = match vcs_mode {
VcsMode::Jj => {
self.sync_repo_jj(&worktree_service, repo, &task_bookmark, &existing_worktrees)?
}
VcsMode::Git => self.sync_repo_git(repo, &slug)?,
};
repo_outcomes.push((repo.repo_path.clone(), outcome));
}
let mut workspaces_created = Vec::new();
let mut workspace_errors = Vec::new();
if vcs_mode == VcsMode::Jj {
let todo_service = TodoService::new(self.db);
let todos = todo_service.list_todos(task_id)?;
for todo in todos {
if todo.worktree_requested && todo.status != TodoStatus::Done {
let worktrees = worktree_service.list_worktrees(task_id)?;
let exists = worktrees.iter().any(|wt| wt.todo_id == Some(todo.id));
if exists {
continue;
}
for repo in &repos {
match worktree_service.add_worktree(
task_id,
&repo.repo_path,
None,
task.ticket_id.as_deref(),
Some(todo.id),
false,
) {
Ok(wt) => workspaces_created.push(WorkspaceCreated {
todo_index: todo.task_index,
todo_content: todo.content.clone(),
repo_path: repo.repo_path.clone(),
workspace_path: wt.path,
branch: wt.branch,
}),
Err(err) => workspace_errors.push(WorkspaceCreateError {
todo_index: todo.task_index,
repo_path: repo.repo_path.clone(),
detail: err.to_string(),
}),
}
}
}
}
}
Ok(SyncTaskOutcome {
vcs_mode,
task,
task_bookmark,
repos: repo_outcomes,
workspaces_created,
workspace_errors,
})
}
fn sync_repo_jj(
&self,
worktree_service: &WorktreeService<'_>,
repo: &crate::models::TaskRepo,
task_bookmark: &str,
existing_worktrees: &[crate::models::Worktree],
) -> Result<RepoSyncOutcome> {
if !Path::new(&repo.repo_path).exists() {
return Ok(RepoSyncOutcome::Missing);
}
let status_output = Command::new("jj")
.args(["-R", &repo.repo_path, "diff", "--summary"])
.output()?;
if !status_output.status.success() {
return Err(TrackError::FailedRepoStatusCheck(repo.repo_path.clone()));
}
if Self::base_workspace_has_changes(
&repo.repo_path,
&status_output.stdout,
existing_worktrees,
)? {
return Err(TrackError::RepoHasPendingChanges(repo.repo_path.clone()));
}
let bookmark_exists =
worktree_service.bookmark_exists_in_repo(&repo.repo_path, task_bookmark)?;
if !bookmark_exists {
let base_ref = repo
.base_branch
.clone()
.or_else(|| repo.base_commit_hash.clone())
.unwrap_or_else(|| "@".to_string());
let create_result = Command::new("jj")
.args([
"-R",
&repo.repo_path,
"bookmark",
"create",
task_bookmark,
"-r",
&base_ref,
])
.output()?;
if create_result.status.success() {
let edit_ok = try_edit_workspace(&repo.repo_path, task_bookmark);
return Ok(RepoSyncOutcome::BookmarkCreated { base_ref, edit_ok });
}
let detail = String::from_utf8_lossy(&create_result.stderr)
.trim()
.to_string();
return Ok(RepoSyncOutcome::BookmarkCreateFailed { base_ref, detail });
}
let edit_ok = try_edit_workspace(&repo.repo_path, task_bookmark);
Ok(RepoSyncOutcome::BookmarkExists { edit_ok })
}
fn sync_repo_git(&self, repo: &crate::models::TaskRepo, slug: &str) -> Result<RepoSyncOutcome> {
if !Path::new(&repo.repo_path).exists() {
return Ok(RepoSyncOutcome::Missing);
}
if !git_worktree::is_git_repository(&repo.repo_path) {
return Err(TrackError::Other(format!(
"Not a git repository: {}",
repo.repo_path
)));
}
let worktree_path = git_worktree::git_worktree_path(&repo.repo_path, slug);
if git_worktree::git_worktree_exists(&worktree_path) {
return Ok(RepoSyncOutcome::WorktreeExists {
workspace_path: worktree_path,
});
}
if git_worktree::base_repo_has_changes(&repo.repo_path, slug)? {
return Err(TrackError::RepoHasPendingChanges(repo.repo_path.clone()));
}
let base_ref = repo
.base_branch
.clone()
.or_else(|| repo.base_commit_hash.clone())
.unwrap_or_else(|| "HEAD".to_string());
match git_worktree::create_git_worktree(&repo.repo_path, slug, &base_ref) {
Ok(path) => Ok(RepoSyncOutcome::WorktreeCreated {
base_ref,
workspace_path: path,
}),
Err(err) => Ok(RepoSyncOutcome::WorktreeCreateFailed {
base_ref,
detail: err.to_string(),
}),
}
}
fn base_workspace_has_changes(
repo_path: &str,
status_stdout: &[u8],
existing_worktrees: &[crate::models::Worktree],
) -> Result<bool> {
let repo_root = Path::new(repo_path)
.canonicalize()
.map_err(|e| TrackError::Other(format!("Failed to resolve repo path: {}", e)))?;
let repo_worktrees: Vec<PathBuf> = existing_worktrees
.iter()
.filter(|wt| wt.base_repo.as_deref() == Some(repo_path))
.filter_map(|wt| Path::new(&wt.path).canonicalize().ok())
.filter_map(|wt_path| wt_path.strip_prefix(&repo_root).ok().map(PathBuf::from))
.collect();
let status_stdout = String::from_utf8_lossy(status_stdout);
for line in status_stdout.lines() {
let path = line.split_whitespace().last().unwrap_or("").trim();
if path.is_empty() {
continue;
}
let path = Path::new(path);
let is_worktree = repo_worktrees
.iter()
.any(|worktree_path| path.starts_with(worktree_path));
if !is_worktree {
return Ok(true);
}
}
Ok(false)
}
}
fn try_edit_workspace(repo_path: &str, task_bookmark: &str) -> bool {
Command::new("jj")
.args(["-R", repo_path, "edit", task_bookmark])
.status()
.map(|status| status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
#[test]
fn sync_requires_registered_repos() {
let db = Database::new_in_memory().unwrap();
let task_service = TaskService::new(&db);
let task = task_service
.create_task("Sync task", None, None, None)
.unwrap();
let result = SyncTaskUseCase::new(&db).execute(task.id);
assert!(matches!(result, Err(TrackError::NoRepositoriesRegistered)));
}
}