Browse Source

Add basic pager support

Jelmer Vernooij 1 month ago
parent
commit
f2c8860200
2 changed files with 314 additions and 69 deletions
  1. 309 69
      dulwich/cli.py
  2. 5 0
      tests/test_porcelain.py

+ 309 - 69
dulwich/cli.py

@@ -30,7 +30,9 @@ a way to test Dulwich.
 
 import argparse
 import os
+import shutil
 import signal
+import subprocess
 import sys
 from pathlib import Path
 from typing import ClassVar, Optional
@@ -121,6 +123,203 @@ def format_bytes(bytes):
     return f"{bytes:.1f} TB"
 
 
+class PagerBuffer:
+    """Binary buffer wrapper for Pager to mimic sys.stdout.buffer."""
+
+    def __init__(self, pager):
+        self.pager = pager
+
+    def write(self, data: bytes):
+        """Write bytes to pager."""
+        if isinstance(data, bytes):
+            text = data.decode("utf-8", errors="replace")
+            return self.pager.write(text)
+        return self.pager.write(data)
+
+    def flush(self):
+        """Flush the pager."""
+        return self.pager.flush()
+
+    def writelines(self, lines):
+        """Write multiple lines to pager."""
+        for line in lines:
+            self.write(line)
+
+
+class Pager:
+    """File-like object that pages output through external pager programs."""
+
+    def __init__(self):
+        self.pager_process = None
+        self.buffer = PagerBuffer(self)
+        self._closed = False
+
+    def _get_pager_command(self) -> str:
+        """Get the pager command to use."""
+        # Priority order: DULWICH_PAGER, GIT_PAGER, PAGER, then fallback
+        for env_var in ["DULWICH_PAGER", "GIT_PAGER", "PAGER"]:
+            pager = os.environ.get(env_var)
+            if pager and pager != "false":
+                return pager
+
+        # Fallback to common pagers
+        for pager in ["less", "more", "cat"]:
+            if shutil.which(pager):
+                if pager == "less":
+                    return "less -FRX"  # -F: quit if one screen, -R: raw control chars, -X: no init/deinit
+                return pager
+
+        return "cat"  # Ultimate fallback
+
+    def _ensure_pager_started(self):
+        """Start the pager process if not already started."""
+        if self.pager_process is None and not self._closed:
+            try:
+                pager_cmd = self._get_pager_command()
+                self.pager_process = subprocess.Popen(
+                    pager_cmd,
+                    shell=True,
+                    stdin=subprocess.PIPE,
+                    stdout=sys.stdout,
+                    stderr=sys.stderr,
+                    text=True,
+                )
+            except (OSError, subprocess.SubprocessError):
+                # Pager failed to start, fall back to direct output
+                self.pager_process = None
+
+    def write(self, text: str) -> int:
+        """Write text to the pager."""
+        if self._closed:
+            raise ValueError("I/O operation on closed file")
+
+        self._ensure_pager_started()
+
+        if self.pager_process and self.pager_process.stdin:
+            try:
+                return self.pager_process.stdin.write(text)
+            except (OSError, subprocess.SubprocessError, BrokenPipeError):
+                # Pager died, fall back to direct output
+                return sys.stdout.write(text)
+        else:
+            # No pager available, write directly to stdout
+            return sys.stdout.write(text)
+
+    def flush(self):
+        """Flush the pager."""
+        if self._closed:
+            return
+
+        if self.pager_process and self.pager_process.stdin:
+            try:
+                self.pager_process.stdin.flush()
+            except (OSError, subprocess.SubprocessError, BrokenPipeError):
+                pass
+        else:
+            sys.stdout.flush()
+
+    def close(self):
+        """Close the pager."""
+        if self._closed:
+            return
+
+        self._closed = True
+        if self.pager_process:
+            try:
+                if self.pager_process.stdin:
+                    self.pager_process.stdin.close()
+                self.pager_process.wait()
+            except (OSError, subprocess.SubprocessError):
+                pass
+            self.pager_process = None
+
+    def __enter__(self):
+        """Context manager entry."""
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        """Context manager exit."""
+        self.close()
+
+    # Additional file-like methods for compatibility
+    def writelines(self, lines):
+        """Write a list of lines to the pager."""
+        for line in lines:
+            self.write(line)
+
+    @property
+    def closed(self):
+        """Return whether the pager is closed."""
+        return self._closed
+
+    def readable(self):
+        """Return whether the pager is readable (it's not)."""
+        return False
+
+    def writable(self):
+        """Return whether the pager is writable."""
+        return not self._closed
+
+    def seekable(self):
+        """Return whether the pager is seekable (it's not)."""
+        return False
+
+
+class _StreamContextAdapter:
+    """Adapter to make streams work with context manager protocol."""
+
+    def __init__(self, stream):
+        self.stream = stream
+        # Expose buffer if it exists
+        if hasattr(stream, "buffer"):
+            self.buffer = stream.buffer
+        else:
+            self.buffer = stream
+
+    def __enter__(self):
+        return self.stream
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        # For stdout/stderr, we don't close them
+        pass
+
+    def __getattr__(self, name):
+        return getattr(self.stream, name)
+
+
+def get_pager():
+    """Get a pager instance if paging should be used, otherwise return sys.stdout.
+
+    Returns:
+        Either a wrapped sys.stdout or a Pager instance (both context managers)
+    """
+    # Check global pager disable flag
+    if getattr(get_pager, "_disabled", False):
+        return _StreamContextAdapter(sys.stdout)
+
+    # Check if paging should be disabled via environment
+    if os.environ.get("DULWICH_PAGER") == "false":
+        return _StreamContextAdapter(sys.stdout)
+    if os.environ.get("GIT_PAGER") == "false":
+        return _StreamContextAdapter(sys.stdout)
+
+    # Don't page if stdout is not a terminal
+    if not sys.stdout.isatty():
+        return _StreamContextAdapter(sys.stdout)
+
+    return Pager()
+
+
+def disable_pager():
+    """Disable pager for this session."""
+    get_pager._disabled = True
+
+
+def enable_pager():
+    """Enable pager for this session."""
+    get_pager._disabled = False
+
+
 class Command:
     """A Dulwich subcommand."""
 
@@ -176,11 +375,12 @@ class cmd_annotate(Command):
         parser.add_argument("committish", nargs="?", help="Commit to start from")
         args = parser.parse_args(argv)
 
-        results = porcelain.annotate(".", args.path, args.committish)
-        for (commit, entry), line in results:
-            # Show shortened commit hash and line content
-            commit_hash = commit.id[:8]
-            print(f"{commit_hash.decode()} {line.decode()}")
+        with get_pager() as outstream:
+            results = porcelain.annotate(".", args.path, args.committish)
+            for (commit, entry), line in results:
+                # Show shortened commit hash and line content
+                commit_hash = commit.id[:8]
+                outstream.write(f"{commit_hash.decode()} {line.decode()}\n")
 
 
 class cmd_blame(Command):
@@ -286,13 +486,14 @@ class cmd_log(Command):
         parser.add_argument("paths", nargs="*", help="Paths to show log for")
         args = parser.parse_args(args)
 
-        porcelain.log(
-            ".",
-            paths=args.paths,
-            reverse=args.reverse,
-            name_status=args.name_status,
-            outstream=sys.stdout,
-        )
+        with get_pager() as outstream:
+            porcelain.log(
+                ".",
+                paths=args.paths,
+                reverse=args.reverse,
+                name_status=args.name_status,
+                outstream=outstream,
+            )
 
 
 class cmd_diff(Command):
@@ -322,36 +523,37 @@ class cmd_diff(Command):
 
         args = parsed_args
 
-        if len(args.committish) == 0:
-            # Show diff for working tree or staged changes
-            porcelain.diff(
-                ".",
-                staged=(args.staged or args.cached),
-                paths=args.paths or None,
-                outstream=sys.stdout.buffer,
-            )
-        elif len(args.committish) == 1:
-            # Show diff between working tree and specified commit
-            if args.staged or args.cached:
-                parser.error("--staged/--cached cannot be used with commits")
-            porcelain.diff(
-                ".",
-                commit=args.committish[0],
-                staged=False,
-                paths=args.paths or None,
-                outstream=sys.stdout.buffer,
-            )
-        elif len(args.committish) == 2:
-            # Show diff between two commits
-            porcelain.diff(
-                ".",
-                commit=args.committish[0],
-                commit2=args.committish[1],
-                paths=args.paths or None,
-                outstream=sys.stdout.buffer,
-            )
-        else:
-            parser.error("Too many arguments - specify at most two commits")
+        with get_pager() as outstream:
+            if len(args.committish) == 0:
+                # Show diff for working tree or staged changes
+                porcelain.diff(
+                    ".",
+                    staged=(args.staged or args.cached),
+                    paths=args.paths or None,
+                    outstream=outstream.buffer,
+                )
+            elif len(args.committish) == 1:
+                # Show diff between working tree and specified commit
+                if args.staged or args.cached:
+                    parser.error("--staged/--cached cannot be used with commits")
+                porcelain.diff(
+                    ".",
+                    commit=args.committish[0],
+                    staged=False,
+                    paths=args.paths or None,
+                    outstream=outstream.buffer,
+                )
+            elif len(args.committish) == 2:
+                # Show diff between two commits
+                porcelain.diff(
+                    ".",
+                    commit=args.committish[0],
+                    commit2=args.committish[1],
+                    paths=args.paths or None,
+                    outstream=outstream.buffer,
+                )
+            else:
+                parser.error("Too many arguments - specify at most two commits")
 
 
 class cmd_dump_pack(Command):
@@ -527,7 +729,8 @@ class cmd_show(Command):
         parser = argparse.ArgumentParser()
         parser.add_argument("objectish", type=str, nargs="*")
         args = parser.parse_args(argv)
-        porcelain.show(".", args.objectish or None, outstream=sys.stdout)
+        with get_pager() as outstream:
+            porcelain.show(".", args.objectish or None, outstream=outstream)
 
 
 class cmd_diff_tree(Command):
@@ -584,23 +787,26 @@ class cmd_reflog(Command):
         )
         args = parser.parse_args(args)
 
-        if args.all:
-            # Show reflogs for all refs
-            for ref_bytes, entry in porcelain.reflog(".", all=True):
-                ref_str = ref_bytes.decode("utf-8", "replace")
-                short_new = entry.new_sha[:8].decode("ascii")
-                print(
-                    f"{short_new} {ref_str}: {entry.message.decode('utf-8', 'replace')}"
+        with get_pager() as outstream:
+            if args.all:
+                # Show reflogs for all refs
+                for ref_bytes, entry in porcelain.reflog(".", all=True):
+                    ref_str = ref_bytes.decode("utf-8", "replace")
+                    short_new = entry.new_sha[:8].decode("ascii")
+                    outstream.write(
+                        f"{short_new} {ref_str}: {entry.message.decode('utf-8', 'replace')}\n"
+                    )
+            else:
+                ref = (
+                    args.ref.encode("utf-8") if isinstance(args.ref, str) else args.ref
                 )
-        else:
-            ref = args.ref.encode("utf-8") if isinstance(args.ref, str) else args.ref
 
-            for i, entry in enumerate(porcelain.reflog(".", ref)):
-                # Format similar to git reflog
-                short_new = entry.new_sha[:8].decode("ascii")
-                print(
-                    f"{short_new} {ref.decode('utf-8', 'replace')}@{{{i}}}: {entry.message.decode('utf-8', 'replace')}"
-                )
+                for i, entry in enumerate(porcelain.reflog(".", ref)):
+                    # Format similar to git reflog
+                    short_new = entry.new_sha[:8].decode("ascii")
+                    outstream.write(
+                        f"{short_new} {ref.decode('utf-8', 'replace')}@{{{i}}}: {entry.message.decode('utf-8', 'replace')}\n"
+                    )
 
 
 class cmd_reset(Command):
@@ -791,13 +997,14 @@ class cmd_ls_tree(Command):
         )
         parser.add_argument("treeish", nargs="?", help="Tree-ish to list")
         args = parser.parse_args(args)
-        porcelain.ls_tree(
-            ".",
-            args.treeish,
-            outstream=sys.stdout,
-            recursive=args.recursive,
-            name_only=args.name_only,
-        )
+        with get_pager() as outstream:
+            porcelain.ls_tree(
+                ".",
+                args.treeish,
+                outstream=outstream,
+                recursive=args.recursive,
+                name_only=args.name_only,
+            )
 
 
 class cmd_pack_objects(Command):
@@ -2292,18 +2499,51 @@ def main(argv=None) -> Optional[int]:
     if argv is None:
         argv = sys.argv[1:]
 
-    if len(argv) < 1:
-        print("Usage: dulwich <{}> [OPTIONS...]".format("|".join(commands.keys())))
+    # Parse only the global options and command, stop at first positional
+    parser = argparse.ArgumentParser(
+        prog="dulwich",
+        description="Simple command-line interface to Dulwich",
+        add_help=False,  # We'll handle help ourselves
+    )
+    parser.add_argument("--no-pager", action="store_true", help="Disable pager")
+    parser.add_argument("--pager", action="store_true", help="Force enable pager")
+    parser.add_argument("--help", "-h", action="store_true", help="Show help")
+
+    # Parse known args to separate global options from command args
+    global_args, remaining = parser.parse_known_args(argv)
+
+    # Apply global pager settings
+    if global_args.no_pager:
+        disable_pager()
+    elif global_args.pager:
+        enable_pager()
+
+    # Handle help
+    if global_args.help or not remaining:
+        parser = argparse.ArgumentParser(
+            prog="dulwich", description="Simple command-line interface to Dulwich"
+        )
+        parser.add_argument("--no-pager", action="store_true", help="Disable pager")
+        parser.add_argument("--pager", action="store_true", help="Force enable pager")
+        parser.add_argument(
+            "command",
+            nargs="?",
+            help=f"Command to run. Available: {', '.join(sorted(commands.keys()))}",
+        )
+        parser.print_help()
         return 1
 
-    cmd = argv[0]
+    # First remaining arg is the command
+    cmd = remaining[0]
+    cmd_args = remaining[1:]
+
     try:
         cmd_kls = commands[cmd]
     except KeyError:
         print(f"No such subcommand: {cmd}")
         return 1
     # TODO(jelmer): Return non-0 on errors
-    return cmd_kls().run(argv[1:])
+    return cmd_kls().run(cmd_args)
 
 
 def _main() -> None:

+ 5 - 0
tests/test_porcelain.py

@@ -76,6 +76,11 @@ def flat_walk_dir(dir_to_walk):
 class PorcelainTestCase(TestCase):
     def setUp(self) -> None:
         super().setUp()
+        # Disable pagers for tests
+        self.overrideEnv("PAGER", "false")
+        self.overrideEnv("GIT_PAGER", "false")
+        self.overrideEnv("DULWICH_PAGER", "false")
+
         self.test_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, self.test_dir)
         self.repo_path = os.path.join(self.test_dir, "repo")