diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..09a5b81 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..190cd77 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: release + +on: + push: + # run only against tags + tags: + - "*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - run: git fetch --force --tags + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: ">=1.22.0" + cache: true + + - run: go generate -v ./... + - run: go vet -v ./... + - run: go test -v ./... + + # https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63 + - run: CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o diffs-cli_${{ github.ref_name }}_darwin_amd64 . + - run: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o diffs-cli_${{ github.ref_name }}_darwin_arm64 . + - run: CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o diffs-cli_${{ github.ref_name }}_linux_386 . + - run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o diffs-cli_${{ github.ref_name }}_linux_amd64 . + - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o diffs-cli_${{ github.ref_name }}_linux_arm64 . + + # create checksums.txt + - run: shasum -a 256 diffs-cli_* > checksums.txt + + - name: Create a Release in a GitHub Action + uses: softprops/action-gh-release@v2 + with: + files: | + diffs-cli_${{ github.ref_name }}_darwin_amd64 + diffs-cli_${{ github.ref_name }}_darwin_arm64 + diffs-cli_${{ github.ref_name }}_linux_386 + diffs-cli_${{ github.ref_name }}_linux_amd64 + diffs-cli_${{ github.ref_name }}_linux_arm64 + checksums.txt diff --git a/.gitignore b/.gitignore index aaadf73..845bcb5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +diffs-cli # Test binary, built with `go test -c` *.test diff --git a/README.md b/README.md index 2b4b26c..7ebb4d3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,21 @@ # diffs-cli -Diffs CLI + +A tiny Go program that shows git diffs in the current directory and all git repositories in subdirectories. + +## Usage + +```bash +# Build +go build -o diffs-cli . + +# Run (default port 8080, current directory) +./diffs-cli + +# Run on custom port +./diffs-cli -port 9000 + +# Scan a different directory +./diffs-cli -C /path/to/workspace +``` + +Then open http://localhost:8080 (or your custom port) in a browser to view the diffs. diff --git a/diffs.html b/diffs.html new file mode 100644 index 0000000..3181ca5 --- /dev/null +++ b/diffs.html @@ -0,0 +1,25 @@ + + + + + Diffs + + +

Git Diffs

+
Loading diffs...
+ + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad3dae5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/kitproj/diffs-cli + +go 1.24.4 diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..1b2cbf5 --- /dev/null +++ b/handlers.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + _ "embed" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +//go:embed diffs.html +var diffsHTML []byte + +func diffsHandler(w http.ResponseWriter, r *http.Request) { + accept := r.Header.Get("Accept") + + if strings.Contains(accept, "text/x-diff") { + serveDiffsText(w, r) + } else { + serveDiffsHTML(w, r) + } +} + +func serveDiffsHTML(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(diffsHTML) +} + +func findGitRepos(root string) ([]string, error) { + var repos []string + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + if info.IsDir() && info.Name() == ".git" { + repoPath := filepath.Dir(path) + repos = append(repos, repoPath) + return filepath.SkipDir + } + + return nil + }) + + return repos, err +} + +func serveDiffsText(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + writer := &maxSizeWriter{Writer: w, maxSize: 5 * 1024 * 1024} + + repos, err := findGitRepos(".") + if err != nil { + http.Error(w, "Failed to find git repositories: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/x-diff; charset=utf-8") + + for _, repoPath := range repos { + relPath, _ := filepath.Rel(".", repoPath) + if relPath == "." { + relPath = "" + } + + repoName := relPath + if repoName == "" { + repoName = filepath.Base(repoPath) + } + + cmd := exec.CommandContext(ctx, "bash", "-c", ` +git diff --src-prefix=a/`+repoName+`/ --dst-prefix=b/`+repoName+`/ HEAD +git ls-files --others --exclude-standard | while IFS= read -r file; do + if [ -n "$file" ]; then + git diff --no-index --src-prefix=a/`+repoName+`/ --dst-prefix=b/`+repoName+`/ /dev/null "$file" 2>/dev/null || true + fi +done + `) + cmd.Dir = repoPath + cmd.Stdout = writer + cmd.Stderr = writer + _ = cmd.Run() + } +} diff --git a/handlers_test.go b/handlers_test.go new file mode 100644 index 0000000..615f0b1 --- /dev/null +++ b/handlers_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func setupTestGitRepo(t *testing.T, dir string) { + t.Helper() + + cmd := exec.Command("git", "init") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to set git email: %v", err) + } + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to set git name: %v", err) + } +} + +func TestServeDiffsHTML(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept", "text/html") + + w := httptest.NewRecorder() + diffsHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "text/html") { + t.Errorf("expected content-type text/html, got %s", contentType) + } + + body := w.Body.String() + if !strings.Contains(body, "") { + t.Errorf("expected HTML document") + } +} + +func TestServeDiffsText_PWDIsGitRepo(t *testing.T) { + tmpDir := t.TempDir() + + setupTestGitRepo(t, tmpDir) + + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("initial content\n"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cmd := exec.Command("git", "add", "test.txt") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "initial commit") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + if err := os.WriteFile(testFile, []byte("modified content\n"), 0644); err != nil { + t.Fatalf("failed to modify test file: %v", err) + } + + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept", "text/x-diff") + + w := httptest.NewRecorder() + diffsHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "text/x-diff") { + t.Errorf("expected content-type text/x-diff, got %s", contentType) + } + + body := w.Body.String() + if !strings.Contains(body, "diff --git") { + t.Errorf("expected diff output, got: %s", body) + } + if !strings.Contains(body, "test.txt") { + t.Errorf("expected test.txt in diff, got: %s", body) + } +} + +func TestServeDiffsText_GitSubdirectory(t *testing.T) { + tmpDir := t.TempDir() + + subDir := filepath.Join(tmpDir, "subproject") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("failed to create subdir: %v", err) + } + + setupTestGitRepo(t, subDir) + + testFile := filepath.Join(subDir, "sub.txt") + if err := os.WriteFile(testFile, []byte("sub content\n"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cmd := exec.Command("git", "add", "sub.txt") + cmd.Dir = subDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "sub commit") + cmd.Dir = subDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + if err := os.WriteFile(testFile, []byte("modified sub\n"), 0644); err != nil { + t.Fatalf("failed to modify test file: %v", err) + } + + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept", "text/x-diff") + + w := httptest.NewRecorder() + diffsHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body := w.Body.String() + if !strings.Contains(body, "diff --git") { + t.Errorf("expected diff output, got: %s", body) + } + if !strings.Contains(body, "sub.txt") { + t.Errorf("expected sub.txt in diff, got: %s", body) + } + if !strings.Contains(body, "subproject") { + t.Errorf("expected subproject in diff path, got: %s", body) + } +} + +func TestServeDiffsText_LargeDiffTruncation(t *testing.T) { + tmpDir := t.TempDir() + + setupTestGitRepo(t, tmpDir) + + testFile := filepath.Join(tmpDir, "large.txt") + largeContent := strings.Repeat("x", 1024*1024) + if err := os.WriteFile(testFile, []byte(largeContent), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cmd := exec.Command("git", "add", "large.txt") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "large commit") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + modifiedContent := strings.Repeat("y", 1024*1024) + if err := os.WriteFile(testFile, []byte(modifiedContent), 0644); err != nil { + t.Fatalf("failed to modify test file: %v", err) + } + + anotherFile := filepath.Join(tmpDir, "another.txt") + moreContent := strings.Repeat("z", 5*1024*1024) + if err := os.WriteFile(anotherFile, []byte(moreContent), 0644); err != nil { + t.Fatalf("failed to write another file: %v", err) + } + + cmd = exec.Command("git", "add", "another.txt") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "another commit") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + yetAnotherContent := strings.Repeat("w", 5*1024*1024) + if err := os.WriteFile(anotherFile, []byte(yetAnotherContent), 0644); err != nil { + t.Fatalf("failed to modify another file: %v", err) + } + + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept", "text/x-diff") + + w := httptest.NewRecorder() + diffsHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body := w.Body.String() + if len(body) > 5*1024*1024 { + t.Errorf("expected diff to be truncated to 5MB, got %d bytes", len(body)) + } + + if len(body) == 0 { + t.Error("expected some diff output") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3873152 --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" +) + +func main() { + port := flag.String("port", "8080", "Port to listen on") + workspaceDir := flag.String("C", ".", "Directory to scan for git repositories") + flag.Parse() + + if err := os.Chdir(*workspaceDir); err != nil { + fmt.Fprintf(os.Stderr, "Failed to change to directory %s: %v\n", *workspaceDir, err) + os.Exit(1) + } + + http.HandleFunc("/", diffsHandler) + + fmt.Printf("Starting server on http://localhost:%s\n", *port) + http.ListenAndServe(":"+*port, nil) +} diff --git a/writer.go b/writer.go new file mode 100644 index 0000000..4c91f2a --- /dev/null +++ b/writer.go @@ -0,0 +1,23 @@ +package main + +import "io" + +type maxSizeWriter struct { + Writer io.Writer + maxSize int + written int +} + +func (w *maxSizeWriter) Write(p []byte) (n int, err error) { + if w.written+len(p) > w.maxSize { + remaining := w.maxSize - w.written + if remaining > 0 { + n, err = w.Writer.Write(p[:remaining]) + w.written += n + } + return n, io.ErrShortWrite + } + n, err = w.Writer.Write(p) + w.written += n + return n, err +}