Преглед на файлове

Add support for GIT_FLUSH environment variable

When GIT_FLUSH=1, output is flushed after each write operation,
enabling real-time output visibility for CI/CD systems and scripts
that parse Git output as it's generated.

When GIT_FLUSH=0, output uses completely buffered I/O.

When GIT_FLUSH is not set, the behavior is auto-detected based on
whether stdout/stderr are connected to a TTY (no flush) or redirected
to a pipe/file (flush enabled).

Fixes #1810
Jelmer Vernooij преди 2 месеца
родител
ревизия
a5c1141e2a
променени са 4 файла, в които са добавени 320 реда и са изтрити 3 реда
  1. 3 3
      Cargo.lock
  2. 4 0
      NEWS
  3. 194 0
      dulwich/cli.py
  4. 119 0
      tests/test_cli.py

+ 3 - 3
Cargo.lock

@@ -10,7 +10,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
 
 [[package]]
 name = "diff-tree-py"
-version = "0.24.7"
+version = "0.24.8"
 dependencies = [
  "pyo3",
 ]
@@ -50,7 +50,7 @@ dependencies = [
 
 [[package]]
 name = "objects-py"
-version = "0.24.7"
+version = "0.24.8"
 dependencies = [
  "memchr",
  "pyo3",
@@ -64,7 +64,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
 
 [[package]]
 name = "pack-py"
-version = "0.24.7"
+version = "0.24.8"
 dependencies = [
  "memchr",
  "pyo3",

+ 4 - 0
NEWS

@@ -4,6 +4,10 @@
    Implements Git's namespace feature for isolating refs within a single
    repository using the ``refs/namespaces/`` prefix. (Jelmer Vernooij, #1809)
 
+ * Add support for GIT_FLUSH environment variable to control output buffering
+   in CLI commands. When GIT_FLUSH=1, output is flushed after each write for
+   real-time visibility. (Jelmer Vernooij, #1810)
+
 0.24.7	2025-10-23
 
  * Add sparse index support for improved performance with large repositories.

+ 194 - 0
dulwich/cli.py

@@ -95,6 +95,196 @@ def to_display_str(value: Union[bytes, str]) -> str:
     return value
 
 
+def _should_auto_flush(
+    stream: Union[TextIO, BinaryIO], env: Optional[Mapping[str, str]] = None
+) -> bool:
+    """Determine if output should be auto-flushed based on GIT_FLUSH environment variable.
+
+    Args:
+        stream: The output stream to check
+        env: Environment variables dict (defaults to os.environ)
+
+    Returns:
+        True if output should be flushed after each write, False otherwise
+    """
+    if env is None:
+        env = os.environ
+    git_flush = env.get("GIT_FLUSH", "").strip()
+    if git_flush == "1":
+        return True
+    elif git_flush == "0":
+        return False
+    else:
+        # Auto-detect: don't flush if redirected to a file
+        return hasattr(stream, "isatty") and not stream.isatty()
+
+
+class AutoFlushTextIOWrapper:
+    """Wrapper that automatically flushes a TextIO stream based on configuration.
+
+    This wrapper can be configured to flush after each write operation,
+    which is useful for real-time output monitoring in CI/CD systems.
+    """
+
+    def __init__(self, stream: TextIO) -> None:
+        """Initialize the wrapper.
+
+        Args:
+            stream: The stream to wrap
+        """
+        self._stream = stream
+
+    @classmethod
+    def env(
+        cls, stream: TextIO, env: Optional[Mapping[str, str]] = None
+    ) -> "AutoFlushTextIOWrapper | TextIO":
+        """Create wrapper respecting the GIT_FLUSH environment variable.
+
+        Respects the GIT_FLUSH environment variable:
+        - GIT_FLUSH=1: Always flush after each write
+        - GIT_FLUSH=0: Never auto-flush (use buffered I/O)
+        - Not set: Auto-detect based on whether output is redirected
+
+        Args:
+            stream: The stream to wrap
+            env: Environment variables dict (defaults to os.environ)
+
+        Returns:
+            AutoFlushTextIOWrapper instance configured based on GIT_FLUSH
+        """
+        if _should_auto_flush(stream, env):
+            return cls(stream)
+        else:
+            return stream
+
+    def write(self, data: str) -> int:
+        """Write data to the stream and optionally flush.
+
+        Args:
+            data: Data to write
+
+        Returns:
+            Number of characters written
+        """
+        result = self._stream.write(data)
+        self._stream.flush()
+        return result
+
+    def writelines(self, lines: Iterable[str]) -> None:
+        """Write multiple lines to the stream and optionally flush.
+
+        Args:
+            lines: Lines to write
+        """
+        self._stream.writelines(lines)
+        self._stream.flush()
+
+    def flush(self) -> None:
+        """Flush the underlying stream."""
+        self._stream.flush()
+
+    def __getattr__(self, name: str) -> object:
+        """Delegate all other attributes to the underlying stream."""
+        return getattr(self._stream, name)
+
+    def __enter__(self) -> "AutoFlushTextIOWrapper":
+        """Support context manager protocol."""
+        return self
+
+    def __exit__(
+        self,
+        exc_type: Optional[type[BaseException]],
+        exc_val: Optional[BaseException],
+        exc_tb: Optional[TracebackType],
+    ) -> None:
+        """Support context manager protocol."""
+        if hasattr(self._stream, "__exit__"):
+            self._stream.__exit__(exc_type, exc_val, exc_tb)
+
+
+class AutoFlushBinaryIOWrapper:
+    """Wrapper that automatically flushes a BinaryIO stream based on configuration.
+
+    This wrapper can be configured to flush after each write operation,
+    which is useful for real-time output monitoring in CI/CD systems.
+    """
+
+    def __init__(self, stream: BinaryIO) -> None:
+        """Initialize the wrapper.
+
+        Args:
+            stream: The stream to wrap
+        """
+        self._stream = stream
+
+    @classmethod
+    def env(
+        cls, stream: BinaryIO, env: Optional[Mapping[str, str]] = None
+    ) -> "AutoFlushBinaryIOWrapper | BinaryIO":
+        """Create wrapper respecting the GIT_FLUSH environment variable.
+
+        Respects the GIT_FLUSH environment variable:
+        - GIT_FLUSH=1: Always flush after each write
+        - GIT_FLUSH=0: Never auto-flush (use buffered I/O)
+        - Not set: Auto-detect based on whether output is redirected
+
+        Args:
+            stream: The stream to wrap
+            env: Environment variables dict (defaults to os.environ)
+
+        Returns:
+            AutoFlushBinaryIOWrapper instance configured based on GIT_FLUSH
+        """
+        if _should_auto_flush(stream, env):
+            return cls(stream)
+        else:
+            return stream
+
+    def write(self, data: Buffer) -> int:
+        """Write data to the stream and optionally flush.
+
+        Args:
+            data: Data to write
+
+        Returns:
+            Number of bytes written
+        """
+        result = self._stream.write(data)
+        self._stream.flush()
+        return result
+
+    def writelines(self, lines: Iterable[Buffer]) -> None:
+        """Write multiple lines to the stream and optionally flush.
+
+        Args:
+            lines: Lines to write
+        """
+        self._stream.writelines(lines)
+        self._stream.flush()
+
+    def flush(self) -> None:
+        """Flush the underlying stream."""
+        self._stream.flush()
+
+    def __getattr__(self, name: str) -> object:
+        """Delegate all other attributes to the underlying stream."""
+        return getattr(self._stream, name)
+
+    def __enter__(self) -> "AutoFlushBinaryIOWrapper":
+        """Support context manager protocol."""
+        return self
+
+    def __exit__(
+        self,
+        exc_type: Optional[type[BaseException]],
+        exc_val: Optional[BaseException],
+        exc_tb: Optional[TracebackType],
+    ) -> None:
+        """Support context manager protocol."""
+        if hasattr(self._stream, "__exit__"):
+            self._stream.__exit__(exc_type, exc_val, exc_tb)
+
+
 class CommitMessageError(Exception):
     """Raised when there's an issue with the commit message."""
 
@@ -5640,6 +5830,10 @@ def main(argv: Optional[Sequence[str]] = None) -> Optional[int]:
     Returns:
         Exit code or None
     """
+    # Wrap stdout and stderr to respect GIT_FLUSH environment variable
+    sys.stdout = AutoFlushTextIOWrapper.env(sys.stdout)
+    sys.stderr = AutoFlushTextIOWrapper.env(sys.stderr)
+
     if argv is None:
         argv = sys.argv[1:]
 

+ 119 - 0
tests/test_cli.py

@@ -3672,5 +3672,124 @@ class ConfigCommandTest(DulwichCliTestCase):
         self.assertEqual(stdout, "value1\nvalue2\nvalue3\n")
 
 
+class GitFlushTest(TestCase):
+    """Tests for GIT_FLUSH environment variable support."""
+
+    def test_should_auto_flush_with_git_flush_1(self):
+        """Test that GIT_FLUSH=1 enables auto-flushing."""
+        from dulwich.cli import _should_auto_flush
+
+        mock_stream = MagicMock()
+        mock_stream.isatty.return_value = True
+
+        self.assertTrue(_should_auto_flush(mock_stream, env={"GIT_FLUSH": "1"}))
+
+    def test_should_auto_flush_with_git_flush_0(self):
+        """Test that GIT_FLUSH=0 disables auto-flushing."""
+        from dulwich.cli import _should_auto_flush
+
+        mock_stream = MagicMock()
+        mock_stream.isatty.return_value = True
+
+        self.assertFalse(_should_auto_flush(mock_stream, env={"GIT_FLUSH": "0"}))
+
+    def test_should_auto_flush_auto_detect_tty(self):
+        """Test that auto-detect returns False for TTY (no flush needed)."""
+        from dulwich.cli import _should_auto_flush
+
+        mock_stream = MagicMock()
+        mock_stream.isatty.return_value = True
+
+        self.assertFalse(_should_auto_flush(mock_stream, env={}))
+
+    def test_should_auto_flush_auto_detect_pipe(self):
+        """Test that auto-detect returns True for pipes (flush needed)."""
+        from dulwich.cli import _should_auto_flush
+
+        mock_stream = MagicMock()
+        mock_stream.isatty.return_value = False
+
+        self.assertTrue(_should_auto_flush(mock_stream, env={}))
+
+    def test_text_wrapper_flushes_on_write(self):
+        """Test that AutoFlushTextIOWrapper flushes after write."""
+        from dulwich.cli import AutoFlushTextIOWrapper
+
+        mock_stream = MagicMock()
+        wrapper = AutoFlushTextIOWrapper(mock_stream)
+
+        wrapper.write("test")
+        mock_stream.write.assert_called_once_with("test")
+        mock_stream.flush.assert_called_once()
+
+    def test_text_wrapper_flushes_on_writelines(self):
+        """Test that AutoFlushTextIOWrapper flushes after writelines."""
+        from dulwich.cli import AutoFlushTextIOWrapper
+
+        mock_stream = MagicMock()
+        wrapper = AutoFlushTextIOWrapper(mock_stream)
+
+        wrapper.writelines(["line1\n", "line2\n"])
+        mock_stream.writelines.assert_called_once()
+        mock_stream.flush.assert_called_once()
+
+    def test_binary_wrapper_flushes_on_write(self):
+        """Test that AutoFlushBinaryIOWrapper flushes after write."""
+        from dulwich.cli import AutoFlushBinaryIOWrapper
+
+        mock_stream = MagicMock()
+        wrapper = AutoFlushBinaryIOWrapper(mock_stream)
+
+        wrapper.write(b"test")
+        mock_stream.write.assert_called_once_with(b"test")
+        mock_stream.flush.assert_called_once()
+
+    def test_text_wrapper_env_classmethod(self):
+        """Test that AutoFlushTextIOWrapper.env() respects GIT_FLUSH."""
+        from dulwich.cli import AutoFlushTextIOWrapper
+
+        mock_stream = MagicMock()
+        mock_stream.isatty.return_value = False
+
+        wrapper = AutoFlushTextIOWrapper.env(mock_stream, env={"GIT_FLUSH": "1"})
+        self.assertIsInstance(wrapper, AutoFlushTextIOWrapper)
+
+        wrapper = AutoFlushTextIOWrapper.env(mock_stream, env={"GIT_FLUSH": "0"})
+        self.assertIs(mock_stream, wrapper)
+
+    def test_binary_wrapper_env_classmethod(self):
+        """Test that AutoFlushBinaryIOWrapper.env() respects GIT_FLUSH."""
+        from dulwich.cli import AutoFlushBinaryIOWrapper
+
+        mock_stream = MagicMock()
+        mock_stream.isatty.return_value = False
+
+        wrapper = AutoFlushBinaryIOWrapper.env(mock_stream, env={"GIT_FLUSH": "1"})
+        self.assertIsInstance(wrapper, AutoFlushBinaryIOWrapper)
+
+        wrapper = AutoFlushBinaryIOWrapper.env(mock_stream, env={"GIT_FLUSH": "0"})
+        self.assertIs(wrapper, mock_stream)
+
+    def test_wrapper_delegates_attributes(self):
+        """Test that wrapper delegates unknown attributes to stream."""
+        from dulwich.cli import AutoFlushTextIOWrapper
+
+        mock_stream = MagicMock()
+        mock_stream.encoding = "utf-8"
+        wrapper = AutoFlushTextIOWrapper(mock_stream)
+
+        self.assertEqual(wrapper.encoding, "utf-8")
+
+    def test_wrapper_context_manager(self):
+        """Test that wrapper supports context manager protocol."""
+        from dulwich.cli import AutoFlushTextIOWrapper
+
+        mock_stream = MagicMock()
+        wrapper = AutoFlushTextIOWrapper(mock_stream)
+
+        with wrapper as w:
+            self.assertIs(w, wrapper)
+
+
 if __name__ == "__main__":
     unittest.main()