Spaces:
Build error
Build error
Commit ·
69d6091
1
Parent(s): acd4e86
Add DiffCompressor and fix hnswlib SIGILL crash on CI
Browse filesDiffCompressor:
- Parse unified diff format and compress by reducing context lines
- Preserve file headers and all +/- change lines
- Score hunks by relevance (error keywords, query matches)
- Add summary line: [N files, +X -Y lines]
- Expected 30-50% savings on typical git diffs
- Wire into content router for CompressionStrategy.DIFF
- 30 tests covering parsing, compression, edge cases
hnswlib SIGILL fix:
- Move hnswlib import from module level to lazy loading
- hnswlib crashes with SIGILL (Illegal Instruction) on CPUs
without AVX support, before Python can catch the error
- Now imports only when HNSWVectorIndex is actually used
- HNSW_AVAILABLE is checked lazily via __getattr__
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- headroom/memory/adapters/__init__.py +15 -5
- headroom/memory/adapters/hnsw.py +42 -13
- headroom/transforms/__init__.py +4 -0
- headroom/transforms/content_router.py +21 -0
- headroom/transforms/diff_compressor.py +615 -0
- tests/test_hnsw_only.py +4 -2
- tests/test_memory/test_hierarchical.py +4 -2
- tests/test_transforms/test_diff_compressor.py +673 -0
headroom/memory/adapters/__init__.py
CHANGED
|
@@ -21,21 +21,31 @@ from headroom.memory.adapters.graph import InMemoryGraphStore
|
|
| 21 |
from headroom.memory.adapters.sqlite import SQLiteMemoryStore
|
| 22 |
|
| 23 |
# Check for optional dependencies availability
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
|
| 29 |
# Lazy imports for optional adapters
|
|
|
|
| 30 |
_HNSWVectorIndex = None
|
| 31 |
_LocalEmbedder = None
|
| 32 |
_OpenAIEmbedder = None
|
| 33 |
_OllamaEmbedder = None
|
| 34 |
|
| 35 |
|
| 36 |
-
def __getattr__(name: str) -> type:
|
| 37 |
"""Lazy import for optional adapters."""
|
| 38 |
global _HNSWVectorIndex, _LocalEmbedder, _OpenAIEmbedder, _OllamaEmbedder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
if name == "HNSWVectorIndex":
|
| 41 |
if _HNSWVectorIndex is None:
|
|
|
|
| 21 |
from headroom.memory.adapters.sqlite import SQLiteMemoryStore
|
| 22 |
|
| 23 |
# Check for optional dependencies availability
|
| 24 |
+
# Note: We don't import from hnsw.py here because hnswlib may crash with
|
| 25 |
+
# "Illegal instruction" on CPUs without required instructions (e.g., AVX).
|
| 26 |
+
# Instead, we check lazily when HNSWVectorIndex is actually used.
|
| 27 |
+
# HNSW_AVAILABLE is handled through __getattr__ to ensure lazy checking.
|
| 28 |
|
| 29 |
# Lazy imports for optional adapters
|
| 30 |
+
_HNSW_AVAILABLE: bool | None = None # Internal cache for HNSW_AVAILABLE
|
| 31 |
_HNSWVectorIndex = None
|
| 32 |
_LocalEmbedder = None
|
| 33 |
_OpenAIEmbedder = None
|
| 34 |
_OllamaEmbedder = None
|
| 35 |
|
| 36 |
|
| 37 |
+
def __getattr__(name: str) -> type | bool:
|
| 38 |
"""Lazy import for optional adapters."""
|
| 39 |
global _HNSWVectorIndex, _LocalEmbedder, _OpenAIEmbedder, _OllamaEmbedder
|
| 40 |
+
global _HNSW_AVAILABLE
|
| 41 |
+
|
| 42 |
+
if name == "HNSW_AVAILABLE":
|
| 43 |
+
# Lazily check hnswlib availability
|
| 44 |
+
if _HNSW_AVAILABLE is None:
|
| 45 |
+
from headroom.memory.adapters.hnsw import _check_hnswlib_available
|
| 46 |
+
|
| 47 |
+
_HNSW_AVAILABLE = _check_hnswlib_available()
|
| 48 |
+
return _HNSW_AVAILABLE
|
| 49 |
|
| 50 |
if name == "HNSWVectorIndex":
|
| 51 |
if _HNSWVectorIndex is None:
|
headroom/memory/adapters/hnsw.py
CHANGED
|
@@ -22,17 +22,44 @@ from typing import TYPE_CHECKING, Any
|
|
| 22 |
|
| 23 |
import numpy as np
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
# hnswlib is optional - may not compile on all platforms
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
HNSW_AVAILABLE = True
|
| 30 |
-
except ImportError:
|
| 31 |
-
hnswlib = None # type: ignore[assignment]
|
| 32 |
-
HNSW_AVAILABLE = False
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
if TYPE_CHECKING:
|
| 38 |
pass
|
|
@@ -187,12 +214,13 @@ class HNSWVectorIndex:
|
|
| 187 |
ValueError: If auto_save is True but save_path is not provided.
|
| 188 |
ImportError: If hnswlib is not installed.
|
| 189 |
"""
|
| 190 |
-
if not
|
| 191 |
raise ImportError(
|
| 192 |
"hnswlib is required for HNSWVectorIndex. "
|
| 193 |
"Install with: pip install hnswlib\n"
|
| 194 |
"Note: hnswlib requires C++ compilation and may not be "
|
| 195 |
-
"available on all platforms
|
|
|
|
| 196 |
)
|
| 197 |
|
| 198 |
if auto_save and save_path is None:
|
|
@@ -208,7 +236,8 @@ class HNSWVectorIndex:
|
|
| 208 |
|
| 209 |
# Initialize HNSW index with cosine similarity
|
| 210 |
# hnswlib uses 'cosine' space which internally normalizes vectors
|
| 211 |
-
|
|
|
|
| 212 |
self._index.init_index(
|
| 213 |
max_elements=max_elements,
|
| 214 |
ef_construction=ef_construction,
|
|
@@ -729,7 +758,7 @@ class HNSWVectorIndex:
|
|
| 729 |
self._ef_search = meta_data["ef_search"]
|
| 730 |
|
| 731 |
# Create new index and load from file
|
| 732 |
-
self._index = hnswlib.Index(space="cosine", dim=self._dimension)
|
| 733 |
self._index.load_index(
|
| 734 |
str(hnsw_path),
|
| 735 |
max_elements=self._max_elements,
|
|
@@ -757,7 +786,7 @@ class HNSWVectorIndex:
|
|
| 757 |
"""Clear all entries from the index."""
|
| 758 |
with self._lock:
|
| 759 |
# Reinitialize the index
|
| 760 |
-
self._index = hnswlib.Index(space="cosine", dim=self._dimension)
|
| 761 |
self._index.init_index(
|
| 762 |
max_elements=self._max_elements,
|
| 763 |
ef_construction=self._ef_construction,
|
|
|
|
| 22 |
|
| 23 |
import numpy as np
|
| 24 |
|
| 25 |
+
from ..models import Memory, ScopeLevel
|
| 26 |
+
from ..ports import VectorFilter, VectorSearchResult
|
| 27 |
+
|
| 28 |
# hnswlib is optional - may not compile on all platforms
|
| 29 |
+
# NOTE: We don't import hnswlib at module level because it can crash with SIGILL
|
| 30 |
+
# (Illegal Instruction) on CPUs without required AVX instructions. The crash
|
| 31 |
+
# happens at the C level before Python's try/except can catch it.
|
| 32 |
+
# Instead, we import lazily when HNSWVectorIndex is actually instantiated.
|
| 33 |
+
hnswlib: Any = None # Will be imported lazily
|
| 34 |
+
HNSW_AVAILABLE: bool | None = None # None = not yet checked, True/False = checked
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
def _check_hnswlib_available() -> bool:
|
| 38 |
+
"""Check if hnswlib is available, importing it lazily.
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
True if hnswlib is available and working.
|
| 42 |
+
|
| 43 |
+
Note:
|
| 44 |
+
This function caches the result in HNSW_AVAILABLE.
|
| 45 |
+
On CPUs without AVX support, importing hnswlib may crash
|
| 46 |
+
the process with SIGILL before we can catch the error.
|
| 47 |
+
"""
|
| 48 |
+
global hnswlib, HNSW_AVAILABLE
|
| 49 |
+
|
| 50 |
+
if HNSW_AVAILABLE is not None:
|
| 51 |
+
return HNSW_AVAILABLE
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
import hnswlib as _hnswlib
|
| 55 |
+
|
| 56 |
+
hnswlib = _hnswlib
|
| 57 |
+
HNSW_AVAILABLE = True
|
| 58 |
+
except ImportError:
|
| 59 |
+
HNSW_AVAILABLE = False
|
| 60 |
+
|
| 61 |
+
return HNSW_AVAILABLE
|
| 62 |
+
|
| 63 |
|
| 64 |
if TYPE_CHECKING:
|
| 65 |
pass
|
|
|
|
| 214 |
ValueError: If auto_save is True but save_path is not provided.
|
| 215 |
ImportError: If hnswlib is not installed.
|
| 216 |
"""
|
| 217 |
+
if not _check_hnswlib_available():
|
| 218 |
raise ImportError(
|
| 219 |
"hnswlib is required for HNSWVectorIndex. "
|
| 220 |
"Install with: pip install hnswlib\n"
|
| 221 |
"Note: hnswlib requires C++ compilation and may not be "
|
| 222 |
+
"available on all platforms (crashes with SIGILL on CPUs "
|
| 223 |
+
"without AVX support)."
|
| 224 |
)
|
| 225 |
|
| 226 |
if auto_save and save_path is None:
|
|
|
|
| 236 |
|
| 237 |
# Initialize HNSW index with cosine similarity
|
| 238 |
# hnswlib uses 'cosine' space which internally normalizes vectors
|
| 239 |
+
# Note: hnswlib is guaranteed non-None here due to _check_hnswlib_available() above
|
| 240 |
+
self._index = hnswlib.Index(space="cosine", dim=dimension) # type: ignore[union-attr]
|
| 241 |
self._index.init_index(
|
| 242 |
max_elements=max_elements,
|
| 243 |
ef_construction=ef_construction,
|
|
|
|
| 758 |
self._ef_search = meta_data["ef_search"]
|
| 759 |
|
| 760 |
# Create new index and load from file
|
| 761 |
+
self._index = hnswlib.Index(space="cosine", dim=self._dimension) # type: ignore[union-attr]
|
| 762 |
self._index.load_index(
|
| 763 |
str(hnsw_path),
|
| 764 |
max_elements=self._max_elements,
|
|
|
|
| 786 |
"""Clear all entries from the index."""
|
| 787 |
with self._lock:
|
| 788 |
# Reinitialize the index
|
| 789 |
+
self._index = hnswlib.Index(space="cosine", dim=self._dimension) # type: ignore[union-attr]
|
| 790 |
self._index.init_index(
|
| 791 |
max_elements=self._max_elements,
|
| 792 |
ef_construction=self._ef_construction,
|
headroom/transforms/__init__.py
CHANGED
|
@@ -11,6 +11,7 @@ from .anchor_selector import (
|
|
| 11 |
from .base import Transform
|
| 12 |
from .cache_aligner import CacheAligner
|
| 13 |
from .content_detector import ContentType, DetectionResult, detect_content_type
|
|
|
|
| 14 |
from .intelligent_context import ContextStrategy, IntelligentContextManager
|
| 15 |
from .log_compressor import LogCompressionResult, LogCompressor, LogCompressorConfig
|
| 16 |
from .pipeline import TransformPipeline
|
|
@@ -84,6 +85,9 @@ __all__ = [
|
|
| 84 |
"LogCompressor",
|
| 85 |
"LogCompressorConfig",
|
| 86 |
"LogCompressionResult",
|
|
|
|
|
|
|
|
|
|
| 87 |
"TextCompressor",
|
| 88 |
"TextCompressorConfig",
|
| 89 |
"TextCompressionResult",
|
|
|
|
| 11 |
from .base import Transform
|
| 12 |
from .cache_aligner import CacheAligner
|
| 13 |
from .content_detector import ContentType, DetectionResult, detect_content_type
|
| 14 |
+
from .diff_compressor import DiffCompressionResult, DiffCompressor, DiffCompressorConfig
|
| 15 |
from .intelligent_context import ContextStrategy, IntelligentContextManager
|
| 16 |
from .log_compressor import LogCompressionResult, LogCompressor, LogCompressorConfig
|
| 17 |
from .pipeline import TransformPipeline
|
|
|
|
| 85 |
"LogCompressor",
|
| 86 |
"LogCompressorConfig",
|
| 87 |
"LogCompressionResult",
|
| 88 |
+
"DiffCompressor",
|
| 89 |
+
"DiffCompressorConfig",
|
| 90 |
+
"DiffCompressionResult",
|
| 91 |
"TextCompressor",
|
| 92 |
"TextCompressorConfig",
|
| 93 |
"TextCompressionResult",
|
headroom/transforms/content_router.py
CHANGED
|
@@ -461,6 +461,7 @@ class ContentRouter(Transform):
|
|
| 461 |
self._smart_crusher: Any = None
|
| 462 |
self._search_compressor: Any = None
|
| 463 |
self._log_compressor: Any = None
|
|
|
|
| 464 |
self._llmlingua: Any = None
|
| 465 |
self._text_compressor: Any = None
|
| 466 |
self._image_optimizer: Any = None
|
|
@@ -772,6 +773,15 @@ class ContentRouter(Transform):
|
|
| 772 |
result.compressed_line_count,
|
| 773 |
)
|
| 774 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
elif strategy == CompressionStrategy.LLMLINGUA:
|
| 776 |
compressed, compressed_tokens = self._try_llmlingua(content, context)
|
| 777 |
|
|
@@ -907,6 +917,17 @@ class ContentRouter(Transform):
|
|
| 907 |
logger.debug("LogCompressor not available")
|
| 908 |
return self._log_compressor
|
| 909 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
def _get_llmlingua(self) -> Any:
|
| 911 |
"""Get LLMLinguaCompressor (lazy load)."""
|
| 912 |
if self._llmlingua is None:
|
|
|
|
| 461 |
self._smart_crusher: Any = None
|
| 462 |
self._search_compressor: Any = None
|
| 463 |
self._log_compressor: Any = None
|
| 464 |
+
self._diff_compressor: Any = None
|
| 465 |
self._llmlingua: Any = None
|
| 466 |
self._text_compressor: Any = None
|
| 467 |
self._image_optimizer: Any = None
|
|
|
|
| 773 |
result.compressed_line_count,
|
| 774 |
)
|
| 775 |
|
| 776 |
+
elif strategy == CompressionStrategy.DIFF:
|
| 777 |
+
compressor = self._get_diff_compressor()
|
| 778 |
+
if compressor:
|
| 779 |
+
result = compressor.compress(content, context=context)
|
| 780 |
+
compressed, compressed_tokens = (
|
| 781 |
+
result.compressed,
|
| 782 |
+
result.compressed_line_count,
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
elif strategy == CompressionStrategy.LLMLINGUA:
|
| 786 |
compressed, compressed_tokens = self._try_llmlingua(content, context)
|
| 787 |
|
|
|
|
| 917 |
logger.debug("LogCompressor not available")
|
| 918 |
return self._log_compressor
|
| 919 |
|
| 920 |
+
def _get_diff_compressor(self) -> Any:
|
| 921 |
+
"""Get DiffCompressor (lazy load)."""
|
| 922 |
+
if self._diff_compressor is None:
|
| 923 |
+
try:
|
| 924 |
+
from .diff_compressor import DiffCompressor
|
| 925 |
+
|
| 926 |
+
self._diff_compressor = DiffCompressor()
|
| 927 |
+
except ImportError:
|
| 928 |
+
logger.debug("DiffCompressor not available")
|
| 929 |
+
return self._diff_compressor
|
| 930 |
+
|
| 931 |
def _get_llmlingua(self) -> Any:
|
| 932 |
"""Get LLMLinguaCompressor (lazy load)."""
|
| 933 |
if self._llmlingua is None:
|
headroom/transforms/diff_compressor.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Git diff output compressor for unified diff format.
|
| 2 |
+
|
| 3 |
+
This module compresses git diff output which can be very verbose with
|
| 4 |
+
many context lines. Typical compression: 3-10x.
|
| 5 |
+
|
| 6 |
+
Supported formats:
|
| 7 |
+
- Unified diff format (git diff, diff -u)
|
| 8 |
+
- Combined diff format (merge conflicts)
|
| 9 |
+
|
| 10 |
+
Compression Strategy:
|
| 11 |
+
1. Parse unified diff format into file sections and hunks
|
| 12 |
+
2. Always keep file headers (diff --git, ---, +++)
|
| 13 |
+
3. Always keep ALL actual changes (+/- lines)
|
| 14 |
+
4. Reduce context lines (` ` prefix) to configurable max
|
| 15 |
+
5. If too many hunks, keep first N and summarize rest
|
| 16 |
+
6. Add summary at end
|
| 17 |
+
|
| 18 |
+
Key Patterns to Preserve:
|
| 19 |
+
- All additions (+)
|
| 20 |
+
- All deletions (-)
|
| 21 |
+
- Hunk headers (@@ ... @@)
|
| 22 |
+
- File headers (diff --git, ---, +++)
|
| 23 |
+
- Context around changes (limited)
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import re
|
| 29 |
+
from dataclasses import dataclass, field
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class DiffHunk:
|
| 34 |
+
"""A single hunk within a diff file."""
|
| 35 |
+
|
| 36 |
+
header: str # @@ -start,count +start,count @@ optional function
|
| 37 |
+
lines: list[str] # All lines in the hunk
|
| 38 |
+
additions: int = 0
|
| 39 |
+
deletions: int = 0
|
| 40 |
+
context_lines: int = 0
|
| 41 |
+
score: float = 0.0 # Relevance score for context-aware compression
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def change_count(self) -> int:
|
| 45 |
+
"""Total number of actual changes (additions + deletions)."""
|
| 46 |
+
return self.additions + self.deletions
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class DiffFile:
|
| 51 |
+
"""A single file's diff."""
|
| 52 |
+
|
| 53 |
+
header: str # diff --git a/... b/...
|
| 54 |
+
old_file: str # --- a/...
|
| 55 |
+
new_file: str # +++ b/...
|
| 56 |
+
hunks: list[DiffHunk] = field(default_factory=list)
|
| 57 |
+
is_binary: bool = False
|
| 58 |
+
is_new_file: bool = False
|
| 59 |
+
is_deleted_file: bool = False
|
| 60 |
+
is_renamed: bool = False
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def total_additions(self) -> int:
|
| 64 |
+
return sum(h.additions for h in self.hunks)
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def total_deletions(self) -> int:
|
| 68 |
+
return sum(h.deletions for h in self.hunks)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@dataclass
|
| 72 |
+
class DiffCompressorConfig:
|
| 73 |
+
"""Configuration for diff compression."""
|
| 74 |
+
|
| 75 |
+
# Context line limits
|
| 76 |
+
max_context_lines: int = 2 # Reduce from default 3 lines before/after changes
|
| 77 |
+
|
| 78 |
+
# Hunk limits
|
| 79 |
+
max_hunks_per_file: int = 10
|
| 80 |
+
|
| 81 |
+
# File limits
|
| 82 |
+
max_files: int = 20
|
| 83 |
+
|
| 84 |
+
# Change preservation
|
| 85 |
+
always_keep_additions: bool = True # Always keep + lines
|
| 86 |
+
always_keep_deletions: bool = True # Always keep - lines
|
| 87 |
+
|
| 88 |
+
# CCR integration
|
| 89 |
+
enable_ccr: bool = True
|
| 90 |
+
min_lines_for_ccr: int = 50
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@dataclass
|
| 94 |
+
class DiffCompressionResult:
|
| 95 |
+
"""Result of diff compression."""
|
| 96 |
+
|
| 97 |
+
compressed: str
|
| 98 |
+
original_line_count: int
|
| 99 |
+
compressed_line_count: int
|
| 100 |
+
files_affected: int
|
| 101 |
+
additions: int
|
| 102 |
+
deletions: int
|
| 103 |
+
hunks_kept: int
|
| 104 |
+
hunks_removed: int
|
| 105 |
+
cache_key: str | None = None
|
| 106 |
+
|
| 107 |
+
@property
|
| 108 |
+
def compression_ratio(self) -> float:
|
| 109 |
+
"""Ratio of compressed to original (lower is better compression)."""
|
| 110 |
+
if self.original_line_count == 0:
|
| 111 |
+
return 1.0
|
| 112 |
+
return self.compressed_line_count / self.original_line_count
|
| 113 |
+
|
| 114 |
+
@property
|
| 115 |
+
def tokens_saved_estimate(self) -> int:
|
| 116 |
+
"""Estimate tokens saved (rough: 1 token per 4 chars)."""
|
| 117 |
+
# Use line counts as proxy for chars
|
| 118 |
+
lines_saved = self.original_line_count - self.compressed_line_count
|
| 119 |
+
# Estimate ~40 chars per line average for diffs
|
| 120 |
+
chars_saved = lines_saved * 40
|
| 121 |
+
return max(0, chars_saved // 4)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class DiffCompressor:
|
| 125 |
+
"""Compresses git diff output.
|
| 126 |
+
|
| 127 |
+
Example:
|
| 128 |
+
>>> compressor = DiffCompressor()
|
| 129 |
+
>>> result = compressor.compress(git_diff_output)
|
| 130 |
+
>>> print(result.compressed) # Reduced diff with summary
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
# Pattern for diff --git header
|
| 134 |
+
_DIFF_GIT_PATTERN = re.compile(r"^diff --git a/(.+) b/(.+)$")
|
| 135 |
+
|
| 136 |
+
# Pattern for --- a/file or --- /dev/null
|
| 137 |
+
_OLD_FILE_PATTERN = re.compile(r"^--- (a/(.+)|/dev/null)$")
|
| 138 |
+
|
| 139 |
+
# Pattern for +++ b/file or +++ /dev/null
|
| 140 |
+
_NEW_FILE_PATTERN = re.compile(r"^\+\+\+ (b/(.+)|/dev/null)$")
|
| 141 |
+
|
| 142 |
+
# Pattern for hunk header @@ -start,count +start,count @@ optional context
|
| 143 |
+
_HUNK_HEADER_PATTERN = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")
|
| 144 |
+
|
| 145 |
+
# Pattern for binary file indication
|
| 146 |
+
_BINARY_PATTERN = re.compile(r"^Binary files .+ differ$")
|
| 147 |
+
|
| 148 |
+
# Patterns for new/deleted file mode
|
| 149 |
+
_NEW_FILE_MODE_PATTERN = re.compile(r"^new file mode")
|
| 150 |
+
_DELETED_FILE_MODE_PATTERN = re.compile(r"^deleted file mode")
|
| 151 |
+
_RENAME_PATTERN = re.compile(r"^(rename|similarity|copy) ")
|
| 152 |
+
|
| 153 |
+
# Priority patterns for context-aware hunk selection
|
| 154 |
+
_PRIORITY_PATTERNS = [
|
| 155 |
+
re.compile(r"\b(error|exception|fail|bug|fix)\b", re.IGNORECASE),
|
| 156 |
+
re.compile(r"\b(todo|fixme|hack|xxx)\b", re.IGNORECASE),
|
| 157 |
+
re.compile(r"\b(security|auth|password|secret|token)\b", re.IGNORECASE),
|
| 158 |
+
]
|
| 159 |
+
|
| 160 |
+
def __init__(self, config: DiffCompressorConfig | None = None):
|
| 161 |
+
"""Initialize diff compressor.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
config: Compression configuration.
|
| 165 |
+
"""
|
| 166 |
+
self.config = config or DiffCompressorConfig()
|
| 167 |
+
|
| 168 |
+
def compress(self, content: str, context: str = "") -> DiffCompressionResult:
|
| 169 |
+
"""Compress diff output.
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
content: Raw git diff output.
|
| 173 |
+
context: User query context for relevance scoring.
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
DiffCompressionResult with compressed output and metadata.
|
| 177 |
+
"""
|
| 178 |
+
lines = content.split("\n")
|
| 179 |
+
original_line_count = len(lines)
|
| 180 |
+
|
| 181 |
+
if original_line_count < self.config.min_lines_for_ccr:
|
| 182 |
+
return DiffCompressionResult(
|
| 183 |
+
compressed=content,
|
| 184 |
+
original_line_count=original_line_count,
|
| 185 |
+
compressed_line_count=original_line_count,
|
| 186 |
+
files_affected=0,
|
| 187 |
+
additions=0,
|
| 188 |
+
deletions=0,
|
| 189 |
+
hunks_kept=0,
|
| 190 |
+
hunks_removed=0,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# Parse diff into structured format
|
| 194 |
+
diff_files = self._parse_diff(lines)
|
| 195 |
+
|
| 196 |
+
if not diff_files:
|
| 197 |
+
return DiffCompressionResult(
|
| 198 |
+
compressed=content,
|
| 199 |
+
original_line_count=original_line_count,
|
| 200 |
+
compressed_line_count=original_line_count,
|
| 201 |
+
files_affected=0,
|
| 202 |
+
additions=0,
|
| 203 |
+
deletions=0,
|
| 204 |
+
hunks_kept=0,
|
| 205 |
+
hunks_removed=0,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
# Score hunks by relevance
|
| 209 |
+
self._score_hunks(diff_files, context)
|
| 210 |
+
|
| 211 |
+
# Compress each file's hunks
|
| 212 |
+
compressed_files, stats = self._compress_files(diff_files)
|
| 213 |
+
|
| 214 |
+
# Format output
|
| 215 |
+
compressed_output = self._format_output(compressed_files, stats)
|
| 216 |
+
compressed_line_count = len(compressed_output.split("\n"))
|
| 217 |
+
|
| 218 |
+
# Store in CCR if significant compression
|
| 219 |
+
cache_key = None
|
| 220 |
+
if self.config.enable_ccr and compressed_line_count < original_line_count * 0.8:
|
| 221 |
+
cache_key = self._store_in_ccr(content, compressed_output, original_line_count)
|
| 222 |
+
if cache_key:
|
| 223 |
+
compressed_output += f"\n[{original_line_count} lines compressed to {compressed_line_count}. Retrieve full diff: hash={cache_key}]"
|
| 224 |
+
|
| 225 |
+
return DiffCompressionResult(
|
| 226 |
+
compressed=compressed_output,
|
| 227 |
+
original_line_count=original_line_count,
|
| 228 |
+
compressed_line_count=compressed_line_count,
|
| 229 |
+
files_affected=stats["files_affected"],
|
| 230 |
+
additions=stats["total_additions"],
|
| 231 |
+
deletions=stats["total_deletions"],
|
| 232 |
+
hunks_kept=stats["hunks_kept"],
|
| 233 |
+
hunks_removed=stats["hunks_removed"],
|
| 234 |
+
cache_key=cache_key,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
def _parse_diff(self, lines: list[str]) -> list[DiffFile]:
|
| 238 |
+
"""Parse diff content into structured format.
|
| 239 |
+
|
| 240 |
+
Args:
|
| 241 |
+
lines: Lines of diff content.
|
| 242 |
+
|
| 243 |
+
Returns:
|
| 244 |
+
List of DiffFile objects.
|
| 245 |
+
"""
|
| 246 |
+
diff_files: list[DiffFile] = []
|
| 247 |
+
current_file: DiffFile | None = None
|
| 248 |
+
current_hunk: DiffHunk | None = None
|
| 249 |
+
i = 0
|
| 250 |
+
|
| 251 |
+
while i < len(lines):
|
| 252 |
+
line = lines[i]
|
| 253 |
+
|
| 254 |
+
# Check for diff --git header (new file section)
|
| 255 |
+
if self._DIFF_GIT_PATTERN.match(line):
|
| 256 |
+
# Save previous hunk and file
|
| 257 |
+
if current_hunk and current_file:
|
| 258 |
+
current_file.hunks.append(current_hunk)
|
| 259 |
+
if current_file:
|
| 260 |
+
diff_files.append(current_file)
|
| 261 |
+
|
| 262 |
+
current_file = DiffFile(
|
| 263 |
+
header=line,
|
| 264 |
+
old_file="",
|
| 265 |
+
new_file="",
|
| 266 |
+
)
|
| 267 |
+
current_hunk = None
|
| 268 |
+
i += 1
|
| 269 |
+
continue
|
| 270 |
+
|
| 271 |
+
# Check for file mode indicators
|
| 272 |
+
if current_file:
|
| 273 |
+
if self._NEW_FILE_MODE_PATTERN.match(line):
|
| 274 |
+
current_file.is_new_file = True
|
| 275 |
+
elif self._DELETED_FILE_MODE_PATTERN.match(line):
|
| 276 |
+
current_file.is_deleted_file = True
|
| 277 |
+
elif self._RENAME_PATTERN.match(line):
|
| 278 |
+
current_file.is_renamed = True
|
| 279 |
+
elif self._BINARY_PATTERN.match(line):
|
| 280 |
+
current_file.is_binary = True
|
| 281 |
+
|
| 282 |
+
# Check for --- a/file
|
| 283 |
+
if self._OLD_FILE_PATTERN.match(line):
|
| 284 |
+
if current_file:
|
| 285 |
+
current_file.old_file = line
|
| 286 |
+
i += 1
|
| 287 |
+
continue
|
| 288 |
+
|
| 289 |
+
# Check for +++ b/file
|
| 290 |
+
if self._NEW_FILE_PATTERN.match(line):
|
| 291 |
+
if current_file:
|
| 292 |
+
current_file.new_file = line
|
| 293 |
+
i += 1
|
| 294 |
+
continue
|
| 295 |
+
|
| 296 |
+
# Check for hunk header
|
| 297 |
+
if self._HUNK_HEADER_PATTERN.match(line):
|
| 298 |
+
# Save previous hunk
|
| 299 |
+
if current_hunk and current_file:
|
| 300 |
+
current_file.hunks.append(current_hunk)
|
| 301 |
+
|
| 302 |
+
current_hunk = DiffHunk(
|
| 303 |
+
header=line,
|
| 304 |
+
lines=[],
|
| 305 |
+
)
|
| 306 |
+
i += 1
|
| 307 |
+
continue
|
| 308 |
+
|
| 309 |
+
# Process hunk content lines
|
| 310 |
+
if current_hunk is not None:
|
| 311 |
+
if line.startswith("+") and not line.startswith("+++"):
|
| 312 |
+
current_hunk.additions += 1
|
| 313 |
+
current_hunk.lines.append(line)
|
| 314 |
+
elif line.startswith("-") and not line.startswith("---"):
|
| 315 |
+
current_hunk.deletions += 1
|
| 316 |
+
current_hunk.lines.append(line)
|
| 317 |
+
elif line.startswith(" ") or line == "":
|
| 318 |
+
current_hunk.context_lines += 1
|
| 319 |
+
current_hunk.lines.append(line)
|
| 320 |
+
else:
|
| 321 |
+
# Other line (e.g., "")
|
| 322 |
+
current_hunk.lines.append(line)
|
| 323 |
+
|
| 324 |
+
i += 1
|
| 325 |
+
|
| 326 |
+
# Save final hunk and file
|
| 327 |
+
if current_hunk and current_file:
|
| 328 |
+
current_file.hunks.append(current_hunk)
|
| 329 |
+
if current_file:
|
| 330 |
+
diff_files.append(current_file)
|
| 331 |
+
|
| 332 |
+
return diff_files
|
| 333 |
+
|
| 334 |
+
def _score_hunks(self, diff_files: list[DiffFile], context: str) -> None:
|
| 335 |
+
"""Score hunks by relevance to context.
|
| 336 |
+
|
| 337 |
+
Args:
|
| 338 |
+
diff_files: Parsed diff files.
|
| 339 |
+
context: User query context.
|
| 340 |
+
"""
|
| 341 |
+
context_lower = context.lower()
|
| 342 |
+
context_words = set(context_lower.split()) if context else set()
|
| 343 |
+
|
| 344 |
+
for diff_file in diff_files:
|
| 345 |
+
for hunk in diff_file.hunks:
|
| 346 |
+
score = 0.0
|
| 347 |
+
|
| 348 |
+
# Base score from change count (more changes = more important)
|
| 349 |
+
score += min(0.3, hunk.change_count * 0.03)
|
| 350 |
+
|
| 351 |
+
hunk_content = "\n".join(hunk.lines).lower()
|
| 352 |
+
|
| 353 |
+
# Score by context word overlap
|
| 354 |
+
for word in context_words:
|
| 355 |
+
if len(word) > 2 and word in hunk_content:
|
| 356 |
+
score += 0.2
|
| 357 |
+
|
| 358 |
+
# Boost for priority patterns
|
| 359 |
+
for pattern in self._PRIORITY_PATTERNS:
|
| 360 |
+
if pattern.search(hunk_content):
|
| 361 |
+
score += 0.3
|
| 362 |
+
break
|
| 363 |
+
|
| 364 |
+
hunk.score = min(1.0, score)
|
| 365 |
+
|
| 366 |
+
def _compress_files(self, diff_files: list[DiffFile]) -> tuple[list[DiffFile], dict[str, int]]:
|
| 367 |
+
"""Compress hunks in each file.
|
| 368 |
+
|
| 369 |
+
Args:
|
| 370 |
+
diff_files: Parsed diff files.
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
Tuple of (compressed files, stats dict).
|
| 374 |
+
"""
|
| 375 |
+
stats = {
|
| 376 |
+
"files_affected": 0,
|
| 377 |
+
"total_additions": 0,
|
| 378 |
+
"total_deletions": 0,
|
| 379 |
+
"hunks_kept": 0,
|
| 380 |
+
"hunks_removed": 0,
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
# Limit files if too many
|
| 384 |
+
if len(diff_files) > self.config.max_files:
|
| 385 |
+
# Sort by total changes (most changes first)
|
| 386 |
+
diff_files = sorted(
|
| 387 |
+
diff_files,
|
| 388 |
+
key=lambda f: f.total_additions + f.total_deletions,
|
| 389 |
+
reverse=True,
|
| 390 |
+
)
|
| 391 |
+
diff_files = diff_files[: self.config.max_files]
|
| 392 |
+
|
| 393 |
+
compressed_files: list[DiffFile] = []
|
| 394 |
+
|
| 395 |
+
for diff_file in diff_files:
|
| 396 |
+
stats["files_affected"] += 1
|
| 397 |
+
stats["total_additions"] += diff_file.total_additions
|
| 398 |
+
stats["total_deletions"] += diff_file.total_deletions
|
| 399 |
+
|
| 400 |
+
# Compress hunks within file
|
| 401 |
+
compressed_hunks = self._compress_hunks(diff_file.hunks)
|
| 402 |
+
|
| 403 |
+
stats["hunks_kept"] += len(compressed_hunks)
|
| 404 |
+
stats["hunks_removed"] += len(diff_file.hunks) - len(compressed_hunks)
|
| 405 |
+
|
| 406 |
+
# Create compressed file with reduced context in hunks
|
| 407 |
+
new_file = DiffFile(
|
| 408 |
+
header=diff_file.header,
|
| 409 |
+
old_file=diff_file.old_file,
|
| 410 |
+
new_file=diff_file.new_file,
|
| 411 |
+
hunks=compressed_hunks,
|
| 412 |
+
is_binary=diff_file.is_binary,
|
| 413 |
+
is_new_file=diff_file.is_new_file,
|
| 414 |
+
is_deleted_file=diff_file.is_deleted_file,
|
| 415 |
+
is_renamed=diff_file.is_renamed,
|
| 416 |
+
)
|
| 417 |
+
compressed_files.append(new_file)
|
| 418 |
+
|
| 419 |
+
return compressed_files, stats
|
| 420 |
+
|
| 421 |
+
def _compress_hunks(self, hunks: list[DiffHunk]) -> list[DiffHunk]:
|
| 422 |
+
"""Compress hunks by reducing context and limiting count.
|
| 423 |
+
|
| 424 |
+
Args:
|
| 425 |
+
hunks: List of hunks to compress.
|
| 426 |
+
|
| 427 |
+
Returns:
|
| 428 |
+
Compressed list of hunks.
|
| 429 |
+
"""
|
| 430 |
+
if not hunks:
|
| 431 |
+
return []
|
| 432 |
+
|
| 433 |
+
# Sort by score if we need to limit
|
| 434 |
+
if len(hunks) > self.config.max_hunks_per_file:
|
| 435 |
+
# Keep first and last hunks (often important)
|
| 436 |
+
first_hunk = hunks[0]
|
| 437 |
+
last_hunk = hunks[-1] if len(hunks) > 1 else None
|
| 438 |
+
|
| 439 |
+
# Sort middle hunks by score
|
| 440 |
+
middle_hunks = sorted(
|
| 441 |
+
hunks[1:-1] if last_hunk else [], key=lambda h: h.score, reverse=True
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
# Take top scoring middle hunks
|
| 445 |
+
remaining_slots = (
|
| 446 |
+
self.config.max_hunks_per_file - 2
|
| 447 |
+
if last_hunk
|
| 448 |
+
else self.config.max_hunks_per_file - 1
|
| 449 |
+
)
|
| 450 |
+
selected_middle = middle_hunks[:remaining_slots]
|
| 451 |
+
|
| 452 |
+
# Rebuild list in original order by re-sorting by appearance
|
| 453 |
+
selected = [first_hunk] + selected_middle
|
| 454 |
+
if last_hunk:
|
| 455 |
+
selected.append(last_hunk)
|
| 456 |
+
|
| 457 |
+
# Sort back to original order (using header line numbers as proxy)
|
| 458 |
+
hunks = sorted(selected, key=lambda h: self._extract_line_number(h.header))
|
| 459 |
+
|
| 460 |
+
# Reduce context in each hunk
|
| 461 |
+
compressed_hunks = []
|
| 462 |
+
for hunk in hunks:
|
| 463 |
+
compressed_hunk = self._reduce_context(hunk)
|
| 464 |
+
compressed_hunks.append(compressed_hunk)
|
| 465 |
+
|
| 466 |
+
return compressed_hunks
|
| 467 |
+
|
| 468 |
+
def _extract_line_number(self, header: str) -> int:
|
| 469 |
+
"""Extract starting line number from hunk header for sorting."""
|
| 470 |
+
match = self._HUNK_HEADER_PATTERN.match(header)
|
| 471 |
+
if match:
|
| 472 |
+
return int(match.group(1))
|
| 473 |
+
return 0
|
| 474 |
+
|
| 475 |
+
def _reduce_context(self, hunk: DiffHunk) -> DiffHunk:
|
| 476 |
+
"""Reduce context lines while preserving all changes.
|
| 477 |
+
|
| 478 |
+
Args:
|
| 479 |
+
hunk: Hunk to reduce context in.
|
| 480 |
+
|
| 481 |
+
Returns:
|
| 482 |
+
New hunk with reduced context.
|
| 483 |
+
"""
|
| 484 |
+
max_context = self.config.max_context_lines
|
| 485 |
+
|
| 486 |
+
# Identify change positions
|
| 487 |
+
change_positions: list[int] = []
|
| 488 |
+
for i, line in enumerate(hunk.lines):
|
| 489 |
+
if line.startswith("+") or line.startswith("-"):
|
| 490 |
+
change_positions.append(i)
|
| 491 |
+
|
| 492 |
+
if not change_positions:
|
| 493 |
+
# No changes, just context - keep minimal
|
| 494 |
+
return DiffHunk(
|
| 495 |
+
header=hunk.header,
|
| 496 |
+
lines=hunk.lines[:max_context] if hunk.lines else [],
|
| 497 |
+
additions=0,
|
| 498 |
+
deletions=0,
|
| 499 |
+
context_lines=min(len(hunk.lines), max_context),
|
| 500 |
+
score=hunk.score,
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
# Determine which lines to keep
|
| 504 |
+
keep_indices: set[int] = set()
|
| 505 |
+
|
| 506 |
+
for pos in change_positions:
|
| 507 |
+
# Always keep the change line
|
| 508 |
+
keep_indices.add(pos)
|
| 509 |
+
|
| 510 |
+
# Keep context before
|
| 511 |
+
for i in range(max(0, pos - max_context), pos):
|
| 512 |
+
keep_indices.add(i)
|
| 513 |
+
|
| 514 |
+
# Keep context after
|
| 515 |
+
for i in range(pos + 1, min(len(hunk.lines), pos + max_context + 1)):
|
| 516 |
+
keep_indices.add(i)
|
| 517 |
+
|
| 518 |
+
# Build new lines list
|
| 519 |
+
new_lines: list[str] = []
|
| 520 |
+
additions = 0
|
| 521 |
+
deletions = 0
|
| 522 |
+
context_lines = 0
|
| 523 |
+
|
| 524 |
+
for i in sorted(keep_indices):
|
| 525 |
+
line = hunk.lines[i]
|
| 526 |
+
new_lines.append(line)
|
| 527 |
+
if line.startswith("+"):
|
| 528 |
+
additions += 1
|
| 529 |
+
elif line.startswith("-"):
|
| 530 |
+
deletions += 1
|
| 531 |
+
else:
|
| 532 |
+
context_lines += 1
|
| 533 |
+
|
| 534 |
+
return DiffHunk(
|
| 535 |
+
header=hunk.header,
|
| 536 |
+
lines=new_lines,
|
| 537 |
+
additions=additions,
|
| 538 |
+
deletions=deletions,
|
| 539 |
+
context_lines=context_lines,
|
| 540 |
+
score=hunk.score,
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
def _format_output(self, diff_files: list[DiffFile], stats: dict[str, int]) -> str:
|
| 544 |
+
"""Format compressed diff files back to unified diff format.
|
| 545 |
+
|
| 546 |
+
Args:
|
| 547 |
+
diff_files: Compressed diff files.
|
| 548 |
+
stats: Compression statistics.
|
| 549 |
+
|
| 550 |
+
Returns:
|
| 551 |
+
Formatted diff string.
|
| 552 |
+
"""
|
| 553 |
+
output_lines: list[str] = []
|
| 554 |
+
|
| 555 |
+
for diff_file in diff_files:
|
| 556 |
+
# File header
|
| 557 |
+
output_lines.append(diff_file.header)
|
| 558 |
+
|
| 559 |
+
# File mode indicators if present
|
| 560 |
+
if diff_file.is_new_file:
|
| 561 |
+
output_lines.append("new file mode 100644")
|
| 562 |
+
elif diff_file.is_deleted_file:
|
| 563 |
+
output_lines.append("deleted file mode 100644")
|
| 564 |
+
|
| 565 |
+
if diff_file.is_binary:
|
| 566 |
+
output_lines.append("Binary files differ")
|
| 567 |
+
continue
|
| 568 |
+
|
| 569 |
+
# Old/new file markers
|
| 570 |
+
if diff_file.old_file:
|
| 571 |
+
output_lines.append(diff_file.old_file)
|
| 572 |
+
if diff_file.new_file:
|
| 573 |
+
output_lines.append(diff_file.new_file)
|
| 574 |
+
|
| 575 |
+
# Hunks
|
| 576 |
+
for hunk in diff_file.hunks:
|
| 577 |
+
output_lines.append(hunk.header)
|
| 578 |
+
output_lines.extend(hunk.lines)
|
| 579 |
+
|
| 580 |
+
# Add summary
|
| 581 |
+
if stats["hunks_removed"] > 0 or stats["files_affected"] > 0:
|
| 582 |
+
summary_parts = [
|
| 583 |
+
f"{stats['files_affected']} files changed",
|
| 584 |
+
f"+{stats['total_additions']} -{stats['total_deletions']} lines",
|
| 585 |
+
]
|
| 586 |
+
if stats["hunks_removed"] > 0:
|
| 587 |
+
summary_parts.append(f"{stats['hunks_removed']} hunks omitted")
|
| 588 |
+
output_lines.append(f"[{', '.join(summary_parts)}]")
|
| 589 |
+
|
| 590 |
+
return "\n".join(output_lines)
|
| 591 |
+
|
| 592 |
+
def _store_in_ccr(self, original: str, compressed: str, original_count: int) -> str | None:
|
| 593 |
+
"""Store original in CCR for later retrieval.
|
| 594 |
+
|
| 595 |
+
Args:
|
| 596 |
+
original: Original diff content.
|
| 597 |
+
compressed: Compressed diff content.
|
| 598 |
+
original_count: Original line count.
|
| 599 |
+
|
| 600 |
+
Returns:
|
| 601 |
+
Cache key if stored, None otherwise.
|
| 602 |
+
"""
|
| 603 |
+
try:
|
| 604 |
+
from ..cache.compression_store import get_compression_store
|
| 605 |
+
|
| 606 |
+
store = get_compression_store()
|
| 607 |
+
return store.store(
|
| 608 |
+
original,
|
| 609 |
+
compressed,
|
| 610 |
+
original_item_count=original_count,
|
| 611 |
+
)
|
| 612 |
+
except ImportError:
|
| 613 |
+
return None
|
| 614 |
+
except Exception:
|
| 615 |
+
return None
|
tests/test_hnsw_only.py
CHANGED
|
@@ -13,9 +13,11 @@ import pytest
|
|
| 13 |
from headroom.memory.models import Memory
|
| 14 |
from headroom.memory.ports import VectorFilter
|
| 15 |
|
| 16 |
-
# Check if hnswlib is available
|
| 17 |
try:
|
| 18 |
-
from headroom.memory.adapters.hnsw import
|
|
|
|
|
|
|
| 19 |
except ImportError:
|
| 20 |
HNSW_AVAILABLE = False
|
| 21 |
|
|
|
|
| 13 |
from headroom.memory.models import Memory
|
| 14 |
from headroom.memory.ports import VectorFilter
|
| 15 |
|
| 16 |
+
# Check if hnswlib is available (use lazy check to avoid SIGILL on incompatible CPUs)
|
| 17 |
try:
|
| 18 |
+
from headroom.memory.adapters.hnsw import _check_hnswlib_available
|
| 19 |
+
|
| 20 |
+
HNSW_AVAILABLE = _check_hnswlib_available()
|
| 21 |
except ImportError:
|
| 22 |
HNSW_AVAILABLE = False
|
| 23 |
|
tests/test_memory/test_hierarchical.py
CHANGED
|
@@ -608,9 +608,11 @@ class TestIntegration:
|
|
| 608 |
# HNSW Vector Index Tests
|
| 609 |
# =============================================================================
|
| 610 |
|
| 611 |
-
# Check if hnswlib is available
|
| 612 |
try:
|
| 613 |
-
from headroom.memory.adapters.hnsw import
|
|
|
|
|
|
|
| 614 |
except ImportError:
|
| 615 |
HNSW_AVAILABLE = False
|
| 616 |
|
|
|
|
| 608 |
# HNSW Vector Index Tests
|
| 609 |
# =============================================================================
|
| 610 |
|
| 611 |
+
# Check if hnswlib is available (use lazy check to avoid SIGILL on incompatible CPUs)
|
| 612 |
try:
|
| 613 |
+
from headroom.memory.adapters.hnsw import _check_hnswlib_available
|
| 614 |
+
|
| 615 |
+
HNSW_AVAILABLE = _check_hnswlib_available()
|
| 616 |
except ImportError:
|
| 617 |
HNSW_AVAILABLE = False
|
| 618 |
|
tests/test_transforms/test_diff_compressor.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Comprehensive tests for diff_compressor.py.
|
| 2 |
+
|
| 3 |
+
Tests cover:
|
| 4 |
+
1. Parsing of unified diff format
|
| 5 |
+
2. Context line reduction
|
| 6 |
+
3. Hunk selection and limiting
|
| 7 |
+
4. Compression ratios
|
| 8 |
+
5. Edge cases
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from headroom.transforms.diff_compressor import (
|
| 12 |
+
DiffCompressionResult,
|
| 13 |
+
DiffCompressor,
|
| 14 |
+
DiffCompressorConfig,
|
| 15 |
+
DiffFile,
|
| 16 |
+
DiffHunk,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TestDiffParsing:
|
| 21 |
+
"""Tests for parsing unified diff format."""
|
| 22 |
+
|
| 23 |
+
def test_parse_simple_diff(self):
|
| 24 |
+
"""Simple single-file diff is parsed correctly."""
|
| 25 |
+
content = """diff --git a/src/main.py b/src/main.py
|
| 26 |
+
--- a/src/main.py
|
| 27 |
+
+++ b/src/main.py
|
| 28 |
+
@@ -10,6 +10,7 @@ def main():
|
| 29 |
+
print("hello")
|
| 30 |
+
+ print("world")
|
| 31 |
+
return 0
|
| 32 |
+
"""
|
| 33 |
+
compressor = DiffCompressor()
|
| 34 |
+
diff_files = compressor._parse_diff(content.split("\n"))
|
| 35 |
+
|
| 36 |
+
assert len(diff_files) == 1
|
| 37 |
+
assert diff_files[0].header == "diff --git a/src/main.py b/src/main.py"
|
| 38 |
+
assert diff_files[0].old_file == "--- a/src/main.py"
|
| 39 |
+
assert diff_files[0].new_file == "+++ b/src/main.py"
|
| 40 |
+
assert len(diff_files[0].hunks) == 1
|
| 41 |
+
assert diff_files[0].hunks[0].additions == 1
|
| 42 |
+
assert diff_files[0].hunks[0].deletions == 0
|
| 43 |
+
|
| 44 |
+
def test_parse_multi_file_diff(self):
|
| 45 |
+
"""Multi-file diff is parsed into separate DiffFile objects."""
|
| 46 |
+
content = """diff --git a/file1.py b/file1.py
|
| 47 |
+
--- a/file1.py
|
| 48 |
+
+++ b/file1.py
|
| 49 |
+
@@ -1,3 +1,4 @@
|
| 50 |
+
line1
|
| 51 |
+
+added line
|
| 52 |
+
line2
|
| 53 |
+
diff --git a/file2.py b/file2.py
|
| 54 |
+
--- a/file2.py
|
| 55 |
+
+++ b/file2.py
|
| 56 |
+
@@ -5,4 +5,3 @@
|
| 57 |
+
keep
|
| 58 |
+
-removed
|
| 59 |
+
keep2
|
| 60 |
+
"""
|
| 61 |
+
compressor = DiffCompressor()
|
| 62 |
+
diff_files = compressor._parse_diff(content.split("\n"))
|
| 63 |
+
|
| 64 |
+
assert len(diff_files) == 2
|
| 65 |
+
assert "file1.py" in diff_files[0].header
|
| 66 |
+
assert "file2.py" in diff_files[1].header
|
| 67 |
+
assert diff_files[0].hunks[0].additions == 1
|
| 68 |
+
assert diff_files[0].hunks[0].deletions == 0
|
| 69 |
+
assert diff_files[1].hunks[0].additions == 0
|
| 70 |
+
assert diff_files[1].hunks[0].deletions == 1
|
| 71 |
+
|
| 72 |
+
def test_parse_multi_hunk_file(self):
|
| 73 |
+
"""File with multiple hunks is parsed correctly."""
|
| 74 |
+
content = """diff --git a/src/utils.py b/src/utils.py
|
| 75 |
+
--- a/src/utils.py
|
| 76 |
+
+++ b/src/utils.py
|
| 77 |
+
@@ -10,4 +10,5 @@ def helper():
|
| 78 |
+
pass
|
| 79 |
+
+ # added comment
|
| 80 |
+
return True
|
| 81 |
+
@@ -50,3 +51,4 @@ def other():
|
| 82 |
+
x = 1
|
| 83 |
+
+ y = 2
|
| 84 |
+
return x
|
| 85 |
+
"""
|
| 86 |
+
compressor = DiffCompressor()
|
| 87 |
+
diff_files = compressor._parse_diff(content.split("\n"))
|
| 88 |
+
|
| 89 |
+
assert len(diff_files) == 1
|
| 90 |
+
assert len(diff_files[0].hunks) == 2
|
| 91 |
+
assert diff_files[0].total_additions == 2
|
| 92 |
+
|
| 93 |
+
def test_parse_new_file(self):
|
| 94 |
+
"""New file diff is detected."""
|
| 95 |
+
content = """diff --git a/newfile.py b/newfile.py
|
| 96 |
+
new file mode 100644
|
| 97 |
+
--- /dev/null
|
| 98 |
+
+++ b/newfile.py
|
| 99 |
+
@@ -0,0 +1,3 @@
|
| 100 |
+
+def new_func():
|
| 101 |
+
+ pass
|
| 102 |
+
+ return None
|
| 103 |
+
"""
|
| 104 |
+
compressor = DiffCompressor()
|
| 105 |
+
diff_files = compressor._parse_diff(content.split("\n"))
|
| 106 |
+
|
| 107 |
+
assert len(diff_files) == 1
|
| 108 |
+
assert diff_files[0].is_new_file is True
|
| 109 |
+
assert diff_files[0].hunks[0].additions == 3
|
| 110 |
+
|
| 111 |
+
def test_parse_deleted_file(self):
|
| 112 |
+
"""Deleted file diff is detected."""
|
| 113 |
+
content = """diff --git a/oldfile.py b/oldfile.py
|
| 114 |
+
deleted file mode 100644
|
| 115 |
+
--- a/oldfile.py
|
| 116 |
+
+++ /dev/null
|
| 117 |
+
@@ -1,2 +0,0 @@
|
| 118 |
+
-def old_func():
|
| 119 |
+
- pass
|
| 120 |
+
"""
|
| 121 |
+
compressor = DiffCompressor()
|
| 122 |
+
diff_files = compressor._parse_diff(content.split("\n"))
|
| 123 |
+
|
| 124 |
+
assert len(diff_files) == 1
|
| 125 |
+
assert diff_files[0].is_deleted_file is True
|
| 126 |
+
assert diff_files[0].hunks[0].deletions == 2
|
| 127 |
+
|
| 128 |
+
def test_parse_binary_file(self):
|
| 129 |
+
"""Binary file diff is detected."""
|
| 130 |
+
content = """diff --git a/image.png b/image.png
|
| 131 |
+
Binary files a/image.png and b/image.png differ
|
| 132 |
+
"""
|
| 133 |
+
compressor = DiffCompressor()
|
| 134 |
+
diff_files = compressor._parse_diff(content.split("\n"))
|
| 135 |
+
|
| 136 |
+
assert len(diff_files) == 1
|
| 137 |
+
assert diff_files[0].is_binary is True
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class TestContextReduction:
|
| 141 |
+
"""Tests for context line reduction."""
|
| 142 |
+
|
| 143 |
+
def test_reduce_context_lines(self):
|
| 144 |
+
"""Context lines are reduced to configured maximum."""
|
| 145 |
+
content = """diff --git a/file.py b/file.py
|
| 146 |
+
--- a/file.py
|
| 147 |
+
+++ b/file.py
|
| 148 |
+
@@ -1,10 +1,11 @@
|
| 149 |
+
context1
|
| 150 |
+
context2
|
| 151 |
+
context3
|
| 152 |
+
context4
|
| 153 |
+
+added
|
| 154 |
+
context5
|
| 155 |
+
context6
|
| 156 |
+
context7
|
| 157 |
+
context8
|
| 158 |
+
"""
|
| 159 |
+
# Default max_context_lines is 2
|
| 160 |
+
compressor = DiffCompressor(
|
| 161 |
+
config=DiffCompressorConfig(
|
| 162 |
+
max_context_lines=2,
|
| 163 |
+
min_lines_for_ccr=5,
|
| 164 |
+
enable_ccr=False,
|
| 165 |
+
)
|
| 166 |
+
)
|
| 167 |
+
result = compressor.compress(content)
|
| 168 |
+
|
| 169 |
+
# Should keep 2 context before and 2 after the +added line
|
| 170 |
+
# Plus the added line itself
|
| 171 |
+
lines = result.compressed.split("\n")
|
| 172 |
+
context_count = sum(1 for line in lines if line.startswith(" "))
|
| 173 |
+
|
| 174 |
+
# At most 4 context lines (2 before + 2 after)
|
| 175 |
+
assert context_count <= 4
|
| 176 |
+
|
| 177 |
+
def test_preserve_all_changes(self):
|
| 178 |
+
"""All addition and deletion lines are preserved."""
|
| 179 |
+
content = """diff --git a/file.py b/file.py
|
| 180 |
+
--- a/file.py
|
| 181 |
+
+++ b/file.py
|
| 182 |
+
@@ -1,10 +1,10 @@
|
| 183 |
+
ctx1
|
| 184 |
+
ctx2
|
| 185 |
+
-removed1
|
| 186 |
+
+added1
|
| 187 |
+
ctx3
|
| 188 |
+
ctx4
|
| 189 |
+
-removed2
|
| 190 |
+
+added2
|
| 191 |
+
ctx5
|
| 192 |
+
ctx6
|
| 193 |
+
"""
|
| 194 |
+
compressor = DiffCompressor(
|
| 195 |
+
config=DiffCompressorConfig(
|
| 196 |
+
min_lines_for_ccr=5,
|
| 197 |
+
enable_ccr=False,
|
| 198 |
+
)
|
| 199 |
+
)
|
| 200 |
+
result = compressor.compress(content)
|
| 201 |
+
|
| 202 |
+
assert "-removed1" in result.compressed
|
| 203 |
+
assert "-removed2" in result.compressed
|
| 204 |
+
assert "+added1" in result.compressed
|
| 205 |
+
assert "+added2" in result.compressed
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class TestHunkSelection:
|
| 209 |
+
"""Tests for hunk selection when limiting."""
|
| 210 |
+
|
| 211 |
+
def test_max_hunks_per_file(self):
|
| 212 |
+
"""Hunks are limited to max_hunks_per_file."""
|
| 213 |
+
# Create a diff with many hunks
|
| 214 |
+
hunks = []
|
| 215 |
+
for i in range(20):
|
| 216 |
+
hunks.append(f"""@@ -{i * 10},3 +{i * 10},4 @@
|
| 217 |
+
context
|
| 218 |
+
+added_{i}
|
| 219 |
+
more
|
| 220 |
+
""")
|
| 221 |
+
|
| 222 |
+
content = f"""diff --git a/bigfile.py b/bigfile.py
|
| 223 |
+
--- a/bigfile.py
|
| 224 |
+
+++ b/bigfile.py
|
| 225 |
+
{"".join(hunks)}"""
|
| 226 |
+
|
| 227 |
+
compressor = DiffCompressor(
|
| 228 |
+
config=DiffCompressorConfig(
|
| 229 |
+
max_hunks_per_file=5,
|
| 230 |
+
min_lines_for_ccr=10,
|
| 231 |
+
enable_ccr=False,
|
| 232 |
+
)
|
| 233 |
+
)
|
| 234 |
+
result = compressor.compress(content)
|
| 235 |
+
|
| 236 |
+
# Should have at most 5 hunks
|
| 237 |
+
hunk_count = result.compressed.count("@@")
|
| 238 |
+
# Each hunk has one @@ header (we count full hunk headers)
|
| 239 |
+
assert hunk_count <= 10 # Each hunk header appears twice @@...@@
|
| 240 |
+
|
| 241 |
+
def test_keeps_first_and_last_hunk(self):
|
| 242 |
+
"""First and last hunks are preserved when limiting."""
|
| 243 |
+
hunks = []
|
| 244 |
+
for i in range(10):
|
| 245 |
+
hunks.append(f"""@@ -{i * 10},3 +{i * 10},4 @@
|
| 246 |
+
context
|
| 247 |
+
+added_{i}
|
| 248 |
+
more
|
| 249 |
+
""")
|
| 250 |
+
|
| 251 |
+
content = f"""diff --git a/file.py b/file.py
|
| 252 |
+
--- a/file.py
|
| 253 |
+
+++ b/file.py
|
| 254 |
+
{"".join(hunks)}"""
|
| 255 |
+
|
| 256 |
+
compressor = DiffCompressor(
|
| 257 |
+
config=DiffCompressorConfig(
|
| 258 |
+
max_hunks_per_file=3,
|
| 259 |
+
min_lines_for_ccr=10,
|
| 260 |
+
enable_ccr=False,
|
| 261 |
+
)
|
| 262 |
+
)
|
| 263 |
+
result = compressor.compress(content)
|
| 264 |
+
|
| 265 |
+
# First hunk (added_0) should be present
|
| 266 |
+
assert "+added_0" in result.compressed
|
| 267 |
+
# Last hunk (added_9) should be present
|
| 268 |
+
assert "+added_9" in result.compressed
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
class TestFileSelection:
|
| 272 |
+
"""Tests for file selection when limiting."""
|
| 273 |
+
|
| 274 |
+
def test_max_files(self):
|
| 275 |
+
"""Files are limited to max_files."""
|
| 276 |
+
# Create diff with many files
|
| 277 |
+
files = []
|
| 278 |
+
for i in range(30):
|
| 279 |
+
files.append(f"""diff --git a/file{i}.py b/file{i}.py
|
| 280 |
+
--- a/file{i}.py
|
| 281 |
+
+++ b/file{i}.py
|
| 282 |
+
@@ -1,2 +1,3 @@
|
| 283 |
+
ctx
|
| 284 |
+
+added
|
| 285 |
+
ctx2
|
| 286 |
+
""")
|
| 287 |
+
|
| 288 |
+
content = "\n".join(files)
|
| 289 |
+
|
| 290 |
+
compressor = DiffCompressor(
|
| 291 |
+
config=DiffCompressorConfig(
|
| 292 |
+
max_files=10,
|
| 293 |
+
min_lines_for_ccr=20,
|
| 294 |
+
enable_ccr=False,
|
| 295 |
+
)
|
| 296 |
+
)
|
| 297 |
+
result = compressor.compress(content)
|
| 298 |
+
|
| 299 |
+
# Count diff --git headers
|
| 300 |
+
file_count = result.compressed.count("diff --git")
|
| 301 |
+
assert file_count <= 10
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
class TestCompressionResult:
|
| 305 |
+
"""Tests for DiffCompressionResult properties."""
|
| 306 |
+
|
| 307 |
+
def test_compression_ratio_calculation(self):
|
| 308 |
+
"""Compression ratio is calculated correctly."""
|
| 309 |
+
result = DiffCompressionResult(
|
| 310 |
+
compressed="a\nb\nc",
|
| 311 |
+
original_line_count=100,
|
| 312 |
+
compressed_line_count=10,
|
| 313 |
+
files_affected=2,
|
| 314 |
+
additions=5,
|
| 315 |
+
deletions=3,
|
| 316 |
+
hunks_kept=2,
|
| 317 |
+
hunks_removed=5,
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
assert result.compression_ratio == 0.1
|
| 321 |
+
|
| 322 |
+
def test_tokens_saved_estimate(self):
|
| 323 |
+
"""Token savings estimation works correctly."""
|
| 324 |
+
result = DiffCompressionResult(
|
| 325 |
+
compressed="short",
|
| 326 |
+
original_line_count=100,
|
| 327 |
+
compressed_line_count=10,
|
| 328 |
+
files_affected=1,
|
| 329 |
+
additions=10,
|
| 330 |
+
deletions=5,
|
| 331 |
+
hunks_kept=1,
|
| 332 |
+
hunks_removed=0,
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# 90 lines saved * 40 chars/line / 4 chars/token = 900 tokens
|
| 336 |
+
assert result.tokens_saved_estimate == 900
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
class TestHunkScoring:
|
| 340 |
+
"""Tests for context-aware hunk scoring."""
|
| 341 |
+
|
| 342 |
+
def test_score_by_context_keywords(self):
|
| 343 |
+
"""Hunks containing context keywords get higher scores."""
|
| 344 |
+
content = """diff --git a/file.py b/file.py
|
| 345 |
+
--- a/file.py
|
| 346 |
+
+++ b/file.py
|
| 347 |
+
@@ -1,3 +1,4 @@
|
| 348 |
+
normal context
|
| 349 |
+
+normal change
|
| 350 |
+
more context
|
| 351 |
+
@@ -10,3 +11,4 @@
|
| 352 |
+
error handling
|
| 353 |
+
+fix the bug here
|
| 354 |
+
return result
|
| 355 |
+
"""
|
| 356 |
+
compressor = DiffCompressor()
|
| 357 |
+
diff_files = compressor._parse_diff(content.split("\n"))
|
| 358 |
+
compressor._score_hunks(diff_files, "fix error bug")
|
| 359 |
+
|
| 360 |
+
# Second hunk should have higher score (contains "fix" and "bug")
|
| 361 |
+
assert len(diff_files[0].hunks) == 2
|
| 362 |
+
assert diff_files[0].hunks[1].score > diff_files[0].hunks[0].score
|
| 363 |
+
|
| 364 |
+
def test_score_priority_patterns(self):
|
| 365 |
+
"""Hunks with priority patterns (error, security) score higher."""
|
| 366 |
+
compressor = DiffCompressor()
|
| 367 |
+
|
| 368 |
+
hunk_normal = DiffHunk(
|
| 369 |
+
header="@@ -1,1 +1,2 @@",
|
| 370 |
+
lines=["+normal change"],
|
| 371 |
+
additions=1,
|
| 372 |
+
)
|
| 373 |
+
hunk_error = DiffHunk(
|
| 374 |
+
header="@@ -10,1 +10,2 @@",
|
| 375 |
+
lines=["+fix critical error"],
|
| 376 |
+
additions=1,
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
diff_file = DiffFile(
|
| 380 |
+
header="diff --git a/f.py b/f.py",
|
| 381 |
+
old_file="--- a/f.py",
|
| 382 |
+
new_file="+++ b/f.py",
|
| 383 |
+
hunks=[hunk_normal, hunk_error],
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
compressor._score_hunks([diff_file], "")
|
| 387 |
+
|
| 388 |
+
assert hunk_error.score > hunk_normal.score
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
class TestSmallDiffPassthrough:
|
| 392 |
+
"""Tests for small diff passthrough behavior."""
|
| 393 |
+
|
| 394 |
+
def test_small_diff_unchanged(self):
|
| 395 |
+
"""Diffs smaller than threshold pass through unchanged."""
|
| 396 |
+
content = """diff --git a/small.py b/small.py
|
| 397 |
+
--- a/small.py
|
| 398 |
+
+++ b/small.py
|
| 399 |
+
@@ -1,2 +1,3 @@
|
| 400 |
+
line1
|
| 401 |
+
+added
|
| 402 |
+
line2
|
| 403 |
+
"""
|
| 404 |
+
compressor = DiffCompressor(
|
| 405 |
+
config=DiffCompressorConfig(
|
| 406 |
+
min_lines_for_ccr=100, # High threshold
|
| 407 |
+
)
|
| 408 |
+
)
|
| 409 |
+
result = compressor.compress(content)
|
| 410 |
+
|
| 411 |
+
# Should be unchanged
|
| 412 |
+
assert result.compressed == content
|
| 413 |
+
assert result.compression_ratio == 1.0
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
class TestOutputFormatting:
|
| 417 |
+
"""Tests for output formatting."""
|
| 418 |
+
|
| 419 |
+
def test_summary_line_added(self):
|
| 420 |
+
"""Summary line is added at end of compressed diff."""
|
| 421 |
+
# Large diff that will be compressed
|
| 422 |
+
hunks = []
|
| 423 |
+
for i in range(15):
|
| 424 |
+
hunks.append(f"""@@ -{i * 10},5 +{i * 10},6 @@
|
| 425 |
+
ctx1
|
| 426 |
+
ctx2
|
| 427 |
+
+added_{i}
|
| 428 |
+
ctx3
|
| 429 |
+
ctx4
|
| 430 |
+
""")
|
| 431 |
+
|
| 432 |
+
content = f"""diff --git a/file.py b/file.py
|
| 433 |
+
--- a/file.py
|
| 434 |
+
+++ b/file.py
|
| 435 |
+
{"".join(hunks)}"""
|
| 436 |
+
|
| 437 |
+
compressor = DiffCompressor(
|
| 438 |
+
config=DiffCompressorConfig(
|
| 439 |
+
max_hunks_per_file=5,
|
| 440 |
+
min_lines_for_ccr=10,
|
| 441 |
+
enable_ccr=False,
|
| 442 |
+
)
|
| 443 |
+
)
|
| 444 |
+
result = compressor.compress(content)
|
| 445 |
+
|
| 446 |
+
# Should have summary at end
|
| 447 |
+
assert "files changed" in result.compressed
|
| 448 |
+
assert "hunks omitted" in result.compressed
|
| 449 |
+
|
| 450 |
+
def test_preserves_diff_format(self):
|
| 451 |
+
"""Output preserves valid unified diff format."""
|
| 452 |
+
content = """diff --git a/test.py b/test.py
|
| 453 |
+
--- a/test.py
|
| 454 |
+
+++ b/test.py
|
| 455 |
+
@@ -1,3 +1,4 @@
|
| 456 |
+
def test():
|
| 457 |
+
+ # new comment
|
| 458 |
+
pass
|
| 459 |
+
return True
|
| 460 |
+
"""
|
| 461 |
+
compressor = DiffCompressor(
|
| 462 |
+
config=DiffCompressorConfig(
|
| 463 |
+
min_lines_for_ccr=5,
|
| 464 |
+
enable_ccr=False,
|
| 465 |
+
)
|
| 466 |
+
)
|
| 467 |
+
result = compressor.compress(content)
|
| 468 |
+
|
| 469 |
+
# Should have all standard diff markers
|
| 470 |
+
assert "diff --git" in result.compressed
|
| 471 |
+
assert "---" in result.compressed
|
| 472 |
+
assert "+++" in result.compressed
|
| 473 |
+
assert "@@" in result.compressed
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
class TestEdgeCases:
|
| 477 |
+
"""Tests for edge cases and boundary conditions."""
|
| 478 |
+
|
| 479 |
+
def test_empty_input(self):
|
| 480 |
+
"""Empty input is handled gracefully."""
|
| 481 |
+
compressor = DiffCompressor()
|
| 482 |
+
result = compressor.compress("")
|
| 483 |
+
|
| 484 |
+
assert result.compressed == ""
|
| 485 |
+
assert result.compression_ratio == 1.0
|
| 486 |
+
|
| 487 |
+
def test_non_diff_input(self):
|
| 488 |
+
"""Non-diff input passes through unchanged."""
|
| 489 |
+
content = "This is not a diff\nJust regular text"
|
| 490 |
+
compressor = DiffCompressor()
|
| 491 |
+
result = compressor.compress(content)
|
| 492 |
+
|
| 493 |
+
# Should pass through (no diff --git found)
|
| 494 |
+
assert result.compressed == content
|
| 495 |
+
|
| 496 |
+
def test_unicode_content(self):
|
| 497 |
+
"""Unicode characters in diff are handled."""
|
| 498 |
+
content = """diff --git a/i18n.py b/i18n.py
|
| 499 |
+
--- a/i18n.py
|
| 500 |
+
+++ b/i18n.py
|
| 501 |
+
@@ -1,2 +1,3 @@
|
| 502 |
+
msg = "hello"
|
| 503 |
+
+msg_ja = "こんにちは"
|
| 504 |
+
return msg
|
| 505 |
+
"""
|
| 506 |
+
compressor = DiffCompressor()
|
| 507 |
+
result = compressor.compress(content)
|
| 508 |
+
|
| 509 |
+
assert "こんにちは" in result.compressed
|
| 510 |
+
|
| 511 |
+
def test_no_newline_at_eof(self):
|
| 512 |
+
"""Handles 'No newline at end of file' indicator."""
|
| 513 |
+
content = """diff --git a/file.py b/file.py
|
| 514 |
+
--- a/file.py
|
| 515 |
+
+++ b/file.py
|
| 516 |
+
@@ -1,2 +1,2 @@
|
| 517 |
+
line1
|
| 518 |
+
-line2
|
| 519 |
+
\
|
| 520 |
+
+line2_modified
|
| 521 |
+
\
|
| 522 |
+
"""
|
| 523 |
+
compressor = DiffCompressor()
|
| 524 |
+
result = compressor.compress(content)
|
| 525 |
+
|
| 526 |
+
# Should not crash and preserve the indicator
|
| 527 |
+
assert "No newline" in result.compressed or "-line2" in result.compressed
|
| 528 |
+
|
| 529 |
+
def test_empty_hunks(self):
|
| 530 |
+
"""Files with no actual hunks are handled."""
|
| 531 |
+
content = """diff --git a/file.py b/file.py
|
| 532 |
+
--- a/file.py
|
| 533 |
+
+++ b/file.py
|
| 534 |
+
"""
|
| 535 |
+
compressor = DiffCompressor()
|
| 536 |
+
result = compressor.compress(content)
|
| 537 |
+
|
| 538 |
+
# Should not crash
|
| 539 |
+
assert result.compressed is not None
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
class TestDiffHunkDataclass:
|
| 543 |
+
"""Tests for DiffHunk dataclass."""
|
| 544 |
+
|
| 545 |
+
def test_change_count_property(self):
|
| 546 |
+
"""change_count returns sum of additions and deletions."""
|
| 547 |
+
hunk = DiffHunk(
|
| 548 |
+
header="@@ -1,5 +1,6 @@",
|
| 549 |
+
lines=["+a", "+b", "-c", " ctx"],
|
| 550 |
+
additions=2,
|
| 551 |
+
deletions=1,
|
| 552 |
+
)
|
| 553 |
+
assert hunk.change_count == 3
|
| 554 |
+
|
| 555 |
+
def test_default_values(self):
|
| 556 |
+
"""DiffHunk default values are correct."""
|
| 557 |
+
hunk = DiffHunk(header="@@", lines=[])
|
| 558 |
+
assert hunk.additions == 0
|
| 559 |
+
assert hunk.deletions == 0
|
| 560 |
+
assert hunk.context_lines == 0
|
| 561 |
+
assert hunk.score == 0.0
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
class TestDiffFileDataclass:
|
| 565 |
+
"""Tests for DiffFile dataclass."""
|
| 566 |
+
|
| 567 |
+
def test_total_additions_property(self):
|
| 568 |
+
"""total_additions sums across all hunks."""
|
| 569 |
+
hunk1 = DiffHunk(header="@@", lines=[], additions=3)
|
| 570 |
+
hunk2 = DiffHunk(header="@@", lines=[], additions=5)
|
| 571 |
+
diff_file = DiffFile(
|
| 572 |
+
header="diff --git",
|
| 573 |
+
old_file="---",
|
| 574 |
+
new_file="+++",
|
| 575 |
+
hunks=[hunk1, hunk2],
|
| 576 |
+
)
|
| 577 |
+
assert diff_file.total_additions == 8
|
| 578 |
+
|
| 579 |
+
def test_total_deletions_property(self):
|
| 580 |
+
"""total_deletions sums across all hunks."""
|
| 581 |
+
hunk1 = DiffHunk(header="@@", lines=[], deletions=2)
|
| 582 |
+
hunk2 = DiffHunk(header="@@", lines=[], deletions=4)
|
| 583 |
+
diff_file = DiffFile(
|
| 584 |
+
header="diff --git",
|
| 585 |
+
old_file="---",
|
| 586 |
+
new_file="+++",
|
| 587 |
+
hunks=[hunk1, hunk2],
|
| 588 |
+
)
|
| 589 |
+
assert diff_file.total_deletions == 6
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
class TestConfigOptions:
|
| 593 |
+
"""Tests for configuration options."""
|
| 594 |
+
|
| 595 |
+
def test_max_context_lines_config(self):
|
| 596 |
+
"""max_context_lines configuration controls context reduction."""
|
| 597 |
+
content = """diff --git a/file.py b/file.py
|
| 598 |
+
--- a/file.py
|
| 599 |
+
+++ b/file.py
|
| 600 |
+
@@ -1,10 +1,11 @@
|
| 601 |
+
c1
|
| 602 |
+
c2
|
| 603 |
+
c3
|
| 604 |
+
c4
|
| 605 |
+
c5
|
| 606 |
+
+added
|
| 607 |
+
c6
|
| 608 |
+
c7
|
| 609 |
+
c8
|
| 610 |
+
c9
|
| 611 |
+
c10
|
| 612 |
+
"""
|
| 613 |
+
# With max_context_lines=1
|
| 614 |
+
compressor = DiffCompressor(
|
| 615 |
+
config=DiffCompressorConfig(
|
| 616 |
+
max_context_lines=1,
|
| 617 |
+
min_lines_for_ccr=5,
|
| 618 |
+
enable_ccr=False,
|
| 619 |
+
)
|
| 620 |
+
)
|
| 621 |
+
result = compressor.compress(content)
|
| 622 |
+
|
| 623 |
+
# Count context lines (lines starting with space)
|
| 624 |
+
context_count = sum(1 for line in result.compressed.split("\n") if line.startswith(" "))
|
| 625 |
+
|
| 626 |
+
# Should have at most 2 context lines (1 before + 1 after)
|
| 627 |
+
assert context_count <= 2
|
| 628 |
+
|
| 629 |
+
def test_always_keep_additions_default(self):
|
| 630 |
+
"""Additions are always kept by default."""
|
| 631 |
+
content = """diff --git a/file.py b/file.py
|
| 632 |
+
--- a/file.py
|
| 633 |
+
+++ b/file.py
|
| 634 |
+
@@ -1,3 +1,5 @@
|
| 635 |
+
ctx
|
| 636 |
+
+add1
|
| 637 |
+
+add2
|
| 638 |
+
ctx
|
| 639 |
+
"""
|
| 640 |
+
compressor = DiffCompressor(
|
| 641 |
+
config=DiffCompressorConfig(
|
| 642 |
+
always_keep_additions=True,
|
| 643 |
+
min_lines_for_ccr=2,
|
| 644 |
+
enable_ccr=False,
|
| 645 |
+
)
|
| 646 |
+
)
|
| 647 |
+
result = compressor.compress(content)
|
| 648 |
+
|
| 649 |
+
assert "+add1" in result.compressed
|
| 650 |
+
assert "+add2" in result.compressed
|
| 651 |
+
|
| 652 |
+
def test_always_keep_deletions_default(self):
|
| 653 |
+
"""Deletions are always kept by default."""
|
| 654 |
+
content = """diff --git a/file.py b/file.py
|
| 655 |
+
--- a/file.py
|
| 656 |
+
+++ b/file.py
|
| 657 |
+
@@ -1,5 +1,3 @@
|
| 658 |
+
ctx
|
| 659 |
+
-del1
|
| 660 |
+
-del2
|
| 661 |
+
ctx
|
| 662 |
+
"""
|
| 663 |
+
compressor = DiffCompressor(
|
| 664 |
+
config=DiffCompressorConfig(
|
| 665 |
+
always_keep_deletions=True,
|
| 666 |
+
min_lines_for_ccr=2,
|
| 667 |
+
enable_ccr=False,
|
| 668 |
+
)
|
| 669 |
+
)
|
| 670 |
+
result = compressor.compress(content)
|
| 671 |
+
|
| 672 |
+
assert "-del1" in result.compressed
|
| 673 |
+
assert "-del2" in result.compressed
|