diff --git a/README.md b/README.md index 0be70e9..5767bb0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/docs/config.md b/docs/config.md index 4335c55..e072bde 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. diff --git a/revup/config.py b/revup/config.py index deb618c..a4fb8bf 100644 --- a/revup/config.py +++ b/revup/config.py @@ -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() diff --git a/revup/revup.py b/revup/revup.py index 21c5bf3..a0616e8 100755 --- a/revup/revup.py +++ b/revup/revup.py @@ -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"): @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..de9c67c --- /dev/null +++ b/tests/test_config.py @@ -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"