diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go
index a5bd5f10de..a0711dfe03 100644
--- a/apps/backend/internal/agentctl/server/api/server.go
+++ b/apps/backend/internal/agentctl/server/api/server.go
@@ -3,6 +3,9 @@ package api
import (
"net/http"
+ "net/http/pprof"
+ "os"
+ "runtime"
"time"
"github.com/gin-gonic/gin"
@@ -155,6 +158,11 @@ func (s *Server) setupRoutes() {
s.mcpServer.RegisterRoutes(s.router)
api.PUT("/mcp/mode", s.handleSetMcpMode)
}
+
+ // pprof + memory stats (enabled via KANDEV_DEBUG_PPROF_ENABLED=true)
+ if os.Getenv("KANDEV_DEBUG_PPROF_ENABLED") == "true" {
+ s.registerPprofRoutes()
+ }
}
// Health check response
@@ -170,6 +178,36 @@ func (s *Server) handleHealth(c *gin.Context) {
})
}
+func (s *Server) registerPprofRoutes() {
+ g := s.router.Group("/debug/pprof")
+ g.GET("/", gin.WrapF(pprof.Index))
+ g.GET("/cmdline", gin.WrapF(pprof.Cmdline))
+ g.GET("/profile", gin.WrapF(pprof.Profile))
+ g.GET("/symbol", gin.WrapF(pprof.Symbol))
+ g.POST("/symbol", gin.WrapF(pprof.Symbol))
+ g.GET("/trace", gin.WrapF(pprof.Trace))
+ for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} {
+ g.GET("/"+name, gin.WrapH(pprof.Handler(name)))
+ }
+
+ s.router.GET("/api/v1/debug/memory", func(c *gin.Context) {
+ var m runtime.MemStats
+ runtime.ReadMemStats(&m)
+ c.JSON(http.StatusOK, gin.H{
+ "heap_alloc_mb": float64(m.HeapAlloc) / (1024 * 1024),
+ "heap_inuse_mb": float64(m.HeapInuse) / (1024 * 1024),
+ "heap_sys_mb": float64(m.HeapSys) / (1024 * 1024),
+ "heap_objects": m.HeapObjects,
+ "goroutines": runtime.NumGoroutine(),
+ "num_gc": m.NumGC,
+ "sys_mb": float64(m.Sys) / (1024 * 1024),
+ "stack_inuse_mb": float64(m.StackInuse) / (1024 * 1024),
+ })
+ })
+
+ s.logger.Info("pprof endpoints registered at /debug/pprof/")
+}
+
// handleSetMcpMode changes the MCP tool mode for this instance.
func (s *Server) handleSetMcpMode(c *gin.Context) {
var req struct {
diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go
index 5732123418..69d39727ec 100644
--- a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go
+++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go
@@ -1,8 +1,12 @@
package process
import (
+ "bytes"
"context"
+ "io"
+ "os"
"os/exec"
+ "path/filepath"
"strconv"
"strings"
@@ -10,6 +14,28 @@ import (
"go.uber.org/zap"
)
+const (
+ // maxDiffFileSize is the maximum file size for which we generate diffs.
+ // Files larger than this are skipped with DiffSkipReason "too_large".
+ maxDiffFileSize = 10 * 1024 * 1024 // 10 MB
+
+ // maxDiffOutputSize is the maximum diff output size per file.
+ // Diffs exceeding this are truncated with DiffSkipReason "truncated".
+ maxDiffOutputSize = 256 * 1024 // 256 KB
+
+ // maxTotalDiffBytes is the cumulative diff budget per GitStatusUpdate.
+ // Once exceeded, remaining files are skipped with DiffSkipReason "budget_exceeded".
+ maxTotalDiffBytes = 2 * 1024 * 1024 // 2 MB
+
+ // binaryCheckSize is how many bytes to inspect for null bytes to detect binary files.
+ binaryCheckSize = 8 * 1024 // 8 KB
+
+ diffSkipReasonTooLarge = "too_large"
+ diffSkipReasonBinary = "binary"
+ diffSkipReasonTruncated = "truncated"
+ diffSkipReasonBudgetExceeded = "budget_exceeded"
+)
+
// enrichWithDiffData adds diff information (additions, deletions, diff content) to file info
func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *types.GitStatusUpdate) {
// Always diff against HEAD for unstaged/staged content so that files committed
@@ -65,6 +91,44 @@ func (wt *WorkspaceTracker) enrichWithBranchDiff(ctx context.Context, update *ty
update.BranchDeletions = deletions
}
+// totalDiffBytes returns the cumulative size of all diff content in the update.
+func totalDiffBytes(update *types.GitStatusUpdate) int64 {
+ var total int64
+ for _, fi := range update.Files {
+ total += int64(len(fi.Diff))
+ }
+ return total
+}
+
+// capDiffOutput runs a git diff command and returns at most maxDiffOutputSize bytes.
+// Returns the output string and whether it was truncated.
+func capDiffOutput(ctx context.Context, workDir string, args ...string) (string, bool) {
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = workDir
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return "", false
+ }
+ if err := cmd.Start(); err != nil {
+ _ = stdout.Close()
+ return "", false
+ }
+
+ limited := io.LimitReader(stdout, maxDiffOutputSize+1)
+ data, _ := io.ReadAll(limited)
+ truncated := len(data) > maxDiffOutputSize
+ if truncated {
+ data = data[:maxDiffOutputSize]
+ }
+
+ // Drain remaining stdout so the process doesn't hang on a full pipe.
+ _, _ = io.Copy(io.Discard, stdout)
+ _ = cmd.Wait()
+
+ return string(data), truncated
+}
+
// enrichWithUnstagedDiff populates additions/deletions and diff content for files
// with unstaged changes by comparing the worktree against baseRef.
func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update *types.GitStatusUpdate, baseRef string) {
@@ -102,11 +166,18 @@ func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update *
fileInfo.Additions = additions
fileInfo.Deletions = deletions
- // Get the actual diff content for this file (compare against base branch)
- diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath)
- diffCmd.Dir = wt.workDir
- if diffOut, err := diffCmd.Output(); err == nil {
- fileInfo.Diff = string(diffOut)
+ if totalDiffBytes(update) >= maxTotalDiffBytes {
+ fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded
+ update.Files[filePath] = fileInfo
+ continue
+ }
+
+ diffOut, truncated := capDiffOutput(ctx, wt.workDir, "diff", baseRef, "--", filePath)
+ if diffOut != "" {
+ fileInfo.Diff = diffOut
+ if truncated {
+ fileInfo.DiffSkipReason = diffSkipReasonTruncated
+ }
}
update.Files[filePath] = fileInfo
@@ -153,16 +224,29 @@ func (wt *WorkspaceTracker) enrichWithStagedDiff(ctx context.Context, update *ty
}
// Get the staged diff content if we don't have diff content yet
if fileInfo.Diff == "" {
- diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", baseRef, "--", filePath)
- diffCmd.Dir = wt.workDir
- if diffOut, err := diffCmd.Output(); err == nil {
- fileInfo.Diff = string(diffOut)
+ if totalDiffBytes(update) >= maxTotalDiffBytes {
+ fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded
+ update.Files[filePath] = fileInfo
+ continue
+ }
+
+ diffOut, truncated := capDiffOutput(ctx, wt.workDir, "diff", "--cached", baseRef, "--", filePath)
+ if diffOut != "" {
+ fileInfo.Diff = diffOut
+ if truncated {
+ fileInfo.DiffSkipReason = diffSkipReasonTruncated
+ }
}
}
update.Files[filePath] = fileInfo
}
}
+// isBinaryContent checks for null bytes in the data, same heuristic git uses.
+func isBinaryContent(data []byte) bool {
+ return bytes.IndexByte(data, 0) != -1
+}
+
// enrichUntrackedFileDiffs builds a synthetic git diff for untracked files showing all
// lines as additions, so the diff viewer can display their full content.
func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update *types.GitStatusUpdate) {
@@ -170,19 +254,62 @@ func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update
if fileInfo.Status != fileStatusUntracked {
continue
}
- catCmd := exec.CommandContext(ctx, "cat", filePath)
- catCmd.Dir = wt.workDir
- catOut, err := catCmd.Output()
+
+ if totalDiffBytes(update) >= maxTotalDiffBytes {
+ fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded
+ update.Files[filePath] = fileInfo
+ continue
+ }
+
+ safePath, err := wt.sanitizePath(filePath)
if err != nil {
continue
}
- content := string(catOut)
+
+ info, err := os.Stat(filepath.Clean(safePath))
+ if err != nil {
+ continue
+ }
+ if info.Size() > maxDiffFileSize {
+ fileInfo.DiffSkipReason = diffSkipReasonTooLarge
+ update.Files[filePath] = fileInfo
+ continue
+ }
+
+ f, err := os.Open(filepath.Clean(safePath))
+ if err != nil {
+ continue
+ }
+
+ // Read first chunk to check for binary content.
+ header := make([]byte, binaryCheckSize)
+ n, _ := f.Read(header)
+ if n > 0 && isBinaryContent(header[:n]) {
+ _ = f.Close()
+ fileInfo.DiffSkipReason = diffSkipReasonBinary
+ update.Files[filePath] = fileInfo
+ continue
+ }
+
+ // Read only enough content to fill maxDiffOutputSize of diff output.
+ // No need to read the full file — any diff beyond maxDiffOutputSize gets truncated anyway.
+ var buf bytes.Buffer
+ buf.Write(header[:n])
+ remaining := int64(maxDiffOutputSize) - int64(n)
+ if remaining > 0 {
+ _, _ = io.Copy(&buf, io.LimitReader(f, remaining))
+ }
+ _ = f.Close()
+
+ content := buf.String()
lines := strings.Split(content, "\n")
+ // Trim trailing empty element from final newline so line count is accurate.
+ if len(lines) > 0 && lines[len(lines)-1] == "" {
+ lines = lines[:len(lines)-1]
+ }
fileInfo.Additions = len(lines)
fileInfo.Deletions = 0
- // Format as a proper git diff with all required headers.
- // The @git-diff-view/react library requires the full git diff format.
var diffBuilder strings.Builder
diffBuilder.WriteString("diff --git a/" + filePath + " b/" + filePath + "\n")
diffBuilder.WriteString("new file mode 100644\n")
@@ -193,7 +320,14 @@ func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update
for _, line := range lines {
diffBuilder.WriteString("+" + line + "\n")
}
- fileInfo.Diff = diffBuilder.String()
+
+ diffContent := diffBuilder.String()
+ if len(diffContent) > maxDiffOutputSize {
+ diffContent = diffContent[:maxDiffOutputSize]
+ fileInfo.DiffSkipReason = diffSkipReasonTruncated
+ }
+
+ fileInfo.Diff = diffContent
update.Files[filePath] = fileInfo
}
}
diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go
new file mode 100644
index 0000000000..17ebf4085f
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go
@@ -0,0 +1,219 @@
+package process
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
+)
+
+func TestIsBinaryContent(t *testing.T) {
+ tests := []struct {
+ name string
+ data []byte
+ binary bool
+ }{
+ {"empty", []byte{}, false},
+ {"text", []byte("hello world\n"), false},
+ {"utf8", []byte("héllo wörld\n"), false},
+ {"null byte", []byte("hello\x00world"), true},
+ {"null at start", []byte{0, 'h', 'i'}, true},
+ {"ELF header", []byte{0x7f, 'E', 'L', 'F', 0, 0}, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isBinaryContent(tt.data); got != tt.binary {
+ t.Errorf("isBinaryContent(%q) = %v, want %v", tt.data, got, tt.binary)
+ }
+ })
+ }
+}
+
+func TestTotalDiffBytes(t *testing.T) {
+ update := &streams.GitStatusUpdate{
+ Files: map[string]streams.FileInfo{
+ "a.go": {Diff: "abc"},
+ "b.go": {Diff: "defgh"},
+ "c.go": {Diff: ""},
+ },
+ }
+ got := totalDiffBytes(update)
+ if got != 8 {
+ t.Errorf("totalDiffBytes = %d, want 8", got)
+ }
+}
+
+func TestCapDiffOutput_Truncation(t *testing.T) {
+ isolateTestGitEnv(t)
+ repoDir, cleanup := setupTestRepo(t)
+ defer cleanup()
+
+ // Create a file with content larger than maxDiffOutputSize
+ bigContent := strings.Repeat("x", maxDiffOutputSize+1000)
+ writeFile(t, repoDir, "big.txt", bigContent)
+ runGit(t, repoDir, "add", "big.txt")
+
+ // Get diff — should be truncated
+ out, truncated := capDiffOutput(context.Background(), repoDir, "diff", "--cached", "--", "big.txt")
+ if !truncated {
+ t.Error("expected truncated=true for large diff")
+ }
+ if len(out) > maxDiffOutputSize {
+ t.Errorf("output len=%d exceeds maxDiffOutputSize=%d", len(out), maxDiffOutputSize)
+ }
+}
+
+func TestEnrichUntrackedFileDiffs_TooLarge(t *testing.T) {
+ isolateTestGitEnv(t)
+ repoDir, cleanup := setupTestRepo(t)
+ defer cleanup()
+
+ // Create a file just over the size limit
+ bigPath := filepath.Join(repoDir, "huge.bin")
+ f, err := os.Create(bigPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Write maxDiffFileSize + 1 bytes (all 'A')
+ buf := make([]byte, 1024*1024) // 1MB chunks
+ for i := range buf {
+ buf[i] = 'A'
+ }
+ written := int64(0)
+ for written < maxDiffFileSize+1 {
+ chunk := buf
+ if remaining := maxDiffFileSize + 1 - written; remaining < int64(len(chunk)) {
+ chunk = chunk[:remaining]
+ }
+ n, err := f.Write(chunk)
+ if err != nil {
+ _ = f.Close()
+ t.Fatal(err)
+ }
+ written += int64(n)
+ }
+ _ = f.Close()
+
+ log := newTestLogger(t)
+ wt := NewWorkspaceTracker(repoDir, log)
+ update := &streams.GitStatusUpdate{
+ Files: map[string]streams.FileInfo{
+ "huge.bin": {Path: "huge.bin", Status: "untracked"},
+ },
+ }
+
+ wt.enrichUntrackedFileDiffs(context.Background(), update)
+
+ fi := update.Files["huge.bin"]
+ if fi.DiffSkipReason != diffSkipReasonTooLarge {
+ t.Errorf("DiffSkipReason = %q, want %q", fi.DiffSkipReason, diffSkipReasonTooLarge)
+ }
+ if fi.Diff != "" {
+ t.Error("expected empty Diff for too-large file")
+ }
+}
+
+func TestEnrichUntrackedFileDiffs_Binary(t *testing.T) {
+ isolateTestGitEnv(t)
+ repoDir, cleanup := setupTestRepo(t)
+ defer cleanup()
+
+ // Create a binary file (contains null bytes)
+ binPath := filepath.Join(repoDir, "image.png")
+ binContent := []byte("PNG\x00\x00\x00fake binary content")
+ if err := os.WriteFile(binPath, binContent, 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ log := newTestLogger(t)
+ wt := NewWorkspaceTracker(repoDir, log)
+ update := &streams.GitStatusUpdate{
+ Files: map[string]streams.FileInfo{
+ "image.png": {Path: "image.png", Status: "untracked"},
+ },
+ }
+
+ wt.enrichUntrackedFileDiffs(context.Background(), update)
+
+ fi := update.Files["image.png"]
+ if fi.DiffSkipReason != diffSkipReasonBinary {
+ t.Errorf("DiffSkipReason = %q, want %q", fi.DiffSkipReason, diffSkipReasonBinary)
+ }
+ if fi.Diff != "" {
+ t.Error("expected empty Diff for binary file")
+ }
+}
+
+func TestEnrichUntrackedFileDiffs_BudgetExceeded(t *testing.T) {
+ isolateTestGitEnv(t)
+ repoDir, cleanup := setupTestRepo(t)
+ defer cleanup()
+
+ // Create files large enough that their synthetic diffs exceed maxTotalDiffBytes.
+ // Each file's diff includes headers + "+line\n" per line, so content of ~maxDiffOutputSize
+ // per file ensures we blow through the 2MB budget in a few files.
+ fileCount := 20
+ contentPerFile := maxDiffOutputSize / 2
+ files := make(map[string]streams.FileInfo)
+
+ for i := 0; i < fileCount; i++ {
+ name := filepath.Join(repoDir, strings.Repeat("a", i+1)+".txt")
+ content := strings.Repeat("x\n", contentPerFile/2)
+ if err := os.WriteFile(name, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+ relName := filepath.Base(name)
+ files[relName] = streams.FileInfo{Path: relName, Status: "untracked"}
+ }
+
+ log := newTestLogger(t)
+ wt := NewWorkspaceTracker(repoDir, log)
+ update := &streams.GitStatusUpdate{Files: files}
+
+ wt.enrichUntrackedFileDiffs(context.Background(), update)
+
+ budgetExceededCount := 0
+ for _, fi := range update.Files {
+ if fi.DiffSkipReason == diffSkipReasonBudgetExceeded {
+ budgetExceededCount++
+ }
+ }
+ if budgetExceededCount == 0 {
+ t.Error("expected at least one file with budget_exceeded skip reason")
+ }
+}
+
+func TestEnrichUntrackedFileDiffs_SmallTextFile(t *testing.T) {
+ isolateTestGitEnv(t)
+ repoDir, cleanup := setupTestRepo(t)
+ defer cleanup()
+
+ txtPath := filepath.Join(repoDir, "hello.txt")
+ if err := os.WriteFile(txtPath, []byte("hello world\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ log := newTestLogger(t)
+ wt := NewWorkspaceTracker(repoDir, log)
+ update := &streams.GitStatusUpdate{
+ Files: map[string]streams.FileInfo{
+ "hello.txt": {Path: "hello.txt", Status: "untracked"},
+ },
+ }
+
+ wt.enrichUntrackedFileDiffs(context.Background(), update)
+
+ fi := update.Files["hello.txt"]
+ if fi.Diff == "" {
+ t.Error("expected non-empty Diff for small text file")
+ }
+ if fi.DiffSkipReason != "" {
+ t.Errorf("expected empty DiffSkipReason, got %q", fi.DiffSkipReason)
+ }
+ if fi.Additions != 1 {
+ t.Errorf("Additions = %d, want 1", fi.Additions)
+ }
+}
diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go
index 1ee829afb1..1bd6e1cf7d 100644
--- a/apps/backend/internal/agentctl/types/streams/git.go
+++ b/apps/backend/internal/agentctl/types/streams/git.go
@@ -78,6 +78,10 @@ type FileInfo struct {
// Diff contains the unified diff content for this file.
Diff string `json:"diff,omitempty"`
+
+ // DiffSkipReason explains why diff content was omitted or truncated.
+ // Values: "too_large", "binary", "truncated", "budget_exceeded".
+ DiffSkipReason string `json:"diff_skip_reason,omitempty"`
}
// GitCommitNotification is sent when a new commit is detected in the workspace.
diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx
index 34148580d5..f1a3311189 100644
--- a/apps/web/components/review/review-diff-list.tsx
+++ b/apps/web/components/review/review-diff-list.tsx
@@ -29,6 +29,7 @@ import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
import type { DiffComment } from "@/lib/diff/types";
+import { diffSkipReasonLabel } from "./types";
import type { ReviewFile } from "./types";
function isMarkdownPath(filePath: string): boolean {
@@ -451,6 +452,60 @@ function useScrollIntoViewOnSelect(
}, [isSelected, sectionRef, setCollapsed]);
}
+function renderDiffContent(opts: {
+ shouldRender: boolean;
+ file: ReviewFile;
+ sessionId: string;
+ wordWrap: boolean;
+ expandUnchanged: boolean;
+ onRevertBlock: (filePath: string, info: RevertBlockInfo) => void;
+ onCommentRun: (comment: DiffComment) => void;
+ onToggleExpandUnchanged: () => void;
+}) {
+ const {
+ shouldRender,
+ file,
+ sessionId,
+ wordWrap,
+ expandUnchanged,
+ onRevertBlock,
+ onCommentRun,
+ onToggleExpandUnchanged,
+ } = opts;
+ if (shouldRender && file.diff) {
+ return (
+ <>
+