diff --git a/docs/config.md b/docs/config.md index 4335c55..85616f9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -16,10 +16,20 @@ from REVUP_CONFIG_PATH if available, otherwise from the default path of be configured. Revup loads options in this order: - The program has built in defaults that are given in the manual. +- The shared config for a "repo" tool checkout (described below) takes precedence over the above. - Repo configs take precedence over the above. - User configs take precedence over the above. - Command line flags specified by the user take highest precedence. +If the current git repo is one project of a "repo" tool checkout, +revup also reads a shared ".revupconfig" from the root of that checkout (the +directory containing the .repo directory), so a single config can apply to +every project without committing one into each. Revup does not place this file +itself; it can be checked in to a git tree that the manifest syncs to the +checkout root, symlinked there from another project via a manifest "linkfile" +rule, generated by a tool such as a "repo" post-sync hook, or simply placed +there by hand. + # OPTIONS **``** diff --git a/revup/config.py b/revup/config.py index deb618c..4e3aac2 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 + # Path to a shared config at the root of a "repo" tool checkout (the + # directory containing .repo), used as a fallback when the current git repo + # is one project of such a checkout. None if not in a repo checkout. + repo_tool_config_path: Optional[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 = "", + repo_tool_config_path: Optional[str] = None, + ): self.config = configparser.ConfigParser() self.config_path = config_path self.repo_config_path = repo_config_path + self.repo_tool_config_path = repo_tool_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 lowest precedence first so later files override earlier ones. + paths = [ + self.repo_tool_config_path, + 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..358f244 100755 --- a/revup/revup.py +++ b/revup/revup.py @@ -1,12 +1,13 @@ from __future__ import annotations import argparse +import asyncio import logging import os import stat import subprocess import sys -from typing import Any, List, Tuple +from typing import Any, List, Optional, Tuple import revup from revup import config, git, logs, shell @@ -76,6 +77,44 @@ def get_config_path() -> str: ) +REPO_TOOL_DIR = ".repo" + + +def repo_tool_config_from_git_dir(git_dir: str) -> Optional[str]: + """Given a resolved git directory path, return the shared config path if the + repo is part of a "repo" tool checkout, else None. + + A repo project's git metadata lives under the checkout's .repo directory, so + a ".repo" path component means the directory just above it is the checkout + root, where a shared .revupconfig may live. + """ + parts = os.path.normpath(git_dir).split(os.sep) + if REPO_TOOL_DIR not in parts: + return None + client_root = os.sep.join(parts[: parts.index(REPO_TOOL_DIR)]) + return os.path.join(client_root, CONFIG_FILE_NAME) + + +def repo_tool_config_from_git_results(rets: List[Tuple[int, str]]) -> Optional[str]: + """Pick the shared "repo" checkout config path out of git metadata queries. + + We don't walk the filesystem (which could pick up an unrelated project's + config); instead we follow git's own metadata paths, which for a repo + project point into the checkout's .repo directory. Different versions of the + repo tool lay out .git differently (a single symlink to .repo/projects vs. a + directory of symlinks whose objects point into .repo/project-objects; this + changed in repo commit 2a089cfe, Dec 2021), so we check both the common git + dir and the objects path. + """ + for ret in rets: + if ret[0] != 0: + continue + config_path = repo_tool_config_from_git_dir(os.path.realpath(ret[1].rstrip())) + if config_path: + return config_path + return None + + async def get_config() -> config.Config: config_path = get_config_path() if os.path.isfile(config_path) and hasattr(os, "getuid"): @@ -90,8 +129,27 @@ 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)) + git_path = git.get_default_git() + toplevel, common_dir, objects_dir = await asyncio.gather( + sh.sh(git_path, "rev-parse", "--show-toplevel"), + sh.sh( + git_path, "rev-parse", "--path-format=absolute", "--git-common-dir", raiseonerror=False + ), + sh.sh( + git_path, + "rev-parse", + "--path-format=absolute", + "--git-path", + "objects", + raiseonerror=False, + ), + ) + repo_root = toplevel[1].rstrip() + conf = config.Config( + config_path, + os.path.join(repo_root, CONFIG_FILE_NAME), + repo_tool_config_from_git_results([common_dir, objects_dir]), + ) conf.read() return conf diff --git a/tests/test_config.py b/tests/test_config.py index 0773d0b..d26dc53 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,6 +8,7 @@ import pytest from revup.config import Config, RevupArgParser, collect_known_keys, config_main +from revup.revup import repo_tool_config_from_git_dir from revup.types import RevupUsageException @@ -294,3 +295,74 @@ def test_collects_all_parser_keys(self): assert "forge_oauth" in known["revup"] assert "auto_topic" in known["upload"] assert "remote_name" in known["upload"] + + +class TestRepoToolDetection: + def test_git_dir_under_repo_returns_checkout_root_config(self): + # .git is a symlink straight into .repo/projects. + git_dir = "/work/checkout/.repo/projects/foo/bar.git" + assert repo_tool_config_from_git_dir(git_dir) == "/work/checkout/.revupconfig" + + def test_objects_under_project_objects_returns_checkout_root_config(self): + # .git is a dir of symlinks whose objects resolve into project-objects. + objects = "/work/checkout/.repo/project-objects/foo-bar.git/objects" + assert repo_tool_config_from_git_dir(objects) == "/work/checkout/.revupconfig" + + def test_non_repo_git_dir_returns_none(self): + assert repo_tool_config_from_git_dir("/home/me/project/.git") is None + + def test_repo_substring_in_name_is_not_matched(self): + # A directory merely containing the text ".repo" is not a path component. + assert repo_tool_config_from_git_dir("/home/me/my.repository/.git") is None + + +def write_main_branch(path, main_branch): + conf = Config(path) + conf.read() + conf.set_value("revup", "main_branch", main_branch) + conf.write() + + +class TestRepoToolConfigPrecedence: + def test_repo_config_overrides_repo_tool(self): + with tempfile.TemporaryDirectory() as tmp: + repo_path = os.path.join(tmp, "repo_config") + shared_path = os.path.join(tmp, "shared_config") + write_main_branch(repo_path, "repo-branch") + write_main_branch(shared_path, "shared-branch") + + conf = Config("", repo_config_path=repo_path, repo_tool_config_path=shared_path) + conf.read() + assert conf.config.get("revup", "main_branch") == "repo-branch" + + def test_repo_tool_used_when_repo_has_no_config(self): + with tempfile.TemporaryDirectory() as tmp: + repo_path = os.path.join(tmp, "repo_config") + shared_path = os.path.join(tmp, "shared_config") + write_main_branch(shared_path, "shared-branch") + + conf = Config("", repo_config_path=repo_path, repo_tool_config_path=shared_path) + conf.read() + assert conf.config.get("revup", "main_branch") == "shared-branch" + + def test_user_config_overrides_repo_and_repo_tool(self): + with tempfile.TemporaryDirectory() as tmp: + user_path = os.path.join(tmp, "user_config") + repo_path = os.path.join(tmp, "repo_config") + shared_path = os.path.join(tmp, "shared_config") + write_main_branch(user_path, "user-branch") + write_main_branch(repo_path, "repo-branch") + write_main_branch(shared_path, "shared-branch") + + conf = Config(user_path, repo_config_path=repo_path, repo_tool_config_path=shared_path) + conf.read() + assert conf.config.get("revup", "main_branch") == "user-branch" + + def test_no_repo_tool_config_is_a_noop(self): + with tempfile.TemporaryDirectory() as tmp: + repo_path = os.path.join(tmp, "repo_config") + write_main_branch(repo_path, "repo-branch") + + conf = Config("", repo_config_path=repo_path, repo_tool_config_path=None) + conf.read() + assert conf.config.get("revup", "main_branch") == "repo-branch"