mirror of
https://github.com/go-task/task.git
synced 2025-06-02 23:27:37 +02:00
feat: recursive config search (#2166)
* refactor: experiments flags * refactor: args.Parse * feat: recursive search for taskrc files * feat: consolidate some code into new fsext package * feat: add tests for search and default dir * fix: linting issues
This commit is contained in:
parent
1ae3bf0b25
commit
c5afffb551
4
.taskrc.yml
Normal file
4
.taskrc.yml
Normal file
@ -0,0 +1,4 @@
|
||||
experiments:
|
||||
GENTLE_FORCE: 0
|
||||
REMOTE_TASKFILES: 0
|
||||
ENV_PRECEDENCE: 0
|
26
args/args.go
26
args/args.go
@ -3,10 +3,36 @@ package args
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
// Get fetches the remaining arguments after CLI parsing and splits them into
|
||||
// two groups: the arguments before the double dash (--) and the arguments after
|
||||
// the double dash.
|
||||
func Get() ([]string, []string, error) {
|
||||
args := pflag.Args()
|
||||
doubleDashPos := pflag.CommandLine.ArgsLenAtDash()
|
||||
|
||||
if doubleDashPos == -1 {
|
||||
return args, nil, nil
|
||||
}
|
||||
|
||||
var quotedCliArgs []string
|
||||
for _, arg := range args[doubleDashPos:] {
|
||||
quotedCliArg, err := syntax.Quote(arg, syntax.LangBash)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quotedCliArgs = append(quotedCliArgs, quotedCliArg)
|
||||
}
|
||||
|
||||
return args[:doubleDashPos], quotedCliArgs, nil
|
||||
}
|
||||
|
||||
// Parse parses command line argument: tasks and global variables
|
||||
func Parse(args ...string) ([]*task.Call, *ast.Vars) {
|
||||
calls := []*task.Call{}
|
||||
|
@ -5,10 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/args"
|
||||
@ -78,7 +76,7 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args, _, err := getArgs()
|
||||
_, args, err := args.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -145,17 +143,12 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
calls []*task.Call
|
||||
globals *ast.Vars
|
||||
)
|
||||
|
||||
tasksAndVars, cliArgs, err := getArgs()
|
||||
// Parse the remaining arguments
|
||||
argv, cliArgs, err := args.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
calls, globals = args.Parse(tasksAndVars...)
|
||||
calls, globals := args.Parse(argv...)
|
||||
|
||||
// If there are no calls, run the default task instead
|
||||
if len(calls) == 0 {
|
||||
@ -181,24 +174,3 @@ func run() error {
|
||||
|
||||
return e.Run(ctx, calls...)
|
||||
}
|
||||
|
||||
func getArgs() ([]string, string, error) {
|
||||
var (
|
||||
args = pflag.Args()
|
||||
doubleDashPos = pflag.CommandLine.ArgsLenAtDash()
|
||||
)
|
||||
|
||||
if doubleDashPos == -1 {
|
||||
return args, "", nil
|
||||
}
|
||||
|
||||
var quotedCliArgs []string
|
||||
for _, arg := range args[doubleDashPos:] {
|
||||
quotedCliArg, err := syntax.Quote(arg, syntax.LangBash)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
quotedCliArgs = append(quotedCliArgs, quotedCliArg)
|
||||
}
|
||||
return args[:doubleDashPos], strings.Join(quotedCliArgs, " "), nil
|
||||
}
|
||||
|
@ -8,6 +8,11 @@ const (
|
||||
CodeUnknown // Used when no other exit code is appropriate
|
||||
)
|
||||
|
||||
// TaskRC related exit codes
|
||||
const (
|
||||
CodeTaskRCNotFoundError int = iota + 50
|
||||
)
|
||||
|
||||
// Taskfile related exit codes
|
||||
const (
|
||||
CodeTaskfileNotFound int = iota + 100
|
||||
|
20
errors/errors_taskrc.go
Normal file
20
errors/errors_taskrc.go
Normal file
@ -0,0 +1,20 @@
|
||||
package errors
|
||||
|
||||
import "fmt"
|
||||
|
||||
type TaskRCNotFoundError struct {
|
||||
URI string
|
||||
Walk bool
|
||||
}
|
||||
|
||||
func (err TaskRCNotFoundError) Error() string {
|
||||
var walkText string
|
||||
if err.Walk {
|
||||
walkText = " (or any of the parent directories)"
|
||||
}
|
||||
return fmt.Sprintf(`task: No Task config file found at %q%s`, err.URI, walkText)
|
||||
}
|
||||
|
||||
func (err TaskRCNotFoundError) Code() int {
|
||||
return CodeTaskRCNotFoundError
|
||||
}
|
@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-task/task/v3/taskrc/ast"
|
||||
)
|
||||
|
||||
type Experiment struct {
|
||||
@ -14,8 +16,11 @@ type Experiment struct {
|
||||
|
||||
// New creates a new experiment with the given name and sets the values that can
|
||||
// enable it.
|
||||
func New(xName string, allowedValues ...int) Experiment {
|
||||
value := experimentConfig.Experiments[xName]
|
||||
func New(xName string, config *ast.TaskRC, allowedValues ...int) Experiment {
|
||||
var value int
|
||||
if config != nil {
|
||||
value = config.Experiments[xName]
|
||||
}
|
||||
|
||||
if value == 0 {
|
||||
value, _ = strconv.Atoi(getEnv(xName))
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/go-task/task/v3/internal/experiments"
|
||||
"github.com/go-task/task/v3/taskrc/ast"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@ -16,43 +17,47 @@ func TestNew(t *testing.T) {
|
||||
)
|
||||
tests := []struct {
|
||||
name string
|
||||
config *ast.TaskRC
|
||||
allowedValues []int
|
||||
value int
|
||||
env int
|
||||
wantEnabled bool
|
||||
wantActive bool
|
||||
wantValid error
|
||||
wantValue int
|
||||
}{
|
||||
{
|
||||
name: `[] allowed, value=""`,
|
||||
name: `[] allowed, env=""`,
|
||||
wantEnabled: false,
|
||||
wantActive: false,
|
||||
},
|
||||
{
|
||||
name: `[] allowed, value="1"`,
|
||||
value: 1,
|
||||
name: `[] allowed, env="1"`,
|
||||
env: 1,
|
||||
wantEnabled: false,
|
||||
wantActive: false,
|
||||
wantValid: &experiments.InactiveError{
|
||||
Name: exampleExperiment,
|
||||
},
|
||||
wantValue: 1,
|
||||
},
|
||||
{
|
||||
name: `[1] allowed, value=""`,
|
||||
name: `[1] allowed, env=""`,
|
||||
allowedValues: []int{1},
|
||||
wantEnabled: false,
|
||||
wantActive: true,
|
||||
},
|
||||
{
|
||||
name: `[1] allowed, value="1"`,
|
||||
name: `[1] allowed, env="1"`,
|
||||
allowedValues: []int{1},
|
||||
value: 1,
|
||||
env: 1,
|
||||
wantEnabled: true,
|
||||
wantActive: true,
|
||||
wantValue: 1,
|
||||
},
|
||||
{
|
||||
name: `[1] allowed, value="2"`,
|
||||
name: `[1] allowed, env="2"`,
|
||||
allowedValues: []int{1},
|
||||
value: 2,
|
||||
env: 2,
|
||||
wantEnabled: false,
|
||||
wantActive: true,
|
||||
wantValid: &experiments.InvalidValueError{
|
||||
@ -60,16 +65,76 @@ func TestNew(t *testing.T) {
|
||||
AllowedValues: []int{1},
|
||||
Value: 2,
|
||||
},
|
||||
wantValue: 2,
|
||||
},
|
||||
{
|
||||
name: `[1, 2] allowed, env="1"`,
|
||||
allowedValues: []int{1, 2},
|
||||
env: 1,
|
||||
wantEnabled: true,
|
||||
wantActive: true,
|
||||
wantValue: 1,
|
||||
},
|
||||
{
|
||||
name: `[1, 2] allowed, env="1"`,
|
||||
allowedValues: []int{1, 2},
|
||||
env: 2,
|
||||
wantEnabled: true,
|
||||
wantActive: true,
|
||||
wantValue: 2,
|
||||
},
|
||||
{
|
||||
name: `[1] allowed, config="1"`,
|
||||
config: &ast.TaskRC{
|
||||
Experiments: map[string]int{
|
||||
exampleExperiment: 1,
|
||||
},
|
||||
},
|
||||
allowedValues: []int{1},
|
||||
wantEnabled: true,
|
||||
wantActive: true,
|
||||
wantValue: 1,
|
||||
},
|
||||
{
|
||||
name: `[1] allowed, config="2"`,
|
||||
config: &ast.TaskRC{
|
||||
Experiments: map[string]int{
|
||||
exampleExperiment: 2,
|
||||
},
|
||||
},
|
||||
allowedValues: []int{1},
|
||||
wantEnabled: false,
|
||||
wantActive: true,
|
||||
wantValid: &experiments.InvalidValueError{
|
||||
Name: exampleExperiment,
|
||||
AllowedValues: []int{1},
|
||||
Value: 2,
|
||||
},
|
||||
wantValue: 2,
|
||||
},
|
||||
{
|
||||
name: `[1, 2] allowed, env="1", config="2"`,
|
||||
config: &ast.TaskRC{
|
||||
Experiments: map[string]int{
|
||||
exampleExperiment: 2,
|
||||
},
|
||||
},
|
||||
allowedValues: []int{1, 2},
|
||||
env: 1,
|
||||
wantEnabled: true,
|
||||
wantActive: true,
|
||||
wantValue: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(exampleExperimentEnv, strconv.Itoa(tt.value))
|
||||
x := experiments.New(exampleExperiment, tt.allowedValues...)
|
||||
t.Setenv(exampleExperimentEnv, strconv.Itoa(tt.env))
|
||||
x := experiments.New(exampleExperiment, tt.config, tt.allowedValues...)
|
||||
assert.Equal(t, exampleExperiment, x.Name)
|
||||
assert.Equal(t, tt.wantEnabled, x.Enabled())
|
||||
assert.Equal(t, tt.wantActive, x.Active())
|
||||
assert.Equal(t, tt.wantValid, x.Valid())
|
||||
assert.Equal(t, tt.wantValue, x.Value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -6,46 +6,47 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/pflag"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/go-task/task/v3/taskrc"
|
||||
)
|
||||
|
||||
const envPrefix = "TASK_X_"
|
||||
|
||||
var defaultConfigFilenames = []string{
|
||||
".taskrc.yml",
|
||||
".taskrc.yaml",
|
||||
}
|
||||
|
||||
type experimentConfigFile struct {
|
||||
Experiments map[string]int `yaml:"experiments"`
|
||||
Version *semver.Version
|
||||
}
|
||||
|
||||
// Active experiments.
|
||||
var (
|
||||
GentleForce Experiment
|
||||
RemoteTaskfiles Experiment
|
||||
AnyVariables Experiment
|
||||
MapVariables Experiment
|
||||
EnvPrecedence Experiment
|
||||
)
|
||||
|
||||
// An internal list of all the initialized experiments used for iterating.
|
||||
// Inactive experiments. These are experiments that cannot be enabled, but are
|
||||
// preserved for error handling.
|
||||
var (
|
||||
xList []Experiment
|
||||
experimentConfig experimentConfigFile
|
||||
AnyVariables Experiment
|
||||
MapVariables Experiment
|
||||
)
|
||||
|
||||
func init() {
|
||||
readDotEnv()
|
||||
experimentConfig = readConfig()
|
||||
GentleForce = New("GENTLE_FORCE", 1)
|
||||
RemoteTaskfiles = New("REMOTE_TASKFILES", 1)
|
||||
AnyVariables = New("ANY_VARIABLES")
|
||||
MapVariables = New("MAP_VARIABLES")
|
||||
EnvPrecedence = New("ENV_PRECEDENCE", 1)
|
||||
// An internal list of all the initialized experiments used for iterating.
|
||||
var xList []Experiment
|
||||
|
||||
func Parse(dir string) {
|
||||
// Read any .env files
|
||||
readDotEnv(dir)
|
||||
|
||||
// Create a node for the Task config reader
|
||||
node, _ := taskrc.NewNode("", dir)
|
||||
|
||||
// Read the Task config file
|
||||
reader := taskrc.NewReader()
|
||||
config, _ := reader.Read(node)
|
||||
|
||||
// Initialize the experiments
|
||||
GentleForce = New("GENTLE_FORCE", config, 1)
|
||||
RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1)
|
||||
EnvPrecedence = New("ENV_PRECEDENCE", config, 1)
|
||||
AnyVariables = New("ANY_VARIABLES", config)
|
||||
MapVariables = New("MAP_VARIABLES", config)
|
||||
}
|
||||
|
||||
// Validate checks if any experiments have been enabled while being inactive.
|
||||
@ -68,29 +69,19 @@ func getEnv(xName string) string {
|
||||
return os.Getenv(envName)
|
||||
}
|
||||
|
||||
func getFilePath(filename string) string {
|
||||
// Parse the CLI flags again to get the directory/taskfile being run
|
||||
// We use a flagset here so that we can parse a subset of flags without exiting on error.
|
||||
var dir, taskfile string
|
||||
fs := pflag.NewFlagSet("experiments", pflag.ContinueOnError)
|
||||
fs.StringVarP(&dir, "dir", "d", "", "Sets directory of execution.")
|
||||
fs.StringVarP(&taskfile, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`)
|
||||
fs.Usage = func() {}
|
||||
_ = fs.Parse(os.Args[1:])
|
||||
// If the directory is set, find a .env file in that directory.
|
||||
func getFilePath(filename, dir string) string {
|
||||
if dir != "" {
|
||||
return filepath.Join(dir, filename)
|
||||
}
|
||||
// If the taskfile is set, find a .env file in the directory containing the Taskfile.
|
||||
if taskfile != "" {
|
||||
return filepath.Join(filepath.Dir(taskfile), filename)
|
||||
}
|
||||
// Otherwise just use the current working directory.
|
||||
return filename
|
||||
}
|
||||
|
||||
func readDotEnv() {
|
||||
env, _ := godotenv.Read(getFilePath(".env"))
|
||||
func readDotEnv(dir string) {
|
||||
env, err := godotenv.Read(getFilePath(".env", dir))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If the env var is an experiment, set it.
|
||||
for key, value := range env {
|
||||
if strings.HasPrefix(key, envPrefix) {
|
||||
@ -98,27 +89,3 @@ func readDotEnv() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readConfig() experimentConfigFile {
|
||||
var cfg experimentConfigFile
|
||||
|
||||
var content []byte
|
||||
var err error
|
||||
for _, filename := range defaultConfigFilenames {
|
||||
path := getFilePath(filename)
|
||||
content, err = os.ReadFile(path)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return experimentConfigFile{}
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(content, &cfg); err != nil {
|
||||
return experimentConfigFile{}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"cmp"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@ -77,6 +78,26 @@ var (
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Config files can enable experiments which alter the availability and/or
|
||||
// behavior of some flags, so we need to parse the experiments before the
|
||||
// flags. However, we need the --taskfile and --dir flags before we can
|
||||
// parse the experiments as they can alter the location of the config files.
|
||||
// Because of this circular dependency, we parse the flags twice. First, we
|
||||
// get the --taskfile and --dir flags, then we parse the experiments, then
|
||||
// we parse the flags again to get the full set. We use a flagset here so
|
||||
// that we can parse a subset of flags without exiting on error.
|
||||
var dir, entrypoint string
|
||||
fs := pflag.NewFlagSet("experiments", pflag.ContinueOnError)
|
||||
fs.StringVarP(&dir, "dir", "d", "", "")
|
||||
fs.StringVarP(&entrypoint, "taskfile", "t", "", "")
|
||||
fs.Usage = func() {}
|
||||
_ = fs.Parse(os.Args[1:])
|
||||
|
||||
// Parse the experiments
|
||||
dir = cmp.Or(dir, filepath.Dir(entrypoint))
|
||||
experiments.Parse(dir)
|
||||
|
||||
// Parse the rest of the flags
|
||||
log.SetFlags(0)
|
||||
log.SetOutput(os.Stderr)
|
||||
pflag.Usage = func() {
|
||||
|
146
internal/fsext/fs.go
Normal file
146
internal/fsext/fs.go
Normal file
@ -0,0 +1,146 @@
|
||||
package fsext
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-task/task/v3/internal/filepathext"
|
||||
"github.com/go-task/task/v3/internal/sysinfo"
|
||||
)
|
||||
|
||||
// DefaultDir will return the default directory given an entrypoint or
|
||||
// directory. If the directory is set, it will ensure it is an absolute path and
|
||||
// return it. If the entrypoint is set, but the directory is not, it will leave
|
||||
// the directory blank. If both are empty, it will default the directory to the
|
||||
// current working directory.
|
||||
func DefaultDir(entrypoint, dir string) string {
|
||||
// If the directory is set, ensure it is an absolute path
|
||||
if dir != "" {
|
||||
var err error
|
||||
dir, err = filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// If the entrypoint and dir are empty, we default the directory to the current working directory
|
||||
if entrypoint == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return wd
|
||||
}
|
||||
|
||||
// If the entrypoint is set, but the directory is not, we leave the directory blank
|
||||
return ""
|
||||
}
|
||||
|
||||
// Search will look for files with the given possible filenames using the given
|
||||
// entrypoint and directory. If the entrypoint is set, it will check if the
|
||||
// entrypoint matches a file or if it matches a directory containing one of the
|
||||
// possible filenames. Otherwise, it will walk up the file tree starting at the
|
||||
// given directory and perform a search in each directory for the possible
|
||||
// filenames until it finds a match or reaches the root directory. If the
|
||||
// entrypoint and directory are both empty, it will default the directory to the
|
||||
// current working directory and perform a recursive search starting there. If a
|
||||
// match is found, the absolute path to the file will be returned with its
|
||||
// directory. If no match is found, an error will be returned.
|
||||
func Search(entrypoint, dir string, possibleFilenames []string) (string, string, error) {
|
||||
var err error
|
||||
if entrypoint != "" {
|
||||
entrypoint, err = SearchPath(entrypoint, possibleFilenames)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if dir == "" {
|
||||
dir = filepath.Dir(entrypoint)
|
||||
} else {
|
||||
dir, err = filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return entrypoint, dir, nil
|
||||
}
|
||||
if dir == "" {
|
||||
dir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
entrypoint, err = SearchPathRecursively(dir, possibleFilenames)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
dir = filepath.Dir(entrypoint)
|
||||
return entrypoint, dir, nil
|
||||
}
|
||||
|
||||
// Search will check if a file at the given path exists or not. If it does, it
|
||||
// will return the path to it. If it does not, it will search for any files at
|
||||
// the given path with any of the given possible names. If any of these match a
|
||||
// file, the first matching path will be returned. If no files are found, an
|
||||
// error will be returned.
|
||||
func SearchPath(path string, possibleFilenames []string) (string, error) {
|
||||
// Get file info about the path
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If the path exists and is a regular file, device, symlink, or named pipe,
|
||||
// return the absolute path to it
|
||||
if fi.Mode().IsRegular() ||
|
||||
fi.Mode()&os.ModeDevice != 0 ||
|
||||
fi.Mode()&os.ModeSymlink != 0 ||
|
||||
fi.Mode()&os.ModeNamedPipe != 0 {
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
|
||||
// If the path is a directory, check if any of the possible names exist
|
||||
// in that directory
|
||||
for _, filename := range possibleFilenames {
|
||||
alt := filepathext.SmartJoin(path, filename)
|
||||
if _, err := os.Stat(alt); err == nil {
|
||||
return filepath.Abs(alt)
|
||||
}
|
||||
}
|
||||
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
// SearchRecursively will check if a file at the given path exists by calling
|
||||
// the exists function. If a file is not found, it will walk up the directory
|
||||
// tree calling the Search function until it finds a file or reaches the root
|
||||
// directory. On supported operating systems, it will also check if the user ID
|
||||
// of the directory changes and abort if it does.
|
||||
func SearchPathRecursively(path string, possibleFilenames []string) (string, error) {
|
||||
owner, err := sysinfo.Owner(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
fpath, err := SearchPath(path, possibleFilenames)
|
||||
if err == nil {
|
||||
return fpath, nil
|
||||
}
|
||||
|
||||
// Get the parent path/user id
|
||||
parentPath := filepath.Dir(path)
|
||||
parentOwner, err := sysinfo.Owner(parentPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Error if we reached the root directory and still haven't found a file
|
||||
// OR if the user id of the directory changes
|
||||
if path == parentPath || (parentOwner != owner) {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
owner = parentOwner
|
||||
path = parentPath
|
||||
}
|
||||
}
|
152
internal/fsext/fs_test.go
Normal file
152
internal/fsext/fs_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
package fsext
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDefaultDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entrypoint string
|
||||
dir string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "default to current working directory",
|
||||
entrypoint: "",
|
||||
dir: "",
|
||||
expected: wd,
|
||||
},
|
||||
{
|
||||
name: "resolves relative dir path",
|
||||
entrypoint: "",
|
||||
dir: "./dir",
|
||||
expected: filepath.Join(wd, "dir"),
|
||||
},
|
||||
{
|
||||
name: "return entrypoint if set",
|
||||
entrypoint: filepath.Join(wd, "entrypoint"),
|
||||
dir: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "if entrypoint and dir are set",
|
||||
entrypoint: filepath.Join(wd, "entrypoint"),
|
||||
dir: filepath.Join(wd, "dir"),
|
||||
expected: filepath.Join(wd, "dir"),
|
||||
},
|
||||
{
|
||||
name: "if entrypoint and dir are set and dir is relative",
|
||||
entrypoint: filepath.Join(wd, "entrypoint"),
|
||||
dir: "./dir",
|
||||
expected: filepath.Join(wd, "dir"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.expected, DefaultDir(tt.entrypoint, tt.dir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entrypoint string
|
||||
dir string
|
||||
possibleFilenames []string
|
||||
expectedEntrypoint string
|
||||
expectedDir string
|
||||
}{
|
||||
{
|
||||
name: "find foo.txt using relative entrypoint",
|
||||
entrypoint: "./testdata/foo.txt",
|
||||
possibleFilenames: []string{"foo.txt"},
|
||||
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
|
||||
expectedDir: filepath.Join(wd, "testdata"),
|
||||
},
|
||||
{
|
||||
name: "find foo.txt using absolute entrypoint",
|
||||
entrypoint: filepath.Join(wd, "testdata", "foo.txt"),
|
||||
possibleFilenames: []string{"foo.txt"},
|
||||
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
|
||||
expectedDir: filepath.Join(wd, "testdata"),
|
||||
},
|
||||
{
|
||||
name: "find foo.txt using relative dir",
|
||||
dir: "./testdata",
|
||||
possibleFilenames: []string{"foo.txt"},
|
||||
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
|
||||
expectedDir: filepath.Join(wd, "testdata"),
|
||||
},
|
||||
{
|
||||
name: "find foo.txt using absolute dir",
|
||||
dir: filepath.Join(wd, "testdata"),
|
||||
possibleFilenames: []string{"foo.txt"},
|
||||
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
|
||||
expectedDir: filepath.Join(wd, "testdata"),
|
||||
},
|
||||
{
|
||||
name: "find foo.txt using relative dir and relative entrypoint",
|
||||
entrypoint: "./testdata/foo.txt",
|
||||
dir: "./testdata/some/other/dir",
|
||||
possibleFilenames: []string{"foo.txt"},
|
||||
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
|
||||
expectedDir: filepath.Join(wd, "testdata", "some", "other", "dir"),
|
||||
},
|
||||
{
|
||||
name: "find fs.go using no entrypoint or dir",
|
||||
entrypoint: "",
|
||||
dir: "",
|
||||
possibleFilenames: []string{"fs.go"},
|
||||
expectedEntrypoint: filepath.Join(wd, "fs.go"),
|
||||
expectedDir: wd,
|
||||
},
|
||||
{
|
||||
name: "find ../../Taskfile.yml using no entrypoint or dir by walking",
|
||||
entrypoint: "",
|
||||
dir: "",
|
||||
possibleFilenames: []string{"Taskfile.yml"},
|
||||
expectedEntrypoint: filepath.Join(wd, "..", "..", "Taskfile.yml"),
|
||||
expectedDir: filepath.Join(wd, "..", ".."),
|
||||
},
|
||||
{
|
||||
name: "find foo.txt first if listed first in possible filenames",
|
||||
entrypoint: "./testdata",
|
||||
possibleFilenames: []string{"foo.txt", "bar.txt"},
|
||||
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
|
||||
expectedDir: filepath.Join(wd, "testdata"),
|
||||
},
|
||||
{
|
||||
name: "find bar.txt first if listed first in possible filenames",
|
||||
entrypoint: "./testdata",
|
||||
possibleFilenames: []string{"bar.txt", "foo.txt"},
|
||||
expectedEntrypoint: filepath.Join(wd, "testdata", "bar.txt"),
|
||||
expectedDir: filepath.Join(wd, "testdata"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
entrypoint, dir, err := Search(tt.entrypoint, tt.dir, tt.possibleFilenames)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expectedEntrypoint, entrypoint)
|
||||
require.Equal(t, tt.expectedDir, dir)
|
||||
})
|
||||
}
|
||||
}
|
0
internal/fsext/testdata/bar.txt
vendored
Normal file
0
internal/fsext/testdata/bar.txt
vendored
Normal file
0
internal/fsext/testdata/foo.txt
vendored
Normal file
0
internal/fsext/testdata/foo.txt
vendored
Normal file
@ -2,8 +2,6 @@ package taskfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -11,6 +9,7 @@ import (
|
||||
|
||||
"github.com/go-task/task/v3/errors"
|
||||
"github.com/go-task/task/v3/internal/experiments"
|
||||
"github.com/go-task/task/v3/internal/fsext"
|
||||
)
|
||||
|
||||
type Node interface {
|
||||
@ -34,7 +33,7 @@ func NewRootNode(
|
||||
insecure bool,
|
||||
timeout time.Duration,
|
||||
) (Node, error) {
|
||||
dir = getDefaultDir(entrypoint, dir)
|
||||
dir = fsext.DefaultDir(entrypoint, dir)
|
||||
// If the entrypoint is "-", we read from stdin
|
||||
if entrypoint == "-" {
|
||||
return NewStdinNode(dir)
|
||||
@ -87,26 +86,3 @@ func getScheme(uri string) (string, error) {
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func getDefaultDir(entrypoint, dir string) string {
|
||||
// If the entrypoint and dir are empty, we default the directory to the current working directory
|
||||
if dir == "" {
|
||||
if entrypoint == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dir = wd
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// If the directory is set, ensure it is an absolute path
|
||||
var err error
|
||||
dir, err = filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/go-task/task/v3/internal/execext"
|
||||
"github.com/go-task/task/v3/internal/filepathext"
|
||||
"github.com/go-task/task/v3/internal/fsext"
|
||||
)
|
||||
|
||||
// A FileNode is a node that reads a taskfile from the local filesystem.
|
||||
@ -19,7 +20,7 @@ type FileNode struct {
|
||||
func NewFileNode(entrypoint, dir string, opts ...NodeOption) (*FileNode, error) {
|
||||
var err error
|
||||
base := NewBaseNode(dir, opts...)
|
||||
entrypoint, base.dir, err = resolveFileNodeEntrypointAndDir(entrypoint, base.dir)
|
||||
entrypoint, base.dir, err = fsext.Search(entrypoint, base.dir, defaultTaskfiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -42,34 +43,6 @@ func (node *FileNode) Read() ([]byte, error) {
|
||||
return io.ReadAll(f)
|
||||
}
|
||||
|
||||
// resolveFileNodeEntrypointAndDir resolves checks the values of entrypoint and dir and
|
||||
// populates them with default values if necessary.
|
||||
func resolveFileNodeEntrypointAndDir(entrypoint, dir string) (string, string, error) {
|
||||
var err error
|
||||
if entrypoint != "" {
|
||||
entrypoint, err = Exists(entrypoint)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if dir == "" {
|
||||
dir = filepath.Dir(entrypoint)
|
||||
}
|
||||
return entrypoint, dir, nil
|
||||
}
|
||||
if dir == "" {
|
||||
dir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
entrypoint, err = ExistsWalk(dir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
dir = filepath.Dir(entrypoint)
|
||||
return entrypoint, dir, nil
|
||||
}
|
||||
|
||||
func (node *FileNode) ResolveEntrypoint(entrypoint string) (string, error) {
|
||||
// If the file is remote, we don't need to resolve the path
|
||||
if strings.Contains(entrypoint, "://") {
|
||||
|
@ -5,14 +5,10 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/go-task/task/v3/errors"
|
||||
"github.com/go-task/task/v3/internal/filepathext"
|
||||
"github.com/go-task/task/v3/internal/sysinfo"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -94,65 +90,3 @@ func RemoteExists(ctx context.Context, u *url.URL) (*url.URL, error) {
|
||||
|
||||
return nil, errors.TaskfileNotFoundError{URI: u.String(), Walk: false}
|
||||
}
|
||||
|
||||
// Exists will check if a file at the given path Exists. If it does, it will
|
||||
// return the path to it. If it does not, it will search for any files at the
|
||||
// given path with any of the default Taskfile files names. If any of these
|
||||
// match a file, the first matching path will be returned. If no files are
|
||||
// found, an error will be returned.
|
||||
func Exists(path string) (string, error) {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fi.Mode().IsRegular() ||
|
||||
fi.Mode()&os.ModeDevice != 0 ||
|
||||
fi.Mode()&os.ModeSymlink != 0 ||
|
||||
fi.Mode()&os.ModeNamedPipe != 0 {
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
|
||||
for _, taskfile := range defaultTaskfiles {
|
||||
alt := filepathext.SmartJoin(path, taskfile)
|
||||
if _, err := os.Stat(alt); err == nil {
|
||||
return filepath.Abs(alt)
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.TaskfileNotFoundError{URI: path, Walk: false}
|
||||
}
|
||||
|
||||
// ExistsWalk will check if a file at the given path exists by calling the
|
||||
// exists function. If a file is not found, it will walk up the directory tree
|
||||
// calling the exists function until it finds a file or reaches the root
|
||||
// directory. On supported operating systems, it will also check if the user ID
|
||||
// of the directory changes and abort if it does.
|
||||
func ExistsWalk(path string) (string, error) {
|
||||
origPath := path
|
||||
owner, err := sysinfo.Owner(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
fpath, err := Exists(path)
|
||||
if err == nil {
|
||||
return fpath, nil
|
||||
}
|
||||
|
||||
// Get the parent path/user id
|
||||
parentPath := filepath.Dir(path)
|
||||
parentOwner, err := sysinfo.Owner(parentPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Error if we reached the root directory and still haven't found a file
|
||||
// OR if the user id of the directory changes
|
||||
if path == parentPath || (parentOwner != owner) {
|
||||
return "", errors.TaskfileNotFoundError{URI: origPath, Walk: false}
|
||||
}
|
||||
|
||||
owner = parentOwner
|
||||
path = parentPath
|
||||
}
|
||||
}
|
||||
|
8
taskrc/ast/taskrc.go
Normal file
8
taskrc/ast/taskrc.go
Normal file
@ -0,0 +1,8 @@
|
||||
package ast
|
||||
|
||||
import "github.com/Masterminds/semver/v3"
|
||||
|
||||
type TaskRC struct {
|
||||
Version *semver.Version `yaml:"version"`
|
||||
Experiments map[string]int `yaml:"experiments"`
|
||||
}
|
24
taskrc/node.go
Normal file
24
taskrc/node.go
Normal file
@ -0,0 +1,24 @@
|
||||
package taskrc
|
||||
|
||||
import "github.com/go-task/task/v3/internal/fsext"
|
||||
|
||||
type Node struct {
|
||||
entrypoint string
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewNode(
|
||||
entrypoint string,
|
||||
dir string,
|
||||
) (*Node, error) {
|
||||
dir = fsext.DefaultDir(entrypoint, dir)
|
||||
var err error
|
||||
entrypoint, dir, err = fsext.Search(entrypoint, dir, defaultTaskRCs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Node{
|
||||
entrypoint: entrypoint,
|
||||
dir: dir,
|
||||
}, nil
|
||||
}
|
79
taskrc/reader.go
Normal file
79
taskrc/reader.go
Normal file
@ -0,0 +1,79 @@
|
||||
package taskrc
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/go-task/task/v3/taskrc/ast"
|
||||
)
|
||||
|
||||
type (
|
||||
// DebugFunc is a function that can be called to log debug messages.
|
||||
DebugFunc func(string)
|
||||
// A ReaderOption is any type that can apply a configuration to a [Reader].
|
||||
ReaderOption interface {
|
||||
ApplyToReader(*Reader)
|
||||
}
|
||||
// A Reader will recursively read Taskfiles from a given [Node] and build a
|
||||
// [ast.TaskRC] from them.
|
||||
Reader struct {
|
||||
debugFunc DebugFunc
|
||||
}
|
||||
)
|
||||
|
||||
// NewReader constructs a new Taskfile [Reader] using the given Node and
|
||||
// options.
|
||||
func NewReader(opts ...ReaderOption) *Reader {
|
||||
r := &Reader{
|
||||
debugFunc: nil,
|
||||
}
|
||||
r.Options(opts...)
|
||||
return r
|
||||
}
|
||||
|
||||
// Options loops through the given [ReaderOption] functions and applies them to
|
||||
// the [Reader].
|
||||
func (r *Reader) Options(opts ...ReaderOption) {
|
||||
for _, opt := range opts {
|
||||
opt.ApplyToReader(r)
|
||||
}
|
||||
}
|
||||
|
||||
// WithDebugFunc sets the debug function to be used by the [Reader]. If set,
|
||||
// this function will be called with debug messages. This can be useful if the
|
||||
// caller wants to log debug messages from the [Reader]. By default, no debug
|
||||
// function is set and the logs are not written.
|
||||
func WithDebugFunc(debugFunc DebugFunc) ReaderOption {
|
||||
return &debugFuncOption{debugFunc: debugFunc}
|
||||
}
|
||||
|
||||
type debugFuncOption struct {
|
||||
debugFunc DebugFunc
|
||||
}
|
||||
|
||||
func (o *debugFuncOption) ApplyToReader(r *Reader) {
|
||||
r.debugFunc = o.debugFunc
|
||||
}
|
||||
|
||||
// Read will read the Task config defined by the [Reader]'s [Node].
|
||||
func (r *Reader) Read(node *Node) (*ast.TaskRC, error) {
|
||||
var config ast.TaskRC
|
||||
|
||||
if node == nil {
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Read the file
|
||||
b, err := os.ReadFile(node.entrypoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the content
|
||||
if err := yaml.Unmarshal(b, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
6
taskrc/taskrc.go
Normal file
6
taskrc/taskrc.go
Normal file
@ -0,0 +1,6 @@
|
||||
package taskrc
|
||||
|
||||
var defaultTaskRCs = []string{
|
||||
".taskrc.yml",
|
||||
".taskrc.yaml",
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user