diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index 0225926..56af18a 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -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' - name: Build run: go build -v ./... - name: Test run: go test -v ./... - diff --git a/go.mod b/go.mod index 1ed6056..c9cb424 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b015290..cfd89a7 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 2de371a..686e09c 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -12,7 +12,7 @@ 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() { @@ -20,8 +20,19 @@ func 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) diff --git a/pkg/diffr/addr.go b/pkg/diffr/addr.go new file mode 100644 index 0000000..f4ae3cb --- /dev/null +++ b/pkg/diffr/addr.go @@ -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 + } + 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 +} diff --git a/pkg/diffr/addr_test.go b/pkg/diffr/addr_test.go new file mode 100644 index 0000000..51f5bd6 --- /dev/null +++ b/pkg/diffr/addr_test.go @@ -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) + } + } +} diff --git a/pkg/diffr/binary.go b/pkg/diffr/binary.go new file mode 100644 index 0000000..f5eb389 --- /dev/null +++ b/pkg/diffr/binary.go @@ -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)) + } +} diff --git a/pkg/diffr/cli.go b/pkg/diffr/cli.go new file mode 100644 index 0000000..19485ad --- /dev/null +++ b/pkg/diffr/cli.go @@ -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 +} diff --git a/pkg/diffr/color.go b/pkg/diffr/color.go new file mode 100644 index 0000000..af9bbb0 --- /dev/null +++ b/pkg/diffr/color.go @@ -0,0 +1,61 @@ +package diffr + +import ( + "os" + "strings" +) + +const ( + ansiReset = "\x1b[0m" + ansiRed = "\x1b[31m" + ansiGreen = "\x1b[32m" + ansiCyan = "\x1b[36m" + ansiBold = "\x1b[1m" + ansiDim = "\x1b[2m" +) + +func stdoutIsTTY() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +func colorizePatch(s string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + if line == "" { + continue + } + lines[i] = colorizeLine(line) + } + return strings.Join(lines, "\n") +} + +func colorizeLine(line string) string { + switch { + case strings.HasPrefix(line, "+++"): + return ansiGreen + line + ansiReset + case strings.HasPrefix(line, "---"): + return ansiRed + line + ansiReset + case strings.HasPrefix(line, "@@"): + return ansiCyan + line + ansiReset + case strings.HasPrefix(line, "+"): + return ansiGreen + line + ansiReset + case strings.HasPrefix(line, "-"): + return ansiRed + line + ansiReset + case strings.HasPrefix(line, "diff "), + strings.HasPrefix(line, "new file"), + strings.HasPrefix(line, "deleted file"), + strings.HasPrefix(line, "index "): + return ansiBold + line + ansiReset + case strings.HasPrefix(line, "#"), strings.HasPrefix(line, "Binary "): + return ansiDim + line + ansiReset + default: + return line + } +} diff --git a/pkg/diffr/compare.go b/pkg/diffr/compare.go new file mode 100644 index 0000000..e7a643d --- /dev/null +++ b/pkg/diffr/compare.go @@ -0,0 +1,293 @@ +package diffr + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +type Status int + +const ( + StatusChanged Status = iota + StatusAdded + StatusRemoved +) + +func (s Status) String() string { + switch s { + case StatusAdded: + return "added" + case StatusRemoved: + return "removed" + default: + return "changed" + } +} + +type FileDiff struct { + RelPath string + Status Status + Kind Kind + Unified string + Summary string + LeftPath string + RightPath string + DiffPNG []byte + LeftHash string + RightHash string +} + +type Stats struct { + Changed int + Added int + Removed int + Identical int + Duration time.Duration + Elapsed string +} + +type Result struct { + Left string + Right string + LeftAbs string + RightAbs string + LeftDir bool + RightDir bool + Files []FileDiff + Stats Stats +} + +func (r *Result) HasChanges() bool { + return r.Stats.Changed+r.Stats.Added+r.Stats.Removed > 0 +} + +type Options struct { + Exclude []string + IgnoreFile string + NoDefaultExclude bool + NoGitignore bool + IgnoreWhitespace bool + IgnoreCase bool + Context int +} + +type filePair struct { + rel string + left string + right string +} + +func Compare(left, right string, opts Options) (*Result, error) { + start := time.Now() + if opts.Context <= 0 { + opts.Context = 3 + } + + leftAbs, err := filepath.Abs(left) + if err != nil { + return nil, err + } + rightAbs, err := filepath.Abs(right) + if err != nil { + return nil, err + } + + leftInfo, err := os.Stat(leftAbs) + if err != nil { + return nil, fmt.Errorf("left path: %w", err) + } + rightInfo, err := os.Stat(rightAbs) + if err != nil { + return nil, fmt.Errorf("right path: %w", err) + } + if leftInfo.IsDir() != rightInfo.IsDir() { + return nil, fmt.Errorf("cannot compare a file with a directory") + } + + ign, err := LoadIgnore(opts, leftAbs, rightAbs) + if err != nil { + return nil, err + } + + res := &Result{ + Left: left, + Right: right, + LeftAbs: leftAbs, + RightAbs: rightAbs, + LeftDir: leftInfo.IsDir(), + RightDir: rightInfo.IsDir(), + } + + var pairs []filePair + if !leftInfo.IsDir() { + pairs = []filePair{{ + rel: filepath.ToSlash(filepath.Base(leftAbs)), + left: leftAbs, + right: rightAbs, + }} + } else { + leftFiles, err := collectFiles(leftAbs, ign) + if err != nil { + return nil, fmt.Errorf("walking %s: %w", leftAbs, err) + } + rightFiles, err := collectFiles(rightAbs, ign) + if err != nil { + return nil, fmt.Errorf("walking %s: %w", rightAbs, err) + } + + seen := make(map[string]struct{}, len(leftFiles)+len(rightFiles)) + var rels []string + for rel := range leftFiles { + seen[rel] = struct{}{} + rels = append(rels, rel) + } + for rel := range rightFiles { + if _, ok := seen[rel]; ok { + continue + } + rels = append(rels, rel) + } + sort.Strings(rels) + + pairs = make([]filePair, 0, len(rels)) + for _, rel := range rels { + pairs = append(pairs, filePair{ + rel: rel, + left: leftFiles[rel], + right: rightFiles[rel], + }) + } + } + + for _, p := range pairs { + fd, identical, err := comparePair(p, opts) + if err != nil { + return nil, err + } + if identical { + res.Stats.Identical++ + continue + } + switch fd.Status { + case StatusAdded: + res.Stats.Added++ + case StatusRemoved: + res.Stats.Removed++ + default: + res.Stats.Changed++ + } + res.Files = append(res.Files, fd) + } + + res.Stats.Duration = time.Since(start) + res.Stats.Elapsed = res.Stats.Duration.Round(time.Millisecond).String() + return res, nil +} + +func collectFiles(root string, ign *Ignore) (map[string]string, error) { + files := make(map[string]string) + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == "." { + return nil + } + if d.IsDir() { + if ign.Match(rel) { + return filepath.SkipDir + } + return nil + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + if ign.Match(rel) { + return nil + } + files[rel] = path + return nil + }) + return files, err +} + +func comparePair(p filePair, opts Options) (FileDiff, bool, error) { + fd := FileDiff{RelPath: p.rel, LeftPath: p.left, RightPath: p.right} + switch { + case p.left == "": + fd.Status = StatusAdded + case p.right == "": + fd.Status = StatusRemoved + default: + fd.Status = StatusChanged + } + + var leftKind, rightKind Kind + var err error + if p.left != "" { + leftKind, err = sniffKind(p.left) + if err != nil { + return fd, false, err + } + } + if p.right != "" { + rightKind, err = sniffKind(p.right) + if err != nil { + return fd, false, err + } + } + fd.Kind = mergeKind(leftKind, rightKind, p.left != "", p.right != "") + + switch fd.Kind { + case KindImage: + return compareImages(fd) + case KindPDF: + return comparePDFs(fd, opts) + case KindBinary: + return compareBinaries(fd) + default: + return compareText(fd, opts) + } +} + +func (r *Result) Patch() string { + var b strings.Builder + for _, f := range r.Files { + if f.Kind == KindText || (f.Kind == KindPDF && f.Unified != "") { + if f.Summary != "" && f.Kind != KindText { + fmt.Fprintf(&b, "# %s\n", f.Summary) + } + if f.Unified != "" { + b.WriteString(f.Unified) + if !strings.HasSuffix(f.Unified, "\n") { + b.WriteByte('\n') + } + } + continue + } + + b.WriteString(gitDiffPrefix(f.RelPath, f.Status)) + switch f.Status { + case StatusAdded: + fmt.Fprintf(&b, "Binary files /dev/null and b/%s differ\n", f.RelPath) + case StatusRemoved: + fmt.Fprintf(&b, "Binary files a/%s and /dev/null differ\n", f.RelPath) + default: + fmt.Fprintf(&b, "Binary files a/%s and b/%s differ\n", f.RelPath, f.RelPath) + } + if f.Summary != "" { + fmt.Fprintf(&b, "# %s\n", f.Summary) + } + } + return b.String() +} diff --git a/pkg/diffr/compare_test.go b/pkg/diffr/compare_test.go new file mode 100644 index 0000000..ada93d0 --- /dev/null +++ b/pkg/diffr/compare_test.go @@ -0,0 +1,546 @@ +package diffr + +import ( + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func writePNG(t *testing.T, path string, mark color.Color) { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for y := range 8 { + for x := range 8 { + img.Set(x, y, color.RGBA{R: 20, G: 20, B: 20, A: 255}) + } + } + if mark != nil { + img.Set(1, 1, mark) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } +} + +func byPath(res *Result) map[string]FileDiff { + m := make(map[string]FileDiff, len(res.Files)) + for _, f := range res.Files { + m[f.RelPath] = f + } + return m +} + +func TestCompareReportsFilesOnlyInRight(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "shared.txt"), "hello\n") + writeFile(t, filepath.Join(right, "shared.txt"), "hello\n") + writeFile(t, filepath.Join(right, "only-right.txt"), "new\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + files := byPath(res) + got, ok := files["only-right.txt"] + if !ok { + t.Fatalf("expected only-right.txt, got %+v", res.Files) + } + if got.Status != StatusAdded { + t.Fatalf("status = %s, want added", got.Status) + } + if res.Stats.Added != 1 || res.Stats.Identical != 1 { + t.Fatalf("stats = %+v", res.Stats) + } +} + +func TestCompareReportsFilesOnlyInLeft(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "gone.txt"), "bye\n") + writeFile(t, filepath.Join(right, "kept.txt"), "ok\n") + writeFile(t, filepath.Join(left, "kept.txt"), "ok\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + got, ok := byPath(res)["gone.txt"] + if !ok { + t.Fatalf("expected gone.txt, got %+v", res.Files) + } + if got.Status != StatusRemoved { + t.Fatalf("status = %s, want removed", got.Status) + } +} + +func TestCompareTextChange(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "a.txt"), "one\n") + writeFile(t, filepath.Join(right, "a.txt"), "two\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + got := byPath(res)["a.txt"] + if got.Kind != KindText || got.Unified == "" { + t.Fatalf("expected text unified diff, got %+v", got) + } + if !strings.Contains(got.Unified, "-one") || !strings.Contains(got.Unified, "+two") { + t.Fatalf("unexpected diff:\n%s", got.Unified) + } +} + +func TestDefaultIgnoreSkipsGitAndNodeModules(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "keep.txt"), "a\n") + writeFile(t, filepath.Join(right, "keep.txt"), "b\n") + writeFile(t, filepath.Join(left, "node_modules", "pkg.js"), "old\n") + writeFile(t, filepath.Join(right, "node_modules", "pkg.js"), "new\n") + writeFile(t, filepath.Join(left, ".git", "config"), "old\n") + writeFile(t, filepath.Join(right, ".git", "config"), "new\n") + + res, err := Compare(left, right, Options{}) + if err != nil { + t.Fatal(err) + } + files := byPath(res) + if _, ok := files["keep.txt"]; !ok { + t.Fatal("expected keep.txt") + } + for _, p := range []string{"node_modules/pkg.js", ".git/config"} { + if _, ok := files[p]; ok { + t.Fatalf("did not expect %s", p) + } + } +} + +func TestExcludeFlag(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "a.txt"), "a\n") + writeFile(t, filepath.Join(right, "a.txt"), "b\n") + writeFile(t, filepath.Join(left, "skip.log"), "a\n") + writeFile(t, filepath.Join(right, "skip.log"), "b\n") + + res, err := Compare(left, right, Options{ + NoDefaultExclude: true, + Exclude: []string{"*.log"}, + }) + if err != nil { + t.Fatal(err) + } + files := byPath(res) + if _, ok := files["skip.log"]; ok { + t.Fatal("skip.log should be excluded") + } + if _, ok := files["a.txt"]; !ok { + t.Fatal("expected a.txt") + } +} + +func TestDiffrignore(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, ".diffrignore"), "secret.txt\n*.tmp\n") + writeFile(t, filepath.Join(left, "secret.txt"), "a\n") + writeFile(t, filepath.Join(right, "secret.txt"), "b\n") + writeFile(t, filepath.Join(left, "x.tmp"), "a\n") + writeFile(t, filepath.Join(right, "x.tmp"), "b\n") + writeFile(t, filepath.Join(left, "visible.txt"), "a\n") + writeFile(t, filepath.Join(right, "visible.txt"), "b\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + files := byPath(res) + if _, ok := files["secret.txt"]; ok { + t.Fatal("secret.txt should be ignored") + } + if _, ok := files["x.tmp"]; ok { + t.Fatal("x.tmp should be ignored") + } + if _, ok := files["visible.txt"]; !ok { + t.Fatal("expected visible.txt") + } +} + +func TestBinaryFilesAreNotDumpedAsText(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "a.bin"), "hello\x00old") + writeFile(t, filepath.Join(right, "a.bin"), "hello\x00new") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + got := byPath(res)["a.bin"] + if got.Kind != KindBinary { + t.Fatalf("kind = %s, want binary", got.Kind) + } + if strings.Contains(got.Unified, "\x00") { + t.Fatal("binary content should not appear in unified diff") + } + if !strings.Contains(got.Summary, "binary files differ") { + t.Fatalf("summary = %q", got.Summary) + } + patch := res.Patch() + if !strings.Contains(patch, "Binary files a/a.bin and b/a.bin differ") { + t.Fatalf("patch = %s", patch) + } +} + +func TestImagePixelDiff(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writePNG(t, filepath.Join(left, "pic.png"), color.RGBA{R: 255, A: 255}) + writePNG(t, filepath.Join(right, "pic.png"), color.RGBA{G: 255, A: 255}) + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + got := byPath(res)["pic.png"] + if got.Kind != KindImage { + t.Fatalf("kind = %s, want image", got.Kind) + } + if len(got.DiffPNG) == 0 { + t.Fatal("expected pixel diff PNG") + } + if !strings.Contains(got.Summary, "pixels") { + t.Fatalf("summary = %q", got.Summary) + } +} + +func TestIdenticalImagesAreSkipped(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writePNG(t, filepath.Join(left, "pic.png"), color.RGBA{R: 255, A: 255}) + writePNG(t, filepath.Join(right, "pic.png"), color.RGBA{R: 255, A: 255}) + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + if len(res.Files) != 0 { + t.Fatalf("expected no diffs, got %+v", res.Files) + } + if res.Stats.Identical != 1 { + t.Fatalf("identical = %d", res.Stats.Identical) + } +} + +func TestPDFDetection(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "doc.pdf"), "%PDF-1.1 left-bytes") + writeFile(t, filepath.Join(right, "doc.pdf"), "%PDF-1.1 right-bytes") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + got := byPath(res)["doc.pdf"] + if got.Kind != KindPDF { + t.Fatalf("kind = %s, want pdf", got.Kind) + } + if got.Status != StatusChanged { + t.Fatalf("status = %s", got.Status) + } +} + +func TestCompareTwoFiles(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.txt") + b := filepath.Join(dir, "b.txt") + writeFile(t, a, "left\n") + writeFile(t, b, "right\n") + + res, err := Compare(a, b, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + if len(res.Files) != 1 { + t.Fatalf("got %+v", res.Files) + } + if res.Files[0].Kind != KindText { + t.Fatalf("kind = %s", res.Files[0].Kind) + } +} + +func TestCompareFileAndDirectoryError(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "a.txt") + writeFile(t, file, "x\n") + _, err := Compare(file, dir, Options{NoDefaultExclude: true}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestPatchIncludesAddedRightFile(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "keep.txt"), "same\n") + writeFile(t, filepath.Join(right, "keep.txt"), "same\n") + writeFile(t, filepath.Join(right, "extra.txt"), "only here\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + patch := res.Patch() + if !strings.Contains(patch, "+++ b/extra.txt") { + t.Fatalf("patch missing added file:\n%s", patch) + } + if !strings.Contains(patch, "diff --git a/extra.txt b/extra.txt") { + t.Fatalf("patch missing git header:\n%s", patch) + } + if !strings.Contains(patch, "new file mode 100644") { + t.Fatalf("patch missing new file marker:\n%s", patch) + } +} + +func TestGitHeadersForAddedAndRemoved(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "gone.txt"), "bye\n") + writeFile(t, filepath.Join(right, "new.txt"), "hi\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + files := byPath(res) + added := files["new.txt"].Unified + if !strings.Contains(added, "new file mode 100644") || !strings.Contains(added, "--- /dev/null") { + t.Fatalf("added unified:\n%s", added) + } + removed := files["gone.txt"].Unified + if !strings.Contains(removed, "deleted file mode 100644") || !strings.Contains(removed, "+++ /dev/null") { + t.Fatalf("removed unified:\n%s", removed) + } +} + +func TestJSONReport(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "a.txt"), "old\n") + writeFile(t, filepath.Join(right, "a.txt"), "new\n") + writeFile(t, filepath.Join(right, "b.txt"), "added\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + out := res.JSON() + if out.Stats.Changed != 1 || out.Stats.Added != 1 { + t.Fatalf("stats = %+v", out.Stats) + } + if len(out.Files) != 2 { + t.Fatalf("files = %+v", out.Files) + } + by := map[string]jsonFile{} + for _, f := range out.Files { + by[f.Path] = f + } + if by["a.txt"].Status != "changed" || by["a.txt"].Kind != "text" { + t.Fatalf("a.txt = %+v", by["a.txt"]) + } + if by["b.txt"].Status != "added" { + t.Fatalf("b.txt = %+v", by["b.txt"]) + } +} + +func TestIgnoreFileMissing(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "a.txt"), "a\n") + writeFile(t, filepath.Join(right, "a.txt"), "a\n") + + _, err := Compare(left, right, Options{ + NoDefaultExclude: true, + IgnoreFile: filepath.Join(dir, "missing.ignore"), + }) + if err == nil { + t.Fatal("expected error for missing ignore file") + } +} + +func TestSniffKind(t *testing.T) { + dir := t.TempDir() + text := filepath.Join(dir, "a.txt") + bin := filepath.Join(dir, "a.bin") + pdf := filepath.Join(dir, "a.pdf") + writeFile(t, text, "hello") + writeFile(t, bin, "a\x00b") + writeFile(t, pdf, "%PDF-1.4 rest") + + k, err := sniffKind(text) + if err != nil || k != KindText { + t.Fatalf("text: %v %v", k, err) + } + k, err = sniffKind(bin) + if err != nil || k != KindBinary { + t.Fatalf("binary: %v %v", k, err) + } + k, err = sniffKind(pdf) + if err != nil || k != KindPDF { + t.Fatalf("pdf: %v %v", k, err) + } +} + +func TestIgnoreWhitespace(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "a.txt"), "hello world\n") + writeFile(t, filepath.Join(right, "a.txt"), "hello world\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + if len(res.Files) != 1 { + t.Fatalf("without -w expected a change, got %+v", res.Files) + } + + res, err = Compare(left, right, Options{NoDefaultExclude: true, IgnoreWhitespace: true}) + if err != nil { + t.Fatal(err) + } + if len(res.Files) != 0 || res.Stats.Identical != 1 { + t.Fatalf("with -w expected identical, stats=%+v files=%+v", res.Stats, res.Files) + } +} + +func TestIgnoreCase(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "a.txt"), "Hello\n") + writeFile(t, filepath.Join(right, "a.txt"), "hello\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true, IgnoreCase: true}) + if err != nil { + t.Fatal(err) + } + if len(res.Files) != 0 || res.Stats.Identical != 1 { + t.Fatalf("expected identical, stats=%+v files=%+v", res.Stats, res.Files) + } +} + +func TestContextLines(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, "a.txt"), "a\nb\nc\nOLD\nd\ne\nf\n") + writeFile(t, filepath.Join(right, "a.txt"), "a\nb\nc\nNEW\nd\ne\nf\n") + + wide, err := Compare(left, right, Options{NoDefaultExclude: true, Context: 3}) + if err != nil { + t.Fatal(err) + } + narrow, err := Compare(left, right, Options{NoDefaultExclude: true, Context: 1}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(wide.Files[0].Unified, "a\n") { + t.Fatalf("context 3 should include distant line a:\n%s", wide.Files[0].Unified) + } + if strings.Contains(narrow.Files[0].Unified, "a\n") { + t.Fatalf("context 1 should not include distant line a:\n%s", narrow.Files[0].Unified) + } +} + +func TestGitignoreFromGitRepo(t *testing.T) { + dir := t.TempDir() + left := filepath.Join(dir, "left") + right := filepath.Join(dir, "right") + writeFile(t, filepath.Join(left, ".git", "HEAD"), "ref: refs/heads/main\n") + writeFile(t, filepath.Join(right, ".git", "HEAD"), "ref: refs/heads/main\n") + writeFile(t, filepath.Join(left, ".gitignore"), "secret.txt\n") + writeFile(t, filepath.Join(right, ".gitignore"), "secret.txt\n") + writeFile(t, filepath.Join(left, "secret.txt"), "a\n") + writeFile(t, filepath.Join(right, "secret.txt"), "b\n") + writeFile(t, filepath.Join(left, "keep.txt"), "a\n") + writeFile(t, filepath.Join(right, "keep.txt"), "b\n") + + res, err := Compare(left, right, Options{NoDefaultExclude: true}) + if err != nil { + t.Fatal(err) + } + files := byPath(res) + if _, ok := files["secret.txt"]; ok { + t.Fatal("secret.txt should be gitignored") + } + if _, ok := files["keep.txt"]; !ok { + t.Fatal("expected keep.txt") + } + + res, err = Compare(left, right, Options{NoDefaultExclude: true, NoGitignore: true}) + if err != nil { + t.Fatal(err) + } + if _, ok := byPath(res)["secret.txt"]; !ok { + t.Fatal("secret.txt should appear with --no-gitignore") + } +} + +func TestColorizePatch(t *testing.T) { + in := "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n# note\n" + out := colorizePatch(in) + if !strings.Contains(out, ansiRed+"-old"+ansiReset) { + t.Fatalf("missing red deletion:\n%s", out) + } + if !strings.Contains(out, ansiGreen+"+new"+ansiReset) { + t.Fatalf("missing green addition:\n%s", out) + } + if !strings.Contains(out, ansiCyan+"@@ -1 +1 @@"+ansiReset) { + t.Fatalf("missing cyan hunk:\n%s", out) + } + if !strings.Contains(out, ansiBold+"diff --git a/f b/f"+ansiReset) { + t.Fatalf("missing bold header:\n%s", out) + } +} diff --git a/pkg/diffr/config.go b/pkg/diffr/config.go index 73cca08..e438dd3 100644 --- a/pkg/diffr/config.go +++ b/pkg/diffr/config.go @@ -1,6 +1,17 @@ package diffr var ( - Port int - Address string + Port int + Address string + Stdout bool + JSON bool + NoOpen bool + PatchFile string + Exclude []string + IgnoreFile string + NoDefaultExclude bool + NoGitignore bool + IgnoreWhitespace bool + IgnoreCase bool + Context int ) diff --git a/pkg/diffr/diff.go b/pkg/diffr/diff.go deleted file mode 100644 index 3c98bce..0000000 --- a/pkg/diffr/diff.go +++ /dev/null @@ -1,124 +0,0 @@ -package diffr - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - - "github.com/pmezard/go-difflib/difflib" -) - -func readFileContent(filePath string) (string, error) { - file, err := os.Open(filePath) - if err != nil { - return "", err - } - defer file.Close() - - bufferedReader := bufio.NewReader(file) - var contentBuilder strings.Builder - bufferSize := 4096 - buffer := make([]byte, bufferSize) - - for { - n, err := bufferedReader.Read(buffer) - if err != nil { - break - } - contentBuilder.Write(buffer[:n]) - } - - return contentBuilder.String(), nil -} - -func compareFiles(file1, file2 string) (string, error) { - content1, err := readFileContent(file1) - if err != nil { - return "", err - } - - content2, err := readFileContent(file2) - if err != nil { - return "", err - } - - diff := difflib.UnifiedDiff{ - A: difflib.SplitLines(content1), - B: difflib.SplitLines(content2), - FromFile: file1, - ToFile: file2, - Context: 3, - } - - diffs, err := difflib.GetUnifiedDiffString(diff) - if err != nil { - return "", err - } - - return diffs, nil -} - -func CompareDirectories(dir1, dir2 string, diffChan chan<- string, errorChan chan<- error, wg *sync.WaitGroup) { - defer wg.Done() - - var comparisonsCount int - filepath.Walk(dir1, func(path1 string, info os.FileInfo, err error) error { - if err != nil { - errorChan <- fmt.Errorf("error accessing %s: %s", path1, err) - return nil - } - - relPath, err := filepath.Rel(dir1, path1) - if err != nil { - errorChan <- fmt.Errorf("error getting relative path of %s: %s", path1, err) - return nil - } - - path2 := filepath.Join(dir2, relPath) - - if info.IsDir() { - return nil - } - - if _, err := os.Stat(path2); err == nil { - diff, err := compareFiles(path1, path2) - if err != nil { - errorChan <- fmt.Errorf("error comparing files %s and %s: %s", path1, path2, err) - return nil - } - - if diff != "" { - comparisonsCount++ - diffChan <- fmt.Sprintf("Differences in file: %s\n%s", relPath, diff) - } - } else if os.IsNotExist(err) { - var nullDevice string - if runtime.GOOS == "windows" { - nullDevice = "NUL" - } else { - nullDevice = "/dev/null" - } - - diff, err := compareFiles(path1, nullDevice) - if err != nil { - errorChan <- fmt.Errorf("error comparing files %s and %s: %s", path1, nullDevice, err) - return nil - } - - if diff != "" { - comparisonsCount++ - diffChan <- fmt.Sprintf("Differences in file: %s (present in %s but not in %s)\n%s", relPath, dir1, dir2, diff) - } - } else { - errorChan <- fmt.Errorf("error accessing %s: %s", path2, err) - } - - return nil - }) - - fmt.Printf("Total file comparisons: %d\n", comparisonsCount) // Print the count at the end -} diff --git a/pkg/diffr/handler.go b/pkg/diffr/handler.go index ad70175..7e884ff 100644 --- a/pkg/diffr/handler.go +++ b/pkg/diffr/handler.go @@ -1,145 +1,282 @@ package diffr import ( + "context" "fmt" - "github.com/imrajdas/diffr/static" "html/template" "net/http" + "net/url" "os" "os/exec" "os/signal" + "path" + "path/filepath" "runtime" - "sync" + "strings" "syscall" "time" + "github.com/imrajdas/diffr/static" "github.com/spf13/cobra" ) -var ( - dir1 string - dir2 string -) - -func openBrowser(url string) error { +func openBrowser(rawURL string) error { var cmd *exec.Cmd - switch runtime.GOOS { case "darwin": - cmd = exec.Command("open", url) + cmd = exec.Command("open", rawURL) case "linux": - cmd = exec.Command("xdg-open", url) + cmd = exec.Command("xdg-open", rawURL) case "windows": - cmd = exec.Command("cmd", "/c", "start", url) + cmd = exec.Command("cmd", "/c", "start", rawURL) default: return fmt.Errorf("unsupported platform") } - return cmd.Start() } -func RunWebServer(cmd *cobra.Command, args []string) { - if len(args) != 2 { - fmt.Errorf("Error: Usage: \n diffr /path/to/dir1 /path/to/dir2") +func Run(cmd *cobra.Command, args []string) { + opts := Options{ + Exclude: Exclude, + IgnoreFile: IgnoreFile, + NoDefaultExclude: NoDefaultExclude, + NoGitignore: NoGitignore, + IgnoreWhitespace: IgnoreWhitespace, + IgnoreCase: IgnoreCase, + Context: Context, + } + + res, err := Compare(args[0], args[1], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + + if Stdout || JSON || PatchFile != "" { + if err := WriteCLI(res, Stdout, JSON, PatchFile); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + if res.HasChanges() { + os.Exit(1) + } return } - dir1 = args[0] - dir2 = args[1] + runWeb(res) +} - serverURL := fmt.Sprintf("%s:%d", Address, Port) +func runWeb(res *Result) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + renderPage(w, res) + }) + mux.HandleFunc("/media/", func(w http.ResponseWriter, r *http.Request) { + serveMedia(w, r, res) + }) - http.HandleFunc("/", handler) - //http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + listenAddr, serverURL, err := listenAndDisplay(Address, Port) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } - server := &http.Server{Addr: fmt.Sprintf(":%d", Port)} + server := &http.Server{ + Addr: listenAddr, + Handler: mux, + } - // Channel to receive signals (e.g., interrupt or termination) signalCh := make(chan os.Signal, 1) signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM) go func() { - fmt.Printf("Server started at %s\n", serverURL) - err := server.ListenAndServe() - if err != nil && err != http.ErrServerClosed { - fmt.Println("Error starting server:", err) - os.Exit(1) + fmt.Printf("Server started at %s (listening on %s)\n", serverURL, listenAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) + os.Exit(2) } }() - fmt.Println("Opening browser...") - err := openBrowser(serverURL) - if err != nil { - fmt.Println("Error opening browser:", err) + if !NoOpen { + fmt.Println("Opening browser...") + if err := openBrowser(serverURL); err != nil { + fmt.Fprintf(os.Stderr, "Error opening browser: %v\n", err) + } + } else { + fmt.Println("Browser launch skipped (--no-open)") } - // Wait for a termination signal <-signalCh - fmt.Println("Shutting down server...") - err = server.Shutdown(nil) - if err != nil { - fmt.Println("Error shutting down server:", err) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Error shutting down server: %v\n", err) } } -type PageData struct { - Title string - Diff string +type ImageView struct { + RelPath string + Status string + Summary string + LeftURL string + RightURL string + DiffURL string + HasLeft bool + HasRight bool + HasDiff bool } -func handler(w http.ResponseWriter, r *http.Request) { - var ( - wg sync.WaitGroup - finalStr = "" - diffChan = make(chan string) - errorChan = make(chan error) - start = time.Now() - elapsed time.Duration - ) +type ExtraView struct { + RelPath string + Status string + Summary string + Kind string + Unified string +} - go func() { - for diff := range diffChan { - finalStr += diff - elapsed = time.Since(start) - } - }() +type PageData struct { + Title string + Diff string + HasText bool + HasAny bool + Stats Stats + Images []ImageView + PDFs []ExtraView + Binaries []ExtraView +} - go func() { - for err := range errorChan { - fmt.Printf("error: %v", err) - } - }() +func renderPage(w http.ResponseWriter, res *Result) { + tmpl, err := template.New("html").Parse(static.HTML) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - wg.Add(1) - go CompareDirectories(dir1, dir2, diffChan, errorChan, &wg) - wg.Wait() + data := pageData(res) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} - close(diffChan) - close(errorChan) +func pageData(res *Result) PageData { + data := PageData{ + Title: "Diffr - A web-based content difference analyzer", + Stats: res.Stats, + HasAny: res.HasChanges(), + } - fmt.Printf("Time taken to analyze all files: %s\n", elapsed) + var textParts []string + for _, f := range res.Files { + switch f.Kind { + case KindText: + if f.Unified != "" { + textParts = append(textParts, f.Unified) + } + case KindImage: + data.Images = append(data.Images, ImageView{ + RelPath: f.RelPath, + Status: f.Status.String(), + Summary: f.Summary, + LeftURL: mediaURL("left", f.RelPath), + RightURL: mediaURL("right", f.RelPath), + DiffURL: mediaURL("diff", f.RelPath), + HasLeft: f.LeftPath != "", + HasRight: f.RightPath != "", + HasDiff: len(f.DiffPNG) > 0, + }) + case KindPDF: + data.PDFs = append(data.PDFs, ExtraView{ + RelPath: f.RelPath, + Status: f.Status.String(), + Summary: f.Summary, + Kind: f.Kind.String(), + Unified: f.Unified, + }) + case KindBinary: + data.Binaries = append(data.Binaries, ExtraView{ + RelPath: f.RelPath, + Status: f.Status.String(), + Summary: f.Summary, + Kind: f.Kind.String(), + }) + } + } + data.Diff = strings.Join(textParts, "") + data.HasText = strings.TrimSpace(data.Diff) != "" + return data +} - wg.Add(1) - go func() { - defer wg.Done() +func mediaURL(side, rel string) string { + parts := strings.Split(rel, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return "/media/" + side + "/" + strings.Join(parts, "/") +} - tmpl := template.Must(template.New("html").Parse(static.HTML)) +func serveMedia(w http.ResponseWriter, r *http.Request, res *Result) { + rest := strings.TrimPrefix(r.URL.Path, "/media/") + side, rel, ok := strings.Cut(rest, "/") + if !ok || rel == "" { + http.NotFound(w, r) + return + } + rel = path.Clean(rel) + if rel == ".." || strings.HasPrefix(rel, "../") { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } - data := PageData{ - Title: "Diffr - A web-based content difference analyzer", - Diff: finalStr, + switch side { + case "diff": + for _, f := range res.Files { + if f.RelPath == rel && len(f.DiffPNG) > 0 { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(f.DiffPNG) + return + } } + http.NotFound(w, r) + case "left": + serveSideFile(w, r, res.LeftAbs, res.LeftDir, rel) + case "right": + serveSideFile(w, r, res.RightAbs, res.RightDir, rel) + default: + http.NotFound(w, r) + } +} - // Execute the templates with the provided data - w.WriteHeader(http.StatusOK) - err := tmpl.Execute(w, data) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }() +func serveSideFile(w http.ResponseWriter, r *http.Request, root string, isDir bool, rel string) { + target, err := safeJoin(root, rel, isDir) + if err != nil { + http.NotFound(w, r) + return + } + http.ServeFile(w, r, target) +} - wg.Wait() +func safeJoin(root, rel string, isDir bool) (string, error) { + if !isDir { + return root, nil + } + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", err + } + joined := filepath.Join(rootAbs, filepath.FromSlash(rel)) + abs, err := filepath.Abs(joined) + if err != nil { + return "", err + } + sep := string(os.PathSeparator) + if abs != rootAbs && !strings.HasPrefix(abs, rootAbs+sep) { + return "", fmt.Errorf("invalid path") + } + return abs, nil } diff --git a/pkg/diffr/handler_test.go b/pkg/diffr/handler_test.go new file mode 100644 index 0000000..55b4dc0 --- /dev/null +++ b/pkg/diffr/handler_test.go @@ -0,0 +1,35 @@ +package diffr + +import ( + "html/template" + "strings" + "testing" + + "github.com/imrajdas/diffr/static" +) + +func TestPageDataOmitsPDFFromTextDiff(t *testing.T) { + res := &Result{ + Files: []FileDiff{ + {RelPath: "a.txt", Kind: KindText, Status: StatusChanged, Unified: "diff --git a/a.txt b/a.txt\n"}, + {RelPath: "docs/notes.pdf", Kind: KindPDF, Status: StatusChanged, Unified: "diff --git a/docs/notes.pdf b/docs/notes.pdf\n-Draft\n+Final\n", Summary: "PDF: text content differs"}, + }, + Stats: Stats{Changed: 2}, + } + pd := pageData(res) + if strings.Contains(pd.Diff, "notes.pdf") { + t.Fatalf("PDF should not be in the main text diff:\n%s", pd.Diff) + } + if !strings.Contains(pd.Diff, "a.txt") { + t.Fatal("text file should remain in the main diff") + } + if len(pd.PDFs) != 1 || pd.PDFs[0].Unified == "" { + t.Fatalf("PDF text diff should stay on the PDF row: %+v", pd.PDFs) + } +} + +func TestHTMLTemplateParses(t *testing.T) { + if _, err := template.New("html").Parse(static.HTML); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/diffr/ignore.go b/pkg/diffr/ignore.go new file mode 100644 index 0000000..00588e7 --- /dev/null +++ b/pkg/diffr/ignore.go @@ -0,0 +1,128 @@ +package diffr + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + ignore "github.com/sabhiram/go-gitignore" +) + +// Built-in patterns applied unless --no-default-exclude is set. +var defaultExcludes = []string{ + ".git", + ".git/", + ".svn", + ".svn/", + ".hg", + ".hg/", + "node_modules", + "node_modules/", + "__pycache__", + "__pycache__/", + ".DS_Store", + "Thumbs.db", +} + +type Ignore struct { + gi *ignore.GitIgnore +} + +func LoadIgnore(opts Options, left, right string) (*Ignore, error) { + var lines []string + if !opts.NoDefaultExclude { + lines = append(lines, defaultExcludes...) + } + + var files []string + files = append(files, filepath.Join(".", ".diffrignore")) + for _, root := range []string{left, right} { + info, err := os.Stat(root) + if err != nil || !info.IsDir() { + continue + } + files = append(files, filepath.Join(root, ".diffrignore")) + } + if opts.IgnoreFile != "" { + files = append(files, opts.IgnoreFile) + } + if !opts.NoGitignore { + for _, root := range []string{left, right} { + if gr := findGitRoot(root); gr != "" { + files = append(files, filepath.Join(gr, ".gitignore")) + } + } + } + + seen := make(map[string]struct{}) + for _, p := range files { + abs, err := filepath.Abs(p) + if err != nil { + if opts.IgnoreFile != "" && p == opts.IgnoreFile { + return nil, fmt.Errorf("ignore file: %w", err) + } + continue + } + if _, ok := seen[abs]; ok { + continue + } + seen[abs] = struct{}{} + + data, err := os.ReadFile(p) + if err != nil { + if opts.IgnoreFile != "" && p == opts.IgnoreFile { + return nil, fmt.Errorf("ignore file: %w", err) + } + continue + } + for _, line := range strings.Split(string(data), "\n") { + lines = append(lines, strings.TrimRight(line, "\r")) + } + } + + lines = append(lines, opts.Exclude...) + if len(lines) == 0 { + return &Ignore{}, nil + } + return &Ignore{gi: ignore.CompileIgnoreLines(lines...)}, nil +} + +func (i *Ignore) Match(rel string) bool { + if i == nil || i.gi == nil { + return false + } + rel = filepath.ToSlash(rel) + if rel == "." || rel == "" { + return false + } + if i.gi.MatchesPath(rel) { + return true + } + if !strings.HasSuffix(rel, "/") && i.gi.MatchesPath(rel+"/") { + return true + } + return false +} + +func findGitRoot(path string) string { + info, err := os.Stat(path) + if err != nil { + return "" + } + dir := path + if !info.IsDir() { + dir = filepath.Dir(path) + } + for { + git := filepath.Join(dir, ".git") + if _, err := os.Stat(git); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} diff --git a/pkg/diffr/image.go b/pkg/diffr/image.go new file mode 100644 index 0000000..898c229 --- /dev/null +++ b/pkg/diffr/image.go @@ -0,0 +1,111 @@ +package diffr + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + "os" + + _ "image/gif" + _ "image/jpeg" +) + +const maxPixels = 20_000_000 + +func compareImages(fd FileDiff) (FileDiff, bool, error) { + fd.Kind = KindImage + + if fd.LeftPath == "" || fd.RightPath == "" { + if fd.Status == StatusAdded { + fd.Summary = "image added" + } else { + fd.Summary = "image removed" + } + return fd, false, nil + } + + left, err := decodeImage(fd.LeftPath) + if err != nil { + return compareBinaries(fd) + } + right, err := decodeImage(fd.RightPath) + if err != nil { + return compareBinaries(fd) + } + + lb, rb := left.Bounds(), right.Bounds() + if lb.Dx()*lb.Dy() > maxPixels || rb.Dx()*rb.Dy() > maxPixels { + same, err := sameHash(fd.LeftPath, fd.RightPath) + if err != nil { + return fd, false, err + } + if same { + return fd, true, nil + } + fd.Summary = fmt.Sprintf("images differ (%dx%d vs %dx%d; too large for pixel diff)", lb.Dx(), lb.Dy(), rb.Dx(), rb.Dy()) + return fd, false, nil + } + + diffImg, changed, total := pixelDiff(left, right) + if lb.Dx() == rb.Dx() && lb.Dy() == rb.Dy() && changed == 0 { + return fd, true, nil + } + + var buf bytes.Buffer + if err := png.Encode(&buf, diffImg); err != nil { + return fd, false, err + } + fd.DiffPNG = buf.Bytes() + + if lb.Dx() != rb.Dx() || lb.Dy() != rb.Dy() { + fd.Summary = fmt.Sprintf("images differ in size: %dx%d vs %dx%d", lb.Dx(), lb.Dy(), rb.Dx(), rb.Dy()) + return fd, false, nil + } + + pct := 100 * float64(changed) / float64(total) + fd.Summary = fmt.Sprintf("images differ: %d/%d pixels (%.2f%%)", changed, total, pct) + return fd, false, nil +} + +func decodeImage(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + return img, err +} + +func pixelDiff(a, b image.Image) (*image.RGBA, int, int) { + ab, bb := a.Bounds(), b.Bounds() + w := max(ab.Dx(), bb.Dx()) + h := max(ab.Dy(), bb.Dy()) + out := image.NewRGBA(image.Rect(0, 0, w, h)) + changed := 0 + total := w * h + + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + inA := x < ab.Dx() && y < ab.Dy() + inB := x < bb.Dx() && y < bb.Dy() + if !inA || !inB { + changed++ + out.Set(x, y, color.RGBA{R: 37, G: 99, B: 235, A: 255}) + continue + } + r1, g1, b1, a1 := a.At(ab.Min.X+x, ab.Min.Y+y).RGBA() + r2, g2, b2, a2 := b.At(bb.Min.X+x, bb.Min.Y+y).RGBA() + if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 { + changed++ + out.Set(x, y, color.RGBA{R: 220, G: 38, B: 38, A: 255}) + continue + } + gray := uint8(((r1 + g1 + b1) / 3) >> 8) + out.Set(x, y, color.RGBA{R: gray, G: gray, B: gray, A: 255}) + } + } + return out, changed, total +} diff --git a/pkg/diffr/kind.go b/pkg/diffr/kind.go new file mode 100644 index 0000000..9c29cc6 --- /dev/null +++ b/pkg/diffr/kind.go @@ -0,0 +1,80 @@ +package diffr + +import ( + "bytes" + "io" + "net/http" + "os" +) + +type Kind int + +const ( + KindText Kind = iota + KindBinary + KindImage + KindPDF +) + +func (k Kind) String() string { + switch k { + case KindBinary: + return "binary" + case KindImage: + return "image" + case KindPDF: + return "pdf" + default: + return "text" + } +} + +const sniffSize = 8192 + +func sniffKind(path string) (Kind, error) { + f, err := os.Open(path) + if err != nil { + return KindText, err + } + defer f.Close() + + buf := make([]byte, sniffSize) + n, err := f.Read(buf) + if n == 0 && err != nil && err != io.EOF { + return KindText, err + } + head := buf[:n] + + if bytes.HasPrefix(head, []byte("%PDF")) { + return KindPDF, nil + } + + switch http.DetectContentType(head) { + case "image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp": + return KindImage, nil + } + + if bytes.IndexByte(head, 0) >= 0 { + return KindBinary, nil + } + return KindText, nil +} + +func mergeKind(left, right Kind, hasLeft, hasRight bool) Kind { + if !hasLeft { + return right + } + if !hasRight { + return left + } + if left == right { + return left + } + if left != KindText && right != KindText { + return KindBinary + } + if left != KindText { + return left + } + return right +} diff --git a/pkg/diffr/pdf.go b/pkg/diffr/pdf.go new file mode 100644 index 0000000..ecaca2c --- /dev/null +++ b/pkg/diffr/pdf.go @@ -0,0 +1,110 @@ +package diffr + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/ledongthuc/pdf" +) + +type pdfInfo struct { + pages int + text string + err error +} + +func readPDF(path string) pdfInfo { + if path == "" { + return pdfInfo{} + } + f, r, err := pdf.Open(path) + if err != nil { + return pdfInfo{err: err} + } + defer f.Close() + + info := pdfInfo{pages: r.NumPage()} + reader, err := r.GetPlainText() + if err != nil { + info.err = err + return info + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, reader); err != nil { + info.err = err + return info + } + info.text = buf.String() + return info +} + +func comparePDFs(fd FileDiff, opts Options) (FileDiff, bool, error) { + fd.Kind = KindPDF + left := readPDF(fd.LeftPath) + right := readPDF(fd.RightPath) + + if (fd.LeftPath != "" && left.err != nil) || (fd.RightPath != "" && right.err != nil) { + bd, ident, err := compareBinaries(fd) + if err != nil { + return fd, false, err + } + bd.Kind = KindPDF + if ident { + return bd, true, nil + } + var parts []string + if left.err != nil { + parts = append(parts, "could not parse left PDF: "+left.err.Error()) + } + if right.err != nil { + parts = append(parts, "could not parse right PDF: "+right.err.Error()) + } + if bd.Summary != "" { + parts = append(parts, bd.Summary) + } + bd.Summary = strings.Join(parts, "; ") + return bd, false, nil + } + + if left.pages == right.pages && equivalentText(left.text, right.text, opts) { + if left.text == right.text && fd.LeftPath != "" && fd.RightPath != "" { + same, err := sameHash(fd.LeftPath, fd.RightPath) + if err != nil { + return fd, false, err + } + if same { + return fd, true, nil + } + fd.Summary = fmt.Sprintf("PDFs differ (same text, %d pages; file bytes differ)", left.pages) + return fd, false, nil + } + return fd, true, nil + } + + var summary []string + switch fd.Status { + case StatusAdded: + summary = append(summary, fmt.Sprintf("PDF added (%d pages)", right.pages)) + case StatusRemoved: + summary = append(summary, fmt.Sprintf("PDF removed (%d pages)", left.pages)) + default: + if left.pages != right.pages { + summary = append(summary, fmt.Sprintf("page count %d vs %d", left.pages, right.pages)) + } + if left.text != right.text && !equivalentText(left.text, right.text, opts) { + summary = append(summary, "text content differs") + } + } + fd.Summary = "PDF: " + strings.Join(summary, "; ") + + if !equivalentText(left.text, right.text, opts) { + unified, err := makeUnified(fd.RelPath, left.text, right.text, fd.Status, opts.Context) + if err != nil { + return fd, false, err + } + fd.Unified = unified + } + return fd, false, nil +} diff --git a/pkg/diffr/text.go b/pkg/diffr/text.go new file mode 100644 index 0000000..53dc837 --- /dev/null +++ b/pkg/diffr/text.go @@ -0,0 +1,136 @@ +package diffr + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/pmezard/go-difflib/difflib" +) + +const maxTextBytes = 32 << 20 + +var errFileTooLarge = errors.New("file too large for text diff") + +func compareText(fd FileDiff, opts Options) (FileDiff, bool, error) { + left, err := readText(fd.LeftPath) + if errors.Is(err, errFileTooLarge) { + return compareBinaries(fd) + } + if err != nil { + return fd, false, err + } + + right, err := readText(fd.RightPath) + if errors.Is(err, errFileTooLarge) { + return compareBinaries(fd) + } + if err != nil { + return fd, false, err + } + + if equivalentText(left, right, opts) { + return fd, true, nil + } + + unified, err := makeUnified(fd.RelPath, left, right, fd.Status, opts.Context) + if err != nil { + return fd, false, err + } + fd.Unified = unified + fd.Summary = "text file " + fd.Status.String() + return fd, false, nil +} + +func equivalentText(a, b string, opts Options) bool { + if a == b { + return true + } + if !opts.IgnoreCase && !opts.IgnoreWhitespace { + return false + } + return normalizeText(a, opts) == normalizeText(b, opts) +} + +func normalizeText(s string, opts Options) string { + if !opts.IgnoreCase && !opts.IgnoreWhitespace { + return s + } + lines := splitLines(s) + var b strings.Builder + for _, line := range lines { + line = strings.TrimSuffix(line, "\n") + if opts.IgnoreWhitespace { + line = strings.Join(strings.Fields(line), "") + } + if opts.IgnoreCase { + line = strings.ToLower(line) + } + b.WriteString(line) + b.WriteByte('\n') + } + return b.String() +} + +func makeUnified(rel, left, right string, status Status, context int) (string, error) { + from, to := "a/"+rel, "b/"+rel + if status == StatusAdded { + from = "/dev/null" + } + if status == StatusRemoved { + to = "/dev/null" + } + body, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: splitLines(left), + B: splitLines(right), + FromFile: from, + ToFile: to, + Context: context, + }) + if err != nil { + return "", err + } + return gitDiffPrefix(rel, status) + body, nil +} + +func gitDiffPrefix(rel string, status Status) string { + var b strings.Builder + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", rel, rel) + switch status { + case StatusAdded: + b.WriteString("new file mode 100644\n") + case StatusRemoved: + b.WriteString("deleted file mode 100644\n") + } + return b.String() +} + +func splitLines(s string) []string { + if s == "" { + return nil + } + lines := difflib.SplitLines(s) + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + return lines +} + +func readText(path string) (string, error) { + if path == "" { + return "", nil + } + info, err := os.Stat(path) + if err != nil { + return "", err + } + if info.Size() > maxTextBytes { + return "", errFileTooLarge + } + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/static/static.go b/static/static.go index d7f4fcb..5355a95 100644 --- a/static/static.go +++ b/static/static.go @@ -7,82 +7,478 @@ const HTML = ` {{.Title}} - - + + - - - - -
-
- +
+
+ {{.Stats.Changed}} changed · + {{.Stats.Added}} added · + {{.Stats.Removed}} removed · + {{.Stats.Identical}} identical + · {{.Stats.Elapsed}} +
+
+ + +
+ + +
+
+ {{if not .HasAny}} +
No differences found.
+ {{end}} +
No files match this filter.
+ + {{if .Images}} +
+
Images
+ {{range .Images}} +
+
+ {{.RelPath}} + {{.Status}} +
+

{{.Summary}}

+ {{if and .HasLeft .HasRight}} +
+
+ right {{.RelPath}} +
+ left {{.RelPath}} +
+
+ Left + Right +
+ +
+ {{else if .HasLeft}} + left {{.RelPath}} + {{else if .HasRight}} + right {{.RelPath}} + {{end}} + {{if .HasDiff}} +
+ Pixel diff + diff {{.RelPath}} +
+ {{end}} +
+ {{end}} +
+ {{end}} + + {{if .PDFs}} +
+
PDFs
+ + + + {{range .PDFs}} + + + + + + {{end}} + +
FileStatusSummary
{{.RelPath}}{{.Status}} + {{.Summary}} + {{if .Unified}} +
Extracted text diff
{{.Unified}}
+ {{end}} +
+
+ {{end}} + + {{if .Binaries}} +
+
Binaries
+ + + + {{range .Binaries}} + + + + + + {{end}} + +
FileStatusSummary
{{.RelPath}}{{.Status}}{{.Summary}}
+
+ {{end}} + +
+
+
+ +
diff --git a/testdata/demo/generate.go b/testdata/demo/generate.go new file mode 100644 index 0000000..f1dbab1 --- /dev/null +++ b/testdata/demo/generate.go @@ -0,0 +1,136 @@ +//go:build ignore + +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strconv" +) + +func main() { + root, err := os.Getwd() + if err != nil { + panic(err) + } + if filepath.Base(root) != "demo" { + root = filepath.Join(root, "testdata", "demo") + } + left := filepath.Join(root, "left") + right := filepath.Join(root, "right") + _ = os.RemoveAll(left) + _ = os.RemoveAll(right) + + write := func(path, content string) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + panic(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + panic(err) + } + } + + write(filepath.Join(left, "README.md"), "# Demo App\n\nVersion 1.0\n\nA sample project for diffr.\n") + write(filepath.Join(right, "README.md"), "# Demo App\n\nVersion 2.0\n\nA sample project for diffr, now with extra features.\n") + + write(filepath.Join(left, "src", "app.go"), "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n") + write(filepath.Join(right, "src", "app.go"), "package main\n\nfunc main() {\n\tprintln(\"hello, world\")\n}\n") + + util := "package main\n\nfunc add(a, b int) int {\n\treturn a + b\n}\n" + write(filepath.Join(left, "src", "util.go"), util) + write(filepath.Join(right, "src", "util.go"), util) + + write(filepath.Join(left, "deprecated.txt"), "This file only exists on the left tree.\n") + write(filepath.Join(right, "changelog.txt"), "This file only exists on the right tree.\n- Added extra.txt\n") + + write(filepath.Join(left, "data", "config.json"), "{\n \"port\": 8080\n}\n") + write(filepath.Join(right, "data", "config.json"), "{\n \"port\": 9090,\n \"debug\": true\n}\n") + + write(filepath.Join(left, "data", "blob.bin"), "BIN\x00LEFT\xff\xfe") + write(filepath.Join(right, "data", "blob.bin"), "BIN\x00RIGHT\xff\xfe") + + write(filepath.Join(left, "noise.log"), "old log line\n") + write(filepath.Join(right, "noise.log"), "new log line\n") + write(filepath.Join(left, ".diffrignore"), "*.log\n") + write(filepath.Join(right, ".diffrignore"), "*.log\n") + + write(filepath.Join(left, "node_modules", "left-pad", "index.js"), "module.exports = function () { return 'old' }\n") + write(filepath.Join(right, "node_modules", "left-pad", "index.js"), "module.exports = function () { return 'new' }\n") + write(filepath.Join(left, ".git", "HEAD"), "ref: refs/heads/main\n") + write(filepath.Join(right, ".git", "HEAD"), "ref: refs/heads/feature\n") + + writePNG(filepath.Join(left, "assets", "logo.png"), color.RGBA{R: 30, G: 90, B: 200, A: 255}) + writePNG(filepath.Join(right, "assets", "logo.png"), color.RGBA{R: 200, G: 40, B: 40, A: 255}) + + if err := os.MkdirAll(filepath.Join(left, "docs"), 0o755); err != nil { + panic(err) + } + if err := os.MkdirAll(filepath.Join(right, "docs"), 0o755); err != nil { + panic(err) + } + if err := os.WriteFile(filepath.Join(left, "docs", "notes.pdf"), miniPDF("Draft notes"), 0o644); err != nil { + panic(err) + } + if err := os.WriteFile(filepath.Join(right, "docs", "notes.pdf"), miniPDF("Final notes"), 0o644); err != nil { + panic(err) + } +} + +func writePNG(path string, mark color.Color) { + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + bg := color.RGBA{R: 245, G: 245, B: 245, A: 255} + for y := 0; y < 64; y++ { + for x := 0; x < 64; x++ { + img.Set(x, y, bg) + } + } + for y := 16; y < 48; y++ { + for x := 16; x < 48; x++ { + img.Set(x, y, mark) + } + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + panic(err) + } + f, err := os.Create(path) + if err != nil { + panic(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + panic(err) + } +} + +func miniPDF(text string) []byte { + stream := "BT /F1 18 Tf 50 700 Td (" + text + ") Tj ET\n" + objs := []string{ + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n", + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n", + "4 0 obj\n<< /Length " + strconv.Itoa(len(stream)) + " >>\nstream\n" + stream + "endstream\nendobj\n", + "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n", + } + + header := "%PDF-1.4\n" + offsets := make([]int, len(objs)) + pos := len(header) + body := header + for i, obj := range objs { + offsets[i] = pos + body += obj + pos += len(obj) + } + + xrefStart := pos + xref := "xref\n0 6\n0000000000 65535 f \n" + for _, off := range offsets { + xref += fmt.Sprintf("%010d 00000 n \n", off) + } + trailer := fmt.Sprintf("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", xrefStart) + return []byte(body + xref + trailer) +} diff --git a/testdata/demo/left/.diffrignore b/testdata/demo/left/.diffrignore new file mode 100644 index 0000000..397b4a7 --- /dev/null +++ b/testdata/demo/left/.diffrignore @@ -0,0 +1 @@ +*.log diff --git a/testdata/demo/left/README.md b/testdata/demo/left/README.md new file mode 100644 index 0000000..b443b04 --- /dev/null +++ b/testdata/demo/left/README.md @@ -0,0 +1,5 @@ +# Demo App + +Version 1.0 + +A sample project for diffr. diff --git a/testdata/demo/left/assets/logo.png b/testdata/demo/left/assets/logo.png new file mode 100644 index 0000000..a7753c6 Binary files /dev/null and b/testdata/demo/left/assets/logo.png differ diff --git a/testdata/demo/left/data/blob.bin b/testdata/demo/left/data/blob.bin new file mode 100644 index 0000000..594a43d Binary files /dev/null and b/testdata/demo/left/data/blob.bin differ diff --git a/testdata/demo/left/data/config.json b/testdata/demo/left/data/config.json new file mode 100644 index 0000000..2fab70e --- /dev/null +++ b/testdata/demo/left/data/config.json @@ -0,0 +1,3 @@ +{ + "port": 8080 +} diff --git a/testdata/demo/left/deprecated.txt b/testdata/demo/left/deprecated.txt new file mode 100644 index 0000000..e07d86a --- /dev/null +++ b/testdata/demo/left/deprecated.txt @@ -0,0 +1 @@ +This file only exists on the left tree. diff --git a/testdata/demo/left/docs/notes.pdf b/testdata/demo/left/docs/notes.pdf new file mode 100644 index 0000000..77ad07d Binary files /dev/null and b/testdata/demo/left/docs/notes.pdf differ diff --git a/testdata/demo/left/node_modules/left-pad/index.js b/testdata/demo/left/node_modules/left-pad/index.js new file mode 100644 index 0000000..7bddde1 --- /dev/null +++ b/testdata/demo/left/node_modules/left-pad/index.js @@ -0,0 +1 @@ +module.exports = function () { return 'old' } diff --git a/testdata/demo/left/noise.log b/testdata/demo/left/noise.log new file mode 100644 index 0000000..d4f2a48 --- /dev/null +++ b/testdata/demo/left/noise.log @@ -0,0 +1 @@ +old log line diff --git a/testdata/demo/left/src/app.go b/testdata/demo/left/src/app.go new file mode 100644 index 0000000..4a73987 --- /dev/null +++ b/testdata/demo/left/src/app.go @@ -0,0 +1,5 @@ +package main + +func main() { + println("hello") +} diff --git a/testdata/demo/left/src/util.go b/testdata/demo/left/src/util.go new file mode 100644 index 0000000..514199c --- /dev/null +++ b/testdata/demo/left/src/util.go @@ -0,0 +1,5 @@ +package main + +func add(a, b int) int { + return a + b +} diff --git a/testdata/demo/right/.diffrignore b/testdata/demo/right/.diffrignore new file mode 100644 index 0000000..397b4a7 --- /dev/null +++ b/testdata/demo/right/.diffrignore @@ -0,0 +1 @@ +*.log diff --git a/testdata/demo/right/README.md b/testdata/demo/right/README.md new file mode 100644 index 0000000..dc42f3e --- /dev/null +++ b/testdata/demo/right/README.md @@ -0,0 +1,5 @@ +# Demo App + +Version 2.0 + +A sample project for diffr, now with extra features. diff --git a/testdata/demo/right/assets/logo.png b/testdata/demo/right/assets/logo.png new file mode 100644 index 0000000..6e16e01 Binary files /dev/null and b/testdata/demo/right/assets/logo.png differ diff --git a/testdata/demo/right/changelog.txt b/testdata/demo/right/changelog.txt new file mode 100644 index 0000000..0cdcc24 --- /dev/null +++ b/testdata/demo/right/changelog.txt @@ -0,0 +1,2 @@ +This file only exists on the right tree. +- Added extra.txt diff --git a/testdata/demo/right/data/blob.bin b/testdata/demo/right/data/blob.bin new file mode 100644 index 0000000..7708fb7 Binary files /dev/null and b/testdata/demo/right/data/blob.bin differ diff --git a/testdata/demo/right/data/config.json b/testdata/demo/right/data/config.json new file mode 100644 index 0000000..a3ac97b --- /dev/null +++ b/testdata/demo/right/data/config.json @@ -0,0 +1,4 @@ +{ + "port": 9090, + "debug": true +} diff --git a/testdata/demo/right/docs/notes.pdf b/testdata/demo/right/docs/notes.pdf new file mode 100644 index 0000000..586f858 Binary files /dev/null and b/testdata/demo/right/docs/notes.pdf differ diff --git a/testdata/demo/right/node_modules/left-pad/index.js b/testdata/demo/right/node_modules/left-pad/index.js new file mode 100644 index 0000000..a698cd6 --- /dev/null +++ b/testdata/demo/right/node_modules/left-pad/index.js @@ -0,0 +1 @@ +module.exports = function () { return 'new' } diff --git a/testdata/demo/right/noise.log b/testdata/demo/right/noise.log new file mode 100644 index 0000000..c00ac63 --- /dev/null +++ b/testdata/demo/right/noise.log @@ -0,0 +1 @@ +new log line diff --git a/testdata/demo/right/src/app.go b/testdata/demo/right/src/app.go new file mode 100644 index 0000000..73d83e6 --- /dev/null +++ b/testdata/demo/right/src/app.go @@ -0,0 +1,5 @@ +package main + +func main() { + println("hello, world") +} diff --git a/testdata/demo/right/src/util.go b/testdata/demo/right/src/util.go new file mode 100644 index 0000000..514199c --- /dev/null +++ b/testdata/demo/right/src/util.go @@ -0,0 +1,5 @@ +package main + +func add(a, b int) int { + return a + b +}