From 9991aa6177d34ee94bf0ae046cfc15dc5a08c328 Mon Sep 17 00:00:00 2001 From: Insaf Latypov Date: Tue, 20 Oct 2026 16:15:23 +0000 Subject: [PATCH] config: add subcommand to manage ~/.repoconfig/config 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 ` prints the value(s), exiting 1 if unset. * `repo config set ` sets it. * `repo config unset ` 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 Tested-by: Insaf Latypov Commit-Queue: Insaf Latypov --- completion.zsh | 10 + docs/internal-fs-layout.md | 3 +- docs/repoconfig.md | 51 +++--- git_config.py | 98 ++++++---- man/repo-config.1 | 60 ++++++ man/repo.1 | 5 +- subcmds/config.py | 185 +++++++++++++++++++ tests/test_git_config.py | 60 +++++- tests/test_subcmds_config.py | 343 +++++++++++++++++++++++++++++++++++ 9 files changed, 751 insertions(+), 64 deletions(-) create mode 100644 man/repo-config.1 create mode 100644 subcmds/config.py create mode 100644 tests/test_subcmds_config.py diff --git a/completion.zsh b/completion.zsh index 217583e7e..2c37f4667 100644 --- a/completion.zsh +++ b/completion.zsh @@ -24,6 +24,7 @@ _repo() { 'branches:View current topic branches' 'checkout:Checkout a branch' 'cherry-pick:Cherry-pick a change' + 'config:Read and write per-user repo settings' 'diff:Show changes between commit and working tree' 'diffmanifests:Show differences between two manifests' 'download:Download and find a change' @@ -117,6 +118,15 @@ _repo() { ${common_opts} \ '1: :_message "sha1"' ;; + config) + _arguments \ + ${common_opts} \ + '--type=[Validate and canonicalize the value as this type]:type:(bool int)' \ + '(-z --null)'{-z,--null}'[Terminate output with NUL]' \ + '1: :(get set unset list)' \ + '2: :_message "name"' \ + '3: :_message "value"' + ;; diff) _arguments \ ${common_opts} \ diff --git a/docs/internal-fs-layout.md b/docs/internal-fs-layout.md index 10f173d5a..7ba500a34 100644 --- a/docs/internal-fs-layout.md +++ b/docs/internal-fs-layout.md @@ -254,7 +254,8 @@ setting `REPO_CONFIG_DIR`. * `.repoconfig/`: Repo's per-user directory for all random config files/state. * `.repoconfig/config`: Per-user settings using [git-config] file format. - See [repoconfig.md](./repoconfig.md) for the available settings. + Managed with `repo config`; see [repoconfig.md](./repoconfig.md) for the + available settings. * `.repoconfig/keyring-version`: Cache file for checking if the gnupg subdir has all the same keys as the repo launcher. Used to avoid running gpg constantly as that can be quite slow. diff --git a/docs/repoconfig.md b/docs/repoconfig.md index aef92fdc6..ee5377944 100644 --- a/docs/repoconfig.md +++ b/docs/repoconfig.md @@ -18,31 +18,33 @@ The file is created on demand; it's fine for it to be missing. ## Format -The file uses the [git-config] syntax, so it can be edited by hand: +The file uses the [git-config] syntax. The easiest way to manage it is the +`repo config` command: + +```sh +# Set a value. +repo config set sync.smartsync true + +# Read a value. +repo config get sync.smartsync + +# Remove a value. +repo config unset sync.smartsync + +# Show all settings. +repo config list +``` + +Like other repo subcommands, `repo config` must be run from inside a repo +checkout. The file can also be edited by hand: ```ini [sync] smartsync = true ``` -or managed with `git config` by pointing it at the file explicitly: - -```sh -# Set a value. -git config --file ~/.repoconfig/config sync.smartsync true - -# Read a value. -git config --file ~/.repoconfig/config sync.smartsync - -# Remove a value. -git config --file ~/.repoconfig/config --unset sync.smartsync -``` - Boolean settings accept `true`/`yes` and `false`/`no`. -Repo caches a parsed copy of the file in `.repoconfig/.repo_config.json`; it is -refreshed automatically whenever `config` changes and can be safely deleted. - ## Settings ### alias.* @@ -65,18 +67,9 @@ ones. Existing subcommands cannot be overridden by an alias. `sync.smartsync` is a boolean that lets a user decide once, for all their checkouts, whether `repo sync` should [Smart Sync](./smart-sync.md) by -default: - -```sh -# Always Smart Sync, as if -s/--smart-sync were passed. -git config --file ~/.repoconfig/config sync.smartsync true - -# Never Smart Sync by default, even if the manifest sets sync-smartsync="true". -git config --file ~/.repoconfig/config sync.smartsync false - -# Remove the preference and go back to following the manifest. -git config --file ~/.repoconfig/config --unset sync.smartsync -``` +default: `true` always Smart Syncs, as if `-s`/`--smart-sync` were passed; +`false` never does by default, even if the manifest sets +`sync-smartsync="true"`; unset follows the manifest. The effective behavior is resolved in this order; the first match wins: diff --git a/git_config.py b/git_config.py index 4facccda6..5117422c3 100644 --- a/git_config.py +++ b/git_config.py @@ -61,6 +61,56 @@ def IsImmutable(rev): return IsChange(rev) or IsId(rev) or IsTag(rev) +def ParseBoolean(value: str) -> Union[bool, None]: + """Parses a git config boolean value. + + Accepts the same spellings as `git config --type=bool`. + https://git-scm.com/docs/git-config#Documentation/git-config.txt-boolean + + Returns: + True/False, or None if |value| is not a valid boolean. + """ + v = value.strip().lower() + if v in ("true", "yes", "on", "1"): + return True + if v in ("false", "no", "off", "0"): + return False + return None + + +def ParseInt(value: str) -> Union[int, None]: + """Parses a git config integer value. + + Accepts the same syntax as `git config --type=int`: an optional k, m or + g suffix (multiples of 1024) and hex with a 0x prefix. + https://git-scm.com/docs/git-config#Documentation/git-config.txt-integer + + Returns: + The integer, or None if |value| is not a valid integer. + """ + v = value.strip().lower() + + mult = 1 + if v.endswith("k"): + v = v[:-1] + mult = 1024 + elif v.endswith("m"): + v = v[:-1] + mult = 1024 * 1024 + elif v.endswith("g"): + v = v[:-1] + mult = 1024 * 1024 * 1024 + + base = 10 + if v.startswith("0x"): + base = 16 + + try: + return int(v, base=base) * mult + except ValueError: + return None + + def _key(name): parts = name.split(".") if len(parts) < 2: @@ -152,32 +202,15 @@ class GitConfig: v = self.GetString(name) if v is None: return None - v = v.strip() - mult = 1 - if v.endswith("k"): - v = v[:-1] - mult = 1024 - elif v.endswith("m"): - v = v[:-1] - mult = 1024 * 1024 - elif v.endswith("g"): - v = v[:-1] - mult = 1024 * 1024 * 1024 - - base = 10 - if v.startswith("0x"): - base = 16 - - try: - return int(v, base=base) * mult - except ValueError: + ret = ParseInt(v) + if ret is None: print( f"warning: expected {name} to represent an integer, got {v} " "instead", file=sys.stderr, ) - return None + return ret def DumpConfigDict(self): """Returns the current configuration dict. @@ -197,24 +230,25 @@ class GitConfig: def GetBoolean(self, name: str) -> Union[bool, None]: """Returns a boolean from the configuration file. + This follows the git config syntax. + Returns: None: The value was not defined, or is not a boolean. - True: The value was set to true or yes. - False: The value was set to false or no. + True: The value was set to true, yes, on or 1. + False: The value was set to false, no, off or 0. """ v = self.GetString(name) if v is None: return None - v = v.lower() - if v in ("true", "yes"): - return True - if v in ("false", "no"): - return False - print( - f"warning: expected {name} to represent a boolean, got {v} instead", - file=sys.stderr, - ) - return None + + ret = ParseBoolean(v) + if ret is None: + print( + f"warning: expected {name} to represent a boolean, got {v} " + "instead", + file=sys.stderr, + ) + return ret def SetBoolean(self, name, value): """Set the truthy value for a key.""" diff --git a/man/repo-config.1 b/man/repo-config.1 new file mode 100644 index 000000000..d67ca759a --- /dev/null +++ b/man/repo-config.1 @@ -0,0 +1,60 @@ +.\" DO NOT MODIFY THIS FILE! It was generated by help2man. +.TH REPO "1" "October 2026" "repo config" "Repo Manual" +.SH NAME +repo \- repo config - manual page for repo config +.SH SYNOPSIS +.B repo +\fI\,config \/\fR[\fI\,--type=\/\fR] [\fI\,-z\/\fR] \fI\,get \/\fR +.SH DESCRIPTION +Summary +.PP +Read and write per\-user repo settings +.PP +repo config [\-\-type=] set +repo config unset +repo config [\-z] list +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +show this help message and exit +.TP +\fB\-\-type\fR=\fI\,TYPE\/\fR +validate and canonicalize the value as this type for get and set (bool, int) +.TP +\fB\-z\fR, \fB\-\-null\fR +terminate output of get and list with NUL instead of newline, and separate key and value with a newline +.SS Logging options: +.TP +\fB\-v\fR, \fB\-\-verbose\fR +show all output +.TP +\fB\-q\fR, \fB\-\-quiet\fR +only show errors +.SS Multi\-manifest options: +.TP +\fB\-\-outer\-manifest\fR +operate starting at the outermost manifest +.TP +\fB\-\-no\-outer\-manifest\fR +do not operate on outer manifests +.TP +\fB\-\-this\-manifest\-only\fR +only operate on this (sub)manifest +.TP +\fB\-\-no\-this\-manifest\-only\fR, \fB\-\-all\-manifests\fR +operate on this manifest and its submanifests +.PP +Run `repo help config` to view the detailed manual. +.SH DETAILS +.PP +The 'repo config' command reads and writes repo's own per\-user settings. These +are separate from git's config and from a checkout's settings; see +docs/repoconfig.md for where they live and which settings repo understands. +.PP +Settings are named
., e.g. sync.smartsync. 'get' prints every +value of the setting and exits 1 without output if it is not set, like `git +config`. +.PP +Values are stored as strings. With \fB\-\-type\fR, 'set' validates the value and stores +it in canonical form (e.g. "on" as "true", "1k" as "1024"), and 'get' prints the +canonical form, like `git config \fB\-\-type\fR`. diff --git a/man/repo.1 b/man/repo.1 index 8a1a00221..ab959b3b1 100644 --- a/man/repo.1 +++ b/man/repo.1 @@ -1,5 +1,5 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man. -.TH REPO "1" "June 2026" "repo" "Repo Manual" +.TH REPO "1" "October 2026" "repo" "Repo Manual" .SH NAME repo \- repository management tool built on top of git .SH SYNOPSIS @@ -65,6 +65,9 @@ Checkout a branch for development cherry\-pick Cherry\-pick a change. .TP +config +Read and write per\-user repo settings +.TP diff Show changes between commit and working tree .TP diff --git a/subcmds/config.py b/subcmds/config.py new file mode 100644 index 000000000..b68956a8f --- /dev/null +++ b/subcmds/config.py @@ -0,0 +1,185 @@ +# 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. + +import optparse +from typing import List, Optional + +import command +import error +import git_config + + +class Error(error.RepoExitError): + """Exit error when the config command fails.""" + + +class NotSetError(error.SilentRepoExitError): + """Exit quietly when a requested setting is not set, like `git config`.""" + + +# Subcommand name -> number of positional arguments it takes. +_ACTIONS = { + "get": 1, + "set": 2, + "unset": 1, + "list": 0, +} + + +def _CanonicalizeBool(name: str, value: str) -> str: + """Returns |value| as "true"/"false", or raises Error.""" + ret = git_config.ParseBoolean(value) + if ret is None: + raise Error(f"error: {name}: '{value}' is not a boolean") + return "true" if ret else "false" + + +def _CanonicalizeInt(name: str, value: str) -> str: + """Returns |value| as a plain decimal integer, or raises Error.""" + ret = git_config.ParseInt(value) + if ret is None: + raise Error(f"error: {name}: '{value}' is not an integer") + return str(ret) + + +# --type name -> function turning a value into its canonical string form. +_TYPES = { + "bool": _CanonicalizeBool, + "int": _CanonicalizeInt, +} + + +class Config(command.Command, command.MirrorSafeCommand): + """Read and write repo's per-user settings.""" + + COMMON = True + MULTI_MANIFEST_SUPPORT = True + helpSummary = "Read and write per-user repo settings" + helpUsage = """ +%prog [--type=] [-z] get +%prog [--type=] set +%prog unset +%prog [-z] list +""" + helpDescription = """ +The '%prog' command reads and writes repo's own per-user settings. These +are separate from git's config and from a checkout's settings; see +docs/repoconfig.md for where they live and which settings repo understands. + +Settings are named
., e.g. sync.smartsync. 'get' prints +every value of the setting and exits 1 without output if it is not set, +like `git config`. + +Values are stored as strings. With --type, 'set' validates the value and +stores it in canonical form (e.g. "on" as "true", "1k" as "1024"), and +'get' prints the canonical form, like `git config --type`. +""" + + def _Options(self, p: optparse.OptionParser) -> None: + p.add_option( + "--type", + choices=sorted(_TYPES), + help="validate and canonicalize the value as this type for " + "get and set (%s)" % ", ".join(sorted(_TYPES)), + ) + p.add_option( + "-z", + "--null", + action="store_true", + help="terminate output of get and list with NUL instead of " + "newline, and separate key and value with a newline", + ) + + def ValidateOptions(self, opt: optparse.Values, args: List[str]) -> None: + if not args: + self.Usage() + action, rest = args[0], args[1:] + if action not in _ACTIONS: + self.OptionParser.error(f"unknown action '{action}'") + if len(rest) != _ACTIONS[action]: + self.Usage() + if opt.type and action not in ("get", "set"): + self.OptionParser.error("--type only applies to get and set") + if opt.null and action not in ("get", "list"): + self.OptionParser.error("-z/--null only applies to get and list") + + def Execute(self, opt: optparse.Values, args: List[str]) -> None: + config = git_config.RepoConfig.ForUser() + action, rest = args[0], args[1:] + dispatch = { + "get": self._ExecuteGet, + "set": self._ExecuteSet, + "unset": self._ExecuteUnset, + "list": self._ExecuteList, + }[action] + dispatch(opt, config, *rest) + + @staticmethod + def _Print(opt: optparse.Values, text: str) -> None: + """Prints |text| terminated by NUL with -z, else by newline.""" + print(text, end="\0" if opt.null else "\n") + + @staticmethod + def _Format(opt: optparse.Values, name: str, value: Optional[str]) -> str: + """Formats a value for output, canonicalizing it per --type.""" + if value is None: + # A key without `=` in the file has no value; git prints a blank. + return "" + if opt.type: + return _TYPES[opt.type](name, value) + return value + + def _ExecuteGet( + self, opt: optparse.Values, config: git_config.RepoConfig, name: str + ) -> None: + values = config.GetString(name, all_keys=True) + if not values: + raise NotSetError(exit_code=1) + for value in values: + self._Print(opt, self._Format(opt, name, value)) + + def _ExecuteSet( + self, + opt: optparse.Values, + config: git_config.RepoConfig, + name: str, + value: str, + ) -> None: + if opt.type: + value = _TYPES[opt.type](name, value) + self._Write(config, name, value) + + def _ExecuteUnset( + self, opt: optparse.Values, config: git_config.RepoConfig, name: str + ) -> None: + self._Write(config, name, None) + + def _ExecuteList( + self, opt: optparse.Values, config: git_config.RepoConfig + ) -> None: + sep = "\n" if opt.null else "=" + for key in sorted(config.DumpConfigDict()): + for value in config.GetString(key, all_keys=True): + self._Print(opt, key if value is None else f"{key}{sep}{value}") + + @staticmethod + def _Write( + config: git_config.RepoConfig, name: str, value: Optional[str] + ) -> None: + """Writes |value| (None to delete) via `git config`, which also + validates the name.""" + try: + config.SetString(name, value) + except error.GitError as e: + raise Error(str(e)) diff --git a/tests/test_git_config.py b/tests/test_git_config.py index 33b0466fa..3bdf56d70 100644 --- a/tests/test_git_config.py +++ b/tests/test_git_config.py @@ -15,7 +15,7 @@ """Unittests for the git_config.py module.""" from pathlib import Path -from typing import Any +from typing import Any, Optional import pytest import utils_for_test @@ -288,3 +288,61 @@ def test_repo_config_for_user_is_not_shared_with_git_config( # And the RepoConfig instance is cached on its own class. assert git_config.RepoConfig.ForUser() is repo_cfg assert git_config.GitConfig.ForUser() is git_cfg + + +@pytest.mark.parametrize( + "value, expected", + ( + ("true", True), + ("True", True), + ("yes", True), + ("on", True), + ("1", True), + (" true ", True), + ("false", False), + ("No", False), + ("off", False), + ("0", False), + ("oops", None), + ("2", None), + ("", None), + ), +) +def test_parse_boolean(value: str, expected: Optional[bool]) -> None: + """Test ParseBoolean accepts the spellings `git config --type=bool` does.""" + assert git_config.ParseBoolean(value) is expected + + +@pytest.mark.parametrize( + "value, expected", + ( + ("10", 10), + ("-7", -7), + ("10k", 10 * 1024), + ("10M", 10 * 1024 * 1024), + ("10g", 10 * 1024 * 1024 * 1024), + ("0x10", 16), + ("0x10k", 16 * 1024), + (" 10 ", 10), + ("oops", None), + ("1x", None), + ("", None), + ), +) +def test_parse_int(value: str, expected: Optional[int]) -> None: + """Test ParseInt accepts the syntax `git config --type=int` does.""" + assert git_config.ParseInt(value) == expected + + +@pytest.mark.parametrize( + "value, expected", + (("on", True), ("off", False), ("1", True), ("0", False)), +) +def test_get_boolean_git_spellings( + tmp_path: Path, value: str, expected: bool +) -> None: + """Test GetBoolean accepts on/off/1/0 like git does.""" + config_file = tmp_path / "config" + config_file.write_text(f"[section]\n\tkey = {value}\n") + config = git_config.GitConfig(config_file) + assert config.GetBoolean("section.key") is expected diff --git a/tests/test_subcmds_config.py b/tests/test_subcmds_config.py new file mode 100644 index 000000000..b3a397278 --- /dev/null +++ b/tests/test_subcmds_config.py @@ -0,0 +1,343 @@ +# 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)