patchpal 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {patchpal-0.3.0/patchpal.egg-info → patchpal-0.3.2}/PKG-INFO +1 -1
  2. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/__init__.py +1 -1
  3. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/agent.py +1 -1
  4. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/cli.py +49 -1
  5. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/tools.py +272 -14
  6. {patchpal-0.3.0 → patchpal-0.3.2/patchpal.egg-info}/PKG-INFO +1 -1
  7. {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_tools.py +123 -5
  8. {patchpal-0.3.0 → patchpal-0.3.2}/LICENSE +0 -0
  9. {patchpal-0.3.0 → patchpal-0.3.2}/MANIFEST.in +0 -0
  10. {patchpal-0.3.0 → patchpal-0.3.2}/README.md +0 -0
  11. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/context.py +0 -0
  12. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/permissions.py +0 -0
  13. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/skills.py +0 -0
  14. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/system_prompt.md +0 -0
  15. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/SOURCES.txt +0 -0
  16. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/dependency_links.txt +0 -0
  17. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/entry_points.txt +0 -0
  18. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/requires.txt +0 -0
  19. {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/top_level.txt +0 -0
  20. {patchpal-0.3.0 → patchpal-0.3.2}/pyproject.toml +0 -0
  21. {patchpal-0.3.0 → patchpal-0.3.2}/setup.cfg +0 -0
  22. {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_agent.py +0 -0
  23. {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_cli.py +0 -0
  24. {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_context.py +0 -0
  25. {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_guardrails.py +0 -0
  26. {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_operational_safety.py +0 -0
  27. {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A lean Claude Code clone in pure Python
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
4
4
 
5
5
  from patchpal.agent import create_agent
6
6
  from patchpal.tools import (
@@ -541,7 +541,7 @@ TOOLS = [
541
541
  "type": "function",
542
542
  "function": {
543
543
  "name": "run_shell",
544
- "description": "Run a safe shell command in the repository. Privilege escalation (sudo, su) blocked by default unless PATCHPAL_ALLOW_SUDO=true.",
544
+ "description": "Run a safe shell command in the repository. Commands execute from repository root automatically (no need for 'cd'). Privilege escalation (sudo, su) blocked by default unless PATCHPAL_ALLOW_SUDO=true.",
545
545
  "parameters": {
546
546
  "type": "object",
547
547
  "properties": {
@@ -248,7 +248,9 @@ Supported models: Any LiteLLM-supported model
248
248
  print(f"\033[1;36m🔧 Using custom system prompt: {custom_prompt_path}\033[0m")
249
249
 
250
250
  print("\nType 'exit' to quit.")
251
- print("Use '/status' to check context window usage, '/compact' to manually compact.")
251
+ print(
252
+ "Use '/status' to check context window usage, '/compact' to manually compact, '/clear' to start fresh."
253
+ )
252
254
  print("Use 'list skills' to see available skills or /skillname to invoke skills.")
253
255
  print("Press Ctrl-C during agent execution to interrupt the agent.\n")
254
256
 
@@ -363,6 +365,52 @@ Supported models: Any LiteLLM-supported model
363
365
  print("=" * 70 + "\n")
364
366
  continue
365
367
 
368
+ # Handle /clear command - clear conversation history
369
+ if user_input.lower() in ["clear", "/clear"]:
370
+ print("\n" + "=" * 70)
371
+ print("\033[1;36mClear Context\033[0m")
372
+ print("=" * 70)
373
+
374
+ if not agent.messages:
375
+ print("\033[1;33m Context is already empty.\033[0m")
376
+ print("=" * 70 + "\n")
377
+ continue
378
+
379
+ # Show current status
380
+ stats = agent.context_manager.get_usage_stats(agent.messages)
381
+ print(
382
+ f" Current: {len(agent.messages)} messages, {stats['total_tokens']:,} tokens"
383
+ )
384
+
385
+ # Confirm before clearing
386
+ try:
387
+ confirm = pt_prompt(
388
+ FormattedText(
389
+ [
390
+ ("ansiyellow", " Clear all context and start fresh? (y/n): "),
391
+ ("", ""),
392
+ ]
393
+ )
394
+ ).strip()
395
+ if confirm.lower() not in ["y", "yes"]:
396
+ print(" Cancelled.")
397
+ print("=" * 70 + "\n")
398
+ continue
399
+ except KeyboardInterrupt:
400
+ print("\n Cancelled.")
401
+ print("=" * 70 + "\n")
402
+ continue
403
+
404
+ # Clear conversation history
405
+ agent.messages = []
406
+ agent._last_compaction_message_count = 0
407
+
408
+ print("\n\033[1;32m✓ Context cleared successfully!\033[0m")
409
+ print(" Starting fresh with empty conversation history.")
410
+ print(" All previous context has been removed - ready for a new task.")
411
+ print("=" * 70 + "\n")
412
+ continue
413
+
366
414
  # Handle /compact command - manually trigger compaction
367
415
  if user_input.lower() in ["compact", "/compact"]:
368
416
  print("\n" + "=" * 70)
@@ -100,6 +100,10 @@ WEB_USER_AGENT = f"PatchPal/{__version__} (AI Code Assistant)"
100
100
  # Shell command configuration
101
101
  SHELL_TIMEOUT = int(os.getenv("PATCHPAL_SHELL_TIMEOUT", 30)) # 30 seconds default
102
102
 
103
+ # Output filtering configuration - reduce token usage from verbose commands
104
+ ENABLE_OUTPUT_FILTERING = os.getenv("PATCHPAL_FILTER_OUTPUTS", "true").lower() == "true"
105
+ MAX_OUTPUT_LINES = int(os.getenv("PATCHPAL_MAX_OUTPUT_LINES", 500)) # Max lines of output
106
+
103
107
  # Global flag for requiring permission on ALL operations (including reads)
104
108
  # Set via CLI flag --require-permission-for-all
105
109
  _REQUIRE_PERMISSION_FOR_ALL = False
@@ -195,10 +199,194 @@ class OperationLimiter:
195
199
  audit_logger.info(f"Operation {self.operations}/{self.max_operations}: {operation}")
196
200
 
197
201
  def reset(self):
198
- """Reset operation counter."""
202
+ """Reset the operation counter (used in tests)."""
199
203
  self.operations = 0
200
204
 
201
205
 
206
+ class OutputFilter:
207
+ """Filter verbose command outputs to reduce token usage.
208
+
209
+ This class implements Claude Code's strategy of filtering verbose outputs
210
+ to show only relevant information (e.g., test failures, error messages).
211
+ Can save 75% or more on output tokens for verbose commands.
212
+ """
213
+
214
+ @staticmethod
215
+ def should_filter(cmd: str) -> bool:
216
+ """Check if a command should have its output filtered.
217
+
218
+ Args:
219
+ cmd: The shell command
220
+
221
+ Returns:
222
+ True if filtering should be applied
223
+ """
224
+ if not ENABLE_OUTPUT_FILTERING:
225
+ return False
226
+
227
+ # Test runners - show only failures
228
+ test_patterns = [
229
+ "pytest",
230
+ "npm test",
231
+ "npm run test",
232
+ "yarn test",
233
+ "go test",
234
+ "cargo test",
235
+ "mvn test",
236
+ "gradle test",
237
+ "ruby -I test",
238
+ "rspec",
239
+ ]
240
+
241
+ # Version control - limit log output
242
+ vcs_patterns = [
243
+ "git log",
244
+ "git reflog",
245
+ ]
246
+
247
+ # Package managers - show only important info
248
+ pkg_patterns = [
249
+ "npm install",
250
+ "pip install",
251
+ "cargo build",
252
+ "go build",
253
+ ]
254
+
255
+ all_patterns = test_patterns + vcs_patterns + pkg_patterns
256
+ return any(pattern in cmd for pattern in all_patterns)
257
+
258
+ @staticmethod
259
+ def filter_output(cmd: str, output: str) -> str:
260
+ """Filter command output to reduce token usage.
261
+
262
+ Args:
263
+ cmd: The shell command
264
+ output: The raw command output
265
+
266
+ Returns:
267
+ Filtered output with only relevant information
268
+ """
269
+ if not output or not ENABLE_OUTPUT_FILTERING:
270
+ return output
271
+
272
+ lines = output.split("\n")
273
+ original_lines = len(lines)
274
+
275
+ # Test output - show only failures and summary
276
+ if any(
277
+ pattern in cmd
278
+ for pattern in ["pytest", "npm test", "yarn test", "go test", "cargo test", "rspec"]
279
+ ):
280
+ filtered_lines = []
281
+ in_failure = False
282
+ failure_context = []
283
+
284
+ for line in lines:
285
+ # Capture failure indicators
286
+ if any(
287
+ keyword in line.upper()
288
+ for keyword in ["FAIL", "ERROR", "FAILED", "✗", "✖", "FAILURE"]
289
+ ):
290
+ in_failure = True
291
+ failure_context = [line]
292
+ elif in_failure:
293
+ # Capture context after failure (up to 10 lines or until next test/blank line)
294
+ failure_context.append(line)
295
+ # End failure context on: blank line, next test case, or 10 lines
296
+ if (
297
+ not line.strip()
298
+ or "::" in line
299
+ or line.startswith("=")
300
+ or len(failure_context) >= 10
301
+ ):
302
+ filtered_lines.extend(failure_context)
303
+ in_failure = False
304
+ failure_context = []
305
+ # Always capture summary lines
306
+ elif any(
307
+ keyword in line.lower()
308
+ for keyword in ["passed", "failed", "error", "summary", "total"]
309
+ ):
310
+ filtered_lines.append(line)
311
+
312
+ # Add remaining failure context
313
+ if failure_context:
314
+ filtered_lines.extend(failure_context)
315
+
316
+ # If we filtered significantly, add header
317
+ if filtered_lines and len(filtered_lines) < original_lines * 0.5:
318
+ header = f"[Filtered test output - showing failures only ({len(filtered_lines)}/{original_lines} lines)]"
319
+ return header + "\n" + "\n".join(filtered_lines)
320
+ else:
321
+ # Not much to filter, return original but truncated if too long
322
+ return OutputFilter._truncate_output(output, lines, original_lines)
323
+
324
+ # Git log - limit to reasonable number of commits
325
+ elif "git log" in cmd or "git reflog" in cmd:
326
+ # Take first 50 lines (typically ~5-10 commits with details)
327
+ if len(lines) > 50:
328
+ truncated = "\n".join(lines[:50])
329
+ footer = f"\n[Output truncated: showing first 50/{original_lines} lines. Use --max-count to limit commits]"
330
+ return truncated + footer
331
+ return output
332
+
333
+ # Build/install output - show only errors and final status
334
+ elif any(
335
+ pattern in cmd for pattern in ["npm install", "pip install", "cargo build", "go build"]
336
+ ):
337
+ filtered_lines = []
338
+
339
+ for line in lines:
340
+ # Keep error/warning lines
341
+ if any(
342
+ keyword in line.upper()
343
+ for keyword in ["ERROR", "WARN", "FAIL", "SUCCESSFULLY", "COMPLETE"]
344
+ ):
345
+ filtered_lines.append(line)
346
+ # Keep final summary lines
347
+ elif any(
348
+ keyword in line.lower()
349
+ for keyword in ["installed", "built", "compiled", "finished"]
350
+ ):
351
+ filtered_lines.append(line)
352
+
353
+ if filtered_lines and len(filtered_lines) < original_lines * 0.3:
354
+ header = f"[Filtered build output - showing errors and summary only ({len(filtered_lines)}/{original_lines} lines)]"
355
+ return header + "\n" + "\n".join(filtered_lines)
356
+ else:
357
+ return OutputFilter._truncate_output(output, lines, original_lines)
358
+
359
+ # Default: truncate if too long
360
+ return OutputFilter._truncate_output(output, lines, original_lines)
361
+
362
+ @staticmethod
363
+ def _truncate_output(output: str, lines: list, original_lines: int) -> str:
364
+ """Truncate output if it exceeds maximum lines.
365
+
366
+ Args:
367
+ output: Original output string
368
+ lines: Split lines
369
+ original_lines: Count of original lines
370
+
371
+ Returns:
372
+ Truncated output if necessary
373
+ """
374
+ if original_lines > MAX_OUTPUT_LINES:
375
+ # Show first and last portions
376
+ keep_start = MAX_OUTPUT_LINES // 2
377
+ keep_end = MAX_OUTPUT_LINES // 2
378
+
379
+ truncated_lines = (
380
+ lines[:keep_start]
381
+ + ["", f"... [truncated {original_lines - MAX_OUTPUT_LINES} lines] ...", ""]
382
+ + lines[-keep_end:]
383
+ )
384
+
385
+ return "\n".join(truncated_lines)
386
+
387
+ return output
388
+
389
+
202
390
  # Global operation limiter
203
391
  _operation_limiter = OperationLimiter()
204
392
 
@@ -1457,7 +1645,7 @@ def _try_line_trimmed_match(content: str, old_string: str) -> Optional[str]:
1457
1645
  content_lines = content.split("\n")
1458
1646
  search_lines = old_string.split("\n")
1459
1647
 
1460
- # Remove trailing empty line if present
1648
+ # Remove trailing empty line if present in search
1461
1649
  if search_lines and search_lines[-1] == "":
1462
1650
  search_lines.pop()
1463
1651
 
@@ -1472,7 +1660,19 @@ def _try_line_trimmed_match(content: str, old_string: str) -> Optional[str]:
1472
1660
  if matches:
1473
1661
  # Found a match - return the original lines (with indentation) joined
1474
1662
  matched_lines = content_lines[i : i + len(search_lines)]
1475
- return "\n".join(matched_lines)
1663
+ result = "\n".join(matched_lines)
1664
+
1665
+ # Preserve trailing newlines if present in the matched section
1666
+ # After the matched lines, check if there's more content (indicating trailing newline)
1667
+ end_index = i + len(search_lines)
1668
+ if end_index < len(content_lines):
1669
+ # There's more content after match, so add the newline that separates them
1670
+ result += "\n"
1671
+ elif content.endswith("\n"):
1672
+ # At end of file and file ends with newline, preserve it
1673
+ result += "\n"
1674
+
1675
+ return result
1476
1676
 
1477
1677
  return None
1478
1678
 
@@ -1726,11 +1926,57 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
1726
1926
  f"💡 Tip: Use read_lines() to see the exact context, or use apply_patch() for multiple changes."
1727
1927
  )
1728
1928
 
1729
- # Check permission before proceeding
1929
+ # Perform indentation adjustment and trailing newline preservation BEFORE showing diff
1930
+ # Important: Adjust indentation and preserve trailing newlines to maintain file structure
1931
+ adjusted_new_string = new_string
1932
+
1933
+ # Step 1: Adjust indentation if needed
1934
+ # Get the indentation of the first line in matched_string vs new_string
1935
+ matched_lines = matched_string.split("\n")
1936
+ new_lines = new_string.split("\n")
1937
+
1938
+ if matched_lines and new_lines and matched_lines[0] and new_lines[0]:
1939
+ # Get leading whitespace of first line in matched string
1940
+ matched_indent = len(matched_lines[0]) - len(matched_lines[0].lstrip())
1941
+ new_indent = len(new_lines[0]) - len(new_lines[0].lstrip())
1942
+
1943
+ if matched_indent != new_indent:
1944
+ # Need to adjust indentation
1945
+ indent_diff = matched_indent - new_indent
1946
+
1947
+ # Apply the indentation adjustment to all non-empty lines in new_string
1948
+ adjusted_lines = []
1949
+ for line in new_lines:
1950
+ if line.strip(): # Non-empty line
1951
+ if indent_diff > 0:
1952
+ # Need to add spaces
1953
+ adjusted_lines.append((" " * indent_diff) + line)
1954
+ else:
1955
+ # Need to remove spaces (if possible)
1956
+ spaces_to_remove = abs(indent_diff)
1957
+ if line[:spaces_to_remove].strip() == "": # All spaces
1958
+ adjusted_lines.append(line[spaces_to_remove:])
1959
+ else:
1960
+ # Can't remove that many spaces, keep as-is
1961
+ adjusted_lines.append(line)
1962
+ else:
1963
+ # Empty line, keep as-is
1964
+ adjusted_lines.append(line)
1965
+
1966
+ adjusted_new_string = "\n".join(adjusted_lines)
1967
+
1968
+ # Step 2: Preserve trailing newlines from matched_string
1969
+ if matched_string.endswith("\n") and not adjusted_new_string.endswith("\n"):
1970
+ # Matched block had trailing newline(s), preserve them
1971
+ # Count consecutive trailing newlines in matched_string
1972
+ trailing_newlines = len(matched_string) - len(matched_string.rstrip("\n"))
1973
+ adjusted_new_string = adjusted_new_string + ("\n" * trailing_newlines)
1974
+
1975
+ # Check permission before proceeding (use adjusted_new_string for accurate diff display)
1730
1976
  permission_manager = _get_permission_manager()
1731
1977
 
1732
- # Format colored diff for permission prompt (use the matched string for accurate diff)
1733
- diff_display = _format_colored_diff(matched_string, new_string, file_path=path)
1978
+ # Format colored diff for permission prompt (use adjusted_new_string so user sees what will actually be written)
1979
+ diff_display = _format_colored_diff(matched_string, adjusted_new_string, file_path=path)
1734
1980
 
1735
1981
  # Add warning if writing outside repository
1736
1982
  outside_repo_warning = ""
@@ -1745,21 +1991,18 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
1745
1991
  # Backup if enabled
1746
1992
  backup_path = _backup_file(p)
1747
1993
 
1748
- # Perform replacement using the matched string
1749
- # Note: newString is used as-is (OpenCode behavior)
1750
- # The LLM should provide newString with proper indentation matching the original
1751
- new_content = content.replace(matched_string, new_string)
1994
+ new_content = content.replace(matched_string, adjusted_new_string)
1752
1995
 
1753
1996
  # Write the new content
1754
1997
  p.write_text(new_content)
1755
1998
 
1756
- # Generate diff for the specific change (use matched_string for accurate diff)
1999
+ # Generate diff for the specific change (use adjusted_new_string for accurate diff)
1757
2000
  old_lines = matched_string.split("\n")
1758
- new_lines = new_string.split("\n")
2001
+ new_lines = adjusted_new_string.split("\n")
1759
2002
  diff = difflib.unified_diff(old_lines, new_lines, fromfile="old", tofile="new", lineterm="")
1760
2003
  diff_str = "\n".join(diff)
1761
2004
 
1762
- audit_logger.info(f"EDIT: {path} ({len(matched_string)} -> {len(new_string)} chars)")
2005
+ audit_logger.info(f"EDIT: {path} ({len(matched_string)} -> {len(adjusted_new_string)} chars)")
1763
2006
 
1764
2007
  backup_msg = f"\n[Backup saved: {backup_path}]" if backup_path else ""
1765
2008
  return f"Successfully edited {path}{backup_msg}\n\nChange:\n{diff_str}"
@@ -2304,4 +2547,19 @@ def run_shell(cmd: str) -> str:
2304
2547
  stdout = result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
2305
2548
  stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
2306
2549
 
2307
- return stdout + stderr
2550
+ output = stdout + stderr
2551
+
2552
+ # Apply output filtering to reduce token usage
2553
+ if OutputFilter.should_filter(cmd):
2554
+ filtered_output = OutputFilter.filter_output(cmd, output)
2555
+ # Log if we filtered significantly
2556
+ original_lines = len(output.split("\n"))
2557
+ filtered_lines = len(filtered_output.split("\n"))
2558
+ if filtered_lines < original_lines * 0.5:
2559
+ audit_logger.info(
2560
+ f"SHELL_FILTER: Reduced output from {original_lines} to {filtered_lines} lines "
2561
+ f"(~{int((1 - filtered_lines / original_lines) * 100)}% reduction)"
2562
+ )
2563
+ return filtered_output
2564
+
2565
+ return output
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A lean Claude Code clone in pure Python
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -1652,9 +1652,9 @@ def test_edit_file_matching_strategies_helper_functions(temp_repo):
1652
1652
  assert _try_simple_match(content, 'print("world")') == 'print("world")'
1653
1653
  assert _try_simple_match(content, "nonexistent") is None
1654
1654
 
1655
- # Test line trimmed match (should find with correct indentation)
1655
+ # Test line trimmed match (should find with correct indentation AND trailing newline)
1656
1656
  match = _try_line_trimmed_match(content, 'print("world")')
1657
- assert match == ' print("world")'
1657
+ assert match == ' print("world")\n' # Now preserves trailing newline
1658
1658
 
1659
1659
  # Test whitespace normalized match
1660
1660
  content2 = "x = 42"
@@ -1679,8 +1679,8 @@ def test_edit_file_multiline_trimmed_match_helper(temp_repo):
1679
1679
  return value"""
1680
1680
 
1681
1681
  match = _try_line_trimmed_match(content, search)
1682
- # Should return with proper indentation (8 spaces)
1683
- assert match == " if True:\n do_something()\n return value"
1682
+ # Should return with proper indentation (8 spaces) AND trailing newline
1683
+ assert match == " if True:\n do_something()\n return value\n"
1684
1684
 
1685
1685
 
1686
1686
  def test_edit_file_finds_match_with_strategy_order(temp_repo):
@@ -1696,5 +1696,123 @@ def test_edit_file_finds_match_with_strategy_order(temp_repo):
1696
1696
 
1697
1697
  # Without indentation, should match the full line not substring in comment
1698
1698
  match = _find_match_with_strategies(content, "return result")
1699
- assert match == " return result"
1699
+ assert match == " return result\n" # Now includes trailing newline
1700
+ # Should prefer full line match for code patterns
1701
+ content = """def calculate():
1702
+ result = process() # result is important
1703
+ return result
1704
+ """
1705
+
1706
+ # Without indentation, should match the full line not substring in comment
1707
+ match = _find_match_with_strategies(content, "return result")
1708
+ assert match == " return result\n" # Now includes trailing newline
1700
1709
  # Should NOT match just "result" in the comment or variable name
1710
+
1711
+
1712
+ def test_edit_file_preserves_trailing_newline(temp_repo):
1713
+ """Test that flexible matching preserves trailing newlines in matched blocks."""
1714
+ from patchpal.tools import edit_file
1715
+
1716
+ # Test case 1: Match in middle of file (should preserve ONE trailing newline, not blank lines)
1717
+ content_middle = """def function1():
1718
+ print("hello")
1719
+ return 1
1720
+
1721
+ def function2():
1722
+ print("world")
1723
+ return 2
1724
+ """
1725
+ (temp_repo / "newline_test1.py").write_text(content_middle)
1726
+
1727
+ # Edit a block in the middle - the matched block should include ONE trailing newline
1728
+ # but NOT the blank line that follows (blank line is not part of the function)
1729
+ old_string = """def function1():
1730
+ print("hello")
1731
+ return 1"""
1732
+
1733
+ new_string = """def function1():
1734
+ print("modified")
1735
+ return 1"""
1736
+
1737
+ edit_file("newline_test1.py", old_string, new_string)
1738
+
1739
+ # Verify: the matched section gets replaced, preserving structure
1740
+ # The blank line should remain because it's between the two functions
1741
+ new_content = (temp_repo / "newline_test1.py").read_text()
1742
+ # After editing, there should still be a blank line between functions
1743
+ assert "\n\ndef function2():" in new_content
1744
+ assert 'print("modified")' in new_content
1745
+
1746
+ # Test case 2: Match at end of file WITH trailing newline
1747
+ content_end_with = """def function():
1748
+ print("test")
1749
+ return True
1750
+ """
1751
+ (temp_repo / "newline_test2.py").write_text(content_end_with)
1752
+
1753
+ old_string = 'print("test")\n return True'
1754
+ new_string = 'print("modified")\n return True'
1755
+
1756
+ edit_file("newline_test2.py", old_string, new_string)
1757
+
1758
+ new_content = (temp_repo / "newline_test2.py").read_text()
1759
+ # File should still end with newline
1760
+ assert new_content.endswith("\n")
1761
+ assert 'print("modified")' in new_content
1762
+
1763
+ # Test case 3: Match at end of file WITHOUT trailing newline
1764
+ content_end_without = """def function():
1765
+ print("test")
1766
+ return False""" # No trailing newline
1767
+
1768
+ (temp_repo / "newline_test3.py").write_text(content_end_without)
1769
+
1770
+ old_string = 'print("test")\n return False'
1771
+ new_string = 'print("changed")\n return False'
1772
+
1773
+ edit_file("newline_test3.py", old_string, new_string)
1774
+
1775
+ new_content = (temp_repo / "newline_test3.py").read_text()
1776
+ # File should NOT have trailing newline (preserving original)
1777
+ assert not new_content.endswith("\n")
1778
+ assert 'print("changed")' in new_content
1779
+
1780
+
1781
+ def test_edit_file_auto_adjusts_indentation(temp_repo):
1782
+ """Test that edit_file automatically adjusts indentation of new_string to match matched_string."""
1783
+ from patchpal.tools import edit_file
1784
+
1785
+ # Create a file with specific indentation (28 spaces for elif)
1786
+ content = """ elif tool_name == "todo_add":
1787
+ print(
1788
+ f"Adding TODO",
1789
+ flush=True,
1790
+ )
1791
+ """
1792
+ (temp_repo / "indent_adjust_test.py").write_text(content)
1793
+
1794
+ # Provide new_string with WRONG indentation (30 spaces)
1795
+ old_string = """elif tool_name == "todo_add":
1796
+ print(
1797
+ f"Adding TODO",
1798
+ flush=True,
1799
+ )"""
1800
+
1801
+ new_string = """ elif tool_name == "todo_add":
1802
+ print(
1803
+ f"Modified TODO",
1804
+ flush=True,
1805
+ )"""
1806
+
1807
+ edit_file("indent_adjust_test.py", old_string, new_string)
1808
+
1809
+ # Verify the indentation was AUTO-ADJUSTED to match original (28 spaces)
1810
+ new_content = (temp_repo / "indent_adjust_test.py").read_text()
1811
+
1812
+ # Should have 28 spaces before elif (not 30)
1813
+ assert " elif tool_name" in new_content
1814
+ # Should have 32 spaces before print (not 34)
1815
+ assert " print(" in new_content
1816
+ # Content should be updated
1817
+ assert "Modified TODO" in new_content
1818
+ assert "Adding TODO" not in new_content
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes