Skip to content
Open
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
10 changes: 10 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would think we want to look inside the .repo directory? otherwise where would you commit the config to?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh actually .repo/manifests is the git project for the overall repo collection

@matt-wagantall-skydio matt-wagantall-skydio Jun 4, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not when we repo init with --local, as we do internally. It's just a plain directory created by repo init, not backed by a git project. My intention is to install the .revupconfig using a repo 'post-sync' hook, which can place it at the top of the tree.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm it would be more ideal to have this automatically work with any repo checkout and not require a separate step

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The post-sync hook setup step isn't required. The .revupconfig could be synced or symlinked to the root of the tree via the repo manifest itself if it was checked in.

In our case, however, I'm trying to avoid checking in a .revupconfig at all. The post-sync hook generates one at the end the end of a 'repo sync' based on the default branch in the manifest. That way we don't need to update the branch name twice (once in in the repo manifest and once in the .revupconfig) when it changes.

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

**`<flag>`**
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

# 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()
Expand Down
64 changes: 61 additions & 3 deletions revup/revup.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"):
Expand All @@ -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

Expand Down
72 changes: 72 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"
Loading