magefile / mage

a Make/rake-like dev tool using Go
https://magefile.org
Apache License 2.0
4.01k stars 250 forks source link

Enhancement: Simpler way to execute multiple commands #454

Closed drsybren closed 1 year ago

drsybren commented 1 year ago

Describe the feature This is a proposal to simplify the execution of multiple commands, where an error should stop this chain. Basically the behaviour of make when executing multiple commands for a single target.

What problem does this feature address? AFAIK this is the way to execute multiple commands in a "build" target:

func Build() error {
    if err := sh.Run("go", "mod", "download"); err != nil {
        return err
    }
    if err := sh.Run("go", "build", "./cmd/gcoderamp"); err != nil {
        return err
    }
    return sh.Run("go", "build", "./cmd/pcb2gcodewrap")
}

This means that for every command to execute there are typically three lines of code necessary. My proposal is to introduce something like this:

func Build(ctx context.Context) error {
    r := NewRunner(ctx)
    r.Run("go", "mod", "download")
    r.Run("go", "build", "./cmd/gcoderamp")
    r.Run("go", "build", "./cmd/pcb2gcodewrap")
    return r.Wait()
}

I think it's a good idea to make running shell commands as direct as possible.

Additional context This is the way I implemented it locally. It does add the dependency on golang.org/x/sync/errgroup, so the code cannot be used as-is. I think it's relatively simple to restructure the code to avoid this dependency. Before doing that, I'd rather discuss the overall idea first, though.

//go:build mage

package main

import (
    "context"

    "github.com/magefile/mage/sh"
    "golang.org/x/sync/errgroup"
)

// Runner allows running a group of commands sequentially, stopping at the first
// failure.
type Runner struct {
    group *errgroup.Group
    ctx   context.Context
}

// NewRunner constructs a new runner that's bound to the given context. If the
// context is done, no new command will be executed. It does NOT abort an
// already-running command.
func NewRunner(ctx context.Context) *Runner {
    group, groupctx := errgroup.WithContext(ctx)
    group.SetLimit(1)

    return &Runner{
        group: group,
        ctx:   groupctx,
    }
}

// Run the given command.
// This only runs a command if no previous command has failed yet.
func (r *Runner) Run(cmd string, args ...string) {
    r.group.Go(func() error {
        if err := r.ctx.Err(); err != nil {
            return err
        }
        return sh.Run(cmd, args...)
    })
}

// Wait for the commands to finish running, and return any error.
func (r *Runner) Wait() error {
    return r.group.Wait()
}
drsybren commented 1 year ago

Closing this issue -- I submitted it with my work account, but I wanted to use my private account. Sorry for the noise!