From ae9bfafb1fac37b35dbebab63f92d16f68040ac6 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 20:01:19 -0400 Subject: [PATCH] polish(v0.5.4): suppress Cobra duplicate Error on archived resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctask resume printed both the helpful [ctask] diagnostic + restore hint AND a redundant trailing "Error: workspace archived" line from Cobra's default error rendering. Cosmetic but unprofessional. Add an errArchivedWorkspace sentinel and have runResume flip SilenceErrors only when the inner error is that sentinel. All other resume errors (lookup failure, metadata write failure, etc.) continue to flow through Cobra's default rendering unchanged — we only silence the case where we have already printed an equivalent diagnostic ourselves. Verified end-to-end through Cobra Execute (not just runResume direct invocation) so the SilenceErrors-flip-from-RunE timing is exercised. --- cmd/resume.go | 18 ++++- cmd/resume_archived_polish_test.go | 117 +++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 cmd/resume_archived_polish_test.go 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) + } +}