package cmd import ( "bufio" "fmt" "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" "github.com/warrenronsiek/ctask/internal/workspace" ) var deleteCmd = &cobra.Command{ Use: "delete ", Short: "Permanently remove a workspace directory", Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: runDelete, } var ( deleteForce bool deleteAll bool ) func init() { deleteCmd.Flags().BoolVarP(&deleteForce, "force", "f", false, "Skip confirmation prompt") deleteCmd.Flags().BoolVarP(&deleteAll, "all", "a", false, "Include archived workspaces in query resolution") rootCmd.AddCommand(deleteCmd) } func runDelete(cmd *cobra.Command, args []string) error { root := config.ResolveRoot() ws := resolveOne(root, args[0], deleteAll) // Active workspace protection — two checks, BOTH run before any mutation. // // Check 1: CTASK_WORKSPACE env var matches (catches same-session delete attempts) // Check 2: .ctask/manifest-start.json exists (catches cross-terminal delete attempts // because the manifest is only present during a live session) // // Either check triggers immediate refusal with no side effects. absTarget, _ := filepath.Abs(ws.Path) activeWs := os.Getenv("CTASK_WORKSPACE") if activeWs != "" { absActive, _ := filepath.Abs(activeWs) if strings.EqualFold(absTarget, absActive) { fmt.Fprintln(os.Stderr, "Cannot delete the active workspace. Exit the session first.") os.Exit(1) } } manifestPath := filepath.Join(ws.Path, ".ctask", "manifest-start.json") if _, err := os.Stat(manifestPath); err == nil { fmt.Fprintln(os.Stderr, "Cannot delete a workspace with an active session. Exit the session first.") os.Exit(1) } // --- Past this point, we are confident no session is active. --- relPath := workspace.RelativePath(root, ws.Path) // Gather contents summary (best-effort, read-only) fileCount, totalSize := summarizeContents(ws.Path) fmt.Println() fmt.Printf(" Workspace: %s\n", relPath) fmt.Printf(" Files: %d (%s)\n", fileCount, formatSize(totalSize)) // Check if this is the most recently updated workspace. // v0.3: union tasks and projects so the "most recent" check spans both types. tasks, _ := workspace.ListWorkspaces(root, workspace.ListOpts{ IncludeArchived: false, Limit: 0, }) projects, _ := workspace.ListWorkspaces(root, workspace.ListOpts{ IncludeArchived: false, Projects: true, Limit: 0, }) results := append(tasks, projects...) if len(results) > 0 { best := results[0] for _, r := range results[1:] { if r.Meta.UpdatedAt.After(best.Meta.UpdatedAt) { best = r } } absBest, _ := filepath.Abs(best.Path) if strings.EqualFold(absTarget, absBest) { fmt.Println() fmt.Println(" Note: This was your most recent workspace.") } } fmt.Println() if !deleteForce { fmt.Print(" Delete this workspace? This cannot be undone. [y/N] ") reader := bufio.NewReader(os.Stdin) answer, _ := reader.ReadString('\n') answer = strings.TrimSpace(strings.ToLower(answer)) if answer != "y" && answer != "yes" { fmt.Println(" Cancelled.") return nil } } if err := os.RemoveAll(ws.Path); err != nil { return fmt.Errorf("deleting workspace: %w", err) } fmt.Println() fmt.Printf("[ctask] deleted: %s\n", relPath) return nil } // summarizeContents counts files and total size in a directory. // Best-effort: errors on individual files are skipped. func summarizeContents(dir string) (int, int64) { count := 0 var total int64 filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // skip errors } if !info.IsDir() { count++ total += info.Size() } return nil }) return count, total } // formatSize returns a human-readable file size. func formatSize(bytes int64) string { if bytes < 1024 { return fmt.Sprintf("%d B", bytes) } kb := float64(bytes) / 1024 if kb < 1024 { return fmt.Sprintf("%.0f KB", kb) } mb := kb / 1024 return fmt.Sprintf("%.1f MB", mb) }