blob: b3a3972780bdd66a62819dcb753ae220e621ba17 [file]
# 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)