Skip to content

Commit 6997ad4

Browse files
fix(foreach): Remove shell invocation from foreach (#136)
* Remove shell invocation from foreach As discussed in [issue 135], turbolift applies shell escaping inconsistently on the arguments in foreach. Upon consideration, I think there is no good reason to apply shell escaping at all. It makes turbolift more fragile, more confusing and ultimately less flexible. Instead, do not use a shell at all and all these problems disappear. None of the examples in the README use this functionality. If users do need to invoke a shell, for example to do redirection, it is much more explicit and understandable for them if they do it themselves. This PR: * Removes the use of a shell, instead directly executing a subprocess based on the arguments provided. * Removes custom handling of arguments. Users are directed to the standard GNU-style `--` to indicate that everything else on the command-line is an argument, not an option. This makes the code substantially simpler. [issue 135]: #135 * Format foreach shell arguments nicely Shell escape the arguments so you can tell when arguments have spaces in them. * Require -- before foreach command Since we are deliberately removing custom handling of arguments, users will find that if they don't include a double hypen (`--`) separator before their foreach command, any options they pass to their command will be interpreted by turbolift and either result in errors, or worse, being quietly omitted from their command (e.g. `-v`), which could be surprising and frustrating! To head this off, refuse immediately if `--` either doesn't appear on the command-line, or appears after one or more arguments. * Reword foreach usage for accuracy Make it clear that the double-hyphen separator `--` is mandatory and not just a good idea. * Wrap long usage message Turns out Cobra doesn't wrap usage messages, so they look pretty ugly if we don't pick a pretty conservative width and wrap by hand. * Fix [flags] appearing at end of usage Turns out Cobra searches your usage line for the specific text "[flags]" and if it doesn't find it, shoves it on the end. Gross. For that reason, let's remove explicit callout of any flags and move it back to *before* the double-hyphen. * Improve logged command format and add tests Wrap braces around the logged command to delimit it clearly. Add unit tests to cover argument formatting. * Add foreach absolute path example --------- Co-authored-by: Daniel Ranson <[email protected]>
1 parent f0b8dc4 commit 6997ad4

File tree

5 files changed

+97
-177
lines changed

5 files changed

+97
-177
lines changed

README.md

+19-8
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ Occasionally you may need to work on different repo files. For instance the repo
111111
The default repo file is called `repos.txt` but you can override this on any command with the `--repos` flag.
112112

113113
```console
114-
turbolift foreach --repos repoFile1.txt sed 's/pattern1/replacement1/g'
115-
turbolift foreach --repos repoFile2.txt sed 's/pattern2/replacement2/g'
114+
turbolift foreach --repos repoFile1.txt -- sed 's/pattern1/replacement1/g'
115+
turbolift foreach --repos repoFile2.txt -- sed 's/pattern2/replacement2/g'
116116
```
117117

118118

@@ -132,16 +132,27 @@ You can do this manually using an editor, using `sed` and similar commands, or u
132132

133133
**You are free to use any tools that help get the job done.**
134134

135-
If you wish to, you can run the same command against every repo using `turbolift foreach ...` (where `...` is the shell command you want to run).
135+
If you wish to, you can run the same command against every repo using `turbolift foreach -- ...` (where `...` is the command you want to run).
136136

137137
For example, you might choose to:
138138

139-
* `turbolift foreach rm somefile` - to delete a particular file
140-
* `turbolift foreach sed -i '' 's/foo/bar/g' somefile` - to find/replace in a common file
141-
* `turbolift foreach make test` - for example, to run tests (using any appropriate command to invoke the tests)
142-
* `turbolift foreach git add somefile` - to stage a file that you have created
139+
* `turbolift foreach -- rm somefile` - to delete a particular file
140+
* `turbolift foreach -- sed -i '' 's/foo/bar/g' somefile` - to find/replace in a common file
141+
* `turbolift foreach -- make test` - for example, to run tests (using any appropriate command to invoke the tests)
142+
* `turbolift foreach -- git add somefile` - to stage a file that you have created
143+
* `turbolift foreach -- sh -c 'grep needle haystack.txt > output.txt'` - use a shell to run a command using redirection
143144

144-
At any time, if you need to update your working copy branches from the upstream, you can run `turbolift foreach git pull upstream master`.
145+
Remember that when the command runs the working directory will be the
146+
repository root. If you want to refer to files from elsewhere you need
147+
to provide an absolute path. You may find the `pwd` command helpful here.
148+
For example, to run a shell script from the current directory against
149+
each repository:
150+
151+
```
152+
turbolift foreach -- sh "$(pwd)/script.sh"
153+
```
154+
155+
At any time, if you need to update your working copy branches from the upstream, you can run `turbolift foreach -- git pull upstream master`.
145156

146157
It is highly recommended that you run tests against affected repos, if it will help validate the changes you have made.
147158

cmd/foreach/foreach.go

+27-54
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
package foreach
1717

1818
import (
19+
"errors"
1920
"os"
2021
"path"
21-
"strconv"
2222
"strings"
2323

2424
"github.com/spf13/cobra"
@@ -27,66 +27,46 @@ import (
2727
"github.com/skyscanner/turbolift/internal/colors"
2828
"github.com/skyscanner/turbolift/internal/executor"
2929
"github.com/skyscanner/turbolift/internal/logging"
30+
31+
"github.com/alessio/shellescape"
3032
)
3133

3234
var exec executor.Executor = executor.NewRealExecutor()
3335

3436
var (
3537
repoFile string = "repos.txt"
36-
helpFlag bool = false
3738
)
3839

39-
func parseForeachArgs(args []string) []string {
40-
strippedArgs := make([]string, 0)
41-
MAIN:
42-
for i := 0; i < len(args); i++ {
43-
switch args[i] {
44-
case "--repos":
45-
repoFile = args[i+1]
46-
i = i + 1
47-
case "--help":
48-
helpFlag = true
49-
default:
50-
// we've parsed everything that could be parsed; this is now the command
51-
strippedArgs = append(strippedArgs, args[i:]...)
52-
break MAIN
53-
}
40+
func formatArguments(arguments []string) string {
41+
quotedArgs := make([]string, len(arguments))
42+
for i, arg := range arguments {
43+
quotedArgs[i] = shellescape.Quote(arg)
5444
}
55-
56-
return strippedArgs
45+
return strings.Join(quotedArgs, " ")
5746
}
5847

5948
func NewForeachCmd() *cobra.Command {
6049
cmd := &cobra.Command{
61-
Use: "foreach [flags] SHELL_COMMAND",
62-
Short: "Run a shell command against each working copy",
63-
Run: run,
64-
Args: cobra.MinimumNArgs(1),
65-
DisableFlagsInUseLine: true,
66-
DisableFlagParsing: true,
50+
Use: "foreach [flags] -- COMMAND [ARGUMENT...]",
51+
Short: "Run COMMAND against each working copy",
52+
Long:
53+
`Run COMMAND against each working copy. Make sure to include a
54+
double hyphen -- with space on both sides before COMMAND, as this
55+
marks that no further options should be interpreted by turbolift.`,
56+
RunE: runE,
57+
Args: cobra.MinimumNArgs(1),
6758
}
6859

69-
// this flag will not be parsed (DisabledFlagParsing is on) but is here for the help context and auto complete
7060
cmd.Flags().StringVar(&repoFile, "repos", "repos.txt", "A file containing a list of repositories to clone.")
7161

7262
return cmd
7363
}
7464

75-
func run(c *cobra.Command, args []string) {
65+
func runE(c *cobra.Command, args []string) error {
7666
logger := logging.NewLogger(c)
7767

78-
/*
79-
Parsing is disabled for this command to make sure it doesn't capture flags from the subsequent command.
80-
E.g.: turbolift foreach ls -l <- here, the -l would be captured by foreach, not by ls
81-
Because of this, we need a manual parsing of the arguments.
82-
Assumption is the foreach arguments will be parsed before the command and its arguments.
83-
*/
84-
args = parseForeachArgs(args)
85-
86-
// check if the help flag was toggled
87-
if helpFlag {
88-
_ = c.Usage()
89-
return
68+
if c.ArgsLenAtDash() != 0 {
69+
return errors.New("Use -- to separate command")
9070
}
9171

9272
readCampaignActivity := logger.StartActivity("Reading campaign data (%s)", repoFile)
@@ -95,22 +75,19 @@ func run(c *cobra.Command, args []string) {
9575
dir, err := campaign.OpenCampaign(options)
9676
if err != nil {
9777
readCampaignActivity.EndWithFailure(err)
98-
return
78+
return nil
9979
}
10080
readCampaignActivity.EndWithSuccess()
10181

102-
for i := range args {
103-
if strings.Contains(args[i], " ") {
104-
args[i] = strconv.Quote(args[i])
105-
}
106-
}
107-
command := strings.Join(args, " ")
82+
// We shell escape these to avoid ambiguity in our logs, and give
83+
// the user something they could copy and paste.
84+
prettyArgs := formatArguments(args)
10885

10986
var doneCount, skippedCount, errorCount int
11087
for _, repo := range dir.Repos {
11188
repoDirPath := path.Join("work", repo.OrgName, repo.RepoName) // i.e. work/org/repo
11289

113-
execActivity := logger.StartActivity("Executing %s in %s", command, repoDirPath)
90+
execActivity := logger.StartActivity("Executing { %s } in %s", prettyArgs, repoDirPath)
11491

11592
// skip if the working copy does not exist
11693
if _, err = os.Stat(repoDirPath); os.IsNotExist(err) {
@@ -119,13 +96,7 @@ func run(c *cobra.Command, args []string) {
11996
continue
12097
}
12198

122-
// Execute within a shell so that piping, redirection, etc are possible
123-
shellCommand := os.Getenv("SHELL")
124-
if shellCommand == "" {
125-
shellCommand = "sh"
126-
}
127-
shellArgs := []string{"-c", command}
128-
err := exec.Execute(execActivity.Writer(), repoDirPath, shellCommand, shellArgs...)
99+
err := exec.Execute(execActivity.Writer(), repoDirPath, args[0], args[1:]...)
129100

130101
if err != nil {
131102
execActivity.EndWithFailure(err)
@@ -141,4 +112,6 @@ func run(c *cobra.Command, args []string) {
141112
} else {
142113
logger.Warnf("turbolift foreach completed with %s %s(%s, %s, %s)\n", colors.Red("errors"), colors.Normal(), colors.Green(doneCount, " OK"), colors.Yellow(skippedCount, " skipped"), colors.Red(errorCount, " errored"))
143114
}
115+
116+
return nil
144117
}

0 commit comments

Comments
 (0)