From ea9e7c0225f6af8179890516b5ce9d511dadb234 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 10 Apr 2026 22:47:34 +0300 Subject: task 30: add proj prefix override for do --- README.md | 1 + docs/usage.md | 4 ++- integrationtests/do_test.go | 41 ++++++++++++++++++++++ internal/askcli/completion.go | 45 ++++++++++++------------ internal/askcli/completion_test.go | 7 +++- internal/askcli/dispatch.go | 7 +++- internal/askcli/dispatch_test.go | 64 ++++++++++++++++++++++++++++++++++ internal/askcli/task_scope.go | 70 ++++++++++++++++++++++++++++++++------ internal/askcli/taskexec.go | 23 +++++++++---- internal/askcli/taskexec_test.go | 42 +++++++++++++++++++++++ 10 files changed, 261 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 5e198b6..c4e47f3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It has got improved capabilities for Go code understanding (for example, create * Task management CLI for agent-managed project work - Entrypoint: `do` (the binary was formerly named `ask`; use `do` in scripts and documentation) - Auto-scopes to `project: +agent` (derived from git repo root) + - Override the project explicitly with `do proj: ` - Never exposes numeric task IDs — uses UUIDs only - Machine-friendly output: UUID-only tables, suppressed decorative text - Subcommands: `do add`, `do list`, `do info`, `do annotate`, `do start`, `do stop`, `do done`, `do priority`, `do tag`, `do dep`, `do urgency`, `do modify`, `do denotate`, `do delete`, `do fish`, `do help` diff --git a/docs/usage.md b/docs/usage.md index ee811a3..7dd0a2e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -129,11 +129,13 @@ cat SOMEFILE.txt | hexai --tps-simulation 20 By default it auto-scopes to `project: +agent` so operations are confined to agent-managed project tasks. +Use `do proj: ` to override the project explicitly instead of deriving it from the current git repository. + Use `do na ` or `do no-agent ` to run the same subcommands against project tasks without the `+agent` tag. Those prefixes keep the project scope but replace the default tag filter with `-agent`. `do` never exposes Taskwarrior numeric task IDs. Human-facing output uses stable local alias IDs where practical, while `do info` shows both the alias ID and the UUID. Commands that accept a task selector support either the alias ID or the UUID. -`do` must be run inside a git repository so the project name can be derived from the repo root. +`do` must be run inside a git repository unless you provide an explicit `proj:` override. ### Subcommands diff --git a/integrationtests/do_test.go b/integrationtests/do_test.go index 56e4372..a8e3bd5 100644 --- a/integrationtests/do_test.go +++ b/integrationtests/do_test.go @@ -64,6 +64,22 @@ func runDo(ctx context.Context, args []string) (stdout, stderr bytes.Buffer, exi return stdout, stderr, ee.ExitCode() } +func runDoInDir(ctx context.Context, dir string, args []string) (stdout, stderr bytes.Buffer, exitCode int) { + cmd := exec.CommandContext(ctx, doBinaryPath(), args...) + cmd.Dir = dir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return + } + var ee *exec.ExitError + if !errors.As(err, &ee) { + return bytes.Buffer{}, stderr, -1 + } + return stdout, stderr, ee.ExitCode() +} + // runDoWithStdin runs do with the given stdin. Only use this for commands // that actually forward stdin to taskwarrior (currently only: delete). func runDoWithStdin(ctx context.Context, args []string, stdin string) (stdout, stderr bytes.Buffer, exitCode int) { @@ -147,6 +163,31 @@ func createTask(ctx context.Context, desc string) (string, error) { return uuid, nil } +func TestProjectPrefixWorksOutsideGitRepo(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + desc := fmt.Sprintf("integration test project override %d", time.Now().UnixNano()) + uuid, err := createTask(ctx, desc) + if err != nil { + t.Fatalf("failed to create task: %v", err) + } + defer deleteTask(ctx, uuid) + + outsideDir := t.TempDir() + stdout, stderr, code := runDoInDir(ctx, outsideDir, []string{"proj:hexai", "list"}) + if code != 0 { + t.Fatalf("do proj:hexai list failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), desc) { + t.Fatalf("output missing task description %q: %s", desc, stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("unexpected stderr: %s", stderr.String()) + } +} + func findTaskUUIDByDescription(ctx context.Context, desc string) (string, error) { stdout, stderr, code := runTask(ctx, []string{"export", "project:hexai", "+integrationtest"}) if code != 0 { diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go index e7b837e..4d4b5d3 100644 --- a/internal/askcli/completion.go +++ b/internal/askcli/completion.go @@ -16,7 +16,7 @@ var askDepCompletionItems = []fishCompletionItem{ } func fishSingleSelectorCompletionContext(positional []string) bool { - positional = trimTaskScopePrefix(positional) + positional = trimTaskPrefixes(positional) if len(positional) != 1 { return false } @@ -30,7 +30,7 @@ func fishSingleSelectorCompletionContext(positional []string) bool { } func fishDepSelectorCompletionContext(positional []string) bool { - positional = trimTaskScopePrefix(positional) + positional = trimTaskPrefixes(positional) if len(positional) < 2 || positional[0] != "dep" { return false } @@ -46,7 +46,7 @@ func fishDepSelectorCompletionContext(positional []string) bool { } func fishAddDependencyModifierCompletionContext(positional []string, current string) bool { - positional = trimTaskScopePrefix(positional) + positional = trimTaskPrefixes(positional) if len(positional) == 0 || positional[0] != "add" { return false } @@ -71,6 +71,7 @@ func FishCompletionFor(binaryPath string) string { for _, item := range []fishCompletionItem{ {name: "na", description: "Run against project tasks without +agent"}, {name: "no-agent", description: "Run against project tasks without +agent"}, + {name: "proj:", description: "Run against an explicit project"}, } { writeFishCompletionLine(&b, "__do_needs_root_completion", item) } @@ -123,13 +124,12 @@ func writeFishPositionalTokensFunction(b *strings.Builder) { func writeFishCommandPositionalsFunction(b *strings.Builder) { b.WriteString("function __do_command_positionals\n") b.WriteString(" set -l positional (__do_positional_tokens)\n") - b.WriteString(" if test (count $positional) -gt 0\n") + b.WriteString(" while test (count $positional) -gt 0\n") b.WriteString(" switch $positional[1]\n") - b.WriteString(" case na no-agent\n") - b.WriteString(" for token in $positional[2..-1]\n") - b.WriteString(" printf '%s\\n' $token\n") - b.WriteString(" end\n") - b.WriteString(" return 0\n") + b.WriteString(" case na no-agent proj:*\n") + b.WriteString(" set positional $positional[2..-1]\n") + b.WriteString(" case '*'\n") + b.WriteString(" break\n") b.WriteString(" end\n") b.WriteString(" end\n") b.WriteString(" for token in $positional\n") @@ -141,17 +141,16 @@ func writeFishCommandPositionalsFunction(b *strings.Builder) { func writeFishScopePrefixFunction(b *strings.Builder) { b.WriteString("function __do_scope_prefix\n") b.WriteString(" set -l positional (__do_positional_tokens)\n") - b.WriteString(" if test (count $positional) -eq 0\n") - b.WriteString(" return 1\n") - b.WriteString(" end\n") - b.WriteString(" switch $positional[1]\n") - b.WriteString(" case na no-agent\n") - b.WriteString(" printf '%s\\n' $positional[1]\n") - b.WriteString(" return 0\n") - b.WriteString(" case '*'\n") - b.WriteString(" return 1\n") + b.WriteString(" while test (count $positional) -gt 0\n") + b.WriteString(" switch $positional[1]\n") + b.WriteString(" case na no-agent proj:*\n") + b.WriteString(" printf '%s\\n' $positional[1]\n") + b.WriteString(" set positional $positional[2..-1]\n") + b.WriteString(" case '*'\n") + b.WriteString(" return 0\n") + b.WriteString(" end\n") b.WriteString(" end\n") - b.WriteString(" return 1\n") + b.WriteString(" return 0\n") b.WriteString("end\n\n") } @@ -173,7 +172,7 @@ func writeFishNeedsCommandCompletionFunction(b *strings.Builder) { b.WriteString(" end\n") b.WriteString(" if test (count $positional) -eq 1\n") b.WriteString(" switch $positional[1]\n") - b.WriteString(" case na no-agent\n") + b.WriteString(" case na no-agent proj:*\n") b.WriteString(" return 0\n") b.WriteString(" end\n") b.WriteString(" end\n") @@ -264,8 +263,8 @@ func writeFishTaskSelectorFunction(b *strings.Builder, binaryPath string) { b.WriteString("\n") b.WriteString(" set -l scope_prefix (__do_scope_prefix)\n") b.WriteString(" set -l cache_key default\n") - b.WriteString(" if test -n \"$scope_prefix\"\n") - b.WriteString(" set cache_key $scope_prefix\n") + b.WriteString(" if test (count $scope_prefix) -gt 0\n") + b.WriteString(" set cache_key (string join ' ' $scope_prefix)\n") b.WriteString(" end\n") b.WriteString(" set -l now (date +%s)\n") b.WriteString(" if set -q __do_task_selector_cache_until; and test $__do_task_selector_cache_until -ge $now; and set -q __do_task_selector_cache_key; and test \"$__do_task_selector_cache_key\" = \"$cache_key\"\n") @@ -273,7 +272,7 @@ func writeFishTaskSelectorFunction(b *strings.Builder, binaryPath string) { b.WriteString(" return 0\n") b.WriteString(" end\n") b.WriteString(" set -l selectors\n") - b.WriteString(" if test -n \"$scope_prefix\"\n") + b.WriteString(" if test (count $scope_prefix) -gt 0\n") b.WriteString(" set selectors (command $do_bin $scope_prefix complete-aliases 2>/dev/null)\n") b.WriteString(" else\n") b.WriteString(" set selectors (command $do_bin complete-aliases 2>/dev/null)\n") diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index 5e18c09..7d85c7b 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -7,7 +7,7 @@ import ( func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { script := FishCompletion() - for _, name := range []string{"na", "no-agent"} { + for _, name := range []string{"na", "no-agent", "proj:"} { if !strings.Contains(script, " -a '"+name+"' ") { t.Fatalf("script missing scope completion for %q", name) } @@ -30,6 +30,8 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { "set -l selectors", "set selectors (command $do_bin complete-aliases 2>/dev/null)", "set selectors (command $do_bin $scope_prefix complete-aliases 2>/dev/null)", + "case na no-agent proj:*", + "set cache_key (string join ' ' $scope_prefix)", "complete -c do -n '__do_in_uuid_context' -a '(__do_task_selectors)' -d 'Task selector'", "complete -c do -n '__do_in_dep_uuid_context' -a '(__do_task_selectors)' -d 'Task selector'", "complete -c do -n '__do_in_add_dep_modifier_context' -a '(__do_add_dependency_modifiers)' -d 'Task dependency'", @@ -63,6 +65,7 @@ func TestFishSingleSelectorCompletionContext(t *testing.T) { }{ {name: "info expects selector", positional: []string{"info"}, want: true}, {name: "info expects selector with no-agent prefix", positional: []string{"na", "info"}, want: true}, + {name: "info expects selector with project prefix", positional: []string{"proj:alpha", "info"}, want: true}, {name: "annotate expects selector", positional: []string{"annotate"}, want: true}, {name: "priority expects selector", positional: []string{"priority"}, want: true}, {name: "delete expects selector", positional: []string{"delete"}, want: true}, @@ -91,6 +94,7 @@ func TestFishDepSelectorCompletionContext(t *testing.T) { }{ {name: "dep add first selector", positional: []string{"dep", "add"}, want: true}, {name: "dep add first selector with no-agent prefix", positional: []string{"na", "dep", "add"}, want: true}, + {name: "dep add first selector with project prefix", positional: []string{"proj:alpha", "dep", "add"}, want: true}, {name: "dep add second selector", positional: []string{"dep", "add", "0"}, want: true}, {name: "dep add stops after second selector", positional: []string{"dep", "add", "0", "1"}, want: false}, {name: "dep rm first selector", positional: []string{"dep", "rm"}, want: true}, @@ -122,6 +126,7 @@ func TestFishAddDependencyModifierCompletionContext(t *testing.T) { {name: "add with depends keyword prefix", positional: []string{"add"}, current: "depends", want: true}, {name: "add with depends modifier", positional: []string{"add", "+cli"}, current: "depends:0", want: true}, {name: "add with depends modifier and no-agent prefix", positional: []string{"na", "add", "+cli"}, current: "depends:0", want: true}, + {name: "add with depends modifier and project prefix", positional: []string{"proj:alpha", "add", "+cli"}, current: "depends:0", want: true}, {name: "add with comma continuation", positional: []string{"add", "+cli"}, current: "depends:0,", want: true}, {name: "non add command", positional: []string{"dep", "add"}, current: "depends:0", want: false}, } diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index 3167421..69310c7 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -45,8 +45,11 @@ func parseGlobalFlags(args []string) ([]string, bool) { func (d *Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { args, jsonOutput := parseGlobalFlags(args) d.jsonOutput = jsonOutput - scope, args := parseTaskScopePrefix(args) + scope, projectName, projectSet, args := parseTaskPrefixes(args) ctx = contextWithTaskScope(ctx, scope) + if projectSet { + ctx = contextWithTaskProject(ctx, projectName) + } if len(args) == 0 { args = []string{"list"} @@ -66,6 +69,8 @@ func (d *Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reade func (d *Dispatcher) help(w io.Writer) (int, error) { _, _ = io.WriteString(w, "do - task management CLI\n") + _, _ = io.WriteString(w, "\nProject prefixes:\n") + _, _ = io.WriteString(w, " do proj: Run a subcommand against an explicit project\n") _, _ = io.WriteString(w, "\nScope prefixes:\n") _, _ = io.WriteString(w, " do na Run a subcommand against project tasks without +agent\n") _, _ = io.WriteString(w, " do no-agent Alias for do na\n") diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 0e13788..68f1e3b 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -26,6 +26,9 @@ func TestDispatcher_Help(t *testing.T) { if !strings.Contains(output, "do - task management CLI") { t.Fatalf("help missing title: %s", output) } + if !strings.Contains(output, "do proj: ") { + t.Fatalf("help missing project prefix: %s", output) + } if !strings.Contains(output, "do na ") || !strings.Contains(output, "do no-agent ") { t.Fatalf("help missing no-agent scope prefixes: %s", output) } @@ -238,6 +241,67 @@ func TestParseTaskScopePrefix(t *testing.T) { } } +func TestParseTaskPrefixes(t *testing.T) { + tests := []struct { + name string + args []string + wantScope taskScopeMode + wantProject string + wantProjectSet bool + wantRemaining []string + }{ + {name: "default", args: []string{"list"}, wantScope: taskScopeAgent, wantRemaining: []string{"list"}}, + {name: "project prefix", args: []string{"proj:alpha", "list"}, wantProject: "alpha", wantProjectSet: true, wantRemaining: []string{"list"}}, + {name: "project prefix with empty name", args: []string{"proj:", "list"}, wantProject: "", wantProjectSet: true, wantRemaining: []string{"list"}}, + {name: "scope then project", args: []string{"na", "proj:alpha", "list"}, wantScope: taskScopeNoAgent, wantProject: "alpha", wantProjectSet: true, wantRemaining: []string{"list"}}, + {name: "project then scope", args: []string{"proj:alpha", "na", "list"}, wantScope: taskScopeNoAgent, wantProject: "alpha", wantProjectSet: true, wantRemaining: []string{"list"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotScope, gotProject, gotProjectSet, gotRemaining := parseTaskPrefixes(tc.args) + if gotScope != tc.wantScope { + t.Fatalf("scope = %v, want %v", gotScope, tc.wantScope) + } + if gotProject != tc.wantProject { + t.Fatalf("project = %q, want %q", gotProject, tc.wantProject) + } + if gotProjectSet != tc.wantProjectSet { + t.Fatalf("projectSet = %t, want %t", gotProjectSet, tc.wantProjectSet) + } + if !reflect.DeepEqual(gotRemaining, tc.wantRemaining) { + t.Fatalf("remaining = %v, want %v", gotRemaining, tc.wantRemaining) + } + }) + } +} + +func TestDispatcher_ProjectPrefix_PassesProjectOverride(t *testing.T) { + var gotArgs []string + var gotProject string + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + gotArgs = append([]string(nil), args...) + gotProject, _ = taskProjectFromContext(ctx) + _, _ = io.WriteString(stdout, `[]`) + return 0, nil + }}) + + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{"proj:alpha", "list"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("Dispatch returned error: %v", err) + } + if code != 0 { + t.Fatalf("Dispatch code = %d, want 0", code) + } + if gotProject != "alpha" { + t.Fatalf("project override = %q, want alpha", gotProject) + } + if !reflect.DeepEqual(gotArgs, []string{"status:pending", "export"}) { + t.Fatalf("runner args = %v, want [status:pending export]", gotArgs) + } +} + func TestDispatcher_NoAgentPrefix_StripsScopePrefix(t *testing.T) { taskJSONFor := func(uuid string) string { return `[{"uuid":"` + uuid + `","description":"Test","status":"pending","priority":"M","tags":[],"urgency":10,"depends":[]}]` diff --git a/internal/askcli/task_scope.go b/internal/askcli/task_scope.go index 42dd022..006407e 100644 --- a/internal/askcli/task_scope.go +++ b/internal/askcli/task_scope.go @@ -1,6 +1,9 @@ package askcli -import "context" +import ( + "context" + "strings" +) type taskScopeMode int @@ -11,6 +14,12 @@ const ( type taskScopeContextKey struct{} +type taskProjectContextKey struct{} + +type taskProjectContextValue struct { + project string +} + func contextWithTaskScope(ctx context.Context, scope taskScopeMode) context.Context { if scope == taskScopeAgent { return ctx @@ -29,6 +38,24 @@ func taskScopeFromContext(ctx context.Context) taskScopeMode { return scope } +func contextWithTaskProject(ctx context.Context, project string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, taskProjectContextKey{}, taskProjectContextValue{project: project}) +} + +func taskProjectFromContext(ctx context.Context) (string, bool) { + if ctx == nil { + return "", false + } + value, ok := ctx.Value(taskProjectContextKey{}).(taskProjectContextValue) + if !ok { + return "", false + } + return value.project, true +} + func taskScopeFilter(scope taskScopeMode) string { if scope == taskScopeNoAgent { return "-agent" @@ -37,13 +64,8 @@ func taskScopeFilter(scope taskScopeMode) string { } func parseTaskScopePrefix(args []string) (taskScopeMode, []string) { - if len(args) == 0 { - return taskScopeAgent, nil - } - if isTaskScopePrefix(args[0]) { - return taskScopeNoAgent, args[1:] - } - return taskScopeAgent, args + scope, _, _, remaining := parseTaskPrefixes(args) + return scope, remaining } func isTaskScopePrefix(arg string) bool { @@ -55,9 +77,35 @@ func isTaskScopePrefix(arg string) bool { } } +func isTaskProjectPrefix(arg string) bool { + return strings.HasPrefix(arg, "proj:") +} + func trimTaskScopePrefix(args []string) []string { - if len(args) == 0 || !isTaskScopePrefix(args[0]) { - return args + return trimTaskPrefixes(args) +} + +func trimTaskPrefixes(args []string) []string { + _, _, _, remaining := parseTaskPrefixes(args) + return remaining +} + +func parseTaskPrefixes(args []string) (taskScopeMode, string, bool, []string) { + scope := taskScopeAgent + projectName := "" + projectSet := false + for len(args) > 0 { + switch { + case isTaskScopePrefix(args[0]): + scope = taskScopeNoAgent + args = args[1:] + case isTaskProjectPrefix(args[0]): + projectName = args[0][len("proj:"):] + projectSet = true + args = args[1:] + default: + return scope, projectName, projectSet, args + } } - return args[1:] + return scope, projectName, projectSet, nil } diff --git a/internal/askcli/taskexec.go b/internal/askcli/taskexec.go index e3915d9..d479c34 100644 --- a/internal/askcli/taskexec.go +++ b/internal/askcli/taskexec.go @@ -35,9 +35,17 @@ func NewExecutor(commandName string) Executor { } func (e Executor) taskArgs(ctx context.Context, repoRoot string, args []string) ([]string, error) { - projectName, err := projectNameFromRoot(repoRoot) - if err != nil { - return nil, err + projectName, ok := taskProjectFromContext(ctx) + if !ok { + var err error + projectName, err = projectNameFromRoot(repoRoot) + if err != nil { + return nil, err + } + } + projectName = strings.TrimSpace(projectName) + if projectName == "" { + return nil, fmt.Errorf("project override proj: requires a project name") } // rc.verbose=nothing suppresses Taskwarrior's configuration override // banner, while rc.confirmation=off keeps non-interactive commands from @@ -69,9 +77,12 @@ func (e Executor) Run(ctx context.Context, args []string, stdin io.Reader, stdou if err != nil { return 1, fmt.Errorf("%s: task binary lookup failed: %w", executor.label(), err) } - repoRoot, err := executor.detectRepoRoot(ctx) - if err != nil { - return 1, fmt.Errorf("%s: must be run inside a git repository: %w", executor.label(), err) + repoRoot := "" + if _, ok := taskProjectFromContext(ctx); !ok { + repoRoot, err = executor.detectRepoRoot(ctx) + if err != nil { + return 1, fmt.Errorf("%s: must be run inside a git repository: %w", executor.label(), err) + } } taskArgs, err := executor.taskArgs(ctx, repoRoot, args) if err != nil { diff --git a/internal/askcli/taskexec_test.go b/internal/askcli/taskexec_test.go index 4db988b..0d744dc 100644 --- a/internal/askcli/taskexec_test.go +++ b/internal/askcli/taskexec_test.go @@ -36,6 +36,19 @@ func TestExecutorTaskArgs_NoAgentScope(t *testing.T) { } } +func TestExecutorTaskArgs_ProjectOverride(t *testing.T) { + exec_ := NewExecutor("do") + ctx := contextWithTaskProject(context.Background(), "alpha") + args, err := exec_.taskArgs(ctx, "", []string{"list", "limit:1"}) + if err != nil { + t.Fatalf("taskArgs returned error: %v", err) + } + want := []string{"rc.verbose=nothing", "rc.confirmation=off", "project:alpha", "+agent", "list", "limit:1"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("task args = %v, want %v", args, want) + } +} + func TestExecutorTaskArgs_AddDefaultScope(t *testing.T) { exec_ := NewExecutor("do") args, err := exec_.taskArgs(context.Background(), "/tmp/work/hexai", []string{"add", "rc.verbose=nothing", "rc.verbose=new-uuid", "new task"}) @@ -117,6 +130,35 @@ func TestExecutorRun_InjectsProjectFilterAndNoAgentTag(t *testing.T) { } } +func TestExecutorRun_ProjectOverrideSkipsRepoDetection(t *testing.T) { + var gotArgs []string + exec_ := Executor{ + commandName: "do", + findBinary: func() (string, error) { return "/usr/bin/task", nil }, + detectRepoRoot: func(context.Context) (string, error) { + t.Fatal("detectRepoRoot should not be called when project override is set") + return "", nil + }, + runCommand: func(_ context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + gotArgs = append([]string(nil), args...) + return nil + }, + } + + ctx := contextWithTaskProject(context.Background(), "alpha") + exitCode, err := exec_.Run(ctx, []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if exitCode != 0 { + t.Fatalf("exitCode = %d, want 0", exitCode) + } + wantArgs := []string{"rc.verbose=nothing", "rc.confirmation=off", "project:alpha", "+agent", "list"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("task args = %v, want %v", gotArgs, wantArgs) + } +} + func TestExecutorRun_OutsideGitRepo_IsActionable(t *testing.T) { exec_ := Executor{ commandName: "do", -- cgit v1.2.3