ce742470b2
Both commands now call workspace.MostRecentActive(root) directly
instead of inlining a ListWorkspaces scan + max-by-UpdatedAt loop.
The cross-type behavior is identical to before this commit (it
was already correct after the v0.3 union fix), but it is now
locked down by the focused unit tests added in the previous
commit and there is no duplicated selection logic.
The (nil, nil) "no active workspaces" return is mapped to:
- cmd/last: prints "No active workspaces found." and exits 1
- cmd/delete: silently skips the "most recent" note (the user
is deleting some specific workspace by name; the
note was always best-effort)
135 lines
3.6 KiB
Go
135 lines
3.6 KiB
Go
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 <query>",
|
|
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 across both
|
|
// tasks and projects.
|
|
if best, _ := workspace.MostRecentActive(root); best != nil {
|
|
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)
|
|
}
|