Browse Source

Add support for git var command

Supported variables:
- GIT_AUTHOR_IDENT
- GIT_COMMITTER_IDENT
- GIT_EDITOR
- GIT_SEQUENCE_EDITOR
- GIT_PAGER
- GIT_DEFAULT_BRANCH

Fixes #1841
Jelmer Vernooij 3 tháng trước cách đây
mục cha
commit
9a870c49f2
4 tập tin đã thay đổi với 305 bổ sung0 xóa
  1. 5 0
      NEWS
  2. 46 0
      dulwich/cli.py
  3. 131 0
      dulwich/porcelain.py
  4. 123 0
      tests/test_porcelain.py

+ 5 - 0
NEWS

@@ -1,5 +1,10 @@
 0.24.3	UNRELEASED
 
+ * Add support for ``git var`` command to display Git's logical variables
+   (GIT_AUTHOR_IDENT, GIT_COMMITTER_IDENT, GIT_EDITOR, GIT_SEQUENCE_EDITOR,
+   GIT_PAGER, GIT_DEFAULT_BRANCH). Available as ``porcelain.var()`` and
+   ``dulwich var`` CLI command. (Jelmer Vernooij, #1841)
+
  * Add support for ``GIT_TRACE`` environment variable for debugging. Supports
    output to stderr (values "1", "2", or "true"), file descriptors (3-9),
    file paths, and directories (creates per-process trace files).

+ 46 - 0
dulwich/cli.py

@@ -1496,6 +1496,51 @@ class cmd_pack_refs(Command):
         porcelain.pack_refs(".", all=args.all)
 
 
+class cmd_var(Command):
+    """Display Git logical variables."""
+
+    def run(self, argv: Sequence[str]) -> Optional[int]:
+        """Execute the var command.
+
+        Args:
+            argv: Command line arguments
+        """
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "variable",
+            nargs="?",
+            help="Variable to query (e.g., GIT_AUTHOR_IDENT)",
+        )
+        parser.add_argument(
+            "-l",
+            "--list",
+            action="store_true",
+            help="List all variables",
+        )
+        args = parser.parse_args(argv)
+
+        if args.list:
+            # List all variables
+            variables = porcelain.var_list(".")
+            for key, value in sorted(variables.items()):
+                print(f"{key}={value}")
+            return 0
+        elif args.variable:
+            # Query specific variable
+            try:
+                value = porcelain.var(".", variable=args.variable)
+                print(value)
+                return 0
+            except KeyError:
+                logger.error("error: variable '%s' has no value", args.variable)
+                return 1
+        else:
+            # No arguments - print error
+            logger.error("error: variable or -l is required")
+            parser.print_help()
+            return 1
+
+
 class cmd_show(Command):
     """Show various types of objects."""
 
@@ -4488,6 +4533,7 @@ commands = {
     "unpack-objects": cmd_unpack_objects,
     "update-server-info": cmd_update_server_info,
     "upload-pack": cmd_upload_pack,
+    "var": cmd_var,
     "web-daemon": cmd_web_daemon,
     "worktree": cmd_worktree,
     "write-tree": cmd_write_tree,

+ 131 - 0
dulwich/porcelain.py

@@ -62,6 +62,7 @@ Currently implemented:
  * tag{_create,_delete,_list}
  * upload_pack
  * update_server_info
+ * var
  * write_commit_graph
  * status
  * shortlog
@@ -566,6 +567,136 @@ def pack_refs(repo: RepoPath, all: bool = False) -> None:
         repo_obj.refs.pack_refs(all=all)
 
 
+def _get_variables(repo: RepoPath = ".") -> dict[str, str]:
+    """Internal function to get all Git logical variables.
+
+    Args:
+      repo: Path to the repository
+
+    Returns:
+      A dictionary of all logical variables with values
+    """
+    from .repo import get_user_identity
+
+    with open_repo_closing(repo) as repo_obj:
+        config = repo_obj.get_config_stack()
+
+        # Define callbacks for each logical variable
+        def get_author_ident() -> Optional[str]:
+            """Get GIT_AUTHOR_IDENT."""
+            try:
+                author_identity = get_user_identity(config, kind="AUTHOR")
+                author_tz, _ = get_user_timezones()
+                timestamp = int(time.time())
+                return f"{author_identity.decode('utf-8', 'replace')} {timestamp} {author_tz:+05d}"
+            except Exception:
+                return None
+
+        def get_committer_ident() -> Optional[str]:
+            """Get GIT_COMMITTER_IDENT."""
+            try:
+                committer_identity = get_user_identity(config, kind="COMMITTER")
+                _, committer_tz = get_user_timezones()
+                timestamp = int(time.time())
+                return f"{committer_identity.decode('utf-8', 'replace')} {timestamp} {committer_tz:+05d}"
+            except Exception:
+                return None
+
+        def get_editor() -> Optional[str]:
+            """Get GIT_EDITOR."""
+            editor = os.environ.get("GIT_EDITOR")
+            if editor is None:
+                try:
+                    editor_bytes = config.get(("core",), "editor")
+                    editor = editor_bytes.decode("utf-8", "replace")
+                except KeyError:
+                    editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
+            return editor
+
+        def get_sequence_editor() -> Optional[str]:
+            """Get GIT_SEQUENCE_EDITOR."""
+            sequence_editor = os.environ.get("GIT_SEQUENCE_EDITOR")
+            if sequence_editor is None:
+                try:
+                    seq_editor_bytes = config.get(("sequence",), "editor")
+                    sequence_editor = seq_editor_bytes.decode("utf-8", "replace")
+                except KeyError:
+                    # Falls back to GIT_EDITOR if not set
+                    sequence_editor = get_editor()
+            return sequence_editor
+
+        def get_pager() -> Optional[str]:
+            """Get GIT_PAGER."""
+            pager = os.environ.get("GIT_PAGER")
+            if pager is None:
+                try:
+                    pager_bytes = config.get(("core",), "pager")
+                    pager = pager_bytes.decode("utf-8", "replace")
+                except KeyError:
+                    pager = os.environ.get("PAGER")
+            return pager
+
+        def get_default_branch() -> str:
+            """Get GIT_DEFAULT_BRANCH."""
+            try:
+                default_branch_bytes = config.get(("init",), "defaultBranch")
+                return default_branch_bytes.decode("utf-8", "replace")
+            except KeyError:
+                # Git's default is "master"
+                return "master"
+
+        # Dictionary mapping variable names to their getter callbacks
+        variable_callbacks: dict[str, Callable[[], Optional[str]]] = {
+            "GIT_AUTHOR_IDENT": get_author_ident,
+            "GIT_COMMITTER_IDENT": get_committer_ident,
+            "GIT_EDITOR": get_editor,
+            "GIT_SEQUENCE_EDITOR": get_sequence_editor,
+            "GIT_PAGER": get_pager,
+            "GIT_DEFAULT_BRANCH": get_default_branch,
+        }
+
+        # Build the variables dictionary by calling callbacks
+        variables: dict[str, str] = {}
+        for var_name, callback in variable_callbacks.items():
+            value = callback()
+            if value is not None:
+                variables[var_name] = value
+
+        return variables
+
+
+def var_list(repo: RepoPath = ".") -> dict[str, str]:
+    """List all Git logical variables.
+
+    Args:
+      repo: Path to the repository
+
+    Returns:
+      A dictionary of all logical variables with their values
+    """
+    return _get_variables(repo)
+
+
+def var(repo: RepoPath = ".", variable: str = "GIT_AUTHOR_IDENT") -> str:
+    """Get the value of a specific Git logical variable.
+
+    Args:
+      repo: Path to the repository
+      variable: The variable to query (e.g., 'GIT_AUTHOR_IDENT')
+
+    Returns:
+      The value of the requested variable as a string
+
+    Raises:
+      KeyError: If the requested variable has no value
+    """
+    variables = _get_variables(repo)
+    if variable in variables:
+        return variables[variable]
+    else:
+        raise KeyError(f"Variable {variable} has no value")
+
+
 def commit(
     repo: RepoPath = ".",
     message: Optional[Union[str, bytes, Callable[[Any, Commit], bytes]]] = None,

+ 123 - 0
tests/test_porcelain.py

@@ -9744,3 +9744,126 @@ class WorktreePorcelainTests(PorcelainTestCase):
         paths = [wt.path for wt in worktrees]
         self.assertIn(new_path, paths)
         self.assertNotIn(old_path, paths)
+
+
+class VarTests(PorcelainTestCase):
+    """Tests for the var command."""
+
+    def test_var_author_ident(self):
+        """Test getting GIT_AUTHOR_IDENT."""
+        # Set up user config
+        config = self.repo.get_config()
+        config.set((b"user",), b"name", b"Test Author")
+        config.set((b"user",), b"email", b"author@example.com")
+        config.write_to_path()
+
+        result = porcelain.var(self.repo_path, variable="GIT_AUTHOR_IDENT")
+        self.assertIn("Test Author <author@example.com>", result)
+        # Check that timestamp and timezone are included
+        # Format: Name <email> timestamp timezone
+        # "Test Author" is 2 words, so we have: Test, Author, <email>, timestamp, timezone
+        parts = result.split()
+        self.assertGreaterEqual(
+            len(parts), 4
+        )  # At least name, <email>, timestamp, timezone
+        # Check last two parts are timestamp and timezone
+        self.assertTrue(parts[-2].isdigit())  # timestamp
+        self.assertRegex(parts[-1], r"[+-]\d{4}")  # timezone
+
+    def test_var_committer_ident(self):
+        """Test getting GIT_COMMITTER_IDENT."""
+        # Set up user config
+        config = self.repo.get_config()
+        config.set((b"user",), b"name", b"Test Committer")
+        config.set((b"user",), b"email", b"committer@example.com")
+        config.write_to_path()
+
+        result = porcelain.var(self.repo_path, variable="GIT_COMMITTER_IDENT")
+        self.assertIn("Test Committer <committer@example.com>", result)
+        # Check that timestamp and timezone are included
+        parts = result.split()
+        self.assertGreaterEqual(
+            len(parts), 4
+        )  # At least name, <email>, timestamp, timezone
+        # Check last two parts are timestamp and timezone
+        self.assertTrue(parts[-2].isdigit())  # timestamp
+        self.assertRegex(parts[-1], r"[+-]\d{4}")  # timezone
+
+    def test_var_editor(self):
+        """Test getting GIT_EDITOR."""
+        # Test with environment variable
+        self.overrideEnv("GIT_EDITOR", "vim")
+        result = porcelain.var(self.repo_path, variable="GIT_EDITOR")
+        self.assertEqual(result, "vim")
+
+    def test_var_editor_from_config(self):
+        """Test getting GIT_EDITOR from config."""
+        # Set up editor in config
+        config = self.repo.get_config()
+        config.set((b"core",), b"editor", b"emacs")
+        config.write_to_path()
+
+        # Make sure env var is not set
+        self.overrideEnv("GIT_EDITOR", None)
+        result = porcelain.var(self.repo_path, variable="GIT_EDITOR")
+        self.assertEqual(result, "emacs")
+
+    def test_var_pager(self):
+        """Test getting GIT_PAGER."""
+        # Test with environment variable
+        self.overrideEnv("GIT_PAGER", "less")
+        result = porcelain.var(self.repo_path, variable="GIT_PAGER")
+        self.assertEqual(result, "less")
+
+    def test_var_pager_from_config(self):
+        """Test getting GIT_PAGER from config."""
+        # Set up pager in config
+        config = self.repo.get_config()
+        config.set((b"core",), b"pager", b"more")
+        config.write_to_path()
+
+        # Make sure env var is not set
+        self.overrideEnv("GIT_PAGER", None)
+        result = porcelain.var(self.repo_path, variable="GIT_PAGER")
+        self.assertEqual(result, "more")
+
+    def test_var_default_branch(self):
+        """Test getting GIT_DEFAULT_BRANCH."""
+        # Set up default branch in config
+        config = self.repo.get_config()
+        config.set((b"init",), b"defaultBranch", b"main")
+        config.write_to_path()
+
+        result = porcelain.var(self.repo_path, variable="GIT_DEFAULT_BRANCH")
+        self.assertEqual(result, "main")
+
+    def test_var_default_branch_default(self):
+        """Test getting GIT_DEFAULT_BRANCH with default value."""
+        result = porcelain.var(self.repo_path, variable="GIT_DEFAULT_BRANCH")
+        self.assertEqual(result, "master")
+
+    def test_var_list_all(self):
+        """Test listing all logical variables."""
+        # Set up some config
+        config = self.repo.get_config()
+        config.set((b"user",), b"name", b"Test User")
+        config.set((b"user",), b"email", b"test@example.com")
+        config.write_to_path()
+
+        result = porcelain.var_list(self.repo_path)
+        self.assertIsInstance(result, dict)
+        # Check that logical variables are present
+        self.assertIn("GIT_AUTHOR_IDENT", result)
+        self.assertIn("GIT_COMMITTER_IDENT", result)
+        self.assertIn("GIT_DEFAULT_BRANCH", result)
+        # Config variables should NOT be included (deprecated feature)
+        self.assertNotIn("user.name", result)
+        self.assertNotIn("user.email", result)
+        # Verify only logical variables are present
+        for key in result.keys():
+            self.assertTrue(key.startswith("GIT_"))
+
+    def test_var_unknown_variable(self):
+        """Test requesting an unknown variable."""
+        with self.assertRaises(KeyError):
+            porcelain.var(self.repo_path, variable="UNKNOWN_VARIABLE")