| # Copyright (C) 2026 The Android Open Source Project | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # 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. | |
| """Unittests for the subcmds/config.py module.""" | |
| from pathlib import Path | |
| from typing import Iterator, List | |
| from unittest import mock | |
| import pytest | |
| import command | |
| import error | |
| import git_config | |
| from subcmds import config | |
| @pytest.mark.parametrize( | |
| "argv", | |
| [ | |
| [], | |
| ["get"], | |
| ["get", "a.b", "c"], | |
| ["set"], | |
| ["set", "a.b"], | |
| ["set", "a.b", "c", "d"], | |
| ["unset"], | |
| ["unset", "a.b", "c"], | |
| ["list", "a.b"], | |
| ], | |
| ) | |
| def test_validate_options_usage(argv: List[str]) -> None: | |
| """Wrong number of arguments for an action shows usage.""" | |
| cmd = config.Config() | |
| opts, args = cmd.OptionParser.parse_args(argv) | |
| with pytest.raises(command.UsageError): | |
| cmd.ValidateOptions(opts, args) | |
| @pytest.mark.parametrize( | |
| "argv", | |
| [ | |
| ["bogus"], | |
| ["a.b", "c"], | |
| ["--type=bool", "unset", "a.b"], | |
| ["--type=bool", "list"], | |
| ["-z", "set", "a.b", "c"], | |
| ["-z", "unset", "a.b"], | |
| ], | |
| ) | |
| def test_validate_options_error(argv: List[str]) -> None: | |
| """Unknown actions and inapplicable options are rejected.""" | |
| cmd = config.Config() | |
| opts, args = cmd.OptionParser.parse_args(argv) | |
| with pytest.raises(SystemExit): | |
| cmd.ValidateOptions(opts, args) | |
| def test_validate_options_unknown_type() -> None: | |
| """--type only accepts the supported types.""" | |
| cmd = config.Config() | |
| with pytest.raises(SystemExit): | |
| cmd.OptionParser.parse_args(["--type=path", "get", "a.b"]) | |
| @pytest.mark.parametrize( | |
| "argv", | |
| [ | |
| ["get", "sync.smartsync"], | |
| ["set", "sync.smartsync", "true"], | |
| ["unset", "sync.smartsync"], | |
| ["list"], | |
| ["--type=bool", "get", "sync.smartsync"], | |
| ["--type=int", "set", "a.b", "1"], | |
| ["-z", "get", "sync.smartsync"], | |
| ["--null", "list"], | |
| ], | |
| ) | |
| def test_validate_options_accepts(argv: List[str]) -> None: | |
| """Valid invocations pass validation.""" | |
| cmd = config.Config() | |
| opts, args = cmd.OptionParser.parse_args(argv) | |
| cmd.ValidateOptions(opts, args) | |
| @pytest.fixture | |
| def cfg(tmp_path: Path) -> Iterator[git_config.RepoConfig]: | |
| """A RepoConfig backed by a temp file, used as the per-user config.""" | |
| cfg = git_config.RepoConfig(configfile=tmp_path / "config") | |
| with mock.patch.object(git_config.RepoConfig, "ForUser", return_value=cfg): | |
| yield cfg | |
| def run(argv: List[str], capsys: pytest.CaptureFixture) -> str: | |
| """Runs `repo config` with |argv| and returns its stdout.""" | |
| cmd = config.Config() | |
| opts, args = cmd.OptionParser.parse_args(argv) | |
| cmd.ValidateOptions(opts, args) | |
| capsys.readouterr() | |
| cmd.Execute(opts, args) | |
| return capsys.readouterr().out | |
| def test_set_creates_file( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """Setting a value writes it to the config file.""" | |
| run(["set", "sync.smartsync", "true"], capsys) | |
| assert "smartsync = true" in Path(cfg.file).read_text() | |
| assert cfg.GetBoolean("sync.smartsync") is True | |
| def test_set_then_get( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """Getting a value prints what was set.""" | |
| run(["set", "sync.smartsync", "false"], capsys) | |
| assert run(["get", "sync.smartsync"], capsys) == "false\n" | |
| def test_set_overwrites( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """Setting an existing key replaces its value.""" | |
| run(["set", "sync.smartsync", "true"], capsys) | |
| run(["set", "sync.smartsync", "false"], capsys) | |
| assert run(["get", "sync.smartsync"], capsys) == "false\n" | |
| assert Path(cfg.file).read_text().count("smartsync") == 1 | |
| def test_set_invalid_name( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """git's own validation of the name is surfaced as a clean error.""" | |
| with pytest.raises(config.Error) as e: | |
| run(["set", "nodot", "true"], capsys) | |
| assert "nodot" in str(e.value) | |
| @pytest.mark.parametrize( | |
| "value, expected", | |
| [ | |
| ("true", "true"), | |
| ("Yes", "true"), | |
| ("on", "true"), | |
| ("1", "true"), | |
| ("false", "false"), | |
| ("No", "false"), | |
| ("off", "false"), | |
| ("0", "false"), | |
| ], | |
| ) | |
| def test_set_type_bool( | |
| cfg: git_config.RepoConfig, | |
| capsys: pytest.CaptureFixture, | |
| value: str, | |
| expected: str, | |
| ) -> None: | |
| """--type=bool canonicalizes accepted spellings on set.""" | |
| run(["--type=bool", "set", "sync.smartsync", value], capsys) | |
| assert run(["get", "sync.smartsync"], capsys) == f"{expected}\n" | |
| @pytest.mark.parametrize( | |
| "value, expected", | |
| [ | |
| ("42", "42"), | |
| ("1k", "1024"), | |
| ("2M", "2097152"), | |
| ("1g", "1073741824"), | |
| ("0x10", "16"), | |
| ], | |
| ) | |
| def test_set_type_int( | |
| cfg: git_config.RepoConfig, | |
| capsys: pytest.CaptureFixture, | |
| value: str, | |
| expected: str, | |
| ) -> None: | |
| """--type=int canonicalizes accepted spellings on set.""" | |
| run(["--type=int", "set", "section.num", value], capsys) | |
| assert run(["get", "section.num"], capsys) == f"{expected}\n" | |
| @pytest.mark.parametrize( | |
| "argv", | |
| [ | |
| ["--type=bool", "set", "sync.smartsync", "maybe"], | |
| ["--type=bool", "set", "sync.smartsync", "2"], | |
| ["--type=int", "set", "section.num", "lots"], | |
| ["--type=int", "set", "section.num", "1x"], | |
| ["--type=int", "set", "section.num", ""], | |
| ], | |
| ) | |
| def test_set_type_rejects_bad_values( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture, argv: List[str] | |
| ) -> None: | |
| """--type rejects values that do not parse and writes nothing.""" | |
| with pytest.raises(config.Error): | |
| run(argv, capsys) | |
| assert not Path(cfg.file).exists() | |
| def test_get_type_canonicalizes( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """--type on get prints the canonical form of a stored value.""" | |
| Path(cfg.file).write_text("[a]\n\tb = Yes\n\tn = 1k\n") | |
| assert run(["--type=bool", "get", "a.b"], capsys) == "true\n" | |
| assert run(["--type=int", "get", "a.n"], capsys) == "1024\n" | |
| with pytest.raises(config.Error): | |
| run(["--type=int", "get", "a.b"], capsys) | |
| def test_get_missing_exits_quietly( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """Reading an unset key exits 1 without output, like git config.""" | |
| cmd = config.Config() | |
| opts, args = cmd.OptionParser.parse_args(["get", "sync.smartsync"]) | |
| with pytest.raises(config.NotSetError) as e: | |
| cmd.Execute(opts, args) | |
| assert e.value.exit_code == 1 | |
| assert capsys.readouterr().out == "" | |
| def test_get_multiple_values( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """All values of a multi-valued key are printed.""" | |
| cfg.SetString("section.multi", ["one", "two"]) | |
| assert run(["get", "section.multi"], capsys) == "one\ntwo\n" | |
| def test_get_valueless_key( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """A key present without a value prints an empty line, like git.""" | |
| Path(cfg.file).write_text("[sync]\n\tsmartsync\n") | |
| assert run(["get", "sync.smartsync"], capsys) == "\n" | |
| def test_get_null( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """-z terminates each value with NUL.""" | |
| cfg.SetString("section.multi", ["one", "two"]) | |
| assert run(["-z", "get", "section.multi"], capsys) == "one\0two\0" | |
| def test_unset( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """unset removes the key.""" | |
| run(["set", "sync.smartsync", "true"], capsys) | |
| run(["unset", "sync.smartsync"], capsys) | |
| assert cfg.GetString("sync.smartsync") is None | |
| assert "smartsync" not in Path(cfg.file).read_text() | |
| def test_unset_missing_is_noop( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """unset on a missing key does not fail or create the file.""" | |
| run(["unset", "sync.smartsync"], capsys) | |
| assert not Path(cfg.file).exists() | |
| def test_list( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """list prints all keys sorted as key=value, with every value.""" | |
| run(["set", "sync.smartsync", "true"], capsys) | |
| run(["set", "alias.ss", "sync -s"], capsys) | |
| cfg.SetString("section.multi", ["one", "two"]) | |
| assert run(["list"], capsys) == ( | |
| "alias.ss=sync -s\n" | |
| "section.multi=one\n" | |
| "section.multi=two\n" | |
| "sync.smartsync=true\n" | |
| ) | |
| def test_list_null( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """-z separates key and value with a newline and entries with NUL.""" | |
| Path(cfg.file).write_text("[alias]\n\tss = sync -s\n[sync]\n\tsmartsync\n") | |
| assert run(["-z", "list"], capsys) == "alias.ss\nsync -s\0sync.smartsync\0" | |
| def test_list_valueless_key( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """list prints a bare key for a key without a value, like git.""" | |
| Path(cfg.file).write_text("[sync]\n\tsmartsync\n") | |
| assert run(["list"], capsys) == "sync.smartsync\n" | |
| def test_list_empty( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """list on a missing file prints nothing.""" | |
| assert run(["list"], capsys) == "" | |
| def test_value_with_spaces( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """Values containing spaces round trip.""" | |
| run(["set", "alias.ss", "sync -s -j 4"], capsys) | |
| assert run(["get", "alias.ss"], capsys) == "sync -s -j 4\n" | |
| def test_key_is_case_insensitive( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """Section and name are case-insensitive, like git.""" | |
| run(["set", "Sync.SmartSync", "true"], capsys) | |
| assert run(["get", "sync.smartsync"], capsys) == "true\n" | |
| def test_write_failure_is_reported( | |
| cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture | |
| ) -> None: | |
| """A git failure while writing becomes a clean error, not a traceback.""" | |
| with mock.patch.object( | |
| cfg, "SetString", side_effect=error.GitError("locked") | |
| ): | |
| with pytest.raises(config.Error) as e: | |
| run(["set", "sync.smartsync", "true"], capsys) | |
| assert "locked" in str(e.value) |