diff --git a/cmd/resume.go b/cmd/resume.go index 53d9957..c1943b3 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "path/filepath" @@ -38,8 +39,21 @@ func init() { rootCmd.AddCommand(resumeCmd) } +// errArchivedWorkspace is the sentinel doResume returns when the user +// asks to resume an archived workspace. doResume already prints the +// full [ctask] diagnostic + restore hint to stderr, so the cmd-layer +// wrapper flips SilenceErrors to suppress Cobra's redundant trailing +// "Error: workspace archived" line. All other errors (lookup failure, +// metadata write failure, etc.) flow through Cobra's default +// rendering unchanged. +var errArchivedWorkspace = errors.New("workspace archived") + func runResume(cmd *cobra.Command, args []string) error { - return doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect) + err := doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect) + if errors.Is(err, errArchivedWorkspace) { + cmd.SilenceErrors = true + } + return err } // doResume is the shared resume logic used by both `resume` and `last`. @@ -59,7 +73,7 @@ func doResume(query string, container, useShell, force bool, agentOverride strin if ws.Meta.Status == "archived" { fmt.Fprint(os.Stderr, formatResumeRestoreHint(query)) - return fmt.Errorf("workspace archived") + return errArchivedWorkspace } // updated_at bump (existing v0.4 behavior). diff --git a/cmd/resume_archived_polish_test.go b/cmd/resume_archived_polish_test.go new file mode 100644 index 0000000..253bfb3 --- /dev/null +++ b/cmd/resume_archived_polish_test.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// TestResumeArchivedHintNoDuplicateError exercises resume through Cobra +// (not just runResume directly) so the SilenceErrors path is observed +// end-to-end. After v0.5.4 the [ctask] diagnostic block must print on +// its own — Cobra's default trailing "Error: workspace archived" line +// is suppressed via the conditional SilenceErrors set inside runResume. +// +// This guards against: +// - Reverting to a generic fmt.Errorf("workspace archived") that no +// longer matches the sentinel. +// - Removing the SilenceErrors flip. +// - A future runResume refactor that returns the error before the +// conditional check runs. +func TestResumeArchivedHintNoDuplicateError(t *testing.T) { + withInvocationName(t, "ctask") + + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-04-22_archived-poll") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + archived := now.Add(-time.Hour) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "archived-poll", Title: "archived-poll", + CreatedAt: now, UpdatedAt: archived, + ArchivedAt: &archived, + Status: "archived", + Category: "general", + Type: "task", + Mode: "local", + Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + t.Cleanup(func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }) + + // resumeCmd is a package global; restore SilenceErrors after the test + // so other tests against the same command see the original setting. + prevSilence := resumeCmd.SilenceErrors + t.Cleanup(func() { resumeCmd.SilenceErrors = prevSilence }) + + // Drive a fresh Cobra parent so we don't invoke unrelated commands + // during the test. AddCommand removes from the previous parent first. + parent := &cobra.Command{Use: "ctask-test"} + parent.AddCommand(resumeCmd) + t.Cleanup(func() { rootCmd.AddCommand(resumeCmd) }) + + parent.SetArgs([]string{"resume", "archived-poll"}) + + // Cobra writes the trailing "Error: ..." line to its configured + // error output, NOT to os.Stderr by default. Capture both: the + // [ctask] diagnostic goes to os.Stderr (via fmt.Fprint), while the + // would-be Cobra trailing line would go to parent's err output. + var cobraErrBuf bytes.Buffer + parent.SetErr(&cobraErrBuf) + parent.SetOut(&bytes.Buffer{}) + + stderrR, stderrW, _ := os.Pipe() + prevStderr := os.Stderr + os.Stderr = stderrW + defer func() { os.Stderr = prevStderr }() + + execErr := parent.Execute() + + stderrW.Close() + var directStderr bytes.Buffer + directStderr.ReadFrom(stderrR) + + if execErr == nil { + t.Fatal("expected error from resuming archived workspace") + } + if !errors.Is(execErr, errArchivedWorkspace) { + t.Errorf("Execute should return errArchivedWorkspace, got %v", execErr) + } + + // The [ctask] diagnostic must be present (printed to os.Stderr by + // runResume's stderr write). + gotStderr := directStderr.String() + if !strings.Contains(gotStderr, "[ctask] error: workspace") { + t.Errorf("[ctask] diagnostic missing from stderr:\n%s", gotStderr) + } + if !strings.Contains(gotStderr, "ctask restore archived-poll") { + t.Errorf("restore hint missing from stderr:\n%s", gotStderr) + } + + // The Cobra-default "Error: workspace archived" line must NOT appear. + // It would normally land on parent's error writer. + cobraOut := cobraErrBuf.String() + if strings.Contains(cobraOut, "Error:") { + t.Errorf("Cobra default Error: line was not suppressed:\n%s", cobraOut) + } +}