Browse Source

Add dulwich config CLI command (#1933)

Supported operations:
- Get configuration values
- Set configuration values
- List all configuration
- Get all values for multivars
- Unset configuration values

Fixes #1775
Jelmer Vernooij 3 tháng trước cách đây
mục cha
commit
2955bfcd49
3 tập tin đã thay đổi với 335 bổ sung0 xóa
  1. 5 0
      NEWS
  2. 217 0
      dulwich/cli.py
  3. 113 0
      tests/test_cli.py

+ 5 - 0
NEWS

@@ -5,6 +5,11 @@
    list mode, independent branch detection, and merge base calculation.
    (Jelmer Vernooij, #1829)
 
+ * Add ``dulwich config`` command to get and set repository or global
+   configuration options. Supports getting/setting values, listing all config,
+   getting all values for multivars, and unsetting values.
+   (Jelmer Vernooij, #1775)
+
 0.24.5	2025-10-15
 
  * Add support for ``git show-ref`` command to list references in a local

+ 217 - 0
dulwich/cli.py

@@ -1340,6 +1340,222 @@ def _get_commit_message_with_template(
     return message
 
 
+class cmd_config(Command):
+    """Get and set repository or global options."""
+
+    def run(self, args: Sequence[str]) -> Optional[int]:
+        """Execute the config command.
+
+        Args:
+            args: Command line arguments
+        """
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "--global",
+            dest="global_config",
+            action="store_true",
+            help="Use global config file",
+        )
+        parser.add_argument(
+            "--local",
+            action="store_true",
+            help="Use repository config file (default)",
+        )
+        parser.add_argument(
+            "-l",
+            "--list",
+            action="store_true",
+            help="List all variables",
+        )
+        parser.add_argument(
+            "--unset",
+            action="store_true",
+            help="Remove a variable",
+        )
+        parser.add_argument(
+            "--unset-all",
+            action="store_true",
+            help="Remove all matches for a variable",
+        )
+        parser.add_argument(
+            "--get-all",
+            action="store_true",
+            help="Get all values for a multivar",
+        )
+        parser.add_argument(
+            "key",
+            nargs="?",
+            help="Config key (e.g., user.name)",
+        )
+        parser.add_argument(
+            "value",
+            nargs="?",
+            help="Config value to set",
+        )
+        parsed_args = parser.parse_args(args)
+
+        # Determine which config file to use
+        if parsed_args.global_config:
+            # Use global config file
+            config_path = os.path.expanduser("~/.gitconfig")
+            try:
+                from .config import ConfigFile
+
+                config = ConfigFile.from_path(config_path)
+            except FileNotFoundError:
+                from .config import ConfigFile
+
+                config = ConfigFile()
+                config.path = config_path
+        else:
+            # Use local repository config (default)
+            try:
+                repo = Repo(".")
+                config = repo.get_config()
+            except NotGitRepository:
+                logger.error("error: not a git repository")
+                return 1
+
+        # Handle --list
+        if parsed_args.list:
+            for section in config.sections():
+                for key, value in config.items(section):
+                    section_str = ".".join(
+                        s.decode("utf-8") if isinstance(s, bytes) else s
+                        for s in section
+                    )
+                    key_str = key.decode("utf-8") if isinstance(key, bytes) else key
+                    value_str = (
+                        value.decode("utf-8") if isinstance(value, bytes) else value
+                    )
+                    print(f"{section_str}.{key_str}={value_str}")
+            return 0
+
+        # Handle --unset or --unset-all
+        if parsed_args.unset or parsed_args.unset_all:
+            if not parsed_args.key:
+                logger.error("error: key is required for --unset")
+                return 1
+
+            # Parse the key (e.g., "user.name" or "remote.origin.url")
+            parts = parsed_args.key.split(".")
+            if len(parts) < 2:
+                logger.error("error: invalid key format")
+                return 1
+
+            if len(parts) == 2:
+                section = (parts[0],)
+                name = parts[1]
+            else:
+                # For keys like "remote.origin.url", section is ("remote", "origin")
+                section = tuple(parts[:-1])
+                name = parts[-1]
+
+            try:
+                # Check if the key exists first
+                try:
+                    config.get(section, name)
+                except KeyError:
+                    logger.error(f"error: key '{parsed_args.key}' not found")
+                    return 1
+
+                # Delete the configuration key using ConfigDict's delete method
+                section_bytes = tuple(
+                    s.encode("utf-8") if isinstance(s, str) else s for s in section
+                )
+                name_bytes = name.encode("utf-8") if isinstance(name, str) else name
+
+                section_dict = config._values.get(section_bytes)
+                if section_dict:
+                    del section_dict[name_bytes]
+                    config.write_to_path()
+                else:
+                    logger.error(f"error: key '{parsed_args.key}' not found")
+                    return 1
+            except Exception as e:
+                logger.error(f"error: {e}")
+                return 1
+
+            return 0
+
+        # Handle --get-all
+        if parsed_args.get_all:
+            if not parsed_args.key:
+                logger.error("error: key is required for --get-all")
+                return 1
+
+            parts = parsed_args.key.split(".")
+            if len(parts) < 2:
+                logger.error("error: invalid key format")
+                return 1
+
+            if len(parts) == 2:
+                section = (parts[0],)
+                name = parts[1]
+            else:
+                section = tuple(parts[:-1])
+                name = parts[-1]
+
+            try:
+                for value in config.get_multivar(section, name):
+                    value_str = (
+                        value.decode("utf-8") if isinstance(value, bytes) else value
+                    )
+                    print(value_str)
+                return 0
+            except KeyError:
+                return 1
+
+        # Handle get (no value provided)
+        if parsed_args.key and not parsed_args.value:
+            parts = parsed_args.key.split(".")
+            if len(parts) < 2:
+                logger.error("error: invalid key format")
+                return 1
+
+            if len(parts) == 2:
+                section = (parts[0],)
+                name = parts[1]
+            else:
+                # For keys like "remote.origin.url", section is ("remote", "origin")
+                section = tuple(parts[:-1])
+                name = parts[-1]
+
+            try:
+                value = config.get(section, name)
+                value_str = value.decode("utf-8") if isinstance(value, bytes) else value
+                print(value_str)
+                return 0
+            except KeyError:
+                return 1
+
+        # Handle set (key and value provided)
+        if parsed_args.key and parsed_args.value:
+            parts = parsed_args.key.split(".")
+            if len(parts) < 2:
+                logger.error("error: invalid key format")
+                return 1
+
+            if len(parts) == 2:
+                section = (parts[0],)
+                name = parts[1]
+            else:
+                # For keys like "remote.origin.url", section is ("remote", "origin")
+                section = tuple(parts[:-1])
+                name = parts[-1]
+
+            config.set(section, name, parsed_args.value)
+            if parsed_args.global_config:
+                config.write_to_path()
+            else:
+                config.write_to_path()
+            return 0
+
+        # No action specified
+        parser.print_help()
+        return 1
+
+
 class cmd_commit(Command):
     """Record changes to the repository."""
 
@@ -4976,6 +5192,7 @@ commands = {
     "clone": cmd_clone,
     "commit": cmd_commit,
     "commit-tree": cmd_commit_tree,
+    "config": cmd_config,
     "count-objects": cmd_count_objects,
     "describe": cmd_describe,
     "daemon": cmd_daemon,

+ 113 - 0
tests/test_cli.py

@@ -3464,5 +3464,118 @@ class MergeBaseCommandTest(DulwichCliTestCase):
         self.assertEqual(result, 1)
 
 
+class ConfigCommandTest(DulwichCliTestCase):
+    """Tests for config command."""
+
+    def test_config_set_and_get(self):
+        """Test setting and getting a config value."""
+        # Set a config value
+        result, stdout, _stderr = self._run_cli("config", "user.name", "Test User")
+        self.assertEqual(result, 0)
+        self.assertEqual(stdout, "")
+
+        # Get the value back
+        result, stdout, _stderr = self._run_cli("config", "user.name")
+        self.assertEqual(result, 0)
+        self.assertEqual(stdout, "Test User\n")
+
+    def test_config_set_and_get_subsection(self):
+        """Test setting and getting a config value with subsection."""
+        # Set a config value with subsection (e.g., remote.origin.url)
+        result, stdout, _stderr = self._run_cli(
+            "config", "remote.origin.url", "https://example.com/repo.git"
+        )
+        self.assertEqual(result, 0)
+        self.assertEqual(stdout, "")
+
+        # Get the value back
+        result, stdout, _stderr = self._run_cli("config", "remote.origin.url")
+        self.assertEqual(result, 0)
+        self.assertEqual(stdout, "https://example.com/repo.git\n")
+
+    def test_config_list(self):
+        """Test listing all config values."""
+        # Set some config values
+        self._run_cli("config", "user.name", "Test User")
+        self._run_cli("config", "user.email", "test@example.com")
+
+        # Get the actual config values that may vary by platform
+        config = self.repo.get_config()
+        filemode = config.get((b"core",), b"filemode")
+        try:
+            symlinks = config.get((b"core",), b"symlinks")
+        except KeyError:
+            symlinks = None
+
+        # List all values
+        result, stdout, _stderr = self._run_cli("config", "--list")
+        self.assertEqual(result, 0)
+
+        # Build expected output with platform-specific values
+        expected = "core.repositoryformatversion=0\n"
+        expected += f"core.filemode={filemode.decode('utf-8')}\n"
+        if symlinks is not None:
+            expected += f"core.symlinks={symlinks.decode('utf-8')}\n"
+        expected += (
+            "core.bare=false\n"
+            "core.logallrefupdates=true\n"
+            "user.name=Test User\n"
+            "user.email=test@example.com\n"
+        )
+
+        self.assertEqual(stdout, expected)
+
+    def test_config_unset(self):
+        """Test unsetting a config value."""
+        # Set a config value
+        self._run_cli("config", "user.name", "Test User")
+
+        # Verify it's set
+        result, stdout, _stderr = self._run_cli("config", "user.name")
+        self.assertEqual(result, 0)
+        self.assertEqual(stdout, "Test User\n")
+
+        # Unset it
+        result, stdout, _stderr = self._run_cli("config", "--unset", "user.name")
+        self.assertEqual(result, 0)
+        self.assertEqual(stdout, "")
+
+        # Verify it's gone
+        result, stdout, _stderr = self._run_cli("config", "user.name")
+        self.assertEqual(result, 1)
+        self.assertEqual(stdout, "")
+
+    def test_config_get_nonexistent(self):
+        """Test getting a nonexistent config value."""
+        result, stdout, _stderr = self._run_cli("config", "nonexistent.key")
+        self.assertEqual(result, 1)
+        self.assertEqual(stdout, "")
+
+    def test_config_unset_nonexistent(self):
+        """Test unsetting a nonexistent config value."""
+        result, _stdout, _stderr = self._run_cli("config", "--unset", "nonexistent.key")
+        self.assertEqual(result, 1)
+
+    def test_config_invalid_key_format(self):
+        """Test config with invalid key format."""
+        result, stdout, _stderr = self._run_cli("config", "invalidkey")
+        self.assertEqual(result, 1)
+        self.assertEqual(stdout, "")
+
+    def test_config_get_all(self):
+        """Test getting all values for a multivar."""
+        # Set multiple values for the same key
+        config = self.repo.get_config()
+        config.set(("test",), "multivar", "value1")
+        config.add(("test",), "multivar", "value2")
+        config.add(("test",), "multivar", "value3")
+        config.write_to_path()
+
+        # Get all values
+        result, stdout, _stderr = self._run_cli("config", "--get-all", "test.multivar")
+        self.assertEqual(result, 0)
+        self.assertEqual(stdout, "value1\nvalue2\nvalue3\n")
+
+
 if __name__ == "__main__":
     unittest.main()