From 990b14e2c64ad374964e7744d45260fc3e722c5b Mon Sep 17 00:00:00 2001 From: Matt Wagantall Date: Thu, 4 Jun 2026 15:26:49 -0700 Subject: [PATCH] config: Discover a shared .revupconfig for a repo-tool checkout Read the current repo's .revupconfig as before, but when that repo is one project of a "repo" tool checkout, fall back to a shared .revupconfig at the root of the checkout (the directory containing .repo). This lets a group of projects that all track the same branch share one config instead of committing an identical one into each. The checkout is detected from git's own metadata rather than by walking the filesystem: a repo project's git directory lives under .repo, so we resolve the git dir and look for a .repo path component. This avoids accidentally picking up an unrelated project's config from a parent directory. A project's own config still overrides the shared one. Co-authored-by: Claude Opus 4.8 Signed-off-by: Matt Wagantall --- docs/config.md | 10 ++++++ revup/config.py | 23 ++++++++++++-- revup/revup.py | 64 +++++++++++++++++++++++++++++++++++++-- tests/test_config.py | 72 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 6 deletions(-) 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"