1package diff 2 3import ( 4 "bufio" 5 "errors" 6 "fmt" 7 "io" 8 "net/http" 9 "strings" 10 "syscall" 11 12 "github.com/MakeNowJust/heredoc" 13 "github.com/cli/cli/v2/api" 14 "github.com/cli/cli/v2/internal/ghinstance" 15 "github.com/cli/cli/v2/internal/ghrepo" 16 "github.com/cli/cli/v2/pkg/cmd/pr/shared" 17 "github.com/cli/cli/v2/pkg/cmdutil" 18 "github.com/cli/cli/v2/pkg/iostreams" 19 "github.com/spf13/cobra" 20) 21 22type DiffOptions struct { 23 HttpClient func() (*http.Client, error) 24 IO *iostreams.IOStreams 25 26 Finder shared.PRFinder 27 28 SelectorArg string 29 UseColor bool 30 Patch bool 31} 32 33func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command { 34 opts := &DiffOptions{ 35 IO: f.IOStreams, 36 HttpClient: f.HttpClient, 37 } 38 39 var colorFlag string 40 41 cmd := &cobra.Command{ 42 Use: "diff [<number> | <url> | <branch>]", 43 Short: "View changes in a pull request", 44 Long: heredoc.Doc(` 45 View changes in a pull request. 46 47 Without an argument, the pull request that belongs to the current branch 48 is selected. 49 `), 50 Args: cobra.MaximumNArgs(1), 51 RunE: func(cmd *cobra.Command, args []string) error { 52 opts.Finder = shared.NewFinder(f) 53 54 if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { 55 return cmdutil.FlagErrorf("argument required when using the `--repo` flag") 56 } 57 58 if len(args) > 0 { 59 opts.SelectorArg = args[0] 60 } 61 62 switch colorFlag { 63 case "always": 64 opts.UseColor = true 65 case "auto": 66 opts.UseColor = opts.IO.ColorEnabled() 67 case "never": 68 opts.UseColor = false 69 default: 70 return cmdutil.FlagErrorf("the value for `--color` must be one of \"auto\", \"always\", or \"never\"") 71 } 72 73 if runF != nil { 74 return runF(opts) 75 } 76 return diffRun(opts) 77 }, 78 } 79 80 cmd.Flags().StringVar(&colorFlag, "color", "auto", "Use color in diff output: {always|never|auto}") 81 cmd.Flags().BoolVar(&opts.Patch, "patch", false, "Display diff in patch format") 82 83 return cmd 84} 85 86func diffRun(opts *DiffOptions) error { 87 findOptions := shared.FindOptions{ 88 Selector: opts.SelectorArg, 89 Fields: []string{"number"}, 90 } 91 pr, baseRepo, err := opts.Finder.Find(findOptions) 92 if err != nil { 93 return err 94 } 95 96 httpClient, err := opts.HttpClient() 97 if err != nil { 98 return err 99 } 100 101 diff, err := fetchDiff(httpClient, baseRepo, pr.Number, opts.Patch) 102 if err != nil { 103 return fmt.Errorf("could not find pull request diff: %w", err) 104 } 105 defer diff.Close() 106 107 err = opts.IO.StartPager() 108 if err != nil { 109 return err 110 } 111 defer opts.IO.StopPager() 112 113 if !opts.UseColor { 114 _, err = io.Copy(opts.IO.Out, diff) 115 if errors.Is(err, syscall.EPIPE) { 116 return nil 117 } 118 return err 119 } 120 121 return colorDiffLines(opts.IO.Out, diff) 122} 123 124func fetchDiff(httpClient *http.Client, baseRepo ghrepo.Interface, prNumber int, asPatch bool) (io.ReadCloser, error) { 125 url := fmt.Sprintf( 126 "%srepos/%s/pulls/%d", 127 ghinstance.RESTPrefix(baseRepo.RepoHost()), 128 ghrepo.FullName(baseRepo), 129 prNumber, 130 ) 131 acceptType := "application/vnd.github.v3.diff" 132 if asPatch { 133 acceptType = "application/vnd.github.v3.patch" 134 } 135 136 req, err := http.NewRequest("GET", url, nil) 137 if err != nil { 138 return nil, err 139 } 140 141 req.Header.Set("Accept", acceptType) 142 143 resp, err := httpClient.Do(req) 144 if err != nil { 145 return nil, err 146 } 147 if resp.StatusCode != 200 { 148 return nil, api.HandleHTTPError(resp) 149 } 150 151 return resp.Body, nil 152} 153 154const lineBufferSize = 4096 155 156var ( 157 colorHeader = []byte("\x1b[1;38m") 158 colorAddition = []byte("\x1b[32m") 159 colorRemoval = []byte("\x1b[31m") 160 colorReset = []byte("\x1b[m") 161) 162 163func colorDiffLines(w io.Writer, r io.Reader) error { 164 diffLines := bufio.NewReaderSize(r, lineBufferSize) 165 wasPrefix := false 166 needsReset := false 167 168 for { 169 diffLine, isPrefix, err := diffLines.ReadLine() 170 if err != nil { 171 if errors.Is(err, io.EOF) { 172 break 173 } 174 return fmt.Errorf("error reading pull request diff: %w", err) 175 } 176 177 var color []byte 178 if !wasPrefix { 179 if isHeaderLine(diffLine) { 180 color = colorHeader 181 } else if isAdditionLine(diffLine) { 182 color = colorAddition 183 } else if isRemovalLine(diffLine) { 184 color = colorRemoval 185 } 186 } 187 188 if color != nil { 189 if _, err := w.Write(color); err != nil { 190 return err 191 } 192 needsReset = true 193 } 194 195 if _, err := w.Write(diffLine); err != nil { 196 return err 197 } 198 199 if !isPrefix { 200 if needsReset { 201 if _, err := w.Write(colorReset); err != nil { 202 return err 203 } 204 needsReset = false 205 } 206 if _, err := w.Write([]byte{'\n'}); err != nil { 207 return err 208 } 209 } 210 wasPrefix = isPrefix 211 } 212 return nil 213} 214 215var diffHeaderPrefixes = []string{"+++", "---", "diff", "index"} 216 217func isHeaderLine(l []byte) bool { 218 dl := string(l) 219 for _, p := range diffHeaderPrefixes { 220 if strings.HasPrefix(dl, p) { 221 return true 222 } 223 } 224 return false 225} 226 227func isAdditionLine(l []byte) bool { 228 return len(l) > 0 && l[0] == '+' 229} 230 231func isRemovalLine(l []byte) bool { 232 return len(l) > 0 && l[0] == '-' 233} 234