mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-11-14 00:07:20 +00:00
Per-user settings such as sync.smartsync live in ~/.repoconfig/config, but reading or changing them required knowing the file location and running `git config --file ~/.repoconfig/config ...`. Add `repo config` as a thin wrapper around RepoConfig.ForUser(), following the subcommand style of modern `git config`: * `repo config get <name>` prints the value(s), exiting 1 if unset. * `repo config set <name> <value>` sets it. * `repo config unset <name>` removes it; a missing key is a no-op. * `repo config list` shows all settings. Values are stored as strings; `--type=bool|int` validates and canonicalizes them on set and get, like `git config --type`. `-z` makes get and list NUL-terminated for scripting. Setting names are validated by `git config` itself when writing. Factor the value parsing out of GitConfig.GetBoolean()/GetInt() into ParseBoolean()/ParseInt() so the command and the readers agree, and accept on/off/1/0 as booleans like git does. Only the per-user file is handled; per-checkout settings in .repo/manifests.git/config are out of scope. Like other subcommands, it runs from inside a repo checkout. Update docs/repoconfig.md to use the new command in its examples and add zsh completion and the man page. Bug: 569927750 Test: ./run_tests Change-Id: I940d0318549fe9d79f6f9173139f0b96a706f1bf Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/645727 Reviewed-by: Mike Frysinger <vapier@google.com> Tested-by: Insaf Latypov <saferif@google.com> Commit-Queue: Insaf Latypov <saferif@google.com>
344 lines
11 KiB
Python
344 lines
11 KiB
Python
# 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)
|