Skip to main content

track/use_cases/
archive_task.rs

1use crate::db::Database;
2use crate::models::{jj_slug, Task, VcsMode};
3use crate::services::{jj_task, RepoService, TaskService, WorktreeService};
4use crate::utils::{Result, TrackError};
5
6/// A workspace with uncommitted JJ changes blocking archive.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct DirtyWorkspace {
9    pub id: i64,
10    pub path: String,
11}
12
13/// Blockers discovered before archive.
14#[derive(Debug, Clone, Default)]
15pub struct ArchiveBlockers {
16    pub dirty_workspaces: Vec<DirtyWorkspace>,
17    pub jj_task_slug: Option<String>,
18    pub jj_task_workspaces: Vec<String>,
19}
20
21/// Result of archiving a task and cleaning up workspaces.
22#[derive(Debug, Clone)]
23pub struct ArchiveTaskOutcome {
24    pub task: Task,
25    pub removed_workspaces: Vec<(i64, String)>,
26    pub workspace_errors: Vec<String>,
27}
28
29/// CLI-facing lines after a successful archive.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ArchiveCompletionView {
32    pub info_lines: Vec<String>,
33    pub error_lines: Vec<String>,
34    pub summary: String,
35}
36
37impl ArchiveTaskOutcome {
38    pub fn completion_view(&self) -> ArchiveCompletionView {
39        let mut info_lines = Vec::new();
40        if !self.removed_workspaces.is_empty() {
41            info_lines.push("Cleaning up workspaces...".to_string());
42            for (id, path) in &self.removed_workspaces {
43                info_lines.push(format!("  Removed workspace #{}: {}", id, path));
44            }
45        }
46
47        let error_lines = self
48            .workspace_errors
49            .iter()
50            .map(|err| format!("  Error removing workspace: {err}"))
51            .collect();
52
53        ArchiveCompletionView {
54            info_lines,
55            error_lines,
56            summary: format!("Archived task #{}: {}", self.task.id, self.task.name),
57        }
58    }
59}
60
61/// Interactive archive flow step.
62#[derive(Debug, Clone)]
63pub enum ArchiveTaskStep {
64    Completed(ArchiveTaskOutcome),
65    NeedsConfirmation(ArchivePrompt),
66}
67
68/// Confirmation required before forcing archive.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ArchivePrompt {
71    pub task_id: i64,
72    pub kind: ArchivePromptKind,
73}
74
75/// Reason archive needs explicit user confirmation.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum ArchivePromptKind {
78    UncommittedWorkspaces(Vec<String>),
79    JjTaskNotCompleted {
80        slug: String,
81        workspaces: Vec<String>,
82    },
83}
84
85/// CLI-facing warning and prompt text for archive confirmation.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ArchivePromptView {
88    pub warning_lines: Vec<String>,
89    pub prompt: String,
90}
91
92impl ArchivePrompt {
93    pub fn view(&self) -> ArchivePromptView {
94        match &self.kind {
95            ArchivePromptKind::UncommittedWorkspaces(workspaces) => {
96                let mut warning_lines =
97                    vec!["WARNING: The following workspaces have uncommitted changes:".to_string()];
98                warning_lines.extend(workspaces.iter().map(|line| format!("  {line}")));
99                warning_lines.push(String::new());
100                ArchivePromptView {
101                    warning_lines,
102                    prompt: "Archive and remove workspaces anyway? [y/N]: ".to_string(),
103                }
104            }
105            ArchivePromptKind::JjTaskNotCompleted { slug, workspaces } => {
106                let mut warning_lines = vec![
107                    format!("WARNING: jj-task workspace '{slug}' is not marked done."),
108                    format!("  Merge your PR with the $jj skill, then run: jj-task done {slug}"),
109                ];
110                warning_lines.extend(workspaces.iter().map(|path| format!("  {path}")));
111                warning_lines.push(String::new());
112                ArchivePromptView {
113                    warning_lines,
114                    prompt: "Archive the track task anyway (jj-task map unchanged)? [y/N]: "
115                        .to_string(),
116                }
117            }
118        }
119    }
120}
121
122/// Archives a task after optionally removing JJ workspaces.
123pub struct ArchiveTaskUseCase<'a> {
124    db: &'a Database,
125}
126
127impl<'a> ArchiveTaskUseCase<'a> {
128    pub fn new(db: &'a Database) -> Self {
129        Self { db }
130    }
131
132    pub fn resolve_task_id(&self, task_ref: Option<&str>) -> Result<i64> {
133        let task_service = TaskService::new(self.db);
134        match task_ref {
135            Some(r) => task_service.resolve_task_id(r),
136            None => self
137                .db
138                .get_current_task_id()?
139                .ok_or(TrackError::NoActiveTask),
140        }
141    }
142
143    pub fn find_archive_blockers(&self, task_id: i64) -> Result<ArchiveBlockers> {
144        let worktree_service = WorktreeService::new(self.db);
145        let mut blockers = ArchiveBlockers {
146            dirty_workspaces: self.find_dirty_track_workspaces(task_id, &worktree_service)?,
147            ..Default::default()
148        };
149
150        if self.db.get_vcs_mode()? != VcsMode::Jj {
151            return Ok(blockers);
152        }
153
154        let task_service = TaskService::new(self.db);
155        let task = task_service.get_task(task_id)?;
156        let repo_service = RepoService::new(self.db);
157        let repos = repo_service.list_repos(task_id)?;
158        if repos.is_empty() {
159            return Ok(blockers);
160        }
161
162        let slug = jj_slug(&task);
163        let repo_paths: Vec<String> = repos.iter().map(|repo| repo.repo_path.clone()).collect();
164        let active = jj_task::active_registrations(&slug, &repo_paths);
165        if !active.is_empty() {
166            blockers.jj_task_slug = Some(slug.clone());
167            blockers.jj_task_workspaces = active
168                .iter()
169                .filter_map(|status| status.workspace_path.clone())
170                .collect();
171        }
172
173        for path in jj_task::active_workspace_paths(&slug, &repo_paths) {
174            if std::path::Path::new(&path).exists()
175                && worktree_service.has_uncommitted_changes(&path)?
176                && !blockers.dirty_workspaces.iter().any(|ws| ws.path == path)
177            {
178                blockers
179                    .dirty_workspaces
180                    .push(DirtyWorkspace { id: 0, path });
181            }
182        }
183
184        Ok(blockers)
185    }
186
187    fn find_dirty_track_workspaces(
188        &self,
189        task_id: i64,
190        worktree_service: &WorktreeService<'_>,
191    ) -> Result<Vec<DirtyWorkspace>> {
192        let worktrees = worktree_service.list_worktrees(task_id)?;
193
194        let mut dirty = Vec::new();
195        for worktree in worktrees {
196            if !std::path::Path::new(&worktree.path).exists() {
197                continue;
198            }
199            if worktree_service.has_uncommitted_changes(&worktree.path)? {
200                dirty.push(DirtyWorkspace {
201                    id: worktree.id,
202                    path: worktree.path,
203                });
204            }
205        }
206        Ok(dirty)
207    }
208
209    /// Runs archive, returning either completion or a confirmation prompt.
210    ///
211    /// When `force` is true, blockers are ignored and archive proceeds immediately.
212    pub fn run(&self, task_id: i64, force: bool) -> Result<ArchiveTaskStep> {
213        match self.execute(task_id, force) {
214            Ok(outcome) => Ok(ArchiveTaskStep::Completed(outcome)),
215            Err(TrackError::UncommittedWorkspaces(workspaces)) => {
216                Ok(ArchiveTaskStep::NeedsConfirmation(ArchivePrompt {
217                    task_id,
218                    kind: ArchivePromptKind::UncommittedWorkspaces(workspaces),
219                }))
220            }
221            Err(TrackError::JjTaskNotCompleted { slug, workspaces }) => {
222                Ok(ArchiveTaskStep::NeedsConfirmation(ArchivePrompt {
223                    task_id,
224                    kind: ArchivePromptKind::JjTaskNotCompleted { slug, workspaces },
225                }))
226            }
227            Err(err) => Err(err),
228        }
229    }
230
231    /// Archives after the user confirmed a [`ArchivePrompt`].
232    pub fn confirm_and_run(&self, task_id: i64) -> Result<ArchiveTaskOutcome> {
233        self.execute(task_id, true)
234    }
235
236    /// Removes workspaces and archives the task.
237    ///
238    /// When `force` is false, returns [`TrackError::UncommittedWorkspaces`] or
239    /// [`TrackError::JjTaskNotCompleted`]. Prefer [`Self::run`] for interactive flows.
240    pub fn execute(&self, task_id: i64, force: bool) -> Result<ArchiveTaskOutcome> {
241        let task_service = TaskService::new(self.db);
242        let worktree_service = WorktreeService::new(self.db);
243
244        let task = task_service.get_task(task_id)?;
245        let blockers = self.find_archive_blockers(task_id)?;
246
247        if !force {
248            if let Some(slug) = &blockers.jj_task_slug {
249                return Err(TrackError::JjTaskNotCompleted {
250                    slug: slug.clone(),
251                    workspaces: blockers.jj_task_workspaces.clone(),
252                });
253            }
254
255            if !blockers.dirty_workspaces.is_empty() {
256                return Err(TrackError::UncommittedWorkspaces(
257                    blockers
258                        .dirty_workspaces
259                        .iter()
260                        .map(|ws| {
261                            if ws.id > 0 {
262                                format!("#{} {}", ws.id, ws.path)
263                            } else {
264                                format!("jj-task {}", ws.path)
265                            }
266                        })
267                        .collect(),
268                ));
269            }
270        }
271
272        let worktrees = worktree_service.list_worktrees(task_id)?;
273        let mut removed_workspaces = Vec::new();
274        let mut workspace_errors = Vec::new();
275
276        for worktree in worktrees {
277            match worktree_service.remove_worktree(worktree.id, force) {
278                Ok(()) => removed_workspaces.push((worktree.id, worktree.path)),
279                Err(err) => workspace_errors.push(format!("#{}: {}", worktree.id, err)),
280            }
281        }
282
283        if !force && !workspace_errors.is_empty() {
284            return Err(TrackError::WorkspaceRemovalFailed(workspace_errors));
285        }
286
287        task_service.archive_task(task_id)?;
288
289        Ok(ArchiveTaskOutcome {
290            task,
291            removed_workspaces,
292            workspace_errors,
293        })
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::models::TaskStatus;
301    use crate::services::jj_task;
302    use std::fs;
303    use std::sync::{Mutex, OnceLock};
304
305    fn jj_task_map_lock() -> std::sync::MutexGuard<'static, ()> {
306        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
307        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
308    }
309
310    fn insert_repo(db: &Database, task_id: i64, repo_path: &str) {
311        db.get_connection()
312            .execute(
313                "INSERT INTO task_repos (task_id, task_index, repo_path, created_at) VALUES (?1, 1, ?2, datetime('now'))",
314                rusqlite::params![task_id, repo_path],
315            )
316            .unwrap();
317    }
318
319    #[test]
320    fn archive_task_marks_task_archived() {
321        let db = Database::new_in_memory().unwrap();
322        let task_service = TaskService::new(&db);
323        let task = task_service
324            .create_task("Archive me", None, None, None)
325            .unwrap();
326
327        let outcome = ArchiveTaskUseCase::new(&db).execute(task.id, true).unwrap();
328
329        assert_eq!(outcome.task.id, task.id);
330        let archived = task_service.get_task(task.id).unwrap();
331        assert_eq!(archived.status, TaskStatus::Archived);
332    }
333
334    #[test]
335    fn find_archive_blockers_detects_active_jj_task() {
336        let _guard = jj_task_map_lock();
337        let temp = tempfile::tempdir().unwrap();
338        let map_path = temp.path().join("task-workspaces.json");
339        let repo_path = temp.path().join("repo");
340        fs::create_dir_all(&repo_path).unwrap();
341        let repo_key = jj_task::repo_key(repo_path.to_str().unwrap());
342        fs::write(
343            &map_path,
344            format!(
345                r#"{{
346              "repos": {{
347                {repo_key:?}: {{
348                  "tasks": {{
349                    "task-1": {{
350                      "workspace": "/repo/.worktrees/task-1",
351                      "phase": "active"
352                    }}
353                  }}
354                }}
355              }}
356            }}"#
357            ),
358        )
359        .unwrap();
360
361        let prev = std::env::var("JJ_TASK_MAP").ok();
362        unsafe { std::env::set_var("JJ_TASK_MAP", &map_path) };
363
364        let db = Database::new_in_memory().unwrap();
365        let task_service = TaskService::new(&db);
366        let task = task_service.create_task("Task", None, None, None).unwrap();
367        insert_repo(&db, task.id, repo_path.to_str().unwrap());
368
369        let blockers = ArchiveTaskUseCase::new(&db)
370            .find_archive_blockers(task.id)
371            .unwrap();
372
373        match prev {
374            Some(value) => unsafe { std::env::set_var("JJ_TASK_MAP", value) },
375            None => unsafe { std::env::remove_var("JJ_TASK_MAP") },
376        }
377
378        assert_eq!(blockers.jj_task_slug.as_deref(), Some("task-1"));
379        assert!(!blockers.jj_task_workspaces.is_empty());
380    }
381
382    #[test]
383    fn archive_allows_done_jj_task_phase() {
384        let _guard = jj_task_map_lock();
385        let temp = tempfile::tempdir().unwrap();
386        let map_path = temp.path().join("task-workspaces.json");
387        let repo_path = temp.path().join("repo");
388        fs::create_dir_all(&repo_path).unwrap();
389        let repo_key = jj_task::repo_key(repo_path.to_str().unwrap());
390        fs::write(
391            &map_path,
392            format!(
393                r#"{{
394              "repos": {{
395                {repo_key:?}: {{
396                  "tasks": {{
397                    "task-1": {{
398                      "workspace": "/repo/.worktrees/task-1",
399                      "phase": "done"
400                    }}
401                  }}
402                }}
403              }}
404            }}"#
405            ),
406        )
407        .unwrap();
408
409        let prev = std::env::var("JJ_TASK_MAP").ok();
410        unsafe { std::env::set_var("JJ_TASK_MAP", &map_path) };
411
412        let db = Database::new_in_memory().unwrap();
413        let task_service = TaskService::new(&db);
414        let task = task_service.create_task("Task", None, None, None).unwrap();
415        insert_repo(&db, task.id, repo_path.to_str().unwrap());
416
417        let result = ArchiveTaskUseCase::new(&db).execute(task.id, false);
418
419        match prev {
420            Some(value) => unsafe { std::env::set_var("JJ_TASK_MAP", value) },
421            None => unsafe { std::env::remove_var("JJ_TASK_MAP") },
422        }
423
424        assert!(result.is_ok());
425    }
426
427    #[test]
428    fn archive_removes_stale_worktree_record_when_path_missing() {
429        let db = Database::new_in_memory().unwrap();
430        let task_service = TaskService::new(&db);
431        let task = task_service.create_task("Task", None, None, None).unwrap();
432
433        let now = chrono::Utc::now().to_rfc3339();
434        db.get_connection()
435            .execute(
436                "INSERT INTO worktrees (task_id, path, branch, base_repo, status, created_at, is_base)
437                 VALUES (?1, ?2, ?3, ?4, 'active', ?5, 0)",
438                rusqlite::params![
439                    task.id,
440                    "/tmp/missing-worktree",
441                    "track/task-1",
442                    "/tmp/missing-repo",
443                    now,
444                ],
445            )
446            .unwrap();
447
448        let outcome = ArchiveTaskUseCase::new(&db)
449            .execute(task.id, false)
450            .unwrap();
451
452        assert_eq!(outcome.removed_workspaces.len(), 1);
453        let archived = task_service.get_task(task.id).unwrap();
454        assert_eq!(archived.status, TaskStatus::Archived);
455    }
456
457    #[test]
458    fn archive_aborts_when_workspace_removal_fails_without_force() {
459        let db = Database::new_in_memory().unwrap();
460        let task_service = TaskService::new(&db);
461        let task = task_service.create_task("Task", None, None, None).unwrap();
462
463        let temp_dir = tempfile::tempdir().unwrap();
464        let worktree_path = temp_dir.path().join("stale-workspace");
465        fs::create_dir(&worktree_path).unwrap();
466
467        let now = chrono::Utc::now().to_rfc3339();
468        db.get_connection()
469            .execute(
470                "INSERT INTO worktrees (task_id, path, branch, base_repo, status, created_at, is_base)
471                 VALUES (?1, ?2, ?3, ?4, 'active', ?5, 0)",
472                rusqlite::params![
473                    task.id,
474                    worktree_path.to_str().unwrap(),
475                    "track/task-1",
476                    "/tmp/missing-repo",
477                    now,
478                ],
479            )
480            .unwrap();
481
482        let result = ArchiveTaskUseCase::new(&db).execute(task.id, false);
483        assert!(
484            matches!(
485                result,
486                Err(TrackError::WorkspaceRemovalFailed(_)) | Err(TrackError::Jj(_))
487            ),
488            "unexpected result: {result:?}"
489        );
490
491        let still_active = task_service.get_task(task.id).unwrap();
492        assert_eq!(still_active.status, TaskStatus::Active);
493    }
494
495    #[test]
496    fn archive_prompt_view_for_dirty_workspaces() {
497        let prompt = ArchivePrompt {
498            task_id: 1,
499            kind: ArchivePromptKind::UncommittedWorkspaces(vec!["#1 /path".to_string()]),
500        };
501        let view = prompt.view();
502        assert!(view.warning_lines[0].contains("uncommitted"));
503        assert_eq!(view.prompt, "Archive and remove workspaces anyway? [y/N]: ");
504    }
505
506    #[test]
507    fn completion_view_formats_removed_workspaces() {
508        let db = Database::new_in_memory().unwrap();
509        let task = TaskService::new(&db)
510            .create_task("Done", None, None, None)
511            .unwrap();
512        let outcome = ArchiveTaskOutcome {
513            task: task.clone(),
514            removed_workspaces: vec![(7, "/tmp/wt".to_string())],
515            workspace_errors: vec!["#8: failed".to_string()],
516        };
517
518        let view = outcome.completion_view();
519        assert!(view.info_lines[0].contains("Cleaning up"));
520        assert!(view.info_lines[1].contains("#7"));
521        assert!(view.error_lines[0].contains("failed"));
522        assert_eq!(view.summary, format!("Archived task #{}: Done", task.id));
523    }
524
525    #[test]
526    fn run_returns_prompt_for_active_jj_task() {
527        let _guard = jj_task_map_lock();
528        let temp = tempfile::tempdir().unwrap();
529        let map_path = temp.path().join("task-workspaces.json");
530        let repo_path = temp.path().join("repo");
531        fs::create_dir_all(&repo_path).unwrap();
532        let repo_key = jj_task::repo_key(repo_path.to_str().unwrap());
533        fs::write(
534            &map_path,
535            format!(
536                r#"{{
537              "repos": {{
538                {repo_key:?}: {{
539                  "tasks": {{
540                    "task-1": {{
541                      "workspace": "/repo/.worktrees/task-1",
542                      "phase": "active"
543                    }}
544                  }}
545                }}
546              }}
547            }}"#
548            ),
549        )
550        .unwrap();
551
552        let prev = std::env::var("JJ_TASK_MAP").ok();
553        unsafe { std::env::set_var("JJ_TASK_MAP", &map_path) };
554
555        let db = Database::new_in_memory().unwrap();
556        let task_service = TaskService::new(&db);
557        let task = task_service.create_task("Task", None, None, None).unwrap();
558        insert_repo(&db, task.id, repo_path.to_str().unwrap());
559
560        let step = ArchiveTaskUseCase::new(&db).run(task.id, false).unwrap();
561
562        match prev {
563            Some(value) => unsafe { std::env::set_var("JJ_TASK_MAP", value) },
564            None => unsafe { std::env::remove_var("JJ_TASK_MAP") },
565        }
566
567        match step {
568            ArchiveTaskStep::NeedsConfirmation(prompt) => {
569                let view = prompt.view();
570                assert!(view.warning_lines[0].contains("jj-task"));
571                assert!(view.prompt.contains("jj-task map unchanged"));
572            }
573            ArchiveTaskStep::Completed(_) => panic!("expected confirmation prompt"),
574        }
575    }
576}