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:
2026-04-22 17:53:58 -04:00
parent 42efcc261a
commit 075000497f
6 changed files with 282 additions and 50 deletions
+4 -4
View File
@@ -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
}
+34 -12
View File
@@ -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))
}
+3 -3
View File
@@ -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,
+26 -8
View File
@@ -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
View File
@@ -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"
}
+142 -8
View File
@@ -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)
}
}