Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ the case where you rebase then upload and will show you a diff with upstream fil

Revup is highly configurable using a standard config file format. Every flag is also a config option, so users can get the exact behavior they need.

Flags specified on the command line take precedence, followed by config in `~/.revupconfig`, followed by `.revupconfig` in the current repo.
Flags specified on the command line take precedence, followed by `.revupconfig` in the current repo's `.git` directory, followed by `.revupconfig` at the repo root, followed by `~/.revupconfig`.

## Repo config

Expand All @@ -258,6 +258,10 @@ For example, after getting used to the workflow, a user might not need the confi
skip_confirm = True
```

## Git-dir config

A `.revupconfig` placed inside the current repo's `.git` directory has the highest config-file precedence and is not tracked by git. This is useful for per-checkout overrides that should not be shared with other contributors.

# Appendix

## Commit based development
Expand Down
25 changes: 16 additions & 9 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ revup config - Edit revup configuration files.
# DESCRIPTION

Revup stores some persistent configuration values in a python configparser
compatible format. A repo specific configuration is read from the root of
the current git repo in a ".revupconfig" file. A user configuration is read
from REVUP_CONFIG_PATH if available, otherwise from the default path of
~/.revupconfig. Any flag or argument to a revup command can
be configured. Revup loads options in this order:
compatible format. Any flag or argument to a revup command can be configured.
Revup loads options in this order:

- The program has built in defaults that are given in the manual.
- Repo configs take precedence over the above.
- User configs take precedence over the above.
- Global configs (~/.revupconfig) take precedence over the above. REVUP_CONFIG_PATH can override this path
- Repo configs (.revupconfig at the repo root) take precedence over the above.
- Repo-local configs (.git/.revupconfig) take precedence over the above.
- Command line flags specified by the user take highest precedence.

# OPTIONS
Expand All @@ -36,9 +34,18 @@ revup will warn if attempting to specify them directly.
**--help, -h**
: Show this help page.

**--global, -g**
: If specified, configuration value will be written to .revupconfig in the user's
home directory (overridable with REVUP_CONFIG_PATH). This is the default if repo
and repo-local are not specified.

**--repo, -r**
: If specified, configuration value will be written to the file in the current
repo. Otherwise, value will apply globally.
: If specified, configuration value will be written to .revupconfig at the root
of the current repo.

**--repo-local, -l**
: If specified, configuration value will be written to .revupconfig inside the
current repo's .git directory (common to all worktrees).

**--delete, -d**
: Delete the value with the given flag key.
Expand Down
30 changes: 26 additions & 4 deletions revup/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ def add_argument(self, *args: Any, **kwargs: Any) -> argparse.Action:

if no_options:
neg_action = _StoreFalseAction(
no_options, action.dest, action.default, False, "autogenerated negation"
no_options,
action.dest,
action.default,
False,
"autogenerated negation",
)
neg_action.completable = ( # type: ignore[attr-defined]
completable and action.default is True
Expand Down Expand Up @@ -110,17 +114,28 @@ class Config:
# Path to config file in current repo
repo_config_path: str

# Path to config file inside the current repo's .git directory
git_dir_config_path: str

# Whether the config contains values that need to be flushed to the file
dirty: bool = False

def __init__(self, config_path: str, repo_config_path: str = ""):
def __init__(
self,
config_path: str,
repo_config_path: str = "",
git_dir_config_path: str = "",
):
self.config = configparser.ConfigParser()
self.config_path = config_path
self.repo_config_path = repo_config_path
self.git_dir_config_path = git_dir_config_path
self.file_configs: List[Tuple[str, configparser.ConfigParser]] = []

def read(self) -> None:
for path in (self.repo_config_path, self.config_path):
# Read in increasing precedence: later reads overwrite earlier ones.
# Precedence: .git/.revupconfig > repo/.revupconfig > ~/.revupconfig
for path in (self.config_path, self.repo_config_path, self.git_dir_config_path):
if not path:
continue
file_conf = configparser.ConfigParser()
Expand Down Expand Up @@ -218,7 +233,14 @@ def config_main(conf: Config, args: argparse.Namespace, all_parsers: List[RevupA
else:
value = getpass.getpass(f"Input value for {command}.{key}: ").strip()

config_path = conf.repo_config_path if args.repo else conf.config_path
if sum(bool(x) for x in (args.use_global, args.repo, args.repo_local)) > 1:
raise RevupUsageException("Can only specify one of --global, --repo, or --repo-local")
if args.repo_local:
config_path = conf.git_dir_config_path
elif args.repo:
config_path = conf.repo_config_path
else:
config_path = conf.config_path
this_config = Config(config_path)
this_config.read()

Expand Down
19 changes: 17 additions & 2 deletions revup/revup.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,21 @@ async def get_config() -> config.Config:
# There's a chicken/egg problem in getting git path from config when we need git
# to find the path of the config file. Just this once, we use the default.
sh = shell.Shell()
repo_root = (await sh.sh(git.get_default_git(), "rev-parse", "--show-toplevel"))[1].rstrip()
conf = config.Config(config_path, os.path.join(repo_root, CONFIG_FILE_NAME))
rev_parse_out = (
await sh.sh(
git.get_default_git(),
"rev-parse",
"--path-format=absolute",
"--show-toplevel",
"--git-common-dir",
Comment thread
jerry-skydio marked this conversation as resolved.
)
)[1].splitlines()
repo_root, git_dir = rev_parse_out[0].rstrip(), rev_parse_out[1].rstrip()
conf = config.Config(
config_path,
os.path.join(repo_root, CONFIG_FILE_NAME),
os.path.join(git_dir, CONFIG_FILE_NAME),
)
conf.read()
return conf

Expand Down Expand Up @@ -223,7 +236,9 @@ def build_parser() -> Tuple[RevupArgParser, List[RevupArgParser]]:
all_parsers
)
config_parser.add_argument("value", nargs="?")
config_parser.add_argument("--global", "-g", action="store_true", dest="use_global")
config_parser.add_argument("--repo", "-r", action="store_true")
config_parser.add_argument("--repo-local", "-l", action="store_true")
config_parser.add_argument("--delete", "-d", action="store_true")

toolkit_subparsers = toolkit_parser.add_subparsers(dest="toolkit_cmd", required=True)
Expand Down
156 changes: 152 additions & 4 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,17 @@ def make_parsers():
return [revup_parser, upload_parser]


def make_config_args(flag, value=None, repo=False, delete=False):
return argparse.Namespace(flag=[flag], value=value, repo=repo, delete=delete)
def make_config_args(
flag, value=None, use_global=False, repo=False, repo_local=False, delete=False
):
return argparse.Namespace(
flag=[flag],
value=value,
use_global=use_global,
repo=repo,
repo_local=repo_local,
delete=delete,
)


class TestConfigSetAndRead:
Expand Down Expand Up @@ -108,10 +117,66 @@ def test_repo_config_overrides_user_config(self):
repo_conf.set_value("revup", "forge_url", "repo.github.com")
repo_conf.write()

# Reading repo then user means user wins (ConfigParser last-read-wins)
combined = Config(user_path, repo_config_path=repo_path)
combined.read()
# repo is read first, then user overrides
assert combined.config.get("revup", "forge_url") == "repo.github.com"

def test_git_dir_config_overrides_repo_config(self):
with tempfile.TemporaryDirectory() as tmp:
user_path = os.path.join(tmp, "user_config")
repo_path = os.path.join(tmp, "repo_config")
git_dir_path = os.path.join(tmp, "git_dir_config")

user_conf = Config(user_path)
user_conf.read()
user_conf.set_value("revup", "forge_url", "user.github.com")
user_conf.write()

repo_conf = Config(repo_path)
repo_conf.read()
repo_conf.set_value("revup", "forge_url", "repo.github.com")
repo_conf.write()

git_dir_conf = Config(git_dir_path)
git_dir_conf.read()
git_dir_conf.set_value("revup", "forge_url", "gitdir.github.com")
git_dir_conf.write()

combined = Config(
user_path,
repo_config_path=repo_path,
git_dir_config_path=git_dir_path,
)
combined.read()
assert combined.config.get("revup", "forge_url") == "gitdir.github.com"

def test_git_dir_config_falls_through_to_lower_layers(self):
with tempfile.TemporaryDirectory() as tmp:
user_path = os.path.join(tmp, "user_config")
repo_path = os.path.join(tmp, "repo_config")
git_dir_path = os.path.join(tmp, "git_dir_config")

user_conf = Config(user_path)
user_conf.read()
user_conf.set_value("revup", "forge_url", "user.github.com")
user_conf.set_value("upload", "remote_name", "user_remote")
user_conf.write()

repo_conf = Config(repo_path)
repo_conf.read()
repo_conf.set_value("upload", "remote_name", "repo_remote")
repo_conf.write()

# git_dir config file does not exist on disk
combined = Config(
user_path,
repo_config_path=repo_path,
git_dir_config_path=git_dir_path,
)
combined.read()
# repo overrides user
assert combined.config.get("upload", "remote_name") == "repo_remote"
# user value preserved when not overridden
assert combined.config.get("revup", "forge_url") == "user.github.com"


Expand Down Expand Up @@ -262,6 +327,89 @@ def test_hyphen_to_underscore_normalization(self):
written.read()
assert written.config.get("revup", "forge_url") == "normalized.com"

def test_repo_local_writes_to_git_dir_config(self):
with tempfile.TemporaryDirectory() as tmp:
user_path = os.path.join(tmp, "user_config")
repo_path = os.path.join(tmp, "repo_config")
git_dir_path = os.path.join(tmp, "git_dir_config")
conf = Config(user_path, repo_path, git_dir_path)
conf.read()
parsers = make_parsers()

args = make_config_args("forge_url", "local.github.com", repo_local=True)
ret = config_main(conf, args, parsers)

assert ret == 0
assert not os.path.exists(user_path)
assert not os.path.exists(repo_path)
written = Config(git_dir_path)
written.read()
assert written.config.get("revup", "forge_url") == "local.github.com"

def test_repo_local_with_repo_raises(self):
with tempfile.TemporaryDirectory() as tmp:
user_path = os.path.join(tmp, "user_config")
repo_path = os.path.join(tmp, "repo_config")
git_dir_path = os.path.join(tmp, "git_dir_config")
conf = Config(user_path, repo_path, git_dir_path)
conf.read()
parsers = make_parsers()

args = make_config_args("forge_url", "x.github.com", repo=True, repo_local=True)
with pytest.raises(RevupUsageException, match="Can only specify one"):
config_main(conf, args, parsers)

def test_global_with_repo_raises(self):
with tempfile.TemporaryDirectory() as tmp:
user_path = os.path.join(tmp, "user_config")
repo_path = os.path.join(tmp, "repo_config")
git_dir_path = os.path.join(tmp, "git_dir_config")
conf = Config(user_path, repo_path, git_dir_path)
conf.read()
parsers = make_parsers()

args = make_config_args("forge_url", "x.github.com", use_global=True, repo=True)
with pytest.raises(RevupUsageException, match="Can only specify one"):
config_main(conf, args, parsers)

def test_global_writes_to_user_config(self):
with tempfile.TemporaryDirectory() as tmp:
user_path = os.path.join(tmp, "user_config")
repo_path = os.path.join(tmp, "repo_config")
git_dir_path = os.path.join(tmp, "git_dir_config")
conf = Config(user_path, repo_path, git_dir_path)
conf.read()
parsers = make_parsers()

args = make_config_args("forge_url", "global.github.com", use_global=True)
ret = config_main(conf, args, parsers)

assert ret == 0
assert not os.path.exists(repo_path)
assert not os.path.exists(git_dir_path)
written = Config(user_path)
written.read()
assert written.config.get("revup", "forge_url") == "global.github.com"

def test_repo_writes_to_repo_config(self):
with tempfile.TemporaryDirectory() as tmp:
user_path = os.path.join(tmp, "user_config")
repo_path = os.path.join(tmp, "repo_config")
git_dir_path = os.path.join(tmp, "git_dir_config")
conf = Config(user_path, repo_path, git_dir_path)
conf.read()
parsers = make_parsers()

args = make_config_args("forge_url", "repo.github.com", repo=True)
ret = config_main(conf, args, parsers)

assert ret == 0
assert not os.path.exists(user_path)
assert not os.path.exists(git_dir_path)
written = Config(repo_path)
written.read()
assert written.config.get("revup", "forge_url") == "repo.github.com"


class TestApplyToParsers:
def test_applies_config_values_as_defaults(self):
Expand Down
Loading