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