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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user