From 47430a1b1e0c7f07cc12b568a5c788366b7db1f2 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Wed, 22 Apr 2026 19:51:07 -0400 Subject: [PATCH] feat(v0.5): include \$CTASK_ROOT/projects/ in SearchRoots by default When CTASK_PROJECT_ROOT is unset, SearchRoots now appends \$CTASK_ROOT/projects/ so that projects created under the default category are discoverable from any shell without per-session env var setup. Dedupe in scanAllRoots prevents double-counting workspaces that are reachable both via the depth-2 scan under CTASK_ROOT and via the new explicit search root. Adds a named regression test asserting no duplicates appear in either ResolveQuery or ListWorkspaces results. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/config/config.go | 41 ++++++++++++++--- internal/config/config_roots_test.go | 66 ++++++++++++++++++++++++++-- internal/workspace/query_test.go | 29 ++++++++++++ 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 02348c9..aebcc07 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,21 +60,48 @@ func ResolveProjectRoot() string { } // SearchRoots returns the deduplicated list of workspace roots that all query -// and listing operations must consult. Always includes CTASK_ROOT; also -// includes CTASK_PROJECT_ROOT when set and different from CTASK_ROOT. +// and listing operations must consult. Always includes CTASK_ROOT. When +// CTASK_PROJECT_ROOT is set, adds that (unless it duplicates CTASK_ROOT). +// When CTASK_PROJECT_ROOT is unset, adds $CTASK_ROOT/projects/ so that +// projects created under the default category are discoverable from any +// shell without per-session env var setup. func SearchRoots() []string { taskRoot := ResolveRoot() roots := []string{taskRoot} + seen := map[string]struct{}{searchRootKey(taskRoot): {}} + + add := func(p string) { + if p == "" { + return + } + key := searchRootKey(p) + if _, dup := seen[key]; dup { + return + } + seen[key] = struct{}{} + roots = append(roots, p) + } projRoot := ResolveProjectRoot() - if projRoot == "" { - return roots + if projRoot != "" { + add(projRoot) + } else { + // Default fallback: projects live under $CTASK_ROOT/projects/ when + // no override is set. Explicit search root makes discovery work + // from any shell. + add(filepath.Join(taskRoot, "projects")) } + return roots +} - if samePath(taskRoot, projRoot) { - return roots +// searchRootKey returns the dedup key for a root path: cleaned, and +// lower-cased on Windows for case-insensitive comparison. +func searchRootKey(p string) string { + c := filepath.Clean(p) + if runtime.GOOS == "windows" { + return strings.ToLower(c) } - return append(roots, projRoot) + return c } // samePath reports whether two absolute paths refer to the same directory diff --git a/internal/config/config_roots_test.go b/internal/config/config_roots_test.go index 179e018..a4f3faf 100644 --- a/internal/config/config_roots_test.go +++ b/internal/config/config_roots_test.go @@ -7,16 +7,18 @@ import ( ) func TestSearchRootsUnsetProjectRoot(t *testing.T) { + // v0.5: when CTASK_PROJECT_ROOT is unset, SearchRoots also appends + // $CTASK_ROOT/projects/ so default-location projects are findable. os.Unsetenv("CTASK_PROJECT_ROOT") os.Setenv("CTASK_ROOT", t.TempDir()) defer os.Unsetenv("CTASK_ROOT") roots := SearchRoots() - if len(roots) != 1 { - t.Fatalf("expected 1 root, got %d: %v", len(roots), roots) + if len(roots) != 2 { + t.Fatalf("expected 2 roots (task + default projects/), got %d: %v", len(roots), roots) } if roots[0] != ResolveRoot() { - t.Errorf("root mismatch: got %q, want %q", roots[0], ResolveRoot()) + t.Errorf("first root mismatch: got %q, want %q", roots[0], ResolveRoot()) } } @@ -51,3 +53,61 @@ func TestSearchRootsSameRootDedupes(t *testing.T) { t.Fatalf("expected 1 root (dedup), got %d: %v", len(roots), roots) } } + +func TestSearchRootsAppendsProjectsDirWhenUnset(t *testing.T) { + taskRoot := t.TempDir() + os.Setenv("CTASK_ROOT", taskRoot) + os.Unsetenv("CTASK_PROJECT_ROOT") + defer os.Unsetenv("CTASK_ROOT") + + roots := SearchRoots() + if len(roots) != 2 { + t.Fatalf("expected 2 roots (task + projects default), got %d: %v", len(roots), roots) + } + want := filepath.Join(taskRoot, "projects") + if !samePath(roots[1], want) { + t.Errorf("second root: got %q, want %q", roots[1], want) + } +} + +func TestSearchRootsSkipsDefaultProjectsWhenExplicitRootSet(t *testing.T) { + taskRoot := t.TempDir() + projRoot := t.TempDir() + os.Setenv("CTASK_ROOT", taskRoot) + os.Setenv("CTASK_PROJECT_ROOT", projRoot) + defer os.Unsetenv("CTASK_ROOT") + defer os.Unsetenv("CTASK_PROJECT_ROOT") + + roots := SearchRoots() + if len(roots) != 2 { + t.Fatalf("expected 2 roots (task + custom proj), got %d: %v", len(roots), roots) + } + if !samePath(roots[0], taskRoot) { + t.Errorf("first root: got %q, want %q", roots[0], taskRoot) + } + if !samePath(roots[1], projRoot) { + t.Errorf("second root: got %q, want %q", roots[1], projRoot) + } +} + +func TestSearchRootsDedupesWhenProjectsIsSameAsTaskRoot(t *testing.T) { + // Edge case: if CTASK_PROJECT_ROOT is set to $CTASK_ROOT/projects + // explicitly, SearchRoots must not return $CTASK_ROOT/projects twice. + taskRoot := t.TempDir() + os.Setenv("CTASK_ROOT", taskRoot) + os.Setenv("CTASK_PROJECT_ROOT", filepath.Join(taskRoot, "projects")) + defer os.Unsetenv("CTASK_ROOT") + defer os.Unsetenv("CTASK_PROJECT_ROOT") + + roots := SearchRoots() + seen := make(map[string]int) + for _, r := range roots { + key := filepath.Clean(r) + seen[key]++ + } + for k, n := range seen { + if n > 1 { + t.Errorf("root %q appeared %d times; roots=%v", k, n, roots) + } + } +} diff --git a/internal/workspace/query_test.go b/internal/workspace/query_test.go index 5278173..c292968 100644 --- a/internal/workspace/query_test.go +++ b/internal/workspace/query_test.go @@ -211,6 +211,35 @@ func TestQueryFindsWorkspaceInProjectRoot(t *testing.T) { } } +// Workspace under $CTASK_ROOT/projects/ must appear exactly once +// in query results, even though SearchRoots includes both $CTASK_ROOT and +// $CTASK_ROOT/projects/ and the two-depth scan under $CTASK_ROOT already +// reaches the projects/ subdirectory. +func TestQueryNoDuplicateFromOverlappingRoots(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "projects", "2026-04-22_only-once", "active") + + // Simulate what v0.5 SearchRoots() produces when CTASK_PROJECT_ROOT is + // unset: the task root plus the default project subdirectory as a + // separate search root. + roots := []string{root, filepath.Join(root, "projects")} + + results, err := ResolveQuery(roots, "only-once", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected exactly 1 result (dedup), got %d: %+v", len(results), results) + } + listed, err := ListWorkspaces(roots, ListOpts{Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(listed) != 1 { + t.Fatalf("expected exactly 1 result from ListWorkspaces (dedup), got %d: %+v", len(listed), listed) + } +} + func TestQueryDedupesOverlappingRoots(t *testing.T) { // If CTASK_PROJECT_ROOT equals CTASK_ROOT, the same workspace must not // appear twice in ResolveQuery results.