feat(v0.5.2): add restore, notes, path commands with completion plumbing
Three new direct-lookup commands per v0.5.2-spec.md:
- ctask restore <ws> un-archive a workspace (metadata-only flip,
mirrors archive's lease guard, refuses to
restore an already-active workspace)
- ctask notes <ws> stream a workspace's notes.md to stdout (raw,
no framing, [ctask]-prefixed stderr on error)
so AI agents can read prior workspace context
through standard shell pipelines
- ctask path <ws> print the absolute filesystem path of a
workspace, OS-native separators, one line
All three resolve archived-inclusive: the user typed a name, so we
find the workspace whether or not it's archived. Listing stays
filtered (active-only by default) per the v0.5.2 design rule
"listing is filtered, direct lookup is comprehensive".
Adds shared completion infrastructure (cmd/completion.go) used by
these commands and wired into the existing workspace-accepting
commands in a follow-up commit. Candidates are workspace directory
basenames (e.g. 2026-04-22_promptvolley) rather than bare slugs
because basenames are unique under the resolver's exact-match step
while slugs can collide across categories or dates.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/warrenronsiek/ctask/internal/config"
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
// completionFilter selects which workspaces a command's tab-completion should
|
||||
// surface. It does not affect lookup — that's still the resolver's job — only
|
||||
// what the user sees as candidates while typing.
|
||||
type completionFilter int
|
||||
|
||||
const (
|
||||
completionActive completionFilter = iota
|
||||
completionArchived
|
||||
completionAny
|
||||
)
|
||||
|
||||
// completeWorkspaces returns a Cobra ValidArgsFunction that enumerates
|
||||
// workspace directory basenames matching the requested filter. Basenames are
|
||||
// emitted (not bare slugs) because the resolver's exact-match step accepts
|
||||
// basenames unambiguously, while bare slugs can collide across categories or
|
||||
// dates.
|
||||
//
|
||||
// On any internal error we surface ShellCompDirectiveError so the shell shows
|
||||
// nothing rather than a partial / misleading list.
|
||||
func completeWorkspaces(filter completionFilter) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// Only the first positional argument is a workspace name.
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
roots := config.SearchRoots()
|
||||
results, err := workspace.ListWorkspaces(roots, workspace.ListOpts{
|
||||
IncludeArchived: filter != completionActive,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, ws := range results {
|
||||
switch filter {
|
||||
case completionActive:
|
||||
if ws.Meta.Status == "archived" {
|
||||
continue
|
||||
}
|
||||
case completionArchived:
|
||||
if ws.Meta.Status != "archived" {
|
||||
continue
|
||||
}
|
||||
case completionAny:
|
||||
// no filter
|
||||
}
|
||||
names = append(names, filepath.Base(ws.Path))
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
// completionTestEnv mirrors listTestEnv but is duplicated here so the
|
||||
// completion tests are self-contained and can vary the fixtures.
|
||||
func completionTestEnv(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
mk := func(category, dirName, status, taskType string) {
|
||||
dir := filepath.Join(root, category, dirName)
|
||||
os.MkdirAll(dir, 0755)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
slug := dirName[11:]
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "t", Slug: slug, Title: slug,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: status, Category: category, Type: taskType,
|
||||
Mode: "local", Agent: "claude",
|
||||
}
|
||||
if status == "archived" {
|
||||
meta.ArchivedAt = &now
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
}
|
||||
mk("general", "2026-04-05_alpha-active", "active", "task")
|
||||
mk("general", "2026-04-04_beta-archived", "archived", "task")
|
||||
mk("projects", "2026-04-03_gamma-active", "active", "project")
|
||||
mk("projects", "2026-04-02_delta-archived", "archived", "project")
|
||||
return root
|
||||
}
|
||||
|
||||
// callCompletion invokes a ValidArgsFunction directly under a CTASK_ROOT
|
||||
// override, returning the candidate list (sorted for stable comparisons)
|
||||
// and the shell directive.
|
||||
func callCompletion(t *testing.T, root string, fn func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective)) ([]string, cobra.ShellCompDirective) {
|
||||
t.Helper()
|
||||
prev := os.Getenv("CTASK_ROOT")
|
||||
os.Setenv("CTASK_ROOT", root)
|
||||
defer func() {
|
||||
if prev == "" {
|
||||
os.Unsetenv("CTASK_ROOT")
|
||||
} else {
|
||||
os.Setenv("CTASK_ROOT", prev)
|
||||
}
|
||||
}()
|
||||
candidates, dir := fn(nil, nil, "")
|
||||
sort.Strings(candidates)
|
||||
return candidates, dir
|
||||
}
|
||||
|
||||
func TestCompleteWorkspacesActiveOnly(t *testing.T) {
|
||||
root := completionTestEnv(t)
|
||||
got, _ := callCompletion(t, root, completeWorkspaces(completionActive))
|
||||
want := []string{
|
||||
"2026-04-03_gamma-active",
|
||||
"2026-04-05_alpha-active",
|
||||
}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Errorf("active filter:\nwant %v\ngot %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteWorkspacesArchivedOnly(t *testing.T) {
|
||||
root := completionTestEnv(t)
|
||||
got, _ := callCompletion(t, root, completeWorkspaces(completionArchived))
|
||||
want := []string{
|
||||
"2026-04-02_delta-archived",
|
||||
"2026-04-04_beta-archived",
|
||||
}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Errorf("archived filter:\nwant %v\ngot %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteWorkspacesAny(t *testing.T) {
|
||||
root := completionTestEnv(t)
|
||||
got, _ := callCompletion(t, root, completeWorkspaces(completionAny))
|
||||
want := []string{
|
||||
"2026-04-02_delta-archived",
|
||||
"2026-04-03_gamma-active",
|
||||
"2026-04-04_beta-archived",
|
||||
"2026-04-05_alpha-active",
|
||||
}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Errorf("any filter:\nwant %v\ngot %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteWorkspacesSecondArgReturnsNoCompletion(t *testing.T) {
|
||||
// Once the user has typed the first positional arg, completion of further
|
||||
// args must not enumerate workspaces.
|
||||
root := completionTestEnv(t)
|
||||
prev := os.Getenv("CTASK_ROOT")
|
||||
os.Setenv("CTASK_ROOT", root)
|
||||
defer func() {
|
||||
if prev == "" {
|
||||
os.Unsetenv("CTASK_ROOT")
|
||||
} else {
|
||||
os.Setenv("CTASK_ROOT", prev)
|
||||
}
|
||||
}()
|
||||
candidates, dir := completeWorkspaces(completionAny)(nil, []string{"already-typed"}, "")
|
||||
if len(candidates) != 0 {
|
||||
t.Errorf("expected no candidates after first positional arg, got: %v", candidates)
|
||||
}
|
||||
if dir != cobra.ShellCompDirectiveNoFileComp {
|
||||
t.Errorf("expected ShellCompDirectiveNoFileComp, got: %v", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// genCompletion calls the shell-specific generator that Cobra's auto-injected
|
||||
// `ctask completion <shell>` subcommand uses internally. Bypasses rootCmd
|
||||
// argument-routing state so the tests are independent.
|
||||
func genCompletion(shell string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
var err error
|
||||
switch shell {
|
||||
case "bash":
|
||||
err = rootCmd.GenBashCompletionV2(&buf, true)
|
||||
case "zsh":
|
||||
err = rootCmd.GenZshCompletion(&buf)
|
||||
case "fish":
|
||||
err = rootCmd.GenFishCompletion(&buf, true)
|
||||
case "powershell":
|
||||
err = rootCmd.GenPowerShellCompletionWithDesc(&buf)
|
||||
}
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func TestCompletionBashGenerates(t *testing.T) {
|
||||
out, err := genCompletion("bash")
|
||||
if err != nil {
|
||||
t.Fatalf("GenBashCompletionV2: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected non-empty bash completion script")
|
||||
}
|
||||
if !strings.Contains(out, "ctask") {
|
||||
t.Errorf("bash completion script should mention 'ctask':\n%s", truncate(out, 200))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionPowerShellGenerates(t *testing.T) {
|
||||
out, err := genCompletion("powershell")
|
||||
if err != nil {
|
||||
t.Fatalf("GenPowerShellCompletionWithDesc: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected non-empty powershell completion script")
|
||||
}
|
||||
if !strings.Contains(out, "ctask") {
|
||||
t.Errorf("powershell completion script should mention 'ctask':\n%s", truncate(out, 200))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionZshGenerates(t *testing.T) {
|
||||
out, err := genCompletion("zsh")
|
||||
if err != nil {
|
||||
t.Fatalf("GenZshCompletion: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected non-empty zsh completion script")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionFishGenerates(t *testing.T) {
|
||||
out, err := genCompletion("fish")
|
||||
if err != nil {
|
||||
t.Fatalf("GenFishCompletion: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected non-empty fish completion script")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompletionSubcommandViaExecute verifies that the user-facing path
|
||||
// `ctask completion bash` actually runs end-to-end through Cobra. Cobra
|
||||
// adds the `completion` subcommand lazily on first Execute(), so a Find()
|
||||
// before any Execute() returns "unknown command" — exercising the real
|
||||
// path is the right test.
|
||||
func TestCompletionSubcommandViaExecute(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
defer rootCmd.SetOut(os.Stdout)
|
||||
defer rootCmd.SetErr(os.Stderr)
|
||||
|
||||
rootCmd.SetArgs([]string{"completion", "bash"})
|
||||
defer rootCmd.SetArgs(nil)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("rootCmd.Execute(\"completion\", \"bash\"): %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Fatal("expected non-empty bash completion via Execute")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "ctask") {
|
||||
t.Errorf("end-to-end bash completion should mention 'ctask'")
|
||||
}
|
||||
}
|
||||
|
||||
func equalStringSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/warrenronsiek/ctask/internal/config"
|
||||
)
|
||||
|
||||
var notesCmd = &cobra.Command{
|
||||
Use: "notes <query>",
|
||||
Short: "Print a workspace's notes.md to stdout",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: runNotes,
|
||||
}
|
||||
|
||||
func init() {
|
||||
notesCmd.ValidArgsFunction = completeWorkspaces(completionAny)
|
||||
rootCmd.AddCommand(notesCmd)
|
||||
}
|
||||
|
||||
// runNotes streams <workspace>/notes.md to stdout. SilenceErrors is enabled
|
||||
// so the [ctask]-prefixed message we print to stderr is the only diagnostic
|
||||
// the user sees — no Cobra "Error: ..." line on top of it.
|
||||
func runNotes(cmd *cobra.Command, args []string) error {
|
||||
roots := config.SearchRoots()
|
||||
ws := resolveOne(roots, args[0], true)
|
||||
|
||||
notesPath := filepath.Join(ws.Path, "notes.md")
|
||||
f, err := os.Open(notesPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[ctask] no notes.md found in workspace %q\n", args[0])
|
||||
return fmt.Errorf("notes.md missing")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[ctask] error reading notes.md: %v\n", err)
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(os.Stdout, f); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ctask] error streaming notes.md: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
// callNotes invokes runNotes with stdout AND stderr captured.
|
||||
func callNotes(t *testing.T, root, query string) (stdout, stderr string, err error) {
|
||||
t.Helper()
|
||||
|
||||
prevRoot := os.Getenv("CTASK_ROOT")
|
||||
os.Setenv("CTASK_ROOT", root)
|
||||
defer func() {
|
||||
if prevRoot == "" {
|
||||
os.Unsetenv("CTASK_ROOT")
|
||||
} else {
|
||||
os.Setenv("CTASK_ROOT", prevRoot)
|
||||
}
|
||||
}()
|
||||
|
||||
outR, outW, _ := os.Pipe()
|
||||
prevStdout := os.Stdout
|
||||
os.Stdout = outW
|
||||
defer func() { os.Stdout = prevStdout }()
|
||||
|
||||
errR, errW, _ := os.Pipe()
|
||||
prevStderr := os.Stderr
|
||||
os.Stderr = errW
|
||||
defer func() { os.Stderr = prevStderr }()
|
||||
|
||||
err = runNotes(notesCmd, []string{query})
|
||||
|
||||
outW.Close()
|
||||
errW.Close()
|
||||
var bo, be bytes.Buffer
|
||||
bo.ReadFrom(outR)
|
||||
be.ReadFrom(errR)
|
||||
return bo.String(), be.String(), err
|
||||
}
|
||||
|
||||
// makeNotesWs creates a workspace with optional notes.md content (empty
|
||||
// string means do not create notes.md).
|
||||
func makeNotesWs(t *testing.T, root, category, dirName, status, notesBody string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, category, dirName)
|
||||
os.MkdirAll(dir, 0755)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
slug := dirName[11:]
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "t", Slug: slug, Title: slug,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: status, Category: category, Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
}
|
||||
if status == "archived" {
|
||||
meta.ArchivedAt = &now
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
if notesBody != "" {
|
||||
os.WriteFile(filepath.Join(dir, "notes.md"), []byte(notesBody), 0644)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestNotesPrintsRawContent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
body := "# my notes\n\n- item one\n- item two\n"
|
||||
makeNotesWs(t, root, "general", "2026-04-22_notes-active", "active", body)
|
||||
|
||||
stdout, stderr, err := callNotes(t, root, "notes-active")
|
||||
if err != nil {
|
||||
t.Fatalf("notes should succeed: %v", err)
|
||||
}
|
||||
if stdout != body {
|
||||
t.Errorf("stdout mismatch:\nwant:\n%q\ngot:\n%q", body, stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Errorf("stderr should be empty on success, got: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesNoFramingOnStdout(t *testing.T) {
|
||||
// Output must be raw — no banner, no [ctask] prefix on stdout.
|
||||
root := t.TempDir()
|
||||
body := "hello world"
|
||||
makeNotesWs(t, root, "general", "2026-04-22_notes-bare", "active", body)
|
||||
|
||||
stdout, _, err := callNotes(t, root, "notes-bare")
|
||||
if err != nil {
|
||||
t.Fatalf("notes: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "[ctask]") {
|
||||
t.Errorf("stdout must not contain [ctask] framing: %q", stdout)
|
||||
}
|
||||
if stdout != body {
|
||||
t.Errorf("stdout must equal raw file content exactly: want=%q got=%q", body, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesMissingFileExitsNonZero(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// notesBody="" → notes.md is NOT created
|
||||
makeNotesWs(t, root, "general", "2026-04-22_notes-gone", "active", "")
|
||||
|
||||
stdout, stderr, err := callNotes(t, root, "notes-gone")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when notes.md is missing")
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout should be empty on error, got: %q", stdout)
|
||||
}
|
||||
if !strings.Contains(stderr, "[ctask] no notes.md found") {
|
||||
t.Errorf("stderr should explain missing notes.md, got: %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "notes-gone") {
|
||||
t.Errorf("stderr should name the workspace, got: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesWorksOnArchivedWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
body := "archived workspace notes\n"
|
||||
makeNotesWs(t, root, "general", "2026-04-22_notes-archived", "archived", body)
|
||||
|
||||
stdout, _, err := callNotes(t, root, "notes-archived")
|
||||
if err != nil {
|
||||
t.Fatalf("notes should find archived workspace: %v", err)
|
||||
}
|
||||
if stdout != body {
|
||||
t.Errorf("archived ws stdout mismatch: want=%q got=%q", body, stdout)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/warrenronsiek/ctask/internal/config"
|
||||
)
|
||||
|
||||
var pathCmd = &cobra.Command{
|
||||
Use: "path <query>",
|
||||
Short: "Print the absolute filesystem path of a workspace",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
RunE: runPath,
|
||||
}
|
||||
|
||||
func init() {
|
||||
pathCmd.ValidArgsFunction = completeWorkspaces(completionAny)
|
||||
rootCmd.AddCommand(pathCmd)
|
||||
}
|
||||
|
||||
// runPath prints QueryResult.Path to stdout with a single trailing newline.
|
||||
// Uses native path separators — backslashes on Windows, forward slashes on
|
||||
// Linux. Output is meant to be consumed by shell pipelines and agent tooling.
|
||||
func runPath(cmd *cobra.Command, args []string) error {
|
||||
roots := config.SearchRoots()
|
||||
ws := resolveOne(roots, args[0], true)
|
||||
fmt.Println(ws.Path)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
// callPath invokes runPath with stdout captured.
|
||||
func callPath(t *testing.T, root, query string) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
prevRoot := os.Getenv("CTASK_ROOT")
|
||||
os.Setenv("CTASK_ROOT", root)
|
||||
defer func() {
|
||||
if prevRoot == "" {
|
||||
os.Unsetenv("CTASK_ROOT")
|
||||
} else {
|
||||
os.Setenv("CTASK_ROOT", prevRoot)
|
||||
}
|
||||
}()
|
||||
|
||||
r, w, _ := os.Pipe()
|
||||
prevStdout := os.Stdout
|
||||
os.Stdout = w
|
||||
defer func() { os.Stdout = prevStdout }()
|
||||
|
||||
err := runPath(pathCmd, []string{query})
|
||||
w.Close()
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(r)
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func TestPathPrintsAbsolutePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dirName := "2026-04-22_path-active"
|
||||
wsDir := filepath.Join(root, "general", dirName)
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "t", Slug: "path-active", Title: "path-active",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
out, err := callPath(t, root, "path-active")
|
||||
if err != nil {
|
||||
t.Fatalf("path: %v", err)
|
||||
}
|
||||
got := strings.TrimRight(out, "\r\n")
|
||||
want, _ := filepath.Abs(wsDir)
|
||||
if got != want {
|
||||
t.Errorf("output path mismatch:\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
if !strings.HasSuffix(out, "\n") {
|
||||
t.Errorf("output should end with a newline, got: %q", out)
|
||||
}
|
||||
if filepath.IsAbs(got) != true {
|
||||
t.Errorf("output must be absolute, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathWorksOnArchivedWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dirName := "2026-04-22_path-archived"
|
||||
wsDir := filepath.Join(root, "general", dirName)
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "t", Slug: "path-archived", Title: "path-archived",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ArchivedAt: &now,
|
||||
Status: "archived",
|
||||
Category: "general",
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
out, err := callPath(t, root, "path-archived")
|
||||
if err != nil {
|
||||
t.Fatalf("path should find archived workspace: %v", err)
|
||||
}
|
||||
got := strings.TrimRight(out, "\r\n")
|
||||
want, _ := filepath.Abs(wsDir)
|
||||
if got != want {
|
||||
t.Errorf("output path mismatch:\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/warrenronsiek/ctask/internal/config"
|
||||
"github.com/warrenronsiek/ctask/internal/session"
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
var restoreCmd = &cobra.Command{
|
||||
Use: "restore <query>",
|
||||
Short: "Un-archive a workspace (set status back to active)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
RunE: runRestore,
|
||||
}
|
||||
|
||||
func init() {
|
||||
restoreCmd.ValidArgsFunction = completeWorkspaces(completionArchived)
|
||||
rootCmd.AddCommand(restoreCmd)
|
||||
}
|
||||
|
||||
func runRestore(cmd *cobra.Command, args []string) error {
|
||||
roots := config.SearchRoots()
|
||||
ws := resolveOne(roots, args[0], true)
|
||||
|
||||
if ws.Meta.Status != "archived" {
|
||||
return fmt.Errorf("workspace %q is already active", args[0])
|
||||
}
|
||||
|
||||
// Mirror the active-session lease guard from archive: if a session is
|
||||
// holding a fresh lease, prompt on TTY and refuse on non-TTY. Restoring
|
||||
// under an active session is unusual but not catastrophic — the same
|
||||
// "refuse before silent surprise" rule applies.
|
||||
if lease, err := session.ReadLease(session.LeasePath(ws.Path)); err == nil && lease != nil {
|
||||
if session.IsFresh(lease, time.Now(), session.StaleLeaseAfter) {
|
||||
fmt.Print(formatRestoreActiveWarning(lease, time.Now()))
|
||||
|
||||
if !isStdinTerminal() {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("refusing to restore workspace with active session (stdin is not a terminal)")
|
||||
}
|
||||
|
||||
if !session.ConfirmYN(os.Stdin, os.Stdout, " Restore anyway? [y/N] ", false) {
|
||||
fmt.Println(" Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
ws.Meta.Status = "active"
|
||||
ws.Meta.ArchivedAt = nil
|
||||
ws.Meta.UpdatedAt = now
|
||||
|
||||
metaPath := filepath.Join(ws.Path, "task.yaml")
|
||||
if err := workspace.WriteMetaLocked(metaPath, ws.Meta); err != nil {
|
||||
return fmt.Errorf("updating metadata: %w", err)
|
||||
}
|
||||
|
||||
relPath := workspace.RelativePath(ws.Root, ws.Path)
|
||||
fmt.Printf("[ctask] restored: %s\n", relPath)
|
||||
fmt.Printf("[ctask] status: active\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatRestoreActiveWarning(l *session.Lease, now time.Time) string {
|
||||
startedAgo := now.Sub(l.StartedAt)
|
||||
return fmt.Sprintf(
|
||||
"[ctask] Warning: this workspace has an active session:\n"+
|
||||
" Host: %s\n"+
|
||||
" Agent: %s\n"+
|
||||
" Started: %s (%s ago)\n\n"+
|
||||
" Restoring will mark it active again.\n\n",
|
||||
l.Hostname,
|
||||
l.Agent,
|
||||
l.StartedAt.Local().Format("2006-01-02 15:04"),
|
||||
session.FormatAgo(startedAgo),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/session"
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
// This file swaps process-global os.Stdin, os.Stdout, and env vars. Do not
|
||||
// call t.Parallel() in this file.
|
||||
|
||||
// makeArchivedWs creates a minimal workspace with status=archived.
|
||||
func makeArchivedWs(t *testing.T, root, category, dirName string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, category, dirName)
|
||||
os.MkdirAll(dir, 0755)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
archived := now.Add(-time.Hour)
|
||||
slug := dirName[11:] // skip "YYYY-MM-DD_"
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
UpdatedAt: archived,
|
||||
ArchivedAt: &archived,
|
||||
Status: "archived",
|
||||
Category: category,
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
return dir
|
||||
}
|
||||
|
||||
// callRestore invokes runRestore with a captured (non-TTY) stdin and stdout.
|
||||
func callRestore(t *testing.T, root, query, stdinInput string) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
prevRoot := os.Getenv("CTASK_ROOT")
|
||||
os.Setenv("CTASK_ROOT", root)
|
||||
defer func() {
|
||||
if prevRoot == "" {
|
||||
os.Unsetenv("CTASK_ROOT")
|
||||
} else {
|
||||
os.Setenv("CTASK_ROOT", prevRoot)
|
||||
}
|
||||
}()
|
||||
|
||||
stdinR, stdinW, _ := os.Pipe()
|
||||
go func() {
|
||||
stdinW.WriteString(stdinInput)
|
||||
stdinW.Close()
|
||||
}()
|
||||
prevStdin := os.Stdin
|
||||
os.Stdin = stdinR
|
||||
defer func() { os.Stdin = prevStdin }()
|
||||
|
||||
stdoutR, stdoutW, _ := os.Pipe()
|
||||
prevStdout := os.Stdout
|
||||
os.Stdout = stdoutW
|
||||
defer func() { os.Stdout = prevStdout }()
|
||||
|
||||
err := runRestore(restoreCmd, []string{query})
|
||||
|
||||
stdoutW.Close()
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(stdoutR)
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func TestRestoreFlipsArchivedToActive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wsDir := makeArchivedWs(t, root, "general", "2026-04-22_revive")
|
||||
|
||||
out, err := callRestore(t, root, "revive", "")
|
||||
if err != nil {
|
||||
t.Fatalf("restore should succeed on archived ws, got error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "[ctask] restored:") {
|
||||
t.Errorf("expected restored confirmation, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "[ctask] status: active") {
|
||||
t.Errorf("expected status confirmation, got: %s", out)
|
||||
}
|
||||
|
||||
// task.yaml on disk: Status active, ArchivedAt cleared, UpdatedAt bumped.
|
||||
got, rerr := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml"))
|
||||
if rerr != nil {
|
||||
t.Fatalf("ReadMeta: %v", rerr)
|
||||
}
|
||||
if got.Status != "active" {
|
||||
t.Errorf("Status: got %q, want \"active\"", got.Status)
|
||||
}
|
||||
if got.ArchivedAt != nil {
|
||||
t.Errorf("ArchivedAt: expected nil after restore, got %v", got.ArchivedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreWritesStatusActiveExplicitly(t *testing.T) {
|
||||
// Per spec: write the value explicitly; do not omit the field or set it
|
||||
// to empty. The on-disk YAML must contain `status: active` literally.
|
||||
root := t.TempDir()
|
||||
wsDir := makeArchivedWs(t, root, "general", "2026-04-22_explicit")
|
||||
|
||||
if _, err := callRestore(t, root, "explicit", ""); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(filepath.Join(wsDir, "task.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "status: active") {
|
||||
t.Errorf("task.yaml must contain literal 'status: active':\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreUpdatesUpdatedAt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wsDir := makeArchivedWs(t, root, "general", "2026-04-22_bumpup")
|
||||
before, _ := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml"))
|
||||
|
||||
// Sleep a hair so UpdatedAt comparison is meaningful at second resolution.
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
if _, err := callRestore(t, root, "bumpup", ""); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
after, _ := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml"))
|
||||
|
||||
if !after.UpdatedAt.After(before.UpdatedAt) {
|
||||
t.Errorf("UpdatedAt did not advance: before=%v, after=%v", before.UpdatedAt, after.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRefusesAlreadyActive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wsDir := filepath.Join(root, "general", "2026-04-22_already")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "t", Slug: "already", Title: "already",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
_, err := callRestore(t, root, "already", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error restoring an already-active workspace")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already active") {
|
||||
t.Errorf("expected 'already active' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRefusesActiveSessionNonTTY(t *testing.T) {
|
||||
// Mirror archive's non-TTY refusal. A fresh lease + non-TTY stdin must
|
||||
// refuse without mutation, matching the archive guard.
|
||||
root := t.TempDir()
|
||||
wsDir := makeArchivedWs(t, root, "general", "2026-04-22_freshlease")
|
||||
|
||||
// Drop a fresh lease at .ctask/session.json
|
||||
now := time.Now().UTC()
|
||||
l := &session.Lease{
|
||||
SessionID: "test-session",
|
||||
PID: 1234,
|
||||
Hostname: "test-host",
|
||||
Username: "tester",
|
||||
Agent: "claude",
|
||||
Mode: "local",
|
||||
StartedAt: now.Add(-30 * time.Second),
|
||||
LastHeartbeatAt: now.Add(-5 * time.Second),
|
||||
Terminal: "test",
|
||||
}
|
||||
if err := session.WriteLease(session.LeasePath(wsDir), l); err != nil {
|
||||
t.Fatalf("WriteLease: %v", err)
|
||||
}
|
||||
|
||||
out, err := callRestore(t, root, "freshlease", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error refusing to restore active session on non-TTY stdin")
|
||||
}
|
||||
if !strings.Contains(out, "active session") {
|
||||
t.Errorf("expected active-session warning in output, got: %s", out)
|
||||
}
|
||||
|
||||
// Workspace must still be archived (no mutation).
|
||||
got, _ := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml"))
|
||||
if got.Status != "archived" {
|
||||
t.Errorf("workspace mutated despite refusal: status=%s", got.Status)
|
||||
}
|
||||
if got.ArchivedAt == nil {
|
||||
t.Errorf("ArchivedAt was cleared despite refusal")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user