Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions cmd/task/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,188 @@ func TestCLIDeleteTask(t *testing.T) {
}
}

// TestCLIMoveTask tests moving a task to a different project
func TestCLIMoveTask(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")

database, err := db.Open(dbPath)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer database.Close()
defer os.Remove(dbPath)

// Create source and target projects
srcProjectDir := filepath.Join(tmpDir, "src-project")
tgtProjectDir := filepath.Join(tmpDir, "tgt-project")
os.MkdirAll(srcProjectDir, 0755)
os.MkdirAll(tgtProjectDir, 0755)

if err := database.CreateProject(&db.Project{Name: "src-project", Path: srcProjectDir}); err != nil {
t.Fatalf("failed to create src-project: %v", err)
}
if err := database.CreateProject(&db.Project{Name: "tgt-project", Path: tgtProjectDir}); err != nil {
t.Fatalf("failed to create tgt-project: %v", err)
}

// Test 1: Basic move preserves task content
t.Run("move preserves task content", func(t *testing.T) {
task := &db.Task{
Title: "Task to move",
Body: "Task description",
Status: db.StatusBacklog,
Type: db.TypeWriting,
Tags: "tag1,tag2",
Project: "src-project",
Pinned: true,
}
if err := database.CreateTask(task); err != nil {
t.Fatalf("failed to create task: %v", err)
}
oldID := task.ID

// Move the task
newID, err := moveTask(database, task, "tgt-project")
if err != nil {
t.Fatalf("moveTask() error = %v", err)
}

// Verify old task is deleted
oldTask, err := database.GetTask(oldID)
if err != nil {
t.Fatalf("GetTask() error = %v", err)
}
if oldTask != nil {
t.Error("expected old task to be deleted")
}

// Verify new task exists with correct content
newTask, err := database.GetTask(newID)
if err != nil {
t.Fatalf("GetTask() error = %v", err)
}
if newTask == nil {
t.Fatal("expected new task to exist")
}
if newTask.Title != "Task to move" {
t.Errorf("Title = %v, want 'Task to move'", newTask.Title)
}
if newTask.Body != "Task description" {
t.Errorf("Body = %v, want 'Task description'", newTask.Body)
}
if newTask.Type != db.TypeWriting {
t.Errorf("Type = %v, want 'writing'", newTask.Type)
}
if newTask.Tags != "tag1,tag2" {
t.Errorf("Tags = %v, want 'tag1,tag2'", newTask.Tags)
}
if newTask.Project != "tgt-project" {
t.Errorf("Project = %v, want 'tgt-project'", newTask.Project)
}
if !newTask.Pinned {
t.Error("expected Pinned to be true")
}
})

// Test 2: Move resets execution-related fields
t.Run("move resets execution fields", func(t *testing.T) {
task := &db.Task{
Title: "Task with execution state",
Status: db.StatusBacklog,
Type: db.TypeCode,
Project: "src-project",
WorktreePath: "/some/path",
BranchName: "task/123-branch",
Port: 3100,
ClaudeSessionID: "session-123",
DaemonSession: "daemon-123",
}
if err := database.CreateTask(task); err != nil {
t.Fatalf("failed to create task: %v", err)
}

newID, err := moveTask(database, task, "tgt-project")
if err != nil {
t.Fatalf("moveTask() error = %v", err)
}

newTask, err := database.GetTask(newID)
if err != nil || newTask == nil {
t.Fatalf("failed to get new task: %v", err)
}

// Verify execution fields are reset
if newTask.WorktreePath != "" {
t.Errorf("WorktreePath = %v, want ''", newTask.WorktreePath)
}
if newTask.BranchName != "" {
t.Errorf("BranchName = %v, want ''", newTask.BranchName)
}
if newTask.Port != 0 {
t.Errorf("Port = %v, want 0", newTask.Port)
}
if newTask.ClaudeSessionID != "" {
t.Errorf("ClaudeSessionID = %v, want ''", newTask.ClaudeSessionID)
}
if newTask.DaemonSession != "" {
t.Errorf("DaemonSession = %v, want ''", newTask.DaemonSession)
}
})

// Test 3: Move resets status for processing/blocked tasks
t.Run("move resets processing status to backlog", func(t *testing.T) {
task := &db.Task{
Title: "Processing task",
Status: db.StatusProcessing,
Type: db.TypeCode,
Project: "src-project",
}
if err := database.CreateTask(task); err != nil {
t.Fatalf("failed to create task: %v", err)
}

newID, err := moveTask(database, task, "tgt-project")
if err != nil {
t.Fatalf("moveTask() error = %v", err)
}

newTask, err := database.GetTask(newID)
if err != nil || newTask == nil {
t.Fatalf("failed to get new task: %v", err)
}
if newTask.Status != db.StatusBacklog {
t.Errorf("Status = %v, want 'backlog' (should reset from processing)", newTask.Status)
}
})

// Test 4: Move preserves queued status
t.Run("move preserves queued status", func(t *testing.T) {
task := &db.Task{
Title: "Queued task",
Status: db.StatusQueued,
Type: db.TypeCode,
Project: "src-project",
}
if err := database.CreateTask(task); err != nil {
t.Fatalf("failed to create task: %v", err)
}

newID, err := moveTask(database, task, "tgt-project")
if err != nil {
t.Fatalf("moveTask() error = %v", err)
}

newTask, err := database.GetTask(newID)
if err != nil || newTask == nil {
t.Fatalf("failed to get new task: %v", err)
}
if newTask.Status != db.StatusQueued {
t.Errorf("Status = %v, want 'queued' (should preserve)", newTask.Status)
}
})
}

// TestCLIRetryTask tests retrying a blocked task
func TestCLIRetryTask(t *testing.T) {
tmpDir := t.TempDir()
Expand Down
185 changes: 185 additions & 0 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,112 @@ Examples:
updateCmd.Flags().StringP("project", "p", "", "Update project name")
rootCmd.AddCommand(updateCmd)

// Move subcommand - move a task to a different project
moveCmd := &cobra.Command{
Use: "move <task-id> <target-project>",
Short: "Move a task to a different project",
Long: `Move a task to a different project.

This properly cleans up the task's worktree and Claude sessions from the old project,
deletes the old task, and creates a new task in the target project.

The new task preserves:
- Title, body, type, and tags
- Status (unless processing/blocked, which resets to backlog)

The new task resets:
- Worktree path, branch name, port
- Claude session ID, daemon session
- Started/completed timestamps

Examples:
task move 42 myapp
task move 42 myapp --execute`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
var taskID int64
if _, err := fmt.Sscanf(args[0], "%d", &taskID); err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Invalid task ID: "+args[0]))
os.Exit(1)
}
targetProject := args[1]

// Open database
dbPath := db.DefaultPath()
database, err := db.Open(dbPath)
if err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
os.Exit(1)
}
defer database.Close()

// Validate target project exists
proj, err := database.GetProjectByName(targetProject)
if err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
os.Exit(1)
}
if proj == nil {
fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Project '%s' not found", targetProject)))
os.Exit(1)
}

// Get task
task, err := database.GetTask(taskID)
if err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
os.Exit(1)
}
if task == nil {
fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Task #%d not found", taskID)))
os.Exit(1)
}

// Check if already in target project
if task.Project == targetProject || task.Project == proj.Name {
fmt.Println(dimStyle.Render(fmt.Sprintf("Task #%d is already in project '%s'", taskID, targetProject)))
return
}

oldProject := task.Project
execute, _ := cmd.Flags().GetBool("execute")

// Confirm unless --force flag is set
force, _ := cmd.Flags().GetBool("force")
if !force {
fmt.Printf("Move task #%d from '%s' to '%s'? [y/N] ", taskID, oldProject, targetProject)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Cancelled")
return
}
}

// Perform the move
newTaskID, err := moveTask(database, task, targetProject)
if err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
os.Exit(1)
}

fmt.Println(successStyle.Render(fmt.Sprintf("Moved task #%d to project '%s' (new task #%d)", taskID, targetProject, newTaskID)))

// Queue for execution if requested
if execute {
if err := database.UpdateTaskStatus(newTaskID, db.StatusQueued); err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error queueing task: "+err.Error()))
os.Exit(1)
}
fmt.Println(successStyle.Render(fmt.Sprintf("Queued task #%d for execution", newTaskID)))
}
},
}
moveCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
moveCmd.Flags().BoolP("execute", "e", false, "Queue the task for execution after moving")
rootCmd.AddCommand(moveCmd)

// Execute subcommand - queue a task for execution
executeCmd := &cobra.Command{
Use: "execute <task-id>",
Expand Down Expand Up @@ -3172,6 +3278,85 @@ func cleanupOrphanedClaudes(force bool) {
fmt.Printf("\n%s\n", dimStyle.Render(fmt.Sprintf("Killed %d/%d processes", killed, totalToKill)))
}

// moveTask moves a task to a different project by cleaning up old resources,
// deleting the old task, and creating a new task in the target project.
// Returns the new task ID.
func moveTask(database *db.DB, oldTask *db.Task, targetProject string) (int64, error) {
cfg := config.New(database)
exec := executor.New(database, cfg)

// Step 1: Clean up old task's resources

// Kill Claude session if running (ignore errors - session may not exist)
killClaudeSession(int(oldTask.ID))

// Clean up worktree and Claude sessions if they exist
if oldTask.WorktreePath != "" {
projectConfigDir := ""
if oldTask.Project != "" {
if project, err := database.GetProjectByName(oldTask.Project); err == nil && project != nil {
projectConfigDir = project.ClaudeConfigDir
}
}
// Clean up Claude session files first (before worktree is removed)
if err := executor.CleanupClaudeSessions(oldTask.WorktreePath, projectConfigDir); err != nil {
fmt.Fprintln(os.Stderr, dimStyle.Render(fmt.Sprintf("Warning: could not remove Claude sessions: %v", err)))
}

// Clean up worktree
if err := exec.CleanupWorktree(oldTask); err != nil {
fmt.Fprintln(os.Stderr, dimStyle.Render(fmt.Sprintf("Warning: could not remove worktree: %v", err)))
}
}

// Step 2: Delete the old task from database
if err := database.DeleteTask(oldTask.ID); err != nil {
return 0, fmt.Errorf("delete old task: %w", err)
}

// Step 3: Create new task in target project
// Reset execution-related fields but preserve content
newTask := &db.Task{
Title: oldTask.Title,
Body: oldTask.Body,
Type: oldTask.Type,
Tags: oldTask.Tags,
Project: targetProject,
Executor: oldTask.Executor,
Pinned: oldTask.Pinned,
// Reset execution state
WorktreePath: "",
BranchName: "",
Port: 0,
ClaudeSessionID: "",
DaemonSession: "",
StartedAt: nil,
CompletedAt: nil,
// Keep status unless it was processing/blocked
Status: oldTask.Status,
}

// Reset status if task was in progress (work is lost)
if newTask.Status == db.StatusProcessing || newTask.Status == db.StatusBlocked {
newTask.Status = db.StatusBacklog
}

if err := database.CreateTask(newTask); err != nil {
return 0, fmt.Errorf("create new task: %w", err)
}

// CreateTask doesn't insert all fields (tags, pinned, etc.), so update them
if err := database.UpdateTask(newTask); err != nil {
return 0, fmt.Errorf("update new task: %w", err)
}

// Notify about the changes
exec.NotifyTaskChange("deleted", oldTask)
exec.NotifyTaskChange("created", newTask)

return newTask.ID, nil
}

// deleteTask deletes a task, its Claude session, and its worktree.
func deleteTask(taskID int64) error {
// Open database
Expand Down