Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,15 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: '1.20'
go-version: '1.26.6'
Comment on lines 19 to +22

- name: Build
run: go build -v ./...

- name: Test
run: go test -v ./...

4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
module github.com/imrajdas/diffr

go 1.20
go 1.26.6

require (
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
github.com/pmezard/go-difflib v1.0.0
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/spf13/cobra v1.7.0
)

Expand Down
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
17 changes: 14 additions & 3 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,27 @@ var rootCmd = &cobra.Command{
Short: "A web-based content difference analyzer",
Long: `A web-based tool to compare content differences between two directories/files` + "\n" + `Find more information at: https://github.com/imrajdas/diffr`,
Args: cobra.ExactArgs(2),
Run: diffr.RunWebServer,
Run: diffr.Run,
}

func Execute() {
cobra.CheckErr(rootCmd.Execute())
}

func init() {
rootCmd.Flags().IntVarP(&diffr.Port, "port", "p", 8675, "Set the port for the web server to listen on, default is 8080")
rootCmd.Flags().StringVarP(&diffr.Address, "address", "a", "http://localhost", "Set the address for the web server to listen on, default is http://localhost")
rootCmd.Flags().IntVarP(&diffr.Port, "port", "p", 8675, "Set the port for the web server to listen on")
rootCmd.Flags().StringVarP(&diffr.Address, "address", "a", "http://localhost", "Bind address for the web server (host or URL)")
rootCmd.Flags().IntVarP(&diffr.Context, "context", "C", 3, "Number of unified-diff context lines")
rootCmd.Flags().BoolVarP(&diffr.IgnoreWhitespace, "ignore-whitespace", "w", false, "Do not report files that differ only in whitespace")
rootCmd.Flags().BoolVarP(&diffr.IgnoreCase, "ignore-case", "i", false, "Do not report files that differ only in case")
rootCmd.Flags().BoolVar(&diffr.Stdout, "stdout", false, "Print diffs to stdout instead of starting the web UI")
rootCmd.Flags().BoolVar(&diffr.JSON, "json", false, "Print a JSON report to stdout instead of starting the web UI")
rootCmd.Flags().BoolVar(&diffr.NoOpen, "no-open", false, "Do not open a browser when starting the web UI")
rootCmd.Flags().StringVar(&diffr.PatchFile, "patch", "", "Write a unified patch to the given file instead of starting the web UI (use - for stdout)")
rootCmd.Flags().StringArrayVarP(&diffr.Exclude, "exclude", "e", nil, "Gitignore-style glob to exclude (repeatable)")
rootCmd.Flags().StringVar(&diffr.IgnoreFile, "ignore-file", "", "Path to a .diffrignore file (gitignore syntax)")
rootCmd.Flags().BoolVar(&diffr.NoDefaultExclude, "no-default-exclude", false, "Do not apply built-in excludes (.git, node_modules, ...)")
rootCmd.Flags().BoolVar(&diffr.NoGitignore, "no-gitignore", false, "Do not apply .gitignore from git repositories")

rootCmd.CompletionOptions.DisableDefaultCmd = true
rootCmd.AddCommand(version.VersionCmd)
Expand Down
56 changes: 56 additions & 0 deletions pkg/diffr/addr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package diffr

import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
)

func listenAndDisplay(address string, port int) (listenAddr, displayURL string, err error) {
scheme := "http"
host := "localhost"
raw := strings.TrimSpace(address)
if raw == "" {
raw = "http://localhost"
}

switch {
case strings.Contains(raw, "://"):
u, perr := url.Parse(raw)
if perr != nil {
return "", "", fmt.Errorf("address: %w", perr)
}
if u.Scheme != "" {
scheme = u.Scheme
}
if h := u.Hostname(); h != "" {
host = h
} else if u.Host != "" {
host = u.Host
}
Comment on lines +25 to +32
case strings.Contains(raw, "/") && !strings.Contains(raw, ":"):
return "", "", fmt.Errorf("address: invalid host %q", raw)
default:
if h, p, splitErr := net.SplitHostPort(raw); splitErr == nil {
host = h
if p != "" {
n, convErr := strconv.Atoi(p)
if convErr != nil {
return "", "", fmt.Errorf("address port: %w", convErr)
}
port = n
}
} else {
host = raw
}
}

if host == "" {
host = "localhost"
}
listenAddr = net.JoinHostPort(host, strconv.Itoa(port))
displayURL = scheme + "://" + listenAddr
return listenAddr, displayURL, nil
}
27 changes: 27 additions & 0 deletions pkg/diffr/addr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package diffr

import "testing"

func TestListenAndDisplay(t *testing.T) {
tests := []struct {
addr string
port int
wantListen string
wantDisplay string
}{
{"http://localhost", 8675, "localhost:8675", "http://localhost:8675"},
{"http://127.0.0.1", 9000, "127.0.0.1:9000", "http://127.0.0.1:9000"},
{"127.0.0.1", 8675, "127.0.0.1:8675", "http://127.0.0.1:8675"},
{"0.0.0.0", 8675, "0.0.0.0:8675", "http://0.0.0.0:8675"},
{"localhost:9000", 8675, "localhost:9000", "http://localhost:9000"},
}
for _, tt := range tests {
listen, display, err := listenAndDisplay(tt.addr, tt.port)
if err != nil {
t.Fatalf("%q: %v", tt.addr, err)
}
if listen != tt.wantListen || display != tt.wantDisplay {
t.Fatalf("%q: listen=%q display=%q, want %q %q", tt.addr, listen, display, tt.wantListen, tt.wantDisplay)
}
}
}
83 changes: 83 additions & 0 deletions pkg/diffr/binary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package diffr

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
)

func compareBinaries(fd FileDiff) (FileDiff, bool, error) {
fd.Kind = KindBinary

var leftSize, rightSize int64
if fd.LeftPath != "" {
sum, n, err := hashFile(fd.LeftPath)
if err != nil {
return fd, false, err
}
fd.LeftHash = sum
leftSize = n
}
if fd.RightPath != "" {
sum, n, err := hashFile(fd.RightPath)
if err != nil {
return fd, false, err
}
fd.RightHash = sum
rightSize = n
}

if fd.LeftPath != "" && fd.RightPath != "" && fd.LeftHash == fd.RightHash {
return fd, true, nil
}

switch fd.Status {
case StatusAdded:
fd.Summary = fmt.Sprintf("binary file added (%s)", formatSize(rightSize))
case StatusRemoved:
fd.Summary = fmt.Sprintf("binary file removed (%s)", formatSize(leftSize))
default:
fd.Summary = fmt.Sprintf("binary files differ (%s vs %s)", formatSize(leftSize), formatSize(rightSize))
}
return fd, false, nil
}

func hashFile(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()

h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}

func sameHash(a, b string) (bool, error) {
ha, _, err := hashFile(a)
if err != nil {
return false, err
}
hb, _, err := hashFile(b)
if err != nil {
return false, err
}
return ha == hb, nil
}

func formatSize(n int64) string {
switch {
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.1f KB", float64(n)/1024)
default:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
}
101 changes: 101 additions & 0 deletions pkg/diffr/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package diffr

import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)

type jsonResult struct {
Left string `json:"left"`
Right string `json:"right"`
Stats jsonStats `json:"stats"`
Files []jsonFile `json:"files"`
}

type jsonStats struct {
Changed int `json:"changed"`
Added int `json:"added"`
Removed int `json:"removed"`
Identical int `json:"identical"`
Elapsed string `json:"elapsed"`
}

type jsonFile struct {
Path string `json:"path"`
Status string `json:"status"`
Kind string `json:"kind"`
Summary string `json:"summary,omitempty"`
LeftHash string `json:"left_hash,omitempty"`
RightHash string `json:"right_hash,omitempty"`
}

func (r *Result) JSON() jsonResult {
out := jsonResult{
Left: r.LeftAbs,
Right: r.RightAbs,
Stats: jsonStats{
Changed: r.Stats.Changed,
Added: r.Stats.Added,
Removed: r.Stats.Removed,
Identical: r.Stats.Identical,
Elapsed: r.Stats.Elapsed,
},
Files: make([]jsonFile, 0, len(r.Files)),
}
for _, f := range r.Files {
out.Files = append(out.Files, jsonFile{
Path: f.RelPath,
Status: f.Status.String(),
Kind: f.Kind.String(),
Summary: f.Summary,
LeftHash: f.LeftHash,
RightHash: f.RightHash,
})
}
return out
}

func WriteCLI(res *Result, stdout, jsonOut bool, patchFile string) error {
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(res.JSON()); err != nil {
return err
}
} else if stdout {
patch := res.Patch()
if stdoutIsTTY() {
patch = colorizePatch(patch)
}
if _, err := os.Stdout.WriteString(patch); err != nil {
return err
}
if patch != "" && !strings.HasSuffix(patch, "\n") {
if _, err := os.Stdout.WriteString("\n"); err != nil {
return err
}
}
}

if patchFile != "" {
patch := res.Patch()
if patchFile == "-" {
if !stdout && !jsonOut {
if _, err := os.Stdout.WriteString(patch); err != nil {
return err
}
}
} else if err := os.WriteFile(patchFile, []byte(patch), 0o644); err != nil {
return err
} else {
fmt.Fprintf(os.Stderr, "wrote patch to %s\n", patchFile)
}
}

fmt.Fprintf(os.Stderr, "diffr: %d changed, %d added, %d removed (%d identical) in %s\n",
res.Stats.Changed, res.Stats.Added, res.Stats.Removed, res.Stats.Identical, res.Stats.Duration.Round(time.Millisecond))
return nil
}
Loading
Loading