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#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct DirtyWorkspace {
9 pub id: i64,
10 pub path: String,
11}
12
13#[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#[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#[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#[derive(Debug, Clone)]
63pub enum ArchiveTaskStep {
64 Completed(ArchiveTaskOutcome),
65 NeedsConfirmation(ArchivePrompt),
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ArchivePrompt {
71 pub task_id: i64,
72 pub kind: ArchivePromptKind,
73}
74
75#[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#[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
122pub 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 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 pub fn confirm_and_run(&self, task_id: i64) -> Result<ArchiveTaskOutcome> {
233 self.execute(task_id, true)
234 }
235
236 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}