mirror of
				https://github.com/go-task/task.git
				synced 2025-10-30 23:58:01 +02:00 
			
		
		
		
	Run Taskfiles from sub/child directories (#920)
This commit is contained in:
		| @@ -2,6 +2,9 @@ | ||||
|  | ||||
| ## Unreleased | ||||
|  | ||||
| - It's now possible to run Taskfiles from subdirectories! A new `USER_WORKING_DIR` special | ||||
|   variable was added to add even more flexibility for monorepos | ||||
|   ([#289](https://github.com/go-task/task/issues/289), [#920](https://github.com/go-task/task/pull/920)). | ||||
| - Add task-level `dotenv` support | ||||
|   ([#389](https://github.com/go-task/task/issues/389), [#904](https://github.com/go-task/task/pull/904)). | ||||
| - It's now possible to use global level variables on `includes` | ||||
|   | ||||
| @@ -55,6 +55,7 @@ There are some special variables that is available on the templating system: | ||||
| | `TASK` | The name of the current task. | | ||||
| | `ROOT_DIR` | The absolute path of the root Taskfile. | | ||||
| | `TASKFILE_DIR` | The absolute path of the included Taskfile. | | ||||
| | `USER_WORKING_DIR` | The absolute path of the directory `task` was called from. | | ||||
| | `CHECKSUM` | The checksum of the files listed in `sources`. Only available within the `status` prop and if method is set to `checksum`. | | ||||
| | `TIMESTAMP` | The date object of the greatest timestamp of the files listes in `sources`. Only available within the `status` prop and if method is set to `timestamp`. | | ||||
|  | ||||
|   | ||||
| @@ -52,6 +52,35 @@ committed version (`.dist`) while still allowing individual users to override | ||||
| the Taskfile by adding an additional `Taskfile.yml` (which would be on | ||||
| `.gitignore`). | ||||
|  | ||||
| ### Running a Taskfile from a subdirectory | ||||
|  | ||||
| If a Taskfile cannot be found in the current working directory, it will walk up | ||||
| the file tree until it finds one (similar to how `git` works). When running Task | ||||
| from a subdirectory like this, it will behave as if you ran it from the | ||||
| directory containing the Taskfile. | ||||
|  | ||||
| You can use this functionality along with the special `{{.USER_WORKING_DIR}}` | ||||
| variable to create some very useful reusable tasks. For example, if you have a | ||||
| monorepo with directories for each microservice, you can `cd` into a | ||||
| microservice directory and run a task command to bring it up without having to | ||||
| create multiple tasks or Taskfiles with identical content. For example: | ||||
|  | ||||
| ```yaml | ||||
| version: '3' | ||||
|  | ||||
| tasks: | ||||
|   up: | ||||
|     dir: '{{.USER_WORKING_DIR}}' | ||||
|     preconditions: | ||||
|       - test -f docker-compose.yml | ||||
|     cmds: | ||||
|       - docker-compose up -d | ||||
| ``` | ||||
|  | ||||
| In this example, we can run `cd <service>` and `task up` and as long as the | ||||
| `<service>` directory contains a `docker-compose.yml`, the Docker composition will be | ||||
| brought up. | ||||
|  | ||||
| ## Environment variables | ||||
|  | ||||
| ### Task | ||||
|   | ||||
| @@ -18,7 +18,8 @@ import ( | ||||
| var _ compiler.Compiler = &CompilerV3{} | ||||
|  | ||||
| type CompilerV3 struct { | ||||
| 	Dir string | ||||
| 	Dir            string | ||||
| 	UserWorkingDir string | ||||
|  | ||||
| 	TaskfileEnv  *taskfile.Vars | ||||
| 	TaskfileVars *taskfile.Vars | ||||
| @@ -179,9 +180,10 @@ func (c *CompilerV3) getSpecialVars(t *taskfile.Task) (map[string]string, error) | ||||
| 	} | ||||
|  | ||||
| 	return map[string]string{ | ||||
| 		"TASK":         t.Task, | ||||
| 		"ROOT_DIR":     c.Dir, | ||||
| 		"TASKFILE_DIR": taskfileDir, | ||||
| 		"TASK":             t.Task, | ||||
| 		"ROOT_DIR":         c.Dir, | ||||
| 		"TASKFILE_DIR":     taskfileDir, | ||||
| 		"USER_WORKING_DIR": c.UserWorkingDir, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										22
									
								
								internal/sysinfo/uid.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								internal/sysinfo/uid.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| //go:build !windows | ||||
|  | ||||
| package sysinfo | ||||
|  | ||||
| import ( | ||||
| 	"os" | ||||
| 	"syscall" | ||||
| ) | ||||
|  | ||||
| func Owner(path string) (int, error) { | ||||
| 	info, err := os.Stat(path) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	var uid int | ||||
| 	if stat, ok := info.Sys().(*syscall.Stat_t); ok { | ||||
| 		uid = int(stat.Uid) | ||||
| 	} else { | ||||
| 		uid = os.Getuid() | ||||
| 	} | ||||
| 	return uid, nil | ||||
| } | ||||
							
								
								
									
										9
									
								
								internal/sysinfo/uid_win.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								internal/sysinfo/uid_win.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| //go:build windows | ||||
|  | ||||
| package sysinfo | ||||
|  | ||||
| // NOTE: This always returns -1 since there is currently no easy way to get | ||||
| // file owner information on Windows. | ||||
| func Owner(path string) (int, error) { | ||||
| 	return -1, nil | ||||
| } | ||||
							
								
								
									
										15
									
								
								setup.go
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								setup.go
									
									
									
									
									
								
							| @@ -80,7 +80,7 @@ func (e *Executor) setCurrentDir() error { | ||||
|  | ||||
| func (e *Executor) readTaskfile() error { | ||||
| 	var err error | ||||
| 	e.Taskfile, err = read.Taskfile(&read.ReaderNode{ | ||||
| 	e.Taskfile, e.Dir, err = read.Taskfile(&read.ReaderNode{ | ||||
| 		Dir:        e.Dir, | ||||
| 		Entrypoint: e.Entrypoint, | ||||
| 		Parent:     nil, | ||||
| @@ -179,11 +179,16 @@ func (e *Executor) setupCompiler(v float64) error { | ||||
| 			Logger:       e.Logger, | ||||
| 		} | ||||
| 	} else { | ||||
| 		userWorkingDir, err := os.Getwd() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		e.Compiler = &compilerv3.CompilerV3{ | ||||
| 			Dir:          e.Dir, | ||||
| 			TaskfileEnv:  e.Taskfile.Env, | ||||
| 			TaskfileVars: e.Taskfile.Vars, | ||||
| 			Logger:       e.Logger, | ||||
| 			Dir:            e.Dir, | ||||
| 			UserWorkingDir: userWorkingDir, | ||||
| 			TaskfileEnv:    e.Taskfile.Env, | ||||
| 			TaskfileVars:   e.Taskfile.Vars, | ||||
| 			Logger:         e.Logger, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|   | ||||
							
								
								
									
										49
									
								
								task_test.go
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								task_test.go
									
									
									
									
									
								
							| @@ -1703,3 +1703,52 @@ Hello, World! | ||||
| 	err = os.RemoveAll(filepathext.SmartJoin(dir, "src")) | ||||
| 	assert.NoError(t, err) | ||||
| } | ||||
|  | ||||
| func TestTaskfileWalk(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
| 		dir      string | ||||
| 		expected string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:     "walk from root directory", | ||||
| 			dir:      "testdata/taskfile_walk", | ||||
| 			expected: "foo\n", | ||||
| 		}, { | ||||
| 			name:     "walk from sub directory", | ||||
| 			dir:      "testdata/taskfile_walk/foo", | ||||
| 			expected: "foo\n", | ||||
| 		}, { | ||||
| 			name:     "walk from sub sub directory", | ||||
| 			dir:      "testdata/taskfile_walk/foo/bar", | ||||
| 			expected: "foo\n", | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, test := range tests { | ||||
| 		t.Run(test.name, func(t *testing.T) { | ||||
| 			var buff bytes.Buffer | ||||
| 			e := task.Executor{ | ||||
| 				Dir:    test.dir, | ||||
| 				Stdout: &buff, | ||||
| 				Stderr: &buff, | ||||
| 			} | ||||
| 			assert.NoError(t, e.Setup()) | ||||
| 			assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "default"})) | ||||
| 			assert.Equal(t, test.expected, buff.String()) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestUserWorkingDirectory(t *testing.T) { | ||||
| 	var buff bytes.Buffer | ||||
| 	e := task.Executor{ | ||||
| 		Dir:    "testdata/user_working_dir", | ||||
| 		Stdout: &buff, | ||||
| 		Stderr: &buff, | ||||
| 	} | ||||
| 	wd, err := os.Getwd() | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.NoError(t, e.Setup()) | ||||
| 	assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "default"})) | ||||
| 	assert.Equal(t, fmt.Sprintf("%s\n", wd), buff.String()) | ||||
| } | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import ( | ||||
| 	"gopkg.in/yaml.v3" | ||||
|  | ||||
| 	"github.com/go-task/task/v3/internal/filepathext" | ||||
| 	"github.com/go-task/task/v3/internal/sysinfo" | ||||
| 	"github.com/go-task/task/v3/internal/templater" | ||||
| 	"github.com/go-task/task/v3/taskfile" | ||||
| ) | ||||
| @@ -36,29 +37,30 @@ type ReaderNode struct { | ||||
| // Taskfile reads a Taskfile for a given directory | ||||
| // Uses current dir when dir is left empty. Uses Taskfile.yml | ||||
| // or Taskfile.yaml when entrypoint is left empty | ||||
| func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { | ||||
| func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, string, error) { | ||||
| 	if readerNode.Dir == "" { | ||||
| 		d, err := os.Getwd() | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 			return nil, "", err | ||||
| 		} | ||||
| 		readerNode.Dir = d | ||||
| 	} | ||||
|  | ||||
| 	path, err := exists(filepathext.SmartJoin(readerNode.Dir, readerNode.Entrypoint)) | ||||
| 	path, err := existsWalk(filepathext.SmartJoin(readerNode.Dir, readerNode.Entrypoint)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, "", err | ||||
| 	} | ||||
| 	readerNode.Dir = filepath.Dir(path) | ||||
| 	readerNode.Entrypoint = filepath.Base(path) | ||||
|  | ||||
| 	t, err := readTaskfile(path) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, "", err | ||||
| 	} | ||||
|  | ||||
| 	v, err := t.ParsedVersion() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, "", err | ||||
| 	} | ||||
|  | ||||
| 	// Annotate any included Taskfile reference with a base directory for resolving relative paths | ||||
| @@ -113,7 +115,7 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		includedTaskfile, err := Taskfile(includeReaderNode) | ||||
| 		includedTaskfile, _, err := Taskfile(includeReaderNode) | ||||
| 		if err != nil { | ||||
| 			if includedTask.Optional { | ||||
| 				return nil | ||||
| @@ -163,7 +165,7 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { | ||||
| 		return nil | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, "", err | ||||
| 	} | ||||
|  | ||||
| 	if v < 3.0 { | ||||
| @@ -171,10 +173,10 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { | ||||
| 		if _, err = os.Stat(path); err == nil { | ||||
| 			osTaskfile, err := readTaskfile(path) | ||||
| 			if err != nil { | ||||
| 				return nil, err | ||||
| 				return nil, "", err | ||||
| 			} | ||||
| 			if err = taskfile.Merge(t, osTaskfile, nil); err != nil { | ||||
| 				return nil, err | ||||
| 				return nil, "", err | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| @@ -187,7 +189,7 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { | ||||
| 		task.Task = name | ||||
| 	} | ||||
|  | ||||
| 	return t, nil | ||||
| 	return t, readerNode.Dir, nil | ||||
| } | ||||
|  | ||||
| func readTaskfile(file string) (*taskfile.Taskfile, error) { | ||||
| @@ -221,6 +223,36 @@ func exists(path string) (string, error) { | ||||
| 	return "", fmt.Errorf(`task: No Taskfile found in "%s". Use "task --init" to create a new one`, path) | ||||
| } | ||||
|  | ||||
| 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 "", fmt.Errorf(`task: No Taskfile found in "%s" (or any of the parent directories). Use "task --init" to create a new one`, origPath) | ||||
| 		} | ||||
|  | ||||
| 		owner = parentOwner | ||||
| 		path = parentPath | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func checkCircularIncludes(node *ReaderNode) error { | ||||
| 	if node == nil { | ||||
| 		return errors.New("task: failed to check for include cycle: node was nil") | ||||
|   | ||||
							
								
								
									
										7
									
								
								testdata/taskfile_walk/Taskfile.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								testdata/taskfile_walk/Taskfile.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| version: '3' | ||||
|  | ||||
| tasks: | ||||
|   default: | ||||
|     cmds: | ||||
|       - echo 'foo' | ||||
|     silent: true | ||||
							
								
								
									
										0
									
								
								testdata/taskfile_walk/foo/bar/.gitkeep
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								testdata/taskfile_walk/foo/bar/.gitkeep
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
								
								
									
										7
									
								
								testdata/user_working_dir/Taskfile.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								testdata/user_working_dir/Taskfile.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| version: '3' | ||||
|  | ||||
| tasks: | ||||
|   default: | ||||
|     cmds: | ||||
|       - echo '{{.USER_WORKING_DIR}}' | ||||
|     silent: true | ||||
		Reference in New Issue
	
	Block a user