fix: prevent agentctl OOM from unbounded diff generation in workspace tracker (#598)

* fix: prevent agentctl OOM from unbounded diff generation in workspace tracker

Add per-file size cap (10MB), binary detection (null-byte in 8KB),
diff output cap (256KB), and total budget (2MB) to workspace tracker
diff enrichment. Strip diff content from event bus path. Surface skip
reasons in frontend. Add pprof endpoints to agentctl.

* fix: address PR review feedback

Close stdout pipe on cmd.Start failure to prevent fd leak.
Limit untracked file read to maxDiffOutputSize (256KB) instead of
maxDiffFileSize (10MB) to reduce peak allocation from ~20MB to ~512KB.

* fix: add filepath.Clean to os.Stat call to satisfy CodeQL path-injection check

* fix: address second round of PR review feedback

Extract all DiffSkipReason strings as named constants. Add diff_skip_reason
to UncommittedFile type removing unsafe cast. Add unit tests for
isBinaryContent, totalDiffBytes, capDiffOutput truncation, and all
enrichUntrackedFileDiffs skip-reason paths.

* fix: use bytes.IndexByte for null-byte check clarity

* fix: show truncation banner when diff has partial content

* fix: revert diff stripping from event bus — frontend needs diffs from this path

E2E tests confirmed the frontend receives git status diffs through the
event bus (agentctl → backend WS → event bus → gateway → frontend).
Stripping diffs broke the changes panel. The per-file size caps in
workspace_git_diff.go already bound the data.

* fix: extract renderDiffContent to keep FileDiffSection under 100-line lint limit

* fix: correct onCommentRun type in renderDiffContent helper

* fix: format long destructuring line for prettier

* fix: move diffSkipReasonLabel to types.ts to keep review-diff-list under 600 lines

Co-authored-by: José Almeida <undefined@users.noreply.github.com>

* fix: coerce optional forceLoad to boolean for renderDiffContent

* fix: shorten className and comment to stay within prettier printWidth

* fix: apply prettier formatting to review-diff-list

* fix: correct off-by-one in untracked file line count

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: José Almeida <undefined@users.noreply.github.com>
This commit is contained in:
José Almeida
2026-04-17 15:16:18 +01:00
committed by GitHub
parent 181a64a5fb
commit 6cbf050fa1
9 changed files with 500 additions and 41 deletions
@@ -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 {
@@ -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
}
}
@@ -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)
}
}
@@ -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.
+67 -24
View File
@@ -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 (
<>
<FileDiffViewer
filePath={file.path}
diff={file.diff}
status={file.status}
enableComments
enableAcceptReject
onRevertBlock={onRevertBlock}
onCommentRun={onCommentRun}
sessionId={sessionId}
wordWrap={wordWrap}
enableExpansion={true}
baseRef="HEAD"
hideHeader
expandUnchanged={expandUnchanged}
onToggleExpandUnchanged={onToggleExpandUnchanged}
/>
{file.diff_skip_reason === "truncated" && (
<div className="py-1 text-center text-xs text-muted-foreground border-t">
Diff truncated — showing first 256 KB
</div>
)}
</>
);
}
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
{diffSkipReasonLabel(file.diff_skip_reason)}
</div>
);
}
function FileDiffSection({
file,
isReviewed,
@@ -478,8 +533,8 @@ function FileDiffSection({
[wordWrap],
);
const { isVisible, sentinelRef } = useLazyVisible(scrollContainer);
// Force load when: visible via intersection observer, or forceLoad is true (all files up to selected)
const shouldRenderContent = isVisible || forceLoad;
// Force load when visible via intersection observer, or forceLoad is true
const shouldRenderContent = isVisible || !!forceLoad;
useScrollIntoViewOnSelect(isSelected, sectionRef, setCollapsed);
const scrollSentinelRef = useAutoMarkOnScroll({
autoMarkOnScroll,
@@ -527,28 +582,16 @@ function FileDiffSection({
/>
<div ref={sentinelRef} />
{!collapsed &&
(shouldRenderContent && file.diff ? (
<FileDiffViewer
filePath={file.path}
diff={file.diff}
status={file.status}
enableComments
enableAcceptReject
onRevertBlock={handleRevertBlock}
onCommentRun={handleCommentRun}
sessionId={sessionId}
wordWrap={effectiveWordWrap}
enableExpansion={true}
baseRef="HEAD"
hideHeader
expandUnchanged={expandUnchanged}
onToggleExpandUnchanged={handleToggleExpandUnchanged}
/>
) : (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
Loading diff...
</div>
))}
renderDiffContent({
shouldRender: shouldRenderContent,
file,
sessionId,
wordWrap: effectiveWordWrap,
expandUnchanged,
onRevertBlock: handleRevertBlock,
onCommentRun: handleCommentRun,
onToggleExpandUnchanged: handleToggleExpandUnchanged,
})}
</div>
);
}
+16
View File
@@ -8,8 +8,24 @@ export type ReviewFile = {
deletions: number;
staged: boolean;
source: "uncommitted" | "committed" | "pr";
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
};
export function diffSkipReasonLabel(reason?: string): string {
switch (reason) {
case "too_large":
return "File too large to diff (>10 MB)";
case "binary":
return "Binary file — not diffable";
case "truncated":
return "Diff truncated (>256 KB)";
case "budget_exceeded":
return "Diff skipped — too many changed files";
default:
return "Loading diff...";
}
}
export type FileTreeNode = {
name: string;
path: string;
@@ -35,6 +35,7 @@ type TaskChangesPanelProps = {
type UncommittedFile = {
diff?: string;
diff_skip_reason?: ReviewFile["diff_skip_reason"];
status?: string;
additions?: number;
deletions?: number;
@@ -48,7 +49,8 @@ function addUncommittedFiles(
) {
for (const [path, file] of Object.entries(files)) {
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
if (diff) {
const skipReason = file.diff_skip_reason;
if (diff || skipReason) {
fileMap.set(path, {
path,
diff,
@@ -57,6 +59,7 @@ function addUncommittedFiles(
deletions: file.deletions ?? 0,
staged: file.staged ?? false,
source: "uncommitted",
diff_skip_reason: skipReason,
});
}
}
@@ -48,6 +48,7 @@ export type FileInfo = {
deletions?: number;
old_path?: string;
diff?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
};
export type GitStatusEntry = {
+1
View File
@@ -264,6 +264,7 @@ export type FileInfo = {
deletions?: number;
old_path?: string;
diff?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
};
export type ProcessOutputPayload = {