@@ -2,159 +2,119 @@ package main
22
33import (
44 "context"
5- "errors"
65 "fmt"
7- "io"
86 "os"
97 "os/exec"
108 "path/filepath"
11-
12- "github.com/dgageot/gogo/taskfile"
139)
1410
15- // fallbackRunner describes an alternative task runner that gogo shells out
16- // to when no gogo.yaml is found anywhere up the directory tree. Each runner
17- // is recognised by the presence of one of `files` and invoked as `bin` with
18- // arguments built by `argsFor`.
19- type fallbackRunner struct {
20- bin string
21- files []string
22- argsFor func (task string , cliArgs []string ) []string
23- }
11+ // This file implements an opt-in convenience: when gogo can't find a
12+ // gogo.yaml, it looks for a foreign task file (Taskfile / mise.toml) up the
13+ // tree and shells out to the matching tool. It's deliberately self-contained
14+ // and side-tested via the package-level hooks below — the rest of the
15+ // codebase stays oblivious.
16+
17+ // fallbackLookPath / fallbackRun are overridden in tests. Production uses
18+ // exec.LookPath and a real exec.CommandContext.
19+ var (
20+ fallbackLookPath = exec .LookPath
21+ fallbackRun = func (ctx context.Context , name string , argv []string , dir string , app * App ) error {
22+ cmd := exec .CommandContext (ctx , name , argv ... )
23+ cmd .Dir = dir
24+ cmd .Stdin = os .Stdin
25+ cmd .Stdout = app .Stdout
26+ cmd .Stderr = app .Stderr
27+ if err := cmd .Run (); err != nil {
28+ return fmt .Errorf ("%s: %w" , name , err )
29+ }
30+ return nil
31+ }
32+ )
2433
25- // fallbackRunners is the ordered list of runners gogo recognises in the
26- // absence of a gogo.yaml. Order matters: a directory that contains both a
27- // Taskfile and a mise.toml is handled by `task`.
28- var fallbackRunners = []fallbackRunner {
34+ // foreignRunners lists, in priority order, the foreign task files we know
35+ // how to delegate to.
36+ var foreignRunners = []struct {
37+ bin string
38+ files []string
39+ build func (task string , cliArgs []string ) []string
40+ }{
2941 {
30- bin : "task" ,
31- files : []string {"Taskfile.yml" , "taskfile.yml" , "Taskfile.yaml" , "taskfile.yaml" },
32- argsFor : taskArgs ,
42+ bin : "task" ,
43+ files : []string {"Taskfile.yml" , "taskfile.yml" , "Taskfile.yaml" , "taskfile.yaml" },
44+ build : func (task string , cliArgs []string ) []string {
45+ return foreignArgs (nil , task , cliArgs )
46+ },
3347 },
3448 {
35- bin : "mise" ,
36- files : []string {"mise.toml" },
37- argsFor : miseArgs ,
49+ bin : "mise" ,
50+ files : []string {"mise.toml" },
51+ build : func (task string , cliArgs []string ) []string {
52+ return foreignArgs ([]string {"run" }, task , cliArgs )
53+ },
54+ },
55+ {
56+ // `make` doesn't understand a `--` separator, so trailing args are
57+ // passed as additional positional words — typically extra targets
58+ // or `VAR=value` overrides, which is the conventional invocation.
59+ bin : "make" ,
60+ files : []string {"Makefile" , "makefile" , "GNUmakefile" },
61+ build : func (task string , cliArgs []string ) []string {
62+ var argv []string
63+ if task != "" && task != "default" {
64+ argv = append (argv , task )
65+ }
66+ return append (argv , cliArgs ... )
67+ },
3868 },
3969}
4070
41- // taskArgs builds the argv for the `task` CLI . The arg-parser default of
42- // "default" is dropped so `gogo` (no positional) maps to `task` (no
43- // positional) , letting the underlying runner pick its own default task .
44- func taskArgs ( task string , cliArgs []string ) []string {
45- var args []string
71+ // foreignArgs assembles the argv passed to the foreign tool . The arg-parser
72+ // default of "default" is dropped so `gogo` (no positional) maps to a
73+ // no- positional invocation , letting the underlying tool pick its own default.
74+ func foreignArgs ( prefix [] string , task string , cliArgs []string ) []string {
75+ argv := append ( []string {}, prefix ... )
4676 if task != "" && task != "default" {
47- args = append (args , task )
77+ argv = append (argv , task )
4878 }
4979 if len (cliArgs ) > 0 {
50- args = append (args , "--" )
51- args = append (args , cliArgs ... )
80+ argv = append (argv , "--" )
81+ argv = append (argv , cliArgs ... )
5282 }
53- return args
83+ return argv
5484}
5585
56- // miseArgs builds the argv for `mise run`. The "default" task name is dropped
57- // for the same reason as in taskArgs.
58- func miseArgs (task string , cliArgs []string ) []string {
59- args := []string {"run" }
60- if task != "" && task != "default" {
61- args = append (args , task )
62- }
63- if len (cliArgs ) > 0 {
64- args = append (args , "--" )
65- args = append (args , cliArgs ... )
86+ // tryForeignFallback walks up from cwd looking for a foreign task file whose
87+ // runner is on PATH. Returns (handled=true, err) when one is invoked; a
88+ // (false, nil) return means the caller should surface the original error.
89+ //
90+ // "Silently ignored if not on PATH" means: if a Taskfile is found but `task`
91+ // isn't installed, we don't try to run it — we keep walking and may pick up
92+ // a sibling/parent runner instead.
93+ func (a * App ) tryForeignFallback (ctx context.Context , parsed * args ) (bool , error ) {
94+ // Tightly coupled to the only error message FindRootDir produces; if
95+ // that ever changes the test suite will catch it.
96+ cwd , err := a .Getwd ()
97+ if err != nil {
98+ return false , nil
6699 }
67- return args
68- }
69-
70- // fallbackCandidate pairs a found task file with the runner that should
71- // handle it.
72- type fallbackCandidate struct {
73- runner fallbackRunner
74- dir string
75- }
76100
77- // findFallback walks up from cwd looking for the first directory that
78- // contains a file recognised by any registered fallback runner whose binary
79- // is on PATH. A matching file whose runner is not installed is treated as
80- // absent — "silently ignore" — so a sibling fallback (or a parent dir
81- // closer to the user) can still apply.
82- func findFallback (cwd string , lookPath func (string ) (string , error )) (fallbackCandidate , bool ) {
83101 dir := cwd
84102 for {
85- for _ , r := range fallbackRunners {
86- if _ , err := lookPath (r .bin ); err != nil {
103+ for _ , r := range foreignRunners {
104+ if _ , err := fallbackLookPath (r .bin ); err != nil {
87105 continue
88106 }
89107 for _ , name := range r .files {
90108 if _ , err := os .Stat (filepath .Join (dir , name )); err == nil {
91- return fallbackCandidate {runner : r , dir : dir }, true
109+ argv := r .build (parsed .Task , parsed .CLIArgs )
110+ return true , fallbackRun (ctx , r .bin , argv , dir , a )
92111 }
93112 }
94113 }
95114 parent := filepath .Dir (dir )
96115 if parent == dir {
97- return fallbackCandidate {}, false
116+ return false , nil
98117 }
99118 dir = parent
100119 }
101120}
102-
103- // tryFallback is invoked when loadConfig fails because no gogo.yaml exists.
104- // It returns (handled=true, err) when a fallback ran; (handled=false, nil)
105- // means no usable fallback was found and the caller should surface the
106- // original ErrNoTaskFile.
107- func (a * App ) tryFallback (ctx context.Context , parsed * args ) (bool , error ) {
108- cwd , err := a .Getwd ()
109- if err != nil {
110- return false , err
111- }
112-
113- candidate , ok := findFallback (cwd , a .lookPath ())
114- if ! ok {
115- return false , nil
116- }
117-
118- argv := candidate .runner .argsFor (parsed .Task , parsed .CLIArgs )
119- return true , a .runCommand ()(ctx , candidate .runner .bin , argv , candidate .dir , a .Stdout , a .Stderr )
120- }
121-
122- // lookPath returns the configured LookPath, falling back to exec.LookPath.
123- // Tests inject a fake to drive the "binary not installed" branch without
124- // touching the host PATH.
125- func (a * App ) lookPath () func (string ) (string , error ) {
126- if a .LookPath != nil {
127- return a .LookPath
128- }
129- return exec .LookPath
130- }
131-
132- // runCommand returns the configured RunCommand, falling back to a real
133- // exec.CommandContext that inherits stdin and writes to the App's streams.
134- func (a * App ) runCommand () func (context.Context , string , []string , string , io.Writer , io.Writer ) error {
135- if a .RunCommand != nil {
136- return a .RunCommand
137- }
138- return defaultRunCommand
139- }
140-
141- // defaultRunCommand is the production implementation: it shells out for real
142- // and propagates the child's exit code as a Go error. Stdin is inherited so
143- // interactive runners (e.g. `task` prompts) keep working.
144- func defaultRunCommand (ctx context.Context , name string , args []string , dir string , stdout , stderr io.Writer ) error {
145- cmd := exec .CommandContext (ctx , name , args ... )
146- cmd .Dir = dir
147- cmd .Stdin = os .Stdin
148- cmd .Stdout = stdout
149- cmd .Stderr = stderr
150- if err := cmd .Run (); err != nil {
151- return fmt .Errorf ("%s: %w" , name , err )
152- }
153- return nil
154- }
155-
156- // isNoTaskFile reports whether err is the sentinel returned by FindRootDir
157- // when nothing is found while walking up from cwd.
158- func isNoTaskFile (err error ) bool {
159- return errors .Is (err , taskfile .ErrNoTaskFile )
160- }
0 commit comments