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 <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>
This commit is contained in:
Insaf Latypov authored and gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com committed 2026-10-22 08:34:46 -07:00
1 parent d744eb15f3
commit 9991aa6177
9 files changed
+751 -64

No files matched your search

+10
View File
@@ -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} \
+2 -1
View File
@@ -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.
+22 -29
View File
@@ -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:
+66 -32
View File
@@ -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."""
+60
View File
@@ -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=<type>\/\fR] [\fI\,-z\/\fR] \fI\,get <name>\/\fR
.SH DESCRIPTION
Summary
.PP
Read and write per\-user repo settings
.PP
repo config [\-\-type=<type>] set <name> <value>
repo config unset <name>
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 <section>.<name>, 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`.
+4 -1
View File
@@ -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
+185
View File
@@ -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=<type>] [-z] get <name>
%prog [--type=<type>] set <name> <value>
%prog unset <name>
%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 <section>.<name>, 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))
+59 -1
View File
@@ -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
+343
View File
@@ -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)