瀏覽代碼

diff: Add colordiff support

Jelmer Vernooij 1 月之前
父節點
當前提交
d708cea428
共有 5 個文件被更改,包括 351 次插入3 次删除
  1. 65 3
      dulwich/cli.py
  2. 100 0
      dulwich/diff.py
  3. 1 0
      pyproject.toml
  4. 1 0
      tests/__init__.py
  5. 184 0
      tests/test_diff.py

+ 65 - 3
dulwich/cli.py

@@ -29,6 +29,7 @@ a way to test Dulwich.
 """
 
 import argparse
+import logging
 import os
 import shutil
 import signal
@@ -146,6 +147,27 @@ class PagerBuffer:
         for line in lines:
             self.write(line)
 
+    def readable(self):
+        """Return whether the buffer is readable (it's not)."""
+        return False
+
+    def writable(self):
+        """Return whether the buffer is writable."""
+        return not self.pager._closed
+
+    def seekable(self):
+        """Return whether the buffer is seekable (it's not)."""
+        return False
+
+    def close(self):
+        """Close the pager."""
+        return self.pager.close()
+
+    @property
+    def closed(self):
+        """Return whether the buffer is closed."""
+        return self.pager.closed
+
 
 class Pager:
     """File-like object that pages output through external pager programs."""
@@ -561,6 +583,12 @@ class cmd_diff(Command):
             action="store_true",
             help="Show staged changes (same as --staged)",
         )
+        parser.add_argument(
+            "--color",
+            choices=["always", "never", "auto"],
+            default="auto",
+            help="Use colored output (requires pygments)",
+        )
         parser.add_argument(
             "--", dest="separator", action="store_true", help=argparse.SUPPRESS
         )
@@ -576,16 +604,46 @@ class cmd_diff(Command):
 
         args = parsed_args
 
+        # Determine if we should use color
+        def _should_use_color():
+            if args.color == "always":
+                return True
+            elif args.color == "never":
+                return False
+            else:  # auto
+                return sys.stdout.isatty()
+
+        def _create_output_stream(outstream):
+            """Create output stream, optionally with colorization."""
+            if not _should_use_color():
+                return outstream.buffer
+
+            from .diff import ColorizedDiffStream
+
+            if not ColorizedDiffStream.is_available():
+                if args.color == "always":
+                    raise ImportError(
+                        "Rich is required for colored output. Install with: pip install 'dulwich[colordiff]'"
+                    )
+                else:
+                    logging.warning(
+                        "Rich not available, disabling colored output. Install with: pip install 'dulwich[colordiff]'"
+                    )
+                    return outstream.buffer
+
+            return ColorizedDiffStream(outstream.buffer)
+
         with Repo(".") as repo:
             config = repo.get_config_stack()
             with get_pager(config=config, cmd_name="diff") as outstream:
+                output_stream = _create_output_stream(outstream)
                 if len(args.committish) == 0:
                     # Show diff for working tree or staged changes
                     porcelain.diff(
                         repo,
                         staged=(args.staged or args.cached),
                         paths=args.paths or None,
-                        outstream=outstream.buffer,
+                        outstream=output_stream,
                     )
                 elif len(args.committish) == 1:
                     # Show diff between working tree and specified commit
@@ -596,7 +654,7 @@ class cmd_diff(Command):
                         commit=args.committish[0],
                         staged=False,
                         paths=args.paths or None,
-                        outstream=outstream.buffer,
+                        outstream=output_stream,
                     )
                 elif len(args.committish) == 2:
                     # Show diff between two commits
@@ -605,11 +663,15 @@ class cmd_diff(Command):
                         commit=args.committish[0],
                         commit2=args.committish[1],
                         paths=args.paths or None,
-                        outstream=outstream.buffer,
+                        outstream=output_stream,
                     )
                 else:
                     parser.error("Too many arguments - specify at most two commits")
 
+                # Flush any remaining output
+                if hasattr(output_stream, "flush"):
+                    output_stream.flush()
+
 
 class cmd_dump_pack(Command):
     def run(self, args) -> None:

+ 100 - 0
dulwich/diff.py

@@ -504,3 +504,103 @@ def diff_working_tree_to_index(
             write_blob_diff(
                 outstream, (tree_path, old_mode, old_blob), (None, None, None)
             )
+
+
+class ColorizedDiffStream:
+    """Stream wrapper that colorizes diff output line by line using Rich.
+
+    This class wraps a binary output stream and applies color formatting
+    to diff output as it's written. It processes data line by line to
+    enable streaming colorization without buffering the entire diff.
+    """
+
+    @staticmethod
+    def is_available():
+        """Check if Rich is available for colorization.
+
+        Returns:
+            bool: True if Rich can be imported, False otherwise
+        """
+        try:
+            import importlib.util
+
+            return importlib.util.find_spec("rich.console") is not None
+        except ImportError:
+            return False
+
+    def __init__(self, output_stream):
+        """Initialize the colorized stream wrapper.
+
+        Args:
+            output_stream: The underlying binary stream to write to
+        """
+        self.output_stream = output_stream
+        import io
+
+        from rich.console import Console
+
+        # Rich expects a text stream, so we need to wrap our binary stream
+        self.text_wrapper = io.TextIOWrapper(
+            output_stream, encoding="utf-8", newline=""
+        )
+        self.console = Console(file=self.text_wrapper, force_terminal=True)
+        self.buffer = b""
+
+    def write(self, data):
+        """Write data to the stream, applying colorization.
+
+        Args:
+            data: Bytes to write
+        """
+        # Add new data to buffer
+        self.buffer += data
+
+        # Process complete lines
+        while b"\n" in self.buffer:
+            line, self.buffer = self.buffer.split(b"\n", 1)
+            self._colorize_and_write_line(line + b"\n")
+
+    def writelines(self, lines):
+        """Write a list of lines to the stream.
+
+        Args:
+            lines: Iterable of bytes to write
+        """
+        for line in lines:
+            self.write(line)
+
+    def _colorize_and_write_line(self, line_bytes):
+        """Apply color formatting to a single line and write it.
+
+        Args:
+            line_bytes: The line to colorize and write (as bytes)
+        """
+        try:
+            line = line_bytes.decode("utf-8", errors="replace")
+
+            # Colorize based on diff line type
+            if line.startswith("+") and not line.startswith("+++"):
+                self.console.print(line, style="green", end="")
+            elif line.startswith("-") and not line.startswith("---"):
+                self.console.print(line, style="red", end="")
+            elif line.startswith("@@"):
+                self.console.print(line, style="cyan", end="")
+            elif line.startswith(("+++", "---")):
+                self.console.print(line, style="bold", end="")
+            else:
+                self.console.print(line, end="")
+        except (UnicodeDecodeError, UnicodeEncodeError):
+            # Fallback to raw output if we can't decode/encode the text
+            self.output_stream.write(line_bytes)
+
+    def flush(self):
+        """Flush any remaining buffered content and the underlying stream."""
+        # Write any remaining buffer content
+        if self.buffer:
+            self._colorize_and_write_line(self.buffer)
+            self.buffer = b""
+        # Flush the text wrapper and underlying stream
+        if hasattr(self.text_wrapper, "flush"):
+            self.text_wrapper.flush()
+        if hasattr(self.output_stream, "flush"):
+            self.output_stream.flush()

+ 1 - 0
pyproject.toml

@@ -41,6 +41,7 @@ fastimport = ["fastimport"]
 https = ["urllib3>=1.24.1"]
 pgp = ["gpg"]
 paramiko = ["paramiko"]
+colordiff = ["rich"]
 dev = [
     "ruff==0.12.2",
     "mypy==1.16.1",

+ 1 - 0
tests/__init__.py

@@ -129,6 +129,7 @@ def self_test_suite():
         "commit_graph",
         "config",
         "credentials",
+        "diff",
         "diff_tree",
         "dumb",
         "fastexport",

+ 184 - 0
tests/test_diff.py

@@ -0,0 +1,184 @@
+# test_diff.py -- Tests for diff functionality.
+# Copyright (C) 2025 Dulwich contributors
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as published by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Tests for diff functionality."""
+
+import io
+import unittest
+
+from dulwich.diff import ColorizedDiffStream
+
+from . import TestCase
+
+
+class ColorizedDiffStreamTests(TestCase):
+    """Tests for ColorizedDiffStream."""
+
+    def setUp(self):
+        super().setUp()
+        self.output = io.BytesIO()
+
+    @unittest.skipUnless(
+        ColorizedDiffStream.is_available(), "Rich not available for colorization"
+    )
+    def test_write_simple_diff(self):
+        """Test writing a simple diff with colorization."""
+        stream = ColorizedDiffStream(self.output)
+
+        diff_content = b"""--- a/file.txt
++++ b/file.txt
+@@ -1,3 +1,3 @@
+ unchanged line
+-removed line
++added line
+ another unchanged line
+"""
+
+        stream.write(diff_content)
+        stream.flush()
+
+        # We can't easily test the exact colored output without mocking Rich,
+        # but we can test that the stream writes something and doesn't crash
+        result = self.output.getvalue()
+        self.assertGreater(len(result), 0)
+
+    @unittest.skipUnless(
+        ColorizedDiffStream.is_available(), "Rich not available for colorization"
+    )
+    def test_write_line_by_line(self):
+        """Test writing diff content line by line."""
+        stream = ColorizedDiffStream(self.output)
+
+        lines = [
+            b"--- a/file.txt\n",
+            b"+++ b/file.txt\n",
+            b"@@ -1,2 +1,2 @@\n",
+            b"-old line\n",
+            b"+new line\n",
+        ]
+
+        for line in lines:
+            stream.write(line)
+
+        stream.flush()
+
+        result = self.output.getvalue()
+        self.assertGreater(len(result), 0)
+
+    @unittest.skipUnless(
+        ColorizedDiffStream.is_available(), "Rich not available for colorization"
+    )
+    def test_writelines(self):
+        """Test writelines method."""
+        stream = ColorizedDiffStream(self.output)
+
+        lines = [
+            b"--- a/file.txt\n",
+            b"+++ b/file.txt\n",
+            b"@@ -1,1 +1,1 @@\n",
+            b"-old\n",
+            b"+new\n",
+        ]
+
+        stream.writelines(lines)
+        stream.flush()
+
+        result = self.output.getvalue()
+        self.assertGreater(len(result), 0)
+
+    @unittest.skipUnless(
+        ColorizedDiffStream.is_available(), "Rich not available for colorization"
+    )
+    def test_partial_line_buffering(self):
+        """Test that partial lines are buffered correctly."""
+        stream = ColorizedDiffStream(self.output)
+
+        # Write partial line
+        stream.write(b"+partial")
+        # Output should be empty as line is not complete
+        result = self.output.getvalue()
+        self.assertEqual(len(result), 0)
+
+        # Complete the line
+        stream.write(b" line\n")
+        result = self.output.getvalue()
+        self.assertGreater(len(result), 0)
+
+    @unittest.skipUnless(
+        ColorizedDiffStream.is_available(), "Rich not available for colorization"
+    )
+    def test_unicode_handling(self):
+        """Test handling of unicode content in diffs."""
+        stream = ColorizedDiffStream(self.output)
+
+        # UTF-8 encoded content
+        unicode_diff = "--- a/ünïcödë.txt\n+++ b/ünïcödë.txt\n@@ -1,1 +1,1 @@\n-ōld\n+nëw\n".encode()
+
+        stream.write(unicode_diff)
+        stream.flush()
+
+        result = self.output.getvalue()
+        self.assertGreater(len(result), 0)
+
+    def test_is_available_static_method(self):
+        """Test is_available static method."""
+        # This should not raise an error regardless of Rich availability
+        result = ColorizedDiffStream.is_available()
+        self.assertIsInstance(result, bool)
+
+    @unittest.skipIf(
+        ColorizedDiffStream.is_available(), "Rich is available, skipping fallback test"
+    )
+    def test_rich_not_available(self):
+        """Test behavior when Rich is not available."""
+        # When Rich is not available, we can't instantiate ColorizedDiffStream
+        # This test only runs when Rich is not available
+        with self.assertRaises(ImportError):
+            ColorizedDiffStream(self.output)
+
+
+class MockColorizedDiffStreamTests(TestCase):
+    """Tests for ColorizedDiffStream using a mock when Rich is not available."""
+
+    def setUp(self):
+        super().setUp()
+        self.output = io.BytesIO()
+
+    def test_fallback_behavior_with_mock(self):
+        """Test that we can handle cases where Rich is not available."""
+        # This test demonstrates how the CLI handles the case where Rich is unavailable
+        if not ColorizedDiffStream.is_available():
+            # When Rich is not available, we should use the raw stream
+            stream = self.output
+        else:
+            # When Rich is available, we can use ColorizedDiffStream
+            stream = ColorizedDiffStream(self.output)
+
+        diff_content = b"+added line\n-removed line\n"
+
+        if hasattr(stream, "write"):
+            stream.write(diff_content)
+            if hasattr(stream, "flush"):
+                stream.flush()
+
+        # Test that some output was produced
+        result = self.output.getvalue()
+        self.assertGreaterEqual(len(result), 0)