Skip to content
Closed
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
12 changes: 11 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 config in `~/.revupconfig`, followed by `.revupconfig` in the current repo, followed by any `.revupconfig` in directories above the repo root (closest wins).

## Repo config

Expand All @@ -247,6 +247,16 @@ base_branch_globs =
rel[1-9].[0-9][0-9]
```

## Shared config for many repos

If you have many git repos checked out under a common directory (for example,
repos managed by Android's `repo` tool), you can place a single `.revupconfig`
in a directory above them instead of committing one into every repo. Revup walks
up from each repo's root and reads the nearest `.revupconfig` it finds, so one
file can configure the `main_branch` and release branch naming for the whole
tree. A config closer to a given repo takes precedence, so individual repos can
still override the shared settings with their own `.revupconfig`.

## User config

The user config at `~/.revupconfig` saves time by defaulting the most commonly used flags.
Expand Down
16 changes: 11 additions & 5 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ revup config - Edit revup configuration files.

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:
the current git repo in a ".revupconfig" file. Revup also searches directories
above the repo root and reads any ".revupconfig" files it finds there. This
lets a single config placed above a tree of nested git repos apply to all of
them (for example, repos managed by Android's "repo" tool) without committing a
config into each one. 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:

- The program has built in defaults that are given in the manual.
- Repo configs take precedence over the above.
- Ancestor configs above the repo root take precedence over the above, with
configs closer to the repo root taking precedence over those further away.
- The repo config (".revupconfig" in the repo root) takes precedence over the above.
- User configs take precedence over the above.
- Command line flags specified by the user take highest precedence.

Expand Down
23 changes: 20 additions & 3 deletions revup/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,37 @@ class Config:
# Path to user global config file
config_path: str

# Path to config file in current repo
# Path to config file in current repo (also the write target for --repo)
repo_config_path: str

# Paths to .revupconfig files found in directories above the repo root, ordered
# nearest-first. These let a single config above a tree of nested repos apply to
# all of them, with closer configs taking precedence.
ancestor_config_paths: List[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 = "",
ancestor_config_paths: Optional[List[str]] = None,
):
self.config = configparser.ConfigParser()
self.config_path = config_path
self.repo_config_path = repo_config_path
self.ancestor_config_paths = ancestor_config_paths or []
self.file_configs: List[Tuple[str, configparser.ConfigParser]] = []

def read(self) -> None:
for path in (self.repo_config_path, self.config_path):
# Read lowest precedence first so later files override earlier ones:
# farthest ancestor, ..., nearest ancestor, repo root, then user config.
paths = list(reversed(self.ancestor_config_paths)) + [
self.repo_config_path,
self.config_path,
]
for path in paths:
if not path:
continue
file_conf = configparser.ConfigParser()
Expand Down
26 changes: 25 additions & 1 deletion revup/revup.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,26 @@ def get_config_path() -> str:
)


def get_ancestor_config_paths(repo_root: str, user_config_path: str) -> List[str]:
"""Find config files in directories above the repo root.

This lets a single config placed above a tree of nested git repos apply to all of
them (closest config to the repo wins), without having to commit a config into each
repo. The user config is excluded so it isn't read twice. Ordered nearest-first.
"""
paths: List[str] = []
current = os.path.dirname(os.path.abspath(repo_root))
while True:
candidate = os.path.join(current, CONFIG_FILE_NAME)
if candidate != user_config_path and os.path.isfile(candidate):
paths.append(candidate)
parent = os.path.dirname(current)
if parent == current:
break
current = parent
return paths


async def get_config() -> config.Config:
config_path = get_config_path()
if os.path.isfile(config_path) and hasattr(os, "getuid"):
Expand All @@ -91,7 +111,11 @@ async def get_config() -> config.Config:
# 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))
conf = config.Config(
config_path,
os.path.join(repo_root, CONFIG_FILE_NAME),
get_ancestor_config_paths(repo_root, config_path),
)
conf.read()
return conf

Expand Down
89 changes: 89 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from pathlib import Path

from revup.config import Config
from revup.revup import get_ancestor_config_paths


def write_config(path: Path, main_branch: str) -> None:
path.write_text(f"[revup]\nmain_branch = {main_branch}\n")


class TestAncestorConfigPaths:
def test_finds_config_above_repo_root(self, tmp_path: Path):
tree = tmp_path / "src"
repo = tree / "project" / "sub"
repo.mkdir(parents=True)
shared = tree / ".revupconfig"
write_config(shared, "shared-branch")

paths = get_ancestor_config_paths(str(repo), str(tmp_path / ".revupconfig"))

assert paths == [str(shared)]

def test_nearest_first_ordering(self, tmp_path: Path):
tree = tmp_path / "src"
repo = tree / "project"
repo.mkdir(parents=True)
near = tree / ".revupconfig"
far = tmp_path / ".revupconfig"
write_config(near, "near")
write_config(far, "far")

# Pass a user config path that doesn't collide with the ancestors.
paths = get_ancestor_config_paths(str(repo), str(tmp_path / "home" / ".revupconfig"))

assert paths == [str(near), str(far)]

def test_excludes_user_config(self, tmp_path: Path):
repo = tmp_path / "project"
repo.mkdir(parents=True)
user_config = tmp_path / ".revupconfig"
write_config(user_config, "user")

paths = get_ancestor_config_paths(str(repo), str(user_config))

assert paths == []


class TestConfigPrecedence:
def test_repo_root_overrides_ancestor(self, tmp_path: Path):
tree = tmp_path / "src"
repo = tree / "project"
repo.mkdir(parents=True)
ancestor = tree / ".revupconfig"
repo_config = repo / ".revupconfig"
write_config(ancestor, "ancestor-branch")
write_config(repo_config, "repo-branch")

conf = Config("", str(repo_config), [str(ancestor)])
conf.read()

assert conf.get_config().get("revup", "main_branch") == "repo-branch"

def test_ancestor_applies_when_repo_has_no_config(self, tmp_path: Path):
tree = tmp_path / "src"
repo = tree / "project"
repo.mkdir(parents=True)
ancestor = tree / ".revupconfig"
write_config(ancestor, "ancestor-branch")

conf = Config("", str(repo / ".revupconfig"), [str(ancestor)])
conf.read()

assert conf.get_config().get("revup", "main_branch") == "ancestor-branch"

def test_user_config_overrides_repo_and_ancestor(self, tmp_path: Path):
tree = tmp_path / "src"
repo = tree / "project"
repo.mkdir(parents=True)
ancestor = tree / ".revupconfig"
repo_config = repo / ".revupconfig"
user_config = tmp_path / "home.revupconfig"
write_config(ancestor, "ancestor-branch")
write_config(repo_config, "repo-branch")
write_config(user_config, "user-branch")

conf = Config(str(user_config), str(repo_config), [str(ancestor)])
conf.read()

assert conf.get_config().get("revup", "main_branch") == "user-branch"
Loading