mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-11-15 08:47:11 +00:00
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:
1 parent
d744eb15f3
commit
9991aa6177
9 files changed
+751
-64
No files matched your search
@@ -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} \
|
||||
|
||||
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user