Repository: go-task/task (go)

Issue:
Task directory: allow to create directory if missing
Hello,
consider the following taskfile:
```
tasks:
  build:
    dir: bin
    cmds: [[go build ../cmd/foo]]
```

The reason why I find it handy to specify `dir:` is because this will always put below `bin/` the OS-correct executable name.

If I where to fiddle with `go build -o`, I would have to do something like

```
tasks:
  build:
    cmds: [[go build -o bin/foo.{{exeExt}} ../cmd/foo]]
```
which is uglier, especially if I have multiple executables to build.

And now the problem: `bin` doesn't exist in the git source repo, so I have to add the classic `bin/.git_keepme` just to keep the directory existing.

To avoid the `.git_keepme`, one would have to make the command heavier with something like

```
tasks:
  build:
    cmds: [[mkdir -p bin && cd bin && go build ../cmd/foo]]
```

and this would still break on Windows because I think that `-p` is not supported.

I was thinking: what about adding a cousin of `dir:`, for example called `mkdir:` that creates the directory if it doesn't exist, or changing the `dir:` behavior to create the directory ?

Or am I missing something and there exist already an easy way to do what I want?

Pull request:
#209 When "dir:" attribute points to a non-existing dir, create it
Also:

* Task directory: test when "dir:" attribute points to an existing dir
* Task directory: test default case (no "dir:" attribute)
* FIx spelling

Closes #209


Patch:
diff --git a/internal/taskfile/task.go b/internal/taskfile/task.go
index 1afcbfa3..a686f04b 100644
--- a/internal/taskfile/task.go
+++ b/internal/taskfile/task.go
@@ -1,5 +1,8 @@
 package taskfile
 
+import "os"
+import "sync"
+
 // Tasks represents a group of tasks
 type Tasks map[string]*Task
 
@@ -14,6 +17,7 @@ type Task struct {
 	Generates   []string
 	Status      []string
 	Dir         string
+	mkdirMutex  sync.Mutex
 	Vars        Vars
 	Env         Vars
 	Silent      bool
@@ -21,3 +25,22 @@ type Task struct {
 	Prefix      string
 	IgnoreError bool `yaml:"ignore_error"`
 }
+
+// Mkdir creates the directory Task.Dir.
+// Safe to be called concurrently.
+func (t *Task) Mkdir() error {
+	if t.Dir == "" {
+		// No "dir:" attribute, so we do nothing.
+		return nil
+	}
+
+	t.mkdirMutex.Lock()
+	defer t.mkdirMutex.Unlock()
+
+	if _, err := os.Stat(t.Dir); os.IsNotExist(err) {
+		if err := os.MkdirAll(t.Dir, 0755); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/task.go b/task.go
index 2df08c2c..bca22c4d 100644
--- a/task.go
+++ b/task.go
@@ -200,6 +200,12 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error {
 		}
 	}
 
+	// When using the "dir:" attribute it can happen that the directory doesn't exist.
+	// If so, we create it.
+	if err := t.Mkdir(); err != nil {
+		e.Logger.Errf("task: cannot make directory %q: %v", t.Dir, err)
+	}
+
 	for i := range t.Cmds {
 		if err := e.runCommand(ctx, t, call, i); err != nil {
 			if err2 := e.statusOnError(t); err2 != nil {


Test patch:
diff --git a/task_test.go b/task_test.go
index e2278dc5..bb73b56a 100644
--- a/task_test.go
+++ b/task_test.go
@@ -236,7 +236,7 @@ func TestDeps(t *testing.T) {
 	for _, f := range files {
 		f = filepath.Join(dir, f)
 		if _, err := os.Stat(f); err != nil {
-			t.Errorf("File %s should exists", f)
+			t.Errorf("File %s should exist", f)
 		}
 	}
 }
@@ -248,7 +248,7 @@ func TestStatus(t *testing.T) {
 	_ = os.Remove(file)
 
 	if _, err := os.Stat(file); err == nil {
-		t.Errorf("File should not exists: %v", err)
+		t.Errorf("File should not exist: %v", err)
 	}
 
 	var buff bytes.Buffer
@@ -262,7 +262,7 @@ func TestStatus(t *testing.T) {
 	assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "gen-foo"}))
 
 	if _, err := os.Stat(file); err != nil {
-		t.Errorf("File should exists: %v", err)
+		t.Errorf("File should exist: %v", err)
 	}
 
 	e.Silent = false
@@ -290,7 +290,7 @@ func TestGenerates(t *testing.T) {
 		path := filepath.Join(dir, task)
 		_ = os.Remove(path)
 		if _, err := os.Stat(path); err == nil {
-			t.Errorf("File should not exists: %v", err)
+			t.Errorf("File should not exist: %v", err)
 		}
 	}
 
@@ -311,10 +311,10 @@ func TestGenerates(t *testing.T) {
 		assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: theTask}))
 
 		if _, err := os.Stat(srcFile); err != nil {
-			t.Errorf("File should exists: %v", err)
+			t.Errorf("File should exist: %v", err)
 		}
 		if _, err := os.Stat(destFile); err != nil {
-			t.Errorf("File should exists: %v", err)
+			t.Errorf("File should exist: %v", err)
 		}
 		// Ensure task was not incorrectly found to be up-to-date on first run.
 		if buff.String() == upToDate {
@@ -371,7 +371,7 @@ func TestInit(t *testing.T) {
 
 	_ = os.Remove(file)
 	if _, err := os.Stat(file); err == nil {
-		t.Errorf("Taskfile.yml should not exists")
+		t.Errorf("Taskfile.yml should not exist")
 	}
 
 	if err := task.InitTaskfile(ioutil.Discard, dir); err != nil {
@@ -379,7 +379,7 @@ func TestInit(t *testing.T) {
 	}
 
 	if _, err := os.Stat(file); err != nil {
-		t.Errorf("Taskfile.yml should exists")
+		t.Errorf("Taskfile.yml should exist")
 	}
 }
 
@@ -575,3 +575,65 @@ func readTestFixture(t *testing.T, dir string, file string) string {
 	assert.NoError(t, err, "error reading text fixture")
 	return string(b)
 }
+
+func TestWhenNoDirAttributeItRunsInSameDirAsTaskfile(t *testing.T) {
+	const expected = "dir"
+	const dir = "testdata/" + expected
+	var out bytes.Buffer
+	e := &task.Executor{
+		Dir:    dir,
+		Stdout: &out,
+		Stderr: &out,
+	}
+
+	assert.NoError(t, e.Setup())
+	assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "whereami"}))
+
+	// got should be the "dir" part of "testdata/dir"
+	got := strings.TrimSuffix(filepath.Base(out.String()), "\n")
+	assert.Equal(t, expected, got, "Mismatch in the working directory")
+}
+
+func TestWhenDirAttributeAndDirExistsItRunsInThatDir(t *testing.T) {
+	const expected = "exists"
+	const dir = "testdata/dir/explicit_exists"
+	var out bytes.Buffer
+	e := &task.Executor{
+		Dir:    dir,
+		Stdout: &out,
+		Stderr: &out,
+	}
+
+	assert.NoError(t, e.Setup())
+	assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "whereami"}))
+
+	got := strings.TrimSuffix(filepath.Base(out.String()), "\n")
+	assert.Equal(t, expected, got, "Mismatch in the working directory")
+}
+
+func TestWhenDirAttributeItCreatesMissingAndRunsInThatDir(t *testing.T) {
+	const expected = "createme"
+	const dir = "testdata/dir/explicit_doesnt_exist/"
+	const toBeCreated = dir + expected
+	const target = "whereami"
+	var out bytes.Buffer
+	e := &task.Executor{
+		Dir:    dir,
+		Stdout: &out,
+		Stderr: &out,
+	}
+
+	// Ensure that the directory to be created doesn't actually exist.
+	_ = os.Remove(toBeCreated)
+	if _, err := os.Stat(toBeCreated); err == nil {
+		t.Errorf("Directory should not exist: %v", err)
+	}
+	assert.NoError(t, e.Setup())
+	assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: target}))
+
+	got := strings.TrimSuffix(filepath.Base(out.String()), "\n")
+	assert.Equal(t, expected, got, "Mismatch in the working directory")
+
+	// Clean-up after ourselves only if no error.
+	_ = os.Remove(toBeCreated)
+}
diff --git a/testdata/dir/Taskfile.yml b/testdata/dir/Taskfile.yml
new file mode 100644
index 00000000..f17dd9fe
--- /dev/null
+++ b/testdata/dir/Taskfile.yml
@@ -0,0 +1,7 @@
+version: '2'
+
+tasks:
+  whereami:
+    cmds:
+      - pwd
+    silent: true
diff --git a/testdata/dir/explicit_doesnt_exist/Taskfile.yml b/testdata/dir/explicit_doesnt_exist/Taskfile.yml
new file mode 100644
index 00000000..1b6fb7d1
--- /dev/null
+++ b/testdata/dir/explicit_doesnt_exist/Taskfile.yml
@@ -0,0 +1,8 @@
+version: '2'
+
+tasks:
+  whereami:
+    dir: createme
+    cmds:
+      - pwd
+    silent: true
diff --git a/testdata/dir/explicit_exists/Taskfile.yml b/testdata/dir/explicit_exists/Taskfile.yml
new file mode 100644
index 00000000..0ab53b26
--- /dev/null
+++ b/testdata/dir/explicit_exists/Taskfile.yml
@@ -0,0 +1,8 @@
+version: '2'
+
+tasks:
+  whereami:
+    dir: exists
+    cmds:
+      - pwd
+    silent: true