Add a new --exclude (-e) flag to gh pr diff that allows users to exclude files matching glob patterns from the diff output. This is useful for filtering out auto-generated files, vendor directories, or other noise when reviewing pull requests. Supports standard glob patterns and can be specified multiple times. Patterns match against both the full path and basename. Closes #8739
578 lines
14 KiB
Go
578 lines
14 KiB
Go
package diff
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"testing/iotest"
|
|
|
|
"github.com/cli/cli/v2/api"
|
|
"github.com/cli/cli/v2/internal/browser"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/httpmock"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
"github.com/google/shlex"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func Test_NewCmdDiff(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args string
|
|
isTTY bool
|
|
want DiffOptions
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "name only",
|
|
args: "--name-only",
|
|
want: DiffOptions{
|
|
NameOnly: true,
|
|
},
|
|
},
|
|
{
|
|
name: "number argument",
|
|
args: "123",
|
|
isTTY: true,
|
|
want: DiffOptions{
|
|
SelectorArg: "123",
|
|
UseColor: true,
|
|
},
|
|
},
|
|
{
|
|
name: "no argument",
|
|
args: "",
|
|
isTTY: true,
|
|
want: DiffOptions{
|
|
SelectorArg: "",
|
|
UseColor: true,
|
|
},
|
|
},
|
|
{
|
|
name: "no color when redirected",
|
|
args: "",
|
|
isTTY: false,
|
|
want: DiffOptions{
|
|
SelectorArg: "",
|
|
UseColor: false,
|
|
},
|
|
},
|
|
{
|
|
name: "force color",
|
|
args: "--color always",
|
|
isTTY: false,
|
|
want: DiffOptions{
|
|
SelectorArg: "",
|
|
UseColor: true,
|
|
},
|
|
},
|
|
{
|
|
name: "disable color",
|
|
args: "--color never",
|
|
isTTY: true,
|
|
want: DiffOptions{
|
|
SelectorArg: "",
|
|
UseColor: false,
|
|
},
|
|
},
|
|
{
|
|
name: "no argument with --repo override",
|
|
args: "-R owner/repo",
|
|
isTTY: true,
|
|
wantErr: "argument required when using the `--repo` flag",
|
|
},
|
|
{
|
|
name: "exclude single pattern",
|
|
args: "--exclude '*.yml'",
|
|
isTTY: true,
|
|
want: DiffOptions{
|
|
SelectorArg: "",
|
|
UseColor: true,
|
|
Exclude: []string{"*.yml"},
|
|
},
|
|
},
|
|
{
|
|
name: "exclude multiple patterns",
|
|
args: "--exclude '*.yml' --exclude Makefile",
|
|
isTTY: true,
|
|
want: DiffOptions{
|
|
SelectorArg: "",
|
|
UseColor: true,
|
|
Exclude: []string{"*.yml", "Makefile"},
|
|
},
|
|
},
|
|
{
|
|
name: "invalid --color argument",
|
|
args: "--color doublerainbow",
|
|
isTTY: true,
|
|
wantErr: "invalid argument \"doublerainbow\" for \"--color\" flag: valid values are {always|never|auto}",
|
|
},
|
|
{
|
|
name: "web mode",
|
|
args: "123 --web",
|
|
isTTY: true,
|
|
want: DiffOptions{
|
|
SelectorArg: "123",
|
|
UseColor: true,
|
|
BrowserMode: true,
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(tt.isTTY)
|
|
ios.SetStdinTTY(tt.isTTY)
|
|
ios.SetStderrTTY(tt.isTTY)
|
|
ios.SetColorEnabled(tt.isTTY)
|
|
|
|
f := &cmdutil.Factory{
|
|
IOStreams: ios,
|
|
}
|
|
|
|
var opts *DiffOptions
|
|
cmd := NewCmdDiff(f, func(o *DiffOptions) error {
|
|
opts = o
|
|
return nil
|
|
})
|
|
cmd.PersistentFlags().StringP("repo", "R", "", "")
|
|
|
|
argv, err := shlex.Split(tt.args)
|
|
require.NoError(t, err)
|
|
cmd.SetArgs(argv)
|
|
|
|
cmd.SetIn(&bytes.Buffer{})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
|
|
_, err = cmd.ExecuteC()
|
|
if tt.wantErr != "" {
|
|
require.EqualError(t, err, tt.wantErr)
|
|
return
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg)
|
|
assert.Equal(t, tt.want.UseColor, opts.UseColor)
|
|
assert.Equal(t, tt.want.BrowserMode, opts.BrowserMode)
|
|
assert.Equal(t, tt.want.Exclude, opts.Exclude)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_diffRun(t *testing.T) {
|
|
pr := &api.PullRequest{Number: 123, URL: "https://github.com/OWNER/REPO/pull/123"}
|
|
|
|
tests := []struct {
|
|
name string
|
|
opts DiffOptions
|
|
wantFields []string
|
|
wantStdout string
|
|
wantStderr string
|
|
wantBrowsedURL string
|
|
httpStubs func(*httpmock.Registry)
|
|
}{
|
|
{
|
|
name: "no color",
|
|
opts: DiffOptions{
|
|
SelectorArg: "123",
|
|
UseColor: false,
|
|
Patch: false,
|
|
},
|
|
wantFields: []string{"number"},
|
|
wantStdout: fmt.Sprintf(testDiff, "", "", "", ""),
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
|
|
},
|
|
},
|
|
{
|
|
name: "with color",
|
|
opts: DiffOptions{
|
|
SelectorArg: "123",
|
|
UseColor: true,
|
|
Patch: false,
|
|
},
|
|
wantFields: []string{"number"},
|
|
wantStdout: fmt.Sprintf(testDiff, "\x1b[m", "\x1b[1;38m", "\x1b[32m", "\x1b[31m"),
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
|
|
},
|
|
},
|
|
{
|
|
name: "patch format",
|
|
opts: DiffOptions{
|
|
SelectorArg: "123",
|
|
UseColor: false,
|
|
Patch: true,
|
|
},
|
|
wantFields: []string{"number"},
|
|
wantStdout: fmt.Sprintf(testDiff, "", "", "", ""),
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
stubDiffRequest(reg, "application/vnd.github.v3.patch", fmt.Sprintf(testDiff, "", "", "", ""))
|
|
},
|
|
},
|
|
{
|
|
name: "name only",
|
|
opts: DiffOptions{
|
|
SelectorArg: "123",
|
|
UseColor: false,
|
|
Patch: false,
|
|
NameOnly: true,
|
|
},
|
|
wantFields: []string{"number"},
|
|
wantStdout: ".github/workflows/releases.yml\nMakefile\n",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
|
|
},
|
|
},
|
|
{
|
|
name: "exclude yml files",
|
|
opts: DiffOptions{
|
|
SelectorArg: "123",
|
|
UseColor: false,
|
|
Exclude: []string{"*.yml"},
|
|
},
|
|
wantFields: []string{"number"},
|
|
wantStdout: `diff --git a/Makefile b/Makefile
|
|
index f2b4805c..3d7bd0f9 100644
|
|
--- a/Makefile
|
|
+++ b/Makefile
|
|
@@ -22,8 +22,8 @@ test:
|
|
go test ./...
|
|
.PHONY: test
|
|
|
|
-site:
|
|
- git clone https://github.com/github/cli.github.com.git "$@"
|
|
+site: bin/gh
|
|
+ bin/gh repo clone github/cli.github.com "$@"
|
|
|
|
site-docs: site
|
|
git -C site pull
|
|
`,
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
|
|
},
|
|
},
|
|
{
|
|
name: "name only with exclude",
|
|
opts: DiffOptions{
|
|
SelectorArg: "123",
|
|
UseColor: false,
|
|
NameOnly: true,
|
|
Exclude: []string{"*.yml"},
|
|
},
|
|
wantFields: []string{"number"},
|
|
wantStdout: "Makefile\n",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
|
|
},
|
|
},
|
|
{
|
|
name: "web mode",
|
|
opts: DiffOptions{
|
|
SelectorArg: "123",
|
|
BrowserMode: true,
|
|
},
|
|
wantFields: []string{"url"},
|
|
wantStderr: "Opening https://github.com/OWNER/REPO/pull/123/files in your browser.\n",
|
|
wantBrowsedURL: "https://github.com/OWNER/REPO/pull/123/files",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
httpReg := &httpmock.Registry{}
|
|
defer httpReg.Verify(t)
|
|
if tt.httpStubs != nil {
|
|
tt.httpStubs(httpReg)
|
|
}
|
|
tt.opts.HttpClient = func() (*http.Client, error) {
|
|
return &http.Client{Transport: httpReg}, nil
|
|
}
|
|
|
|
browser := &browser.Stub{}
|
|
tt.opts.Browser = browser
|
|
|
|
ios, _, stdout, stderr := iostreams.Test()
|
|
ios.SetStdoutTTY(true)
|
|
tt.opts.IO = ios
|
|
|
|
finder := shared.NewMockFinder("123", pr, ghrepo.New("OWNER", "REPO"))
|
|
finder.ExpectFields(tt.wantFields)
|
|
tt.opts.Finder = finder
|
|
|
|
err := diffRun(&tt.opts)
|
|
assert.NoError(t, err)
|
|
|
|
assert.Equal(t, tt.wantStdout, stdout.String())
|
|
assert.Equal(t, tt.wantStderr, stderr.String())
|
|
assert.Equal(t, tt.wantBrowsedURL, browser.BrowsedURL())
|
|
})
|
|
}
|
|
}
|
|
|
|
const testDiff = `%[2]sdiff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml%[1]s
|
|
%[2]sindex 73974448..b7fc0154 100644%[1]s
|
|
%[2]s--- a/.github/workflows/releases.yml%[1]s
|
|
%[2]s+++ b/.github/workflows/releases.yml%[1]s
|
|
@@ -44,6 +44,11 @@ jobs:
|
|
token: ${{secrets.SITE_GITHUB_TOKEN}}
|
|
- name: Publish documentation site
|
|
if: "!contains(github.ref, '-')" # skip prereleases
|
|
%[3]s+ env:%[1]s
|
|
%[3]s+ GIT_COMMITTER_NAME: cli automation%[1]s
|
|
%[3]s+ GIT_AUTHOR_NAME: cli automation%[1]s
|
|
%[3]s+ GIT_COMMITTER_EMAIL: noreply@github.com%[1]s
|
|
%[3]s+ GIT_AUTHOR_EMAIL: noreply@github.com%[1]s
|
|
run: make site-publish
|
|
- name: Move project cards
|
|
if: "!contains(github.ref, '-')" # skip prereleases
|
|
%[2]sdiff --git a/Makefile b/Makefile%[1]s
|
|
%[2]sindex f2b4805c..3d7bd0f9 100644%[1]s
|
|
%[2]s--- a/Makefile%[1]s
|
|
%[2]s+++ b/Makefile%[1]s
|
|
@@ -22,8 +22,8 @@ test:
|
|
go test ./...
|
|
.PHONY: test
|
|
|
|
%[4]s-site:%[1]s
|
|
%[4]s- git clone https://github.com/github/cli.github.com.git "$@"%[1]s
|
|
%[3]s+site: bin/gh%[1]s
|
|
%[3]s+ bin/gh repo clone github/cli.github.com "$@"%[1]s
|
|
|
|
site-docs: site
|
|
git -C site pull
|
|
`
|
|
|
|
func Test_colorDiffLines(t *testing.T) {
|
|
inputs := []struct {
|
|
input, output string
|
|
}{
|
|
{
|
|
input: "",
|
|
output: "",
|
|
},
|
|
{
|
|
input: "\n",
|
|
output: "\n",
|
|
},
|
|
{
|
|
input: "foo\nbar\nbaz\n",
|
|
output: "foo\nbar\nbaz\n",
|
|
},
|
|
{
|
|
input: "foo\nbar\nbaz",
|
|
output: "foo\nbar\nbaz\n",
|
|
},
|
|
{
|
|
input: fmt.Sprintf("+foo\n-b%sr\n+++ baz\n", strings.Repeat("a", 2*lineBufferSize)),
|
|
output: fmt.Sprintf(
|
|
"%[4]s+foo%[2]s\n%[5]s-b%[1]sr%[2]s\n%[3]s+++ baz%[2]s\n",
|
|
strings.Repeat("a", 2*lineBufferSize),
|
|
"\x1b[m",
|
|
"\x1b[1;38m",
|
|
"\x1b[32m",
|
|
"\x1b[31m",
|
|
),
|
|
},
|
|
}
|
|
for _, tt := range inputs {
|
|
buf := bytes.Buffer{}
|
|
if err := colorDiffLines(&buf, strings.NewReader(tt.input)); err != nil {
|
|
t.Fatalf("unexpected error: %s", err)
|
|
}
|
|
if got := buf.String(); got != tt.output {
|
|
t.Errorf("expected: %q, got: %q", tt.output, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func Test_changedFileNames(t *testing.T) {
|
|
inputs := []struct {
|
|
input, output string
|
|
}{
|
|
{
|
|
input: "",
|
|
output: "",
|
|
},
|
|
{
|
|
input: "\n",
|
|
output: "",
|
|
},
|
|
{
|
|
input: "diff --git a/cmd.go b/cmd.go\n--- /dev/null\n+++ b/cmd.go\n@@ -0,0 +1,313 @@",
|
|
output: "cmd.go\n",
|
|
},
|
|
{
|
|
input: "diff --git a/cmd.go b/cmd.go\n--- a/cmd.go\n+++ /dev/null\n@@ -0,0 +1,313 @@",
|
|
output: "cmd.go\n",
|
|
},
|
|
{
|
|
input: fmt.Sprintf("diff --git a/baz.go b/rename.go\n--- a/baz.go\n+++ b/rename.go\n+foo\n-b%sr", strings.Repeat("a", 2*lineBufferSize)),
|
|
output: "rename.go\n",
|
|
},
|
|
{
|
|
input: fmt.Sprintf("diff --git a/baz.go b/baz.go\n--- a/baz.go\n+++ b/baz.go\n+foo\n-b%sr", strings.Repeat("a", 2*lineBufferSize)),
|
|
output: "baz.go\n",
|
|
},
|
|
{
|
|
input: "diff --git \"a/\343\202\212\343\203\274\343\201\251\343\201\277\343\203\274.md\" \"b/\343\202\212\343\203\274\343\201\251\343\201\277\343\203\274.md\"",
|
|
output: "\"\343\202\212\343\203\274\343\201\251\343\201\277\343\203\274.md\"\n",
|
|
},
|
|
}
|
|
for _, tt := range inputs {
|
|
buf := bytes.Buffer{}
|
|
if err := changedFilesNames(&buf, strings.NewReader(tt.input)); err != nil {
|
|
t.Fatalf("unexpected error: %s", err)
|
|
}
|
|
if got := buf.String(); got != tt.output {
|
|
t.Errorf("expected: %q, got: %q", tt.output, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func stubDiffRequest(reg *httpmock.Registry, accept, diff string) {
|
|
reg.Register(
|
|
func(req *http.Request) bool {
|
|
if !strings.EqualFold(req.Method, "GET") {
|
|
return false
|
|
}
|
|
if req.URL.EscapedPath() != "/repos/OWNER/REPO/pulls/123" {
|
|
return false
|
|
}
|
|
return req.Header.Get("Accept") == accept
|
|
},
|
|
func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Request: req,
|
|
Body: io.NopCloser(strings.NewReader(diff)),
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
func Test_filterDiff(t *testing.T) {
|
|
rawDiff := fmt.Sprintf(testDiff, "", "", "", "")
|
|
|
|
tests := []struct {
|
|
name string
|
|
patterns []string
|
|
want string
|
|
}{
|
|
{
|
|
name: "exclude yml files",
|
|
patterns: []string{"*.yml"},
|
|
want: `diff --git a/Makefile b/Makefile
|
|
index f2b4805c..3d7bd0f9 100644
|
|
--- a/Makefile
|
|
+++ b/Makefile
|
|
@@ -22,8 +22,8 @@ test:
|
|
go test ./...
|
|
.PHONY: test
|
|
|
|
-site:
|
|
- git clone https://github.com/github/cli.github.com.git "$@"
|
|
+site: bin/gh
|
|
+ bin/gh repo clone github/cli.github.com "$@"
|
|
|
|
site-docs: site
|
|
git -C site pull
|
|
`,
|
|
},
|
|
{
|
|
name: "exclude Makefile",
|
|
patterns: []string{"Makefile"},
|
|
want: `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
|
|
index 73974448..b7fc0154 100644
|
|
--- a/.github/workflows/releases.yml
|
|
+++ b/.github/workflows/releases.yml
|
|
@@ -44,6 +44,11 @@ jobs:
|
|
token: ${{secrets.SITE_GITHUB_TOKEN}}
|
|
- name: Publish documentation site
|
|
if: "!contains(github.ref, '-')" # skip prereleases
|
|
+ env:
|
|
+ GIT_COMMITTER_NAME: cli automation
|
|
+ GIT_AUTHOR_NAME: cli automation
|
|
+ GIT_COMMITTER_EMAIL: noreply@github.com
|
|
+ GIT_AUTHOR_EMAIL: noreply@github.com
|
|
run: make site-publish
|
|
- name: Move project cards
|
|
if: "!contains(github.ref, '-')" # skip prereleases
|
|
`,
|
|
},
|
|
{
|
|
name: "exclude all files",
|
|
patterns: []string{"*.yml", "Makefile"},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "no matches",
|
|
patterns: []string{"*.go"},
|
|
want: rawDiff,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reader, err := filterDiff(strings.NewReader(rawDiff), tt.patterns)
|
|
require.NoError(t, err)
|
|
got, err := io.ReadAll(reader)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, string(got))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_matchesAny(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filename string
|
|
patterns []string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "exact match",
|
|
filename: "Makefile",
|
|
patterns: []string{"Makefile"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "glob extension",
|
|
filename: ".github/workflows/releases.yml",
|
|
patterns: []string{"*.yml"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "no match",
|
|
filename: "main.go",
|
|
patterns: []string{"*.yml"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "directory glob",
|
|
filename: ".github/workflows/releases.yml",
|
|
patterns: []string{".github/*/*"},
|
|
want: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, matchesAny(tt.filename, tt.patterns))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_sanitizedReader(t *testing.T) {
|
|
input := strings.NewReader("\t hello \x1B[m world! ăѣ𝔠ծề\r\n")
|
|
expected := "\t hello \\u{1b}[m world! ăѣ𝔠ծề\r\n"
|
|
|
|
err := iotest.TestReader(sanitizedReader(input), []byte(expected))
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|