From f5ca85a788a9bdcec1295c0ac410e356e1d35cae Mon Sep 17 00:00:00 2001 From: warren Date: Mon, 6 Apr 2026 09:59:59 -0400 Subject: [PATCH] feat: ctask delete command with confirmation and active workspace protection Contents summary, confirmation prompt, --force flag, --all for archived, refuses deletion of active session workspace. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/delete.go | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 cmd/delete.go diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..d190645 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,130 @@ +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: refuse if this is the current session workspace + activeWs := os.Getenv("CTASK_WORKSPACE") + if activeWs != "" { + // Normalize both paths for comparison + absTarget, _ := filepath.Abs(ws.Path) + 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) + } + } + + relPath := workspace.RelativePath(root, ws.Path) + + // Gather contents summary (best-effort) + 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 + results, _ := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: false, + Limit: 0, + }) + if len(results) > 0 { + best := results[0] + for _, r := range results[1:] { + if r.Meta.UpdatedAt.After(best.Meta.UpdatedAt) { + best = r + } + } + absTarget, _ := filepath.Abs(ws.Path) + 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) +}