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) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 19:51:07 -04:00
parent cdff7f32eb
commit 47430a1b1e
3 changed files with 126 additions and 10 deletions
+34 -7
View File
@@ -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
+63 -3
View File
@@ -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)
}
}
}
+29
View File
@@ -211,6 +211,35 @@ func TestQueryFindsWorkspaceInProjectRoot(t *testing.T) {
}
}
// Workspace under $CTASK_ROOT/projects/<workspace> 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.