From 8c4bbd8808f1c5e4f328139cdb09f7fe635f44f6 Mon Sep 17 00:00:00 2001 From: Alexander Kluth <1059742+akluth@users.noreply.github.com> Date: Sun, 31 May 2026 01:04:35 +0200 Subject: [PATCH] modernize task execution --- .github/workflows/go.yml | 7 +- README.md | 4 + main.go | 195 +++++++++++++++++++++++---------------- main_test.go | 168 +++++++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 80 deletions(-) create mode 100644 main_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0065213..d60a589 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,12 +14,13 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '1.20' + go-version-file: go.mod + cache: true - name: Build run: go build -v ./... diff --git a/README.md b/README.md index d63a95c..40b0fce 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,7 @@ Then simply type to execute those super-important tasks. +When no task name is given, tasks are executed in the order they are declared in +the Dofile. Subtasks are executed before the commands of the task that references +them, which makes them behave like dependencies. + diff --git a/main.go b/main.go index 4479b69..8e7208d 100644 --- a/main.go +++ b/main.go @@ -20,13 +20,13 @@ package main import ( - "bufio" "fmt" - "io/ioutil" + "io" "log" "os" "os/exec" "path/filepath" + "sort" "strings" "github.com/BurntSushi/toml" @@ -37,7 +37,7 @@ import ( var args struct { TaskName []string `arg:"positional"` - File string `arg:"-f" help:"Path to Dofile whne it's not in current directory"` + File string `arg:"-f" help:"Path to Dofile when it's not in current directory"` Init bool `arg:"-i" help:"Create a skeleton Dofile"` NotVerbose bool `arg:"-n" help:"Not verbose mode, print only command output"` Silent bool `arg:"-s" help:"Silent mode, print nothing except errors"` @@ -55,90 +55,115 @@ type task struct { Piped bool } -func remove(slice []string, s int) []string { - return append(slice[:s], slice[s+1:]...) +type executor struct { + doFile Dofile + dirPrefix string + out io.Writer + verbose bool + silent bool + processing map[string]bool } -func parseCommand(command string) []string { +func parseCommand(command string) ([]string, error) { parts, err := shlex.Split(strings.TrimSpace(command), true) if err != nil { - log.Fatal(err) + return nil, err } - return parts + return parts, nil } -func executeTask(doFile Dofile, dirPrefix string, taskName string) { - if _, found := doFile.Tasks[taskName]; found { - if !args.Silent && !args.NotVerbose { - fmt.Println(aurora.Bold("-> Executing task\t"), aurora.Bold(aurora.Cyan(taskName))) +func (e *executor) executeTask(taskName string) error { + currentTask, found := e.doFile.Tasks[taskName] + if !found { + return fmt.Errorf("could not find task %q", taskName) + } + + if e.processing[taskName] { + return fmt.Errorf("task cycle detected at %q", taskName) + } + e.processing[taskName] = true + defer delete(e.processing, taskName) + + if e.verbose { + fmt.Fprintln(e.out, aurora.Bold("-> Executing task\t"), aurora.Bold(aurora.Cyan(taskName))) + } + + for _, subtask := range currentTask.Tasks { + if e.verbose { + fmt.Fprintln(e.out, aurora.Bold(aurora.Cyan("-> Executing subtask\t")), aurora.Bold(subtask)) } - for _, command := range doFile.Tasks[taskName].Commands { - if !args.Silent && !args.NotVerbose { - fmt.Println(" ", aurora.Bold(aurora.Yellow(taskName)), "(", command, ")") - } + if err := e.executeTask(subtask); err != nil { + return err + } + } - tokens := parseCommand(command) - cmdName := tokens[0] - tokens = remove(tokens, 0) - - cmd := exec.Command(cmdName, tokens...) - cmd.Dir = dirPrefix - - if doFile.Tasks[taskName].Output { - if doFile.Tasks[taskName].Piped { - cmdReader, err := cmd.StdoutPipe() - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Error creating StdoutPipe for Cmd", err) - os.Exit(1) - } - - scanner := bufio.NewScanner(cmdReader) - go func() { - for scanner.Scan() { - if args.Silent { - fmt.Printf("%s\n", scanner.Text()) - } else { - fmt.Printf("\t%s\n", scanner.Text()) - } - } - }() - - err = cmd.Start() - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Error starting Cmd,", err) - os.Exit(1) - } - - err = cmd.Wait() - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Error waiting for Cmd,", err) - os.Exit(1) - } - } else { - out, _ := cmd.CombinedOutput() - fmt.Printf("\t%s", string(out)) - } - } else { - if err := cmd.Run(); err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - } + for _, command := range currentTask.Commands { + if e.verbose { + fmt.Fprintln(e.out, " ", aurora.Bold(aurora.Yellow(taskName)), "(", command, ")") + } + + if err := e.executeCommand(currentTask, command); err != nil { + return fmt.Errorf("task %q command %q failed: %w", taskName, command, err) } + } + + return nil +} + +func (e *executor) executeCommand(currentTask task, command string) error { + tokens, err := parseCommand(command) + if err != nil { + return err + } + if len(tokens) == 0 { + return fmt.Errorf("empty command") + } + + cmd := exec.Command(tokens[0], tokens[1:]...) + cmd.Dir = e.dirPrefix + + if !currentTask.Output || e.silent { + return cmd.Run() + } + + if currentTask.Piped { + cmd.Stdout = e.out + cmd.Stderr = e.out + return cmd.Run() + } + + output, err := cmd.CombinedOutput() + if len(output) > 0 { + _, _ = fmt.Fprintf(e.out, "\t%s", string(output)) + } + return err +} + +func taskNamesInFileOrder(metadata toml.MetaData, tasks map[string]task) []string { + seen := make(map[string]bool, len(tasks)) + names := make([]string, 0, len(tasks)) - for _, task := range doFile.Tasks[taskName].Tasks { - if !args.Silent && !args.NotVerbose { - fmt.Println(aurora.Bold(aurora.Cyan("-> Executing subtask\t")), aurora.Bold(task)) + for _, key := range metadata.Keys() { + if len(key) >= 2 && key[0] == "tasks" { + name := key[1] + if _, ok := tasks[name]; ok && !seen[name] { + names = append(names, name) + seen[name] = true } + } + } - executeTask(doFile, dirPrefix, task) + unordered := make([]string, 0) + for name := range tasks { + if !seen[name] { + unordered = append(unordered, name) } - } else { - fmt.Println(aurora.Bold(aurora.Red("Could not find task")), aurora.Bold(aurora.Yellow(taskName)), aurora.Bold(aurora.Red("aborting!"))) - os.Exit(-1) } + sort.Strings(unordered) + names = append(names, unordered...) + return names } func createDoFileSkeleton() { @@ -150,7 +175,7 @@ func createDoFileSkeleton() { var Dofile = ` # A somewhat descriptive name for your project/Dofile -desc = 'Dofile example' +description = 'Dofile example' # All tasks are listed here [tasks] @@ -175,7 +200,7 @@ desc = 'Dofile example' # You can combine tasks under one task name tasks = [ - "yourTaskName". + "yourTaskName", "thisTaskDoesNotExistNow" ] ` @@ -211,13 +236,14 @@ func main() { dirPrefix = filepath.Dir(fileName) } - fileContents, err := ioutil.ReadFile(fileName) + fileContents, err := os.ReadFile(fileName) if err != nil { log.Fatal(err) } var doFile Dofile - if _, err := toml.Decode(string(fileContents), &doFile); err != nil { + metadata, err := toml.Decode(string(fileContents), &doFile) + if err != nil { log.Fatal(err) } @@ -226,13 +252,28 @@ func main() { fmt.Println() } + runner := executor{ + doFile: doFile, + dirPrefix: dirPrefix, + out: os.Stdout, + verbose: !args.Silent && !args.NotVerbose, + silent: args.Silent, + processing: make(map[string]bool), + } + if len(args.TaskName) > 0 { for _, taskName := range args.TaskName { - executeTask(doFile, dirPrefix, taskName) + if err := runner.executeTask(taskName); err != nil { + fmt.Fprintln(os.Stderr, aurora.Bold(aurora.Red(err.Error()))) + os.Exit(1) + } } } else { - for taskName := range doFile.Tasks { - executeTask(doFile, dirPrefix, taskName) + for _, taskName := range taskNamesInFileOrder(metadata, doFile.Tasks) { + if err := runner.executeTask(taskName); err != nil { + fmt.Fprintln(os.Stderr, aurora.Bold(aurora.Red(err.Error()))) + os.Exit(1) + } } } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..c5f34c3 --- /dev/null +++ b/main_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "strconv" + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +func TestTaskNamesInFileOrder(t *testing.T) { + var doFile Dofile + metadata, err := toml.Decode(` +description = "test" + +[tasks] + [tasks.build] + commands = ["build"] + + [tasks.clean] + commands = ["clean"] +`, &doFile) + if err != nil { + t.Fatal(err) + } + + got := taskNamesInFileOrder(metadata, doFile.Tasks) + want := []string{"build", "clean"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("task order = %v, want %v", got, want) + } +} + +func TestExecuteTaskRunsSubtasksBeforeCommands(t *testing.T) { + t.Setenv("GO_WANT_HELPER_PROCESS", "1") + + var out bytes.Buffer + runner := testExecutor(&out, Dofile{ + Tasks: map[string]task{ + "build": { + Tasks: []string{"clean"}, + Commands: []string{helperCommand("print", "build")}, + Output: true, + Piped: true, + }, + "clean": { + Commands: []string{helperCommand("print", "clean")}, + Output: true, + Piped: true, + }, + }, + }) + + if err := runner.executeTask("build"); err != nil { + t.Fatal(err) + } + + if got, want := out.String(), "clean\nbuild\n"; got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestSilentSuppressesCommandOutput(t *testing.T) { + t.Setenv("GO_WANT_HELPER_PROCESS", "1") + + var out bytes.Buffer + runner := testExecutor(&out, Dofile{ + Tasks: map[string]task{ + "quiet": { + Commands: []string{helperCommand("print", "hidden")}, + Output: true, + Piped: true, + }, + }, + }) + runner.silent = true + + if err := runner.executeTask("quiet"); err != nil { + t.Fatal(err) + } + + if got := out.String(); got != "" { + t.Fatalf("silent output = %q, want empty", got) + } +} + +func TestCommandFailureReturnsErrorAndKeepsBufferedOutput(t *testing.T) { + t.Setenv("GO_WANT_HELPER_PROCESS", "1") + + var out bytes.Buffer + runner := testExecutor(&out, Dofile{ + Tasks: map[string]task{ + "fail": { + Commands: []string{helperCommand("fail")}, + Output: true, + }, + }, + }) + + err := runner.executeTask("fail") + if err == nil { + t.Fatal("expected command failure") + } + if got := out.String(); !strings.Contains(got, "before fail") { + t.Fatalf("output = %q, want buffered command output", got) + } +} + +func TestTaskCycleReturnsError(t *testing.T) { + var out bytes.Buffer + runner := testExecutor(&out, Dofile{ + Tasks: map[string]task{ + "loop": {Tasks: []string{"loop"}}, + }, + }) + + err := runner.executeTask("loop") + if err == nil || !strings.Contains(err.Error(), "cycle") { + t.Fatalf("error = %v, want cycle error", err) + } +} + +func testExecutor(out *bytes.Buffer, doFile Dofile) *executor { + return &executor{ + doFile: doFile, + dirPrefix: ".", + out: out, + processing: make(map[string]bool), + } +} + +func helperCommand(args ...string) string { + parts := []string{strconv.Quote(os.Args[0]), "-test.run=TestHelperProcess", "--"} + for _, arg := range args { + parts = append(parts, strconv.Quote(arg)) + } + return strings.Join(parts, " ") +} + +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + + args := os.Args + for len(args) > 0 && args[0] != "--" { + args = args[1:] + } + if len(args) == 0 { + os.Exit(2) + } + args = args[1:] + + switch args[0] { + case "print": + fmt.Println(args[1]) + case "fail": + fmt.Println("before fail") + os.Exit(7) + default: + os.Exit(2) + } + + os.Exit(0) +}