fj/cmd/pr_diff.go

271 lines
6.1 KiB
Go

package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var prDiffCmd = &cobra.Command{
Use: "diff <number>",
Short: "Show the diff for a pull request",
Long: "Fetch and display the diff for a pull request.",
Example: ` # View the diff for PR #123
fgj pr diff 123
# Colorized diff output
fgj pr diff 123 --color always
# Show only changed file names
fgj pr diff 123 --name-only
# Show diffstat summary
fgj pr diff 123 --stat`,
Args: cobra.ExactArgs(1),
RunE: runPRDiff,
}
func init() {
prCmd.AddCommand(prDiffCmd)
prDiffCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prDiffCmd.Flags().String("color", "auto", "Colorize the diff output: auto, always, never")
prDiffCmd.Flags().Bool("name-only", false, "Show only changed file names")
prDiffCmd.Flags().Bool("stat", false, "Show diffstat summary")
}
func runPRDiff(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
colorMode, _ := cmd.Flags().GetString("color")
nameOnly, _ := cmd.Flags().GetBool("name-only")
stat, _ := cmd.Flags().GetBool("stat")
prNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
diffURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d.diff",
client.Hostname(), owner, name, prNumber)
diff, err := client.GetRawLog(diffURL)
if err != nil {
return fmt.Errorf("failed to get pull request diff: %w", err)
}
if nameOnly {
return printNameOnly(diff)
}
if stat {
return printDiffStat(diff)
}
useColor := shouldColorize(colorMode)
if useColor {
return printColorizedDiff(diff)
}
fmt.Print(diff)
return nil
}
// shouldColorize determines whether to colorize output based on the color flag.
func shouldColorize(mode string) bool {
switch strings.ToLower(mode) {
case "always":
return true
case "never":
return false
default: // "auto"
return term.IsTerminal(int(os.Stdout.Fd()))
}
}
// printNameOnly extracts and prints changed file names from the diff.
func printNameOnly(diff string) error {
seen := make(map[string]bool)
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "+++ b/") {
name := strings.TrimPrefix(line, "+++ b/")
if name != "" && !seen[name] {
seen[name] = true
fmt.Println(name)
}
}
}
return nil
}
// fileStat holds per-file diff statistics.
type fileStat struct {
name string
additions int
deletions int
}
// printDiffStat parses the diff and prints a diffstat summary.
func printDiffStat(diff string) error {
var stats []fileStat
var current *fileStat
inHeader := true
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "diff --git ") {
if current != nil {
stats = append(stats, *current)
}
current = &fileStat{}
inHeader = true
continue
}
if current == nil {
continue
}
if strings.HasPrefix(line, "+++ b/") {
current.name = strings.TrimPrefix(line, "+++ b/")
continue
}
if strings.HasPrefix(line, "@@") {
inHeader = false
continue
}
if inHeader {
continue
}
if strings.HasPrefix(line, "+") {
current.additions++
} else if strings.HasPrefix(line, "-") {
current.deletions++
}
}
if current != nil && current.name != "" {
stats = append(stats, *current)
}
if len(stats) == 0 {
fmt.Println("0 files changed")
return nil
}
// Find the longest file name for alignment
maxNameLen := 0
maxChanges := 0
for _, s := range stats {
if len(s.name) > maxNameLen {
maxNameLen = len(s.name)
}
total := s.additions + s.deletions
if total > maxChanges {
maxChanges = total
}
}
// Cap the bar width
barWidth := maxChanges
if barWidth > 50 {
barWidth = 50
}
totalAdditions := 0
totalDeletions := 0
for _, s := range stats {
total := s.additions + s.deletions
totalAdditions += s.additions
totalDeletions += s.deletions
var bar string
if maxChanges > 0 {
scaledAdd := s.additions
scaledDel := s.deletions
if maxChanges > 50 {
scaledAdd = s.additions * 50 / maxChanges
scaledDel = s.deletions * 50 / maxChanges
if s.additions > 0 && scaledAdd == 0 {
scaledAdd = 1
}
if s.deletions > 0 && scaledDel == 0 {
scaledDel = 1
}
}
bar = strings.Repeat("+", scaledAdd) + strings.Repeat("-", scaledDel)
}
fmt.Printf(" %-*s | %4d %s\n", maxNameLen, s.name, total, bar)
}
fmt.Printf(" %d file", len(stats))
if len(stats) != 1 {
fmt.Print("s")
}
fmt.Printf(" changed, %d insertion", totalAdditions)
if totalAdditions != 1 {
fmt.Print("s")
}
fmt.Printf("(+), %d deletion", totalDeletions)
if totalDeletions != 1 {
fmt.Print("s")
}
fmt.Println("(-)")
return nil
}
// ANSI color codes for diff output.
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorCyan = "\033[36m"
colorBold = "\033[1m"
)
// printColorizedDiff prints the diff with ANSI color codes.
func printColorizedDiff(diff string) error {
for _, line := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(line, "diff --git "):
fmt.Println(colorBold + line + colorReset)
case strings.HasPrefix(line, "index "),
strings.HasPrefix(line, "--- "),
strings.HasPrefix(line, "+++ "),
strings.HasPrefix(line, "new file"),
strings.HasPrefix(line, "deleted file"),
strings.HasPrefix(line, "similarity index"),
strings.HasPrefix(line, "rename from"),
strings.HasPrefix(line, "rename to"):
fmt.Println(colorBold + line + colorReset)
case strings.HasPrefix(line, "@@"):
fmt.Println(colorCyan + line + colorReset)
case strings.HasPrefix(line, "+"):
fmt.Println(colorGreen + line + colorReset)
case strings.HasPrefix(line, "-"):
fmt.Println(colorRed + line + colorReset)
default:
fmt.Println(line)
}
}
return nil
}