Skip to content

Commit 4ae2743

Browse files
authored
Merge pull request cli#12655 from yuvrajangadsingh/feature/pr-diff-exclude
feat(pr diff): add --exclude flag to filter files from diff output
2 parents 6e49747 + 78891fc commit 4ae2743

File tree

2 files changed

+266
-2
lines changed

2 files changed

+266
-2
lines changed

pkg/cmd/pr/diff/diff.go

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package diff
22

33
import (
44
"bufio"
5+
"bytes"
56
"errors"
67
"fmt"
78
"io"
89
"net/http"
10+
"path"
911
"regexp"
1012
"strings"
1113
"unicode"
@@ -36,6 +38,7 @@ type DiffOptions struct {
3638
Patch bool
3739
NameOnly bool
3840
BrowserMode bool
41+
Exclude []string
3942
}
4043

4144
func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command {
@@ -57,7 +60,24 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman
5760
is selected.
5861
5962
With %[1]s--web%[1]s flag, open the pull request diff in a web browser instead.
63+
64+
Use %[1]s--exclude%[1]s to filter out files matching a glob pattern. The pattern
65+
uses forward slashes as path separators on all platforms. You can repeat
66+
the flag to exclude multiple patterns.
6067
`, "`"),
68+
Example: heredoc.Doc(`
69+
# See diff for current branch
70+
$ gh pr diff
71+
72+
# See diff for a specific PR
73+
$ gh pr diff 123
74+
75+
# Exclude files from diff output
76+
$ gh pr diff --exclude '*.yml' --exclude 'generated/*'
77+
78+
# Exclude matching files by name
79+
$ gh pr diff --name-only --exclude '*.generated.*'
80+
`),
6181
Args: cobra.MaximumNArgs(1),
6282
RunE: func(cmd *cobra.Command, args []string) error {
6383
opts.Finder = shared.NewFinder(f)
@@ -92,6 +112,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman
92112
cmd.Flags().BoolVar(&opts.Patch, "patch", false, "Display diff in patch format")
93113
cmd.Flags().BoolVar(&opts.NameOnly, "name-only", false, "Display only names of changed files")
94114
cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open the pull request diff in the browser")
115+
cmd.Flags().StringSliceVarP(&opts.Exclude, "exclude", "e", nil, "Exclude files matching glob `patterns` from the diff")
95116

96117
return cmd
97118
}
@@ -135,6 +156,13 @@ func diffRun(opts *DiffOptions) error {
135156
defer diffReadCloser.Close()
136157

137158
var diff io.Reader = diffReadCloser
159+
if len(opts.Exclude) > 0 {
160+
filtered, err := filterDiff(diff, opts.Exclude)
161+
if err != nil {
162+
return err
163+
}
164+
diff = filtered
165+
}
138166
if opts.IO.IsStdoutTTY() {
139167
diff = sanitizedReader(diff)
140168
}
@@ -292,8 +320,7 @@ func changedFilesNames(w io.Writer, r io.Reader) error {
292320
// `"`` + hello-\360\237\230\200-world"
293321
//
294322
// Where I'm using the `` to indicate a string to avoid confusion with the " character.
295-
pattern := regexp.MustCompile(`(?:^|\n)diff\s--git.*\s(["]?)b/(.*)`)
296-
matches := pattern.FindAllStringSubmatch(string(diff), -1)
323+
matches := diffHeaderRegexp.FindAllStringSubmatch(string(diff), -1)
297324

298325
for _, val := range matches {
299326
name := strings.TrimSpace(val[1] + val[2])
@@ -357,3 +384,67 @@ func (t sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err e
357384
func isPrint(r rune) bool {
358385
return r == '\n' || r == '\r' || r == '\t' || unicode.IsPrint(r)
359386
}
387+
388+
var diffHeaderRegexp = regexp.MustCompile(`(?:^|\n)diff\s--git.*\s("?)b/(.*)`)
389+
390+
// filterDiff reads a unified diff and returns a new reader with file entries
391+
// matching any of the exclude patterns removed.
392+
func filterDiff(r io.Reader, excludePatterns []string) (io.Reader, error) {
393+
data, err := io.ReadAll(r)
394+
if err != nil {
395+
return nil, err
396+
}
397+
398+
var result bytes.Buffer
399+
for _, section := range splitDiffSections(string(data)) {
400+
name := extractFileName(section)
401+
if name != "" && matchesAny(name, excludePatterns) {
402+
continue
403+
}
404+
result.WriteString(section)
405+
}
406+
return &result, nil
407+
}
408+
409+
// splitDiffSections splits a unified diff string into per-file sections.
410+
// Each section starts with "diff --git" and includes all content up to (but
411+
// not including) the next "diff --git" line.
412+
func splitDiffSections(diff string) []string {
413+
marker := "\ndiff --git "
414+
parts := strings.Split(diff, marker)
415+
if len(parts) == 1 {
416+
return []string{diff}
417+
}
418+
sections := make([]string, 0, len(parts))
419+
for i, p := range parts {
420+
if i == 0 {
421+
if len(p) > 0 {
422+
sections = append(sections, p+"\n")
423+
}
424+
} else {
425+
sections = append(sections, "diff --git "+p)
426+
}
427+
}
428+
return sections
429+
}
430+
431+
func extractFileName(section string) string {
432+
m := diffHeaderRegexp.FindStringSubmatch(section)
433+
if m == nil {
434+
return ""
435+
}
436+
return strings.TrimSpace(m[1] + m[2])
437+
}
438+
439+
func matchesAny(name string, excludePatterns []string) bool {
440+
for _, p := range excludePatterns {
441+
if matched, _ := path.Match(p, name); matched {
442+
return true
443+
}
444+
// Also match against the basename so "*.yml" matches "dir/file.yml"
445+
if matched, _ := path.Match(p, path.Base(name)); matched {
446+
return true
447+
}
448+
}
449+
return false
450+
}

pkg/cmd/pr/diff/diff_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,26 @@ func Test_NewCmdDiff(t *testing.T) {
8787
isTTY: true,
8888
wantErr: "argument required when using the `--repo` flag",
8989
},
90+
{
91+
name: "exclude single pattern",
92+
args: "--exclude '*.yml'",
93+
isTTY: true,
94+
want: DiffOptions{
95+
SelectorArg: "",
96+
UseColor: true,
97+
Exclude: []string{"*.yml"},
98+
},
99+
},
100+
{
101+
name: "exclude multiple patterns",
102+
args: "--exclude '*.yml' --exclude Makefile",
103+
isTTY: true,
104+
want: DiffOptions{
105+
SelectorArg: "",
106+
UseColor: true,
107+
Exclude: []string{"*.yml", "Makefile"},
108+
},
109+
},
90110
{
91111
name: "invalid --color argument",
92112
args: "--color doublerainbow",
@@ -142,6 +162,7 @@ func Test_NewCmdDiff(t *testing.T) {
142162
assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg)
143163
assert.Equal(t, tt.want.UseColor, opts.UseColor)
144164
assert.Equal(t, tt.want.BrowserMode, opts.BrowserMode)
165+
assert.Equal(t, tt.want.Exclude, opts.Exclude)
145166
})
146167
}
147168
}
@@ -211,6 +232,48 @@ func Test_diffRun(t *testing.T) {
211232
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
212233
},
213234
},
235+
{
236+
name: "exclude yml files",
237+
opts: DiffOptions{
238+
SelectorArg: "123",
239+
UseColor: false,
240+
Exclude: []string{"*.yml"},
241+
},
242+
wantFields: []string{"number"},
243+
wantStdout: `diff --git a/Makefile b/Makefile
244+
index f2b4805c..3d7bd0f9 100644
245+
--- a/Makefile
246+
+++ b/Makefile
247+
@@ -22,8 +22,8 @@ test:
248+
go test ./...
249+
.PHONY: test
250+
251+
-site:
252+
- git clone https://github.com/github/cli.github.com.git "$@"
253+
+site: bin/gh
254+
+ bin/gh repo clone github/cli.github.com "$@"
255+
256+
site-docs: site
257+
git -C site pull
258+
`,
259+
httpStubs: func(reg *httpmock.Registry) {
260+
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
261+
},
262+
},
263+
{
264+
name: "name only with exclude",
265+
opts: DiffOptions{
266+
SelectorArg: "123",
267+
UseColor: false,
268+
NameOnly: true,
269+
Exclude: []string{"*.yml"},
270+
},
271+
wantFields: []string{"number"},
272+
wantStdout: "Makefile\n",
273+
httpStubs: func(reg *httpmock.Registry) {
274+
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
275+
},
276+
},
214277
{
215278
name: "web mode",
216279
opts: DiffOptions{
@@ -394,6 +457,116 @@ func stubDiffRequest(reg *httpmock.Registry, accept, diff string) {
394457
})
395458
}
396459

460+
func Test_filterDiff(t *testing.T) {
461+
rawDiff := fmt.Sprintf(testDiff, "", "", "", "")
462+
463+
tests := []struct {
464+
name string
465+
patterns []string
466+
want string
467+
}{
468+
{
469+
name: "exclude yml files",
470+
patterns: []string{"*.yml"},
471+
want: `diff --git a/Makefile b/Makefile
472+
index f2b4805c..3d7bd0f9 100644
473+
--- a/Makefile
474+
+++ b/Makefile
475+
@@ -22,8 +22,8 @@ test:
476+
go test ./...
477+
.PHONY: test
478+
479+
-site:
480+
- git clone https://github.com/github/cli.github.com.git "$@"
481+
+site: bin/gh
482+
+ bin/gh repo clone github/cli.github.com "$@"
483+
484+
site-docs: site
485+
git -C site pull
486+
`,
487+
},
488+
{
489+
name: "exclude Makefile",
490+
patterns: []string{"Makefile"},
491+
want: `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
492+
index 73974448..b7fc0154 100644
493+
--- a/.github/workflows/releases.yml
494+
+++ b/.github/workflows/releases.yml
495+
@@ -44,6 +44,11 @@ jobs:
496+
token: ${{secrets.SITE_GITHUB_TOKEN}}
497+
- name: Publish documentation site
498+
if: "!contains(github.ref, '-')" # skip prereleases
499+
+ env:
500+
+ GIT_COMMITTER_NAME: cli automation
501+
+ GIT_AUTHOR_NAME: cli automation
502+
+ GIT_COMMITTER_EMAIL: noreply@github.com
503+
+ GIT_AUTHOR_EMAIL: noreply@github.com
504+
run: make site-publish
505+
- name: Move project cards
506+
if: "!contains(github.ref, '-')" # skip prereleases
507+
`,
508+
},
509+
{
510+
name: "exclude all files",
511+
patterns: []string{"*.yml", "Makefile"},
512+
want: "",
513+
},
514+
{
515+
name: "no matches",
516+
patterns: []string{"*.go"},
517+
want: rawDiff,
518+
},
519+
}
520+
for _, tt := range tests {
521+
t.Run(tt.name, func(t *testing.T) {
522+
reader, err := filterDiff(strings.NewReader(rawDiff), tt.patterns)
523+
require.NoError(t, err)
524+
got, err := io.ReadAll(reader)
525+
require.NoError(t, err)
526+
assert.Equal(t, tt.want, string(got))
527+
})
528+
}
529+
}
530+
531+
func Test_matchesAny(t *testing.T) {
532+
tests := []struct {
533+
name string
534+
filename string
535+
patterns []string
536+
want bool
537+
}{
538+
{
539+
name: "exact match",
540+
filename: "Makefile",
541+
patterns: []string{"Makefile"},
542+
want: true,
543+
},
544+
{
545+
name: "glob extension",
546+
filename: ".github/workflows/releases.yml",
547+
patterns: []string{"*.yml"},
548+
want: true,
549+
},
550+
{
551+
name: "no match",
552+
filename: "main.go",
553+
patterns: []string{"*.yml"},
554+
want: false,
555+
},
556+
{
557+
name: "directory glob",
558+
filename: ".github/workflows/releases.yml",
559+
patterns: []string{".github/*/*"},
560+
want: true,
561+
},
562+
}
563+
for _, tt := range tests {
564+
t.Run(tt.name, func(t *testing.T) {
565+
assert.Equal(t, tt.want, matchesAny(tt.filename, tt.patterns))
566+
})
567+
}
568+
}
569+
397570
func Test_sanitizedReader(t *testing.T) {
398571
input := strings.NewReader("\t hello \x1B[m world! ăѣ𝔠ծề\r\n")
399572
expected := "\t hello \\u{1b}[m world! ăѣ𝔠ծề\r\n"

0 commit comments

Comments
 (0)