fix(v0.4.1): scan both CTASK_ROOT and CTASK_PROJECT_ROOT in workspace queries
Rewrites scanWorkspaces to handle both category layout (root/<category>/<workspace>/task.yaml) and flat layout (root/<workspace>/task.yaml used under CTASK_PROJECT_ROOT). Adds scanAllRoots to walk multiple roots with absolute-path dedupe. ResolveQuery, ListWorkspaces, and MostRecentActive now accept []string. QueryResult gains a Root field so callers can render display paths and session env vars relative to the originating root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,9 +26,9 @@ type ListOpts struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
// ListWorkspaces returns workspaces in reverse-chronological order.
|
||||
// Returns an error if Type is set to an unrecognized value.
|
||||
func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) {
|
||||
// ListWorkspaces returns workspaces in reverse-chronological order across one
|
||||
// or more roots. Returns an error if Type is set to an unrecognized value.
|
||||
func ListWorkspaces(roots []string, opts ListOpts) ([]QueryResult, error) {
|
||||
switch opts.Type {
|
||||
case TypeAny, TypeTask, TypeProject:
|
||||
// ok
|
||||
@@ -36,7 +36,7 @@ func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) {
|
||||
return nil, fmt.Errorf("invalid Type filter: %q (want %q, %q, or %q)", opts.Type, TypeAny, TypeTask, TypeProject)
|
||||
}
|
||||
|
||||
all, err := scanWorkspaces(root)
|
||||
all, err := scanAllRoots(roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ func TestListWorkspaces(t *testing.T) {
|
||||
createTestWorkspace(t, root, "general", "2026-04-01_archived-task", "archived")
|
||||
|
||||
// Default: no archived, no filter
|
||||
results, err := ListWorkspaces(root, ListOpts{Limit: 20})
|
||||
results, err := ListWorkspaces([]string{root}, ListOpts{Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces: %v", err)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestListWorkspaces(t *testing.T) {
|
||||
}
|
||||
|
||||
// With --all
|
||||
results, err = ListWorkspaces(root, ListOpts{IncludeArchived: true, Limit: 20})
|
||||
results, err = ListWorkspaces([]string{root}, ListOpts{IncludeArchived: true, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces --all: %v", err)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func TestListWorkspaces(t *testing.T) {
|
||||
}
|
||||
|
||||
// Category filter
|
||||
results, err = ListWorkspaces(root, ListOpts{Category: "scripts", Limit: 20})
|
||||
results, err = ListWorkspaces([]string{root}, ListOpts{Category: "scripts", Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces -c scripts: %v", err)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func TestListWorkspaces(t *testing.T) {
|
||||
}
|
||||
|
||||
// Limit
|
||||
results, err = ListWorkspaces(root, ListOpts{Limit: 1})
|
||||
results, err = ListWorkspaces([]string{root}, ListOpts{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces -n 1: %v", err)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func TestListTypeFilter(t *testing.T) {
|
||||
createTestWorkspaceTyped(t, root, "projects", "2026-04-02_proj-b", "archived", "project")
|
||||
|
||||
// TypeAny: both types, active only
|
||||
results, err := ListWorkspaces(root, ListOpts{Type: TypeAny, Limit: 20})
|
||||
results, err := ListWorkspaces([]string{root}, ListOpts{Type: TypeAny, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces TypeAny: %v", err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestListTypeFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// TypeAny + IncludeArchived: everything
|
||||
results, err = ListWorkspaces(root, ListOpts{Type: TypeAny, IncludeArchived: true, Limit: 20})
|
||||
results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeAny, IncludeArchived: true, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces TypeAny+all: %v", err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestListTypeFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// TypeTask: tasks only, active
|
||||
results, err = ListWorkspaces(root, ListOpts{Type: TypeTask, Limit: 20})
|
||||
results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeTask, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces TypeTask: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestListTypeFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// TypeTask + IncludeArchived: still tasks only (no archived tasks here, so 2)
|
||||
results, err = ListWorkspaces(root, ListOpts{Type: TypeTask, IncludeArchived: true, Limit: 20})
|
||||
results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeTask, IncludeArchived: true, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces TypeTask+all: %v", err)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestListTypeFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// TypeProject: projects only, active
|
||||
results, err = ListWorkspaces(root, ListOpts{Type: TypeProject, Limit: 20})
|
||||
results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeProject, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces TypeProject: %v", err)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestListTypeFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// TypeProject + IncludeArchived
|
||||
results, err = ListWorkspaces(root, ListOpts{Type: TypeProject, IncludeArchived: true, Limit: 20})
|
||||
results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeProject, IncludeArchived: true, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces TypeProject+all: %v", err)
|
||||
}
|
||||
@@ -124,18 +124,40 @@ func TestListTypeFilter(t *testing.T) {
|
||||
|
||||
func TestListInvalidTypeFilterReturnsError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
_, err := ListWorkspaces(root, ListOpts{Type: "weird"})
|
||||
_, err := ListWorkspaces([]string{root}, ListOpts{Type: "weird"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid Type filter, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMergesTaskRootAndProjectRoot(t *testing.T) {
|
||||
taskRoot := t.TempDir()
|
||||
projRoot := t.TempDir()
|
||||
createTestWorkspace(t, taskRoot, "general", "2026-04-05_task-one", "active")
|
||||
createFlatProjectWorkspace(t, projRoot, "2026-04-06_proj-one", "active")
|
||||
|
||||
results, err := ListWorkspaces([]string{taskRoot, projRoot}, ListOpts{Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results across roots, got %d", len(results))
|
||||
}
|
||||
slugs := map[string]bool{}
|
||||
for _, r := range results {
|
||||
slugs[r.Meta.Slug] = true
|
||||
}
|
||||
if !slugs["task-one"] || !slugs["proj-one"] {
|
||||
t.Errorf("expected both task-one and proj-one: got %+v", slugs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReverseChronological(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestWorkspace(t, root, "general", "2026-04-01_first", "active")
|
||||
createTestWorkspace(t, root, "general", "2026-04-05_second", "active")
|
||||
|
||||
results, _ := ListWorkspaces(root, ListOpts{Limit: 20})
|
||||
results, _ := ListWorkspaces([]string{root}, ListOpts{Limit: 20})
|
||||
if len(results) < 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package workspace
|
||||
|
||||
// MostRecentActive returns the active workspace with the latest UpdatedAt
|
||||
// timestamp under root, considering both tasks and projects.
|
||||
// timestamp across the given roots, considering both tasks and projects.
|
||||
//
|
||||
// Returns (nil, nil) if no active workspaces exist (this is not an error
|
||||
// condition; callers decide how to surface "nothing to do").
|
||||
//
|
||||
// Legacy v0.2 workspaces (no Type field) are included as tasks via
|
||||
// EffectiveType. Archived workspaces are always excluded.
|
||||
func MostRecentActive(root string) (*QueryResult, error) {
|
||||
results, err := ListWorkspaces(root, ListOpts{
|
||||
func MostRecentActive(roots []string) (*QueryResult, error) {
|
||||
results, err := ListWorkspaces(roots, ListOpts{
|
||||
IncludeArchived: false,
|
||||
Limit: 0,
|
||||
Type: TypeAny,
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestMostRecentActiveTasksOnly(t *testing.T) {
|
||||
createTestWorkspaceFull(t, root, "general", "2026-04-01_old", "active", "task", baseT)
|
||||
createTestWorkspaceFull(t, root, "general", "2026-04-02_new", "active", "task", baseT.Add(2*time.Hour))
|
||||
|
||||
got, err := MostRecentActive(root)
|
||||
got, err := MostRecentActive([]string{root})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestMostRecentActiveProjectsOnly(t *testing.T) {
|
||||
createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT)
|
||||
createTestWorkspaceFull(t, root, "projects", "2026-04-02_new-proj", "active", "project", baseT.Add(2*time.Hour))
|
||||
|
||||
got, err := MostRecentActive(root)
|
||||
got, err := MostRecentActive([]string{root})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestMostRecentActiveProjectWinsOverOlderTask(t *testing.T) {
|
||||
createTestWorkspaceFull(t, root, "general", "2026-04-01_old-task", "active", "task", baseT)
|
||||
createTestWorkspaceFull(t, root, "projects", "2026-04-02_new-proj", "active", "project", baseT.Add(2*time.Hour))
|
||||
|
||||
got, err := MostRecentActive(root)
|
||||
got, err := MostRecentActive([]string{root})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestMostRecentActiveTaskWinsOverOlderProject(t *testing.T) {
|
||||
createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT)
|
||||
createTestWorkspaceFull(t, root, "general", "2026-04-02_new-task", "active", "task", baseT.Add(2*time.Hour))
|
||||
|
||||
got, err := MostRecentActive(root)
|
||||
got, err := MostRecentActive([]string{root})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TestMostRecentActiveLegacyWorkspaceCounts(t *testing.T) {
|
||||
createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT)
|
||||
createTestWorkspaceFull(t, root, "general", "2026-04-02_legacy", "active", "", baseT.Add(2*time.Hour))
|
||||
|
||||
got, err := MostRecentActive(root)
|
||||
got, err := MostRecentActive([]string{root})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestMostRecentActiveExcludesArchived(t *testing.T) {
|
||||
createTestWorkspaceFull(t, root, "projects", "2026-04-02_active-proj", "active", "project", baseT.Add(2*time.Hour))
|
||||
createTestWorkspaceFull(t, root, "general", "2026-04-01_old-task", "active", "task", baseT)
|
||||
|
||||
got, err := MostRecentActive(root)
|
||||
got, err := MostRecentActive([]string{root})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func TestMostRecentActiveExcludesArchived(t *testing.T) {
|
||||
|
||||
func TestMostRecentActiveReturnsNilWhenEmpty(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
got, err := MostRecentActive(root)
|
||||
got, err := MostRecentActive([]string{root})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
@@ -123,12 +123,30 @@ func TestMostRecentActiveReturnsNilWhenEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMostRecentActiveAcrossRoots(t *testing.T) {
|
||||
taskRoot := t.TempDir()
|
||||
projRoot := t.TempDir()
|
||||
createTestWorkspaceFull(t, taskRoot, "general", "2026-04-05_old-task", "active", "task", baseT)
|
||||
createFlatProjectWorkspaceFull(t, projRoot, "2026-04-06_new-proj", baseT.Add(time.Hour))
|
||||
|
||||
got, err := MostRecentActive([]string{taskRoot, projRoot})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
if got == nil || got.Meta.Slug != "new-proj" {
|
||||
t.Fatalf("expected new-proj (newest across roots), got %+v", got)
|
||||
}
|
||||
if got.Root != projRoot {
|
||||
t.Errorf("Root: got %q, want %q", got.Root, projRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMostRecentActiveReturnsNilWhenAllArchived(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestWorkspaceFull(t, root, "general", "2026-04-01_arch-task", "archived", "task", baseT)
|
||||
createTestWorkspaceFull(t, root, "projects", "2026-04-02_arch-proj", "archived", "project", baseT.Add(time.Hour))
|
||||
|
||||
got, err := MostRecentActive(root)
|
||||
got, err := MostRecentActive([]string{root})
|
||||
if err != nil {
|
||||
t.Fatalf("MostRecentActive: %v", err)
|
||||
}
|
||||
|
||||
+73
-15
@@ -3,24 +3,29 @@ package workspace
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QueryResult holds a resolved workspace match.
|
||||
// Root is the search root (absolute path) under which this workspace was found.
|
||||
// Callers use Root to compute display paths and session env vars so that a
|
||||
// workspace under CTASK_PROJECT_ROOT is displayed relative to that root.
|
||||
type QueryResult struct {
|
||||
Path string
|
||||
Root string
|
||||
Meta *TaskMeta
|
||||
}
|
||||
|
||||
// ResolveQuery implements the 5-step query resolution algorithm.
|
||||
// Returns matching workspaces. Caller decides what to do with 0, 1, or N results.
|
||||
func ResolveQuery(root, query string, includeArchived bool) ([]QueryResult, error) {
|
||||
all, err := scanWorkspaces(root)
|
||||
// ResolveQuery implements the 5-step query resolution algorithm across one or
|
||||
// more workspace roots. Returns matching workspaces. Caller decides what to do
|
||||
// with 0, 1, or N results.
|
||||
func ResolveQuery(roots []string, query string, includeArchived bool) ([]QueryResult, error) {
|
||||
all, err := scanAllRoots(roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter archived unless includeArchived
|
||||
var candidates []QueryResult
|
||||
for _, ws := range all {
|
||||
if !includeArchived && ws.Meta.Status == "archived" {
|
||||
@@ -60,11 +65,49 @@ func ResolveQuery(root, query string, includeArchived bool) ([]QueryResult, erro
|
||||
return substring, nil
|
||||
}
|
||||
|
||||
// scanWorkspaces walks root/*/dir looking for task.yaml files.
|
||||
// scanAllRoots scans each root with scanWorkspaces, annotates every result
|
||||
// with the originating root, and deduplicates by absolute workspace path.
|
||||
// Order is stable: results from earlier roots appear before later ones.
|
||||
func scanAllRoots(roots []string) ([]QueryResult, error) {
|
||||
seen := make(map[string]struct{})
|
||||
var out []QueryResult
|
||||
for _, root := range roots {
|
||||
perRoot, err := scanWorkspaces(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ws := range perRoot {
|
||||
absPath, err := filepath.Abs(ws.Path)
|
||||
if err != nil {
|
||||
absPath = ws.Path
|
||||
}
|
||||
key := absPath
|
||||
if isWindows() {
|
||||
key = strings.ToLower(absPath)
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
ws.Root = root
|
||||
out = append(out, ws)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanWorkspaces walks root looking for task.yaml files at two depths:
|
||||
// - root/<workspace>/task.yaml (flat project layout under CTASK_PROJECT_ROOT)
|
||||
// - root/<category>/<workspace>/task.yaml (task layout, and projects with -c)
|
||||
//
|
||||
// A directory that directly contains task.yaml is treated as a workspace and
|
||||
// its subdirectories are not descended — this both avoids double-counting and
|
||||
// ensures non-workspace subdirs (src/, output/, logs/, context/) never leak.
|
||||
// Returns QueryResults with Path and Meta populated; Root is filled in by scanAllRoots.
|
||||
func scanWorkspaces(root string) ([]QueryResult, error) {
|
||||
var results []QueryResult
|
||||
|
||||
categories, err := os.ReadDir(root)
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
@@ -72,26 +115,37 @@ func scanWorkspaces(root string) ([]QueryResult, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, cat := range categories {
|
||||
if !cat.IsDir() {
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
catPath := filepath.Join(root, cat.Name())
|
||||
entries, err := os.ReadDir(catPath)
|
||||
entryPath := filepath.Join(root, entry.Name())
|
||||
|
||||
// Depth 1: root/<entry>/task.yaml (flat workspace layout)
|
||||
if meta, err := ReadMeta(filepath.Join(entryPath, "task.yaml")); err == nil {
|
||||
results = append(results, QueryResult{
|
||||
Path: entryPath,
|
||||
Meta: meta,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Depth 2: root/<entry>/<sub>/task.yaml (category layout)
|
||||
subs, err := os.ReadDir(entryPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
for _, sub := range subs {
|
||||
if !sub.IsDir() {
|
||||
continue
|
||||
}
|
||||
metaPath := filepath.Join(catPath, entry.Name(), "task.yaml")
|
||||
metaPath := filepath.Join(entryPath, sub.Name(), "task.yaml")
|
||||
meta, err := ReadMeta(metaPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, QueryResult{
|
||||
Path: filepath.Join(catPath, entry.Name()),
|
||||
Path: filepath.Join(entryPath, sub.Name()),
|
||||
Meta: meta,
|
||||
})
|
||||
}
|
||||
@@ -99,3 +153,7 @@ func scanWorkspaces(root string) ([]QueryResult, error) {
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func isWindows() bool {
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestQueryExactDirName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active")
|
||||
|
||||
results, err := ResolveQuery(root, "2026-04-05_arch-notes", false)
|
||||
results, err := ResolveQuery([]string{root}, "2026-04-05_arch-notes", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestQueryExactSlug(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active")
|
||||
|
||||
results, err := ResolveQuery(root, "arch-notes", false)
|
||||
results, err := ResolveQuery([]string{root}, "arch-notes", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestQuerySubstringUnique(t *testing.T) {
|
||||
createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active")
|
||||
createTestWorkspace(t, root, "scripts", "2026-04-03_backup-helper", "active")
|
||||
|
||||
results, err := ResolveQuery(root, "arch", false)
|
||||
results, err := ResolveQuery([]string{root}, "arch", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestQuerySubstringMultiple(t *testing.T) {
|
||||
createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active")
|
||||
createTestWorkspace(t, root, "research", "2026-04-03_migration-notes", "active")
|
||||
|
||||
results, err := ResolveQuery(root, "notes", false)
|
||||
results, err := ResolveQuery([]string{root}, "notes", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func TestQueryNoMatch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active")
|
||||
|
||||
results, err := ResolveQuery(root, "nonexistent", false)
|
||||
results, err := ResolveQuery([]string{root}, "nonexistent", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
@@ -124,13 +124,13 @@ func TestQueryExcludesArchived(t *testing.T) {
|
||||
createTestWorkspace(t, root, "general", "2026-04-05_new-task", "active")
|
||||
|
||||
// Without --all: archived excluded
|
||||
results, _ := ResolveQuery(root, "old-task", false)
|
||||
results, _ := ResolveQuery([]string{root}, "old-task", false)
|
||||
if len(results) != 0 {
|
||||
t.Errorf("archived should be excluded, got %d results", len(results))
|
||||
}
|
||||
|
||||
// With --all: archived included
|
||||
results, _ = ResolveQuery(root, "old-task", true)
|
||||
results, _ = ResolveQuery([]string{root}, "old-task", true)
|
||||
if len(results) != 1 {
|
||||
t.Errorf("with includeArchived, expected 1, got %d", len(results))
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func TestQueryResolutionOrder(t *testing.T) {
|
||||
createTestWorkspace(t, root, "scripts", "2026-04-03_backup-helper", "active")
|
||||
|
||||
// "backup" should match exactly by slug, not return both as substring matches
|
||||
results, _ := ResolveQuery(root, "backup", false)
|
||||
results, _ := ResolveQuery([]string{root}, "backup", false)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("exact slug should return 1, got %d", len(results))
|
||||
}
|
||||
@@ -151,3 +151,137 @@ func TestQueryResolutionOrder(t *testing.T) {
|
||||
t.Errorf("should match exact slug \"backup\", got %q", results[0].Meta.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
// createFlatProjectWorkspace creates a workspace directly under root
|
||||
// (no category subdir), matching the CTASK_PROJECT_ROOT default layout.
|
||||
func createFlatProjectWorkspace(t *testing.T, root, dirName, status string) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
createFlatProjectWorkspaceFull(t, root, dirName, now)
|
||||
// Status override: createFlatProjectWorkspaceFull hard-codes active; fix up.
|
||||
if status != "active" {
|
||||
dir := filepath.Join(root, dirName)
|
||||
metaPath := filepath.Join(dir, "task.yaml")
|
||||
meta, _ := ReadMeta(metaPath)
|
||||
meta.Status = status
|
||||
WriteMeta(metaPath, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// createFlatProjectWorkspaceFull creates a flat-layout workspace with an
|
||||
// explicit UpdatedAt timestamp.
|
||||
func createFlatProjectWorkspaceFull(t *testing.T, root, dirName string, updatedAt time.Time) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, dirName)
|
||||
os.MkdirAll(dir, 0755)
|
||||
slug := dirName[11:]
|
||||
meta := &TaskMeta{
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: updatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Status: "active",
|
||||
Category: "projects",
|
||||
Type: "project",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
WorkspacePath: dir,
|
||||
}
|
||||
WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
}
|
||||
|
||||
func TestQueryFindsWorkspaceInProjectRoot(t *testing.T) {
|
||||
taskRoot := t.TempDir()
|
||||
projRoot := t.TempDir()
|
||||
createFlatProjectWorkspace(t, projRoot, "2026-04-22_standalone-proj", "active")
|
||||
|
||||
results, err := ResolveQuery([]string{taskRoot, projRoot}, "standalone-proj", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
if results[0].Root != projRoot {
|
||||
t.Errorf("Root: got %q, want %q", results[0].Root, projRoot)
|
||||
}
|
||||
if results[0].Meta.Slug != "standalone-proj" {
|
||||
t.Errorf("Slug: got %q", results[0].Meta.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDedupesOverlappingRoots(t *testing.T) {
|
||||
// If CTASK_PROJECT_ROOT equals CTASK_ROOT, the same workspace must not
|
||||
// appear twice in ResolveQuery results.
|
||||
root := t.TempDir()
|
||||
createTestWorkspace(t, root, "general", "2026-04-22_only-one", "active")
|
||||
|
||||
results, err := ResolveQuery([]string{root, root}, "only-one", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result after dedupe, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// A category directory without its own task.yaml but containing valid
|
||||
// workspaces must NOT be mistaken for a flat-layout workspace.
|
||||
func TestScanSkipsCategoryDirWithoutTaskYaml(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestWorkspace(t, root, "general", "2026-04-05_nested-one", "active")
|
||||
|
||||
results, err := ResolveQuery([]string{root}, "general", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
// "general" is the category directory, not a workspace — no match expected
|
||||
// (neither exact dir-name nor slug; substring against "nested-one" fails).
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("category dir leaked as workspace: got %d results (%+v)", len(results), results)
|
||||
}
|
||||
}
|
||||
|
||||
// Subdirectories inside a flat-layout project workspace (src/, output/, etc.)
|
||||
// must be ignored — only the workspace root's task.yaml counts.
|
||||
func TestScanIgnoresNestedDirsInsideFlatWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wsDir := filepath.Join(root, "2026-04-22_flat-proj")
|
||||
os.MkdirAll(filepath.Join(wsDir, "src"), 0755)
|
||||
os.MkdirAll(filepath.Join(wsDir, "output"), 0755)
|
||||
createFlatProjectWorkspace(t, root, "2026-04-22_flat-proj", "active")
|
||||
|
||||
results, err := ResolveQuery([]string{root}, "flat-proj", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected exactly 1 result, got %d (nested src/output leaked?): %+v", len(results), results)
|
||||
}
|
||||
if filepath.Base(results[0].Path) != "2026-04-22_flat-proj" {
|
||||
t.Errorf("resolved path: got %q, want workspace root", results[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
// A category dir under one root must not shadow a workspace under another
|
||||
// root that happens to share the same directory name.
|
||||
func TestScanCategoryNameDoesNotClashWithFlatWorkspaceName(t *testing.T) {
|
||||
taskRoot := t.TempDir()
|
||||
projRoot := t.TempDir()
|
||||
|
||||
// Under taskRoot, "projects" is a category holding a real workspace.
|
||||
createTestWorkspace(t, taskRoot, "projects", "2026-04-05_in-category", "active")
|
||||
|
||||
// Under projRoot, a flat workspace literally named "projects" (contrived
|
||||
// but possible if a user sets -c projects under CTASK_PROJECT_ROOT).
|
||||
createFlatProjectWorkspace(t, projRoot, "2026-04-06_projects", "active")
|
||||
|
||||
results, err := ResolveQuery([]string{taskRoot, projRoot}, "in-category", false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveQuery: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Meta.Slug != "in-category" {
|
||||
t.Fatalf("expected in-category to resolve; got %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user