Skip to content

Commit 98b306b

Browse files
authored
Allow reading repo names via stdin (#16)
Thanks for reviewing! Going to merge this in now.
1 parent bff0e23 commit 98b306b

12 files changed

+253
-81
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
git-xargs
22
test-repo/
3+
.idea
4+
*.iml

README.md

Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ You can handle these use cases and many more with a single `git-xargs` command.
3030
As an example, let's use `git-xargs` to create a new file in every repo:
3131

3232
```
33-
./git-xargs \
33+
git-xargs \
3434
--branch-name test-branch \
3535
--github-org <your-github-org> \
3636
--commit-message "Create hello-world.txt" \
@@ -129,68 +129,71 @@ COMMAND SUPPLIED
129129

130130
## Getting started
131131

132-
### 1. Export a valid Github token
133-
134-
See the guide on [Github personal access tokens](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) for information on how to generate one.
135-
136-
```
137-
export GITHUB_OAUTH_TOKEN=<your-secret-github-oauth-token>
138-
```
132+
1. **Export a valid Github token**. See the guide on [Github personal access
133+
tokens](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token)
134+
for information on how to generate one. For example, on Linux or Mac, you'd run:
139135

140-
### 2. Download the correct binary for your platform
136+
```bash
137+
export GITHUB_OAUTH_TOKEN=<your-secret-github-oauth-token>
138+
```
141139

142-
Visit [the releases page](https://github.com/gruntwork-io/git-xargs/releases) and download the correct binary depending on your system. For example, you might opt to save it to `/usr/local/bin/git-xargs`.
140+
1. **Download the correct binary for your platform**. Visit [the releases
141+
page](https://github.com/gruntwork-io/git-xargs/releases) and download the correct binary depending on your system.
142+
Save it to somewhere on your `PATH`, such as `/usr/local/bin/git-xargs`.
143143

144-
### 3. Set the correct permissions
144+
1. **Set execute permissions**. For example, on Linux or Mac, you'd run:
145145
146-
`chmod u+x /usr/local/bin/git-xargs`
146+
```bash
147+
chmod u+x /usr/local/bin/git-xargs
148+
```
147149
148-
### 4. Run the version command to ensure everything is working properly
150+
1. **Check it's working**. Run the version command to ensure everything is working properly:
149151

150-
```
151-
git-xargs --version
152-
```
152+
```bash
153+
git-xargs --version
154+
```
153155

154-
### 5. Provide a script or command and target some repos
156+
1. **Provide a script or command and target some repos**. Here's a simple example of running the `touch` command in
157+
every repo in your Github organization. Follow the same pattern to start running your own scripts and commands
158+
against your own repos!
155159
156-
Here's a simple example of running a touch commnand in every repo in your Github organization. Follow the same pattern to start running your own scripts and commands against your own repos!
160+
```bash
161+
git-xargs \
162+
--branch-name "test-branch" \
163+
--commit-message "Testing git-xargs" \
164+
--github-org <enter-your-github-org-name> \
165+
touch git-xargs-is-awesome.txt
166+
```
157167
158-
```
159-
git-xargs \
160-
--branch-name "test-branch" \
161-
--commit-message "Testing git-xargs" \
162-
--github-org <enter-your-github-org-name> \
163-
touch git-xargs-is-awesome.txt
164-
```
165168
# Reference
166169
167170
## How to supply commands or scripts to run
168171
169172
The API for `git-xargs` is:
170173
171174
```
172-
./git-xargs [-flags] <CMD>
175+
git-xargs [-flags] <CMD>
173176
```
174177
175178
Where `CMD` is either the full path to a (Bash, Python, Ruby) etc script on your local system or a binary. Note that, because the tool supports Bash scripts, Ruby scripts, Python scripts, etc, you must include the full filename for any given script, including its file extension.
176179
177180
In other words, all the following usages are valid:
178181
179182
```
180-
./git-xargs --repo --repo gruntwork-io/cloud-nuke \
183+
git-xargs --repo --repo gruntwork-io/cloud-nuke \
181184
--repo gruntwork-io/terraform-aws-eks \
182185
--branch-name my-branch \
183186
/usr/local/bin/my-bash-script.sh
184187
```
185188
186189
```
187-
./git-xargs --repos ./my-repos.txt \
190+
git-xargs --repos ./my-repos.txt \
188191
--branch-name my-other-branch \
189192
touch file1.txt file2.txt
190193
```
191194
192195
```
193-
./git-xargs --github-org my-github-org \
196+
git-xargs --github-org my-github-org \
194197
--branch-name my-new-branch \
195198
"$(pwd)/scripts/my-ruby-script.rb"
196199
```
@@ -208,7 +211,7 @@ Currently, `git-xargs` will find and add any and all new files, as well as any e
208211
Scripts may be placed anywhere on your system, but you are responsible for providing absolute paths to your scripts when invoking `git-xargs`:
209212
210213
```
211-
./git-xargs \
214+
git-xargs \
212215
--branch-name upgrade-tf-14 \
213216
--commit-message "Update modules to Terraform 0.14" \
214217
--repos data/batch3.txt \
@@ -217,7 +220,7 @@ Scripts may be placed anywhere on your system, but you are responsible for provi
217220
or
218221
219222
```
220-
./git-xargs \
223+
git-xargs \
221224
--branch-name upgrade-tf-14 \
222225
--commit-message "Update modules to Terraform 0.14" \
223226
--repos data/batch3.txt \
@@ -228,13 +231,14 @@ If you need to compose more complex behavior into a single pull request, write a
228231
229232
## How to target repos to run your scripts against
230233
231-
`git-xargs` supports **two** methods of targeting repos to run your selected scripts against. Note that you **must** select one or the other option when running `git-xargs`, meaning you must either pass `--repos` or `--github-org` as demonstrated below:
234+
`git-xargs` supports **four** methods of targeting repos to run your selected scripts against. They are processed in
235+
the order listed below, with whichever option is found first being used, and all others after it being ignored.
232236
233237
### Option #1: Github organization lookup
234238
If you want the tool to find and select every repo in your Github organization, you can pass the name of your organization via the `--github-org` flag:
235239
236240
```
237-
./git-xargs \
241+
git-xargs \
238242
--commit-message "Update copyright year" \
239243
--github-org <your-github-org> \
240244
"$(pwd)/scripts/update-copyright-year.sh"
@@ -247,7 +251,7 @@ This will signal the tool to look up, and page through, every repository in your
247251
Oftentimes, you want finer-grained control over the exact repos you are going to run your script against. In this case, you can use the `--repos` flag and supply the path to a file defining the exact repos you want the tool to run your selected scripts against, like so:
248252
249253
```
250-
./git-xargs \
254+
git-xargs \
251255
--commit-mesage "Update copyright year" \
252256
--repos data/batch2.txt \
253257
"$(pwd)/scripts/update-copyright-year.sh"
@@ -264,9 +268,34 @@ gruntwork-io/infrastructure-modules-multi-account-acme
264268
265269
Flat files contain one repo per line, each repository in the format of `<github-organization>/<repo-name>`. Commas, trailing or preceding spaces, and quotes are all filtered out at runtime. This is done in case you end up copying your repo list from a JSON list or CSV file.
266270
271+
### Option #3: Pass in repos via command line args
272+
273+
Another way to get fine-grained control is to pass in the individual repos you want to use via one or more `--repo`
274+
arguments:
275+
276+
```
277+
git-xargs \
278+
--commit-mesage "Update copyright year" \
279+
--repo gruntwork-io/terragrunt \
280+
--repo gruntwork-io/terratest \
281+
--repo gruntwork-io/cloud-nuke \
282+
"$(pwd)/scripts/update-copyright-year.sh"
283+
```
284+
285+
### Option #4: Pass in repos via stdin
286+
287+
And one more (Unix-philosophy friendly) way to get fine-grained control is to pass in the individual repos you want to
288+
use by piping them in via `stdin`, separating repo names with whitespace or newlines:
289+
290+
```
291+
echo "gruntwork-io/terragrunt gruntwork-io/terratest" | git-xargs \
292+
--commit-mesage "Update copyright year" \
293+
"$(pwd)/scripts/update-copyright-year.sh"
294+
```
295+
267296
## Notable flags
268297
269-
`git-xargs` exposes several flags that allow you to customize its behavior to better suit your needs. For the latest info on flags, you should run `./git-xargs --help`. However, a couple of the flags are worth explaining more in depth here:
298+
`git-xargs` exposes several flags that allow you to customize its behavior to better suit your needs. For the latest info on flags, you should run `git-xargs --help`. However, a couple of the flags are worth explaining more in depth here:
270299
271300
|Flag|Description|Type|Required|
272301
|---|---|---|---|

cmd/common.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ var (
4141
genericBranchFlag = cli.StringFlag{
4242
Name: BranchFlagName,
4343
Usage: "The name of the branch on which changes will be made",
44-
Required: true,
4544
}
4645
genericCommitMessageFlag = cli.StringFlag{
4746
Name: CommitMessageFlagName,

cmd/git-xargs.go

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package main
22

33
import (
4+
"bufio"
45
"github.com/gruntwork-io/go-commons/errors"
56
"github.com/gruntwork-io/go-commons/logging"
67
"github.com/urfave/cli"
8+
"io"
9+
"os"
10+
"strings"
711
)
812

913
// GitXargsConfig is the internal representation of a given git-xargs run as specified by the user
@@ -17,6 +21,7 @@ type GitXargsConfig struct {
1721
ReposFile string
1822
GithubOrg string
1923
RepoSlice []string
24+
RepoFromStdIn []string
2025
Args []string
2126
GithubClient GithubClient
2227
GitClient GitClient
@@ -35,6 +40,7 @@ func NewGitXargsConfig() *GitXargsConfig {
3540
ReposFile: "",
3641
GithubOrg: "",
3742
RepoSlice: []string{},
43+
RepoFromStdIn: []string{},
3844
Args: []string{},
3945
GithubClient: configureGithubClient(),
4046
GitClient: NewGitClient(GitProductionProvider{}),
@@ -44,7 +50,7 @@ func NewGitXargsConfig() *GitXargsConfig {
4450

4551
// parseGitXargsConfig accepts a urfave cli context and binds its values
4652
// to an internal representation of the data supplied by the user
47-
func parseGitXargsConfig(c *cli.Context) *GitXargsConfig {
53+
func parseGitXargsConfig(c *cli.Context) (*GitXargsConfig, error) {
4854
config := NewGitXargsConfig()
4955
config.DryRun = c.Bool("dry-run")
5056
config.SkipPullRequests = c.Bool("skip-pull-requests")
@@ -57,7 +63,56 @@ func parseGitXargsConfig(c *cli.Context) *GitXargsConfig {
5763
config.RepoSlice = c.StringSlice("repo")
5864
config.Args = c.Args()
5965

60-
return config
66+
shouldReadStdIn, err := dataBeingPipedToStdIn()
67+
if err != nil {
68+
return nil, err
69+
}
70+
if shouldReadStdIn {
71+
repos, err := parseSliceFromStdIn()
72+
if err != nil {
73+
return nil, err
74+
}
75+
config.RepoFromStdIn = repos
76+
}
77+
78+
return config, nil
79+
}
80+
81+
// Return true if there is data being piped to stdin and false otherwise
82+
// Based on https://stackoverflow.com/a/26567513/483528.
83+
func dataBeingPipedToStdIn() (bool, error) {
84+
stat, err := os.Stdin.Stat()
85+
if err != nil {
86+
return false, err
87+
}
88+
89+
return stat.Mode()&os.ModeCharDevice == 0, nil
90+
}
91+
92+
// Read the data being passed to stdin and parse it as a slice of strings, where we assume strings are separated by
93+
// whitespace or newlines. All extra whitespace and empty lines are ignored.
94+
func parseSliceFromStdIn() ([]string, error) {
95+
return parseSliceFromReader(os.Stdin)
96+
}
97+
98+
// Read the data from the given reader and parse it as a slice of strings, where we assume strings are separated by
99+
// whitespace or newlines. All extra whitespace and empty lines are ignored.
100+
func parseSliceFromReader(reader io.Reader) ([]string, error) {
101+
out := []string{}
102+
103+
scanner := bufio.NewScanner(reader)
104+
for scanner.Scan() {
105+
words := strings.Fields(scanner.Text())
106+
for _, word := range words {
107+
text := strings.TrimSpace(word)
108+
if text != "" {
109+
out = append(out, text)
110+
}
111+
}
112+
}
113+
114+
err := scanner.Err()
115+
return out, errors.WithStackTrace(err)
61116
}
62117

63118
// handleRepoProcessing encapsulates the main processing logic for the supplied repos and printing the run report that
@@ -101,11 +156,19 @@ func sanityCheckInputs(config *GitXargsConfig) error {
101156

102157
// runGitXargs is the urfave cli app's Action that is called when the user executes the binary
103158
func runGitXargs(c *cli.Context) error {
159+
// If someone calls us with no args at all, show the help text and exit
160+
if !c.Args().Present() {
161+
return cli.ShowAppHelp(c)
162+
}
163+
104164
logger := logging.GetLogger("git-xargs")
105165

106166
logger.Info("git-xargs running...")
107167

108-
config := parseGitXargsConfig(c)
168+
config, err := parseGitXargsConfig(c)
169+
if err != nil {
170+
return err
171+
}
109172

110173
if err := sanityCheckInputs(config); err != nil {
111174
return err

cmd/git-xargs_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package main
22

33
import (
4+
"github.com/stretchr/testify/require"
5+
"strings"
46
"testing"
57

68
"github.com/stretchr/testify/assert"
@@ -21,3 +23,34 @@ func TestHandleRepoProcessing(t *testing.T) {
2123

2224
assert.NoError(t, err)
2325
}
26+
27+
func TestParseSliceFromReader(t *testing.T) {
28+
t.Parallel()
29+
30+
testCases := []struct {
31+
name string
32+
input string
33+
expected []string
34+
}{
35+
{"empty string", "", []string{}},
36+
{"one string", "foo", []string{"foo"}},
37+
{"one string with whitespace", " foo\t\t\t", []string{"foo"}},
38+
{"multiple strings separated by whitespace", "foo bar baz\t\tblah", []string{"foo", "bar", "baz", "blah"}},
39+
{"multiple strings separated by newlines", "foo\nbar\nbaz\nblah", []string{"foo", "bar", "baz", "blah"}},
40+
{"multiple strings separated by newlines, with extra newlines", "\n\nfoo\nbar\n\nbaz\nblah\n\n\n", []string{"foo", "bar", "baz", "blah"}},
41+
}
42+
43+
for _, testCase := range testCases {
44+
// The following is necessary to make sure testCase's values don't
45+
// get updated due to concurrency within the scope of t.Run(..) below
46+
testCase := testCase
47+
48+
t.Run(testCase.name, func(t *testing.T) {
49+
t.Parallel()
50+
51+
actual, err := parseSliceFromReader(strings.NewReader(testCase.input))
52+
require.NoError(t, err)
53+
require.Equal(t, testCase.expected, actual)
54+
})
55+
}
56+
}

cmd/main_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"flag"
5+
"strings"
56
"testing"
67

78
"github.com/stretchr/testify/assert"
@@ -13,13 +14,19 @@ func TestSetupApp(t *testing.T) {
1314
assert.NotNil(t, app)
1415
}
1516

16-
func TestGitXargsRejectsEmptyArgs(t *testing.T) {
17+
func TestGitXargsShowsHelpTextForEmptyArgs(t *testing.T) {
1718
app := setupApp()
1819

20+
// Capture the app's stdout
21+
var stdout strings.Builder
22+
app.Writer = &stdout
23+
1924
emptyFlagSet := flag.NewFlagSet("git-xargs-test", flag.ContinueOnError)
2025
emptyTestContext := cli.NewContext(app, emptyFlagSet, nil)
2126

2227
err := runGitXargs(emptyTestContext)
2328

24-
assert.Error(t, err)
29+
// Make sure we see the help text
30+
assert.NoError(t, err)
31+
assert.Contains(t, stdout.String(), app.Description)
2532
}

0 commit comments

Comments
 (0)