New Upstream Release - ostree-push

Ready changes

Summary

Merged new upstream version: 1.1.0 (was: 1.0.1).

Diff

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..cf2976f
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,171 @@
+---
+name: Tests
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+  workflow_dispatch:
+
+jobs:
+  lint:
+    name: Code style and lint checks
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v3
+
+      - name: Install system dependencies
+        run: |
+          sudo apt-get update
+          sudo apt-get -y install \
+            python3-pip
+
+      - name: Install python dependencies
+        run: |
+          python3 -m pip install flake8
+
+      - name: Run flake8
+        run: |
+          python3 -m flake8
+
+  tests:
+    strategy:
+      # Let other configurations continue if one fails.
+      fail-fast: false
+      matrix:
+        include:
+          - name: Arch Base
+            image: archlinux:base
+            setup: |
+              pacman -Sy --noconfirm \
+                base-devel \
+                cairo \
+                flatpak \
+                gobject-introspection \
+                openssh \
+                ostree \
+                python-pip \
+                python-setuptools \
+                python-wheel
+
+          - name: Debian Stable
+            image: debian:stable-slim
+            setup: |
+              apt-get update
+              apt-get -y install \
+                build-essential \
+                flatpak \
+                gir1.2-ostree-1.0 \
+                libcairo2-dev \
+                libgirepository1.0-dev \
+                openssh-client \
+                openssh-server \
+                ostree \
+                python3-dev \
+                python3-pip \
+                python3-setuptools \
+                python3-wheel
+
+          - name: Debian Testing
+            image: debian:testing-slim
+            setup: |
+              apt-get update
+              apt-get -y install \
+                build-essential \
+                flatpak \
+                gir1.2-ostree-1.0 \
+                libcairo2-dev \
+                libgirepository1.0-dev \
+                openssh-client \
+                openssh-server \
+                ostree \
+                python3-dev \
+                python3-pip \
+                python3-setuptools \
+                python3-wheel
+
+          - name: Fedora Stable
+            image: fedora:latest
+            setup: |
+              dnf -y install \
+                cairo-gobject-devel \
+                flatpak \
+                gobject-introspection-devel \
+                openssh-clients \
+                openssh-server \
+                ostree \
+                ostree-libs \
+                passwd \
+                python3-devel \
+                python3-pip
+
+          - name: Ubuntu LTS
+            image: ubuntu:latest
+            setup: |
+              apt-get update
+              apt-get -y install \
+                build-essential \
+                flatpak \
+                gir1.2-ostree-1.0 \
+                libcairo2-dev \
+                libgirepository1.0-dev \
+                openssh-client \
+                openssh-server \
+                ostree \
+                python3-dev \
+                python3-pip \
+                python3-setuptools \
+                python3-wheel
+
+          - name: Ubuntu Rolling
+            image: ubuntu:rolling
+            setup: |
+              apt-get update
+              apt-get -y install \
+                build-essential \
+                flatpak \
+                gir1.2-ostree-1.0 \
+                libcairo2-dev \
+                libgirepository1.0-dev \
+                openssh-client \
+                openssh-server \
+                ostree \
+                python3-dev \
+                python3-pip \
+                python3-setuptools \
+                python3-wheel
+
+    name: ${{ matrix.name }}
+    runs-on: ubuntu-latest
+    container: ${{ matrix.image }}
+    env:
+      DEBIAN_FRONTEND: noninteractive
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v3
+
+      - name: System setup
+        run: ${{ matrix.setup }}
+
+      # sshd refuses to run if the hardcoded privilege separation
+      # directory doesn't exist.
+      - name: Create sshd privilege separation directory
+        run: |
+          mkdir -p /run/sshd
+
+      # sshd won't allow root login if the account is locked.
+      - name: Ensure root account unlocked
+        run: |
+          passwd -u root
+
+      - name: Install python dependencies
+        run: |
+          python3 -m pip install tox
+
+      - name: Run tests
+        run: |
+          python3 -m tox
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a59ed6b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+*~
+*.py[co]
+__pycache__
+/.pytest_cache/
+/.tox/
+/build/
+/dist/
+/ostree_push.egg-info/
diff --git a/NEWS b/NEWS
index 8f78d00..0adc146 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,9 @@
+# 1.1.0 (2022-12-02)
+
+ostree-receive now supports optional per-repository configuration. This
+is useful if you have repositories that require different settings such
+as the key IDs to sign commits with.
+
 # 1.0.1 (2022-10-27)
 
 ostree-receive now supports ostree ed25519 signing and verification. See
diff --git a/PKG-INFO b/PKG-INFO
deleted file mode 100644
index ed23cd6..0000000
--- a/PKG-INFO
+++ /dev/null
@@ -1,136 +0,0 @@
-Metadata-Version: 2.1
-Name: ostree-push
-Version: 1.0.1
-Summary: Push and receive OSTree commits
-Home-page: https://github.com/dbnicholson/ostree-push
-Author: Dan Nicholson
-Author-email: dbn@endlessos.org
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)
-Classifier: Operating System :: POSIX
-Classifier: Topic :: Software Development :: Build Tools
-Classifier: Topic :: System :: Archiving :: Mirroring
-Classifier: Topic :: System :: Archiving :: Packaging
-Classifier: Topic :: System :: Software Distribution
-Requires-Python: >=3.7
-Description-Content-Type: text/markdown
-License-File: COPYING
-
-# ostree-push
-
-## Background
-
-`ostree-push` uses `ssh` to push commits from a local OSTree repo to a
-remote OSTree repo. This is to fill a gap where currently you can only
-pull commits in core ostree. To publish commits to a remote repository,
-you either have to `pull` from the local repo to the remote repo or use
-an out of band mechanism like `rsync`.
-
-Both approaches have significant limitations. To pull over the network,
-only http is supported. So, in addition to having to login on the remote
-machine and run `ostree pull`, the local repository needs to be served
-over http. This means your build machine needs to be an http server with
-appropriate configuration in addition to simply making commits. This
-pushes the builds to be done on the public repository server, which
-prevents reasonable separation of duties and makes multiarch
-repositories impossible.
-
-Using `rsync` for publishing has some major benefits since only updated
-objects are published. However, it has no concept of the OSTree object
-store or refs structures. There are a few problems deriving from this
-issue. First, objects are published in sort order, but this means that
-objects can be published before their children. In the most extreme
-case, a commit object could be published before it's complete. The
-remote repo would assume this commit object was valid even though some
-children might be missing. Second, the refs might get updated before the
-commit objects are in place. If a client pulls while `rsync` is
-publishing, it may attempt to pull an incomplete or entirely missing
-commit. Finally, `rsync` will push the objects directly into the store
-rather than using a staging directory like `pull` or `commit` do. If
-`rsync` is interrupted, it could leave partial objects in the store.
-
-`ostree-push` tries to offer functionality like `git` where commits can
-be pushed over `ssh` to avoid these issues.
-
-## Operation
-
-When `ostree-push` is started, it first starts a local HTTP server
-providing the contents of the local ostree repo. It then connects to the
-remote host with `ssh` and tunnels the HTTP server port through the SSH
-connection. Finally, it runs `ostree-receive` on the remote host with
-the URL of the tunneled HTTP server. `ostree-receive` then creates a
-temporary remote using this URL and pulls the desired refs from it.
-
-In essence, `ostree-push` and `ostree-receive` coordinate to pull from
-the local repo to a remote repo while avoiding the limitations described
-above. Namely, no HTTP server needs to be running and no port needs to
-be exposed on the local host. Both resources are created temporarily and
-only exposed to the remote host through the secure SSH connection.
-
-## Installation
-
-Use `pip` to install the `otpush` package and the `ostree-push` and
-`ostree-receive` scripts. From a git checkout, run:
-
-```
-pip install .
-```
-
-If `ostree-receive` is not in a default `PATH` location, it may not be
-located when run in the environment spawned by the SSH server. As a
-workaround, make a symbolic link in a standard location:
-
-```
-sudo ln -s /path/to/ostree-receive /usr/bin/ostree-receive
-```
-
-In order to restrict SSH usage to only running `ostree-receive`, the
-`ostree-receive-shell` script can be used as a login shell. This way
-someone with SSH access to the remote machine cannot run arbitrary
-commands as the user owning the repositories. To use it, set the login
-shell of the repo owner to `ostree-receive-shell`:
-
-```
-sudo chsh -s /path/to/ostree-receive-shell <user>
-```
-
-`ostree-receive-shell` will also append the directory it's installed in
-to `PATH` to allow `ostree-receive` to be found in non-standard
-locations. In that scenario, the symbolic link to `ostree-receive`
-described above is not needed.
-
-Both `ostree-push` and `ostree-receive` require the OSTree GObject
-Introspection bindings. Typically these would be installed from the host
-distro. On Debian systems the package is `gir1.2-ostree-1.0` while on
-RedHat systems they are in the `ostree-libs` package.
-
-`ostree-push` relies on the connection sharing and port forwarding
-features of OpenSSH and is unlikely to work with another SSH client.
-Similarly, `ostree-receive` has only be tested with the OpenSSH server,
-but it might work correctly with other SSH servers.
-
-## Configuration
-
-`ostree-receive` can be configured from YAML formatted files. It will
-load `~/.config/ostree/ostree-receive.conf` and
-`/etc/ostree/ostree-receive.conf` or a file specified in the
-`OSTREE_RECEIVE_CONF` environment variable. See the example
-[`ostree-receive.conf`](ostree-receive.conf) file for available options.
-
-## Testing
-
-A test suite is provided using [pytest][pytest]. Most of the time simply
-running `pytest` from a git checkout will run it correctly. [tox][tox]
-can also be used to automate running the test suite in a prepared Python
-environment.
-
-In addition to the `ostree-push` dependencies, many of the tests depend
-on using OpenSSH `sshd` locally. On both Debian and RedHat systems this
-is available in the `openssh-server` package.
-
-[pytest]: https://docs.pytest.org/en/stable/
-[tox]: https://tox.readthedocs.io/en/stable/
diff --git a/debian/changelog b/debian/changelog
index 15ad94d..105540b 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+ostree-push (1.1.0-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Sat, 13 May 2023 04:35:08 -0000
+
 ostree-push (1.0.1-1) unstable; urgency=medium
 
   [ Andrej Shadura ]
diff --git a/ostree-receive.conf b/ostree-receive.conf
index 15e2304..3108d90 100644
--- a/ostree-receive.conf
+++ b/ostree-receive.conf
@@ -57,6 +57,22 @@
 # whitespace.
 #update_hook: null
 
+# Optional per-repository configuration settings. All of the above settings
+# except for root can be set and will override the global value. The value is a
+# map of repository path to map of settings. The repository path can be
+# relative or absolute. If root is specified, relative paths are resolved below
+# it.
+#
+# For example:
+#
+# repos:
+#   foo:
+#     gpg_sign: ['76543210']
+#   /path/to/bar:
+#     update: no
+#
+#repos: {}
+
 # Set the log level. See https://docs.python.org/3/library/logging.html#levels
 # for the list of log levels.
 #log_level: INFO
diff --git a/ostree_push.egg-info/PKG-INFO b/ostree_push.egg-info/PKG-INFO
deleted file mode 100644
index ed23cd6..0000000
--- a/ostree_push.egg-info/PKG-INFO
+++ /dev/null
@@ -1,136 +0,0 @@
-Metadata-Version: 2.1
-Name: ostree-push
-Version: 1.0.1
-Summary: Push and receive OSTree commits
-Home-page: https://github.com/dbnicholson/ostree-push
-Author: Dan Nicholson
-Author-email: dbn@endlessos.org
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)
-Classifier: Operating System :: POSIX
-Classifier: Topic :: Software Development :: Build Tools
-Classifier: Topic :: System :: Archiving :: Mirroring
-Classifier: Topic :: System :: Archiving :: Packaging
-Classifier: Topic :: System :: Software Distribution
-Requires-Python: >=3.7
-Description-Content-Type: text/markdown
-License-File: COPYING
-
-# ostree-push
-
-## Background
-
-`ostree-push` uses `ssh` to push commits from a local OSTree repo to a
-remote OSTree repo. This is to fill a gap where currently you can only
-pull commits in core ostree. To publish commits to a remote repository,
-you either have to `pull` from the local repo to the remote repo or use
-an out of band mechanism like `rsync`.
-
-Both approaches have significant limitations. To pull over the network,
-only http is supported. So, in addition to having to login on the remote
-machine and run `ostree pull`, the local repository needs to be served
-over http. This means your build machine needs to be an http server with
-appropriate configuration in addition to simply making commits. This
-pushes the builds to be done on the public repository server, which
-prevents reasonable separation of duties and makes multiarch
-repositories impossible.
-
-Using `rsync` for publishing has some major benefits since only updated
-objects are published. However, it has no concept of the OSTree object
-store or refs structures. There are a few problems deriving from this
-issue. First, objects are published in sort order, but this means that
-objects can be published before their children. In the most extreme
-case, a commit object could be published before it's complete. The
-remote repo would assume this commit object was valid even though some
-children might be missing. Second, the refs might get updated before the
-commit objects are in place. If a client pulls while `rsync` is
-publishing, it may attempt to pull an incomplete or entirely missing
-commit. Finally, `rsync` will push the objects directly into the store
-rather than using a staging directory like `pull` or `commit` do. If
-`rsync` is interrupted, it could leave partial objects in the store.
-
-`ostree-push` tries to offer functionality like `git` where commits can
-be pushed over `ssh` to avoid these issues.
-
-## Operation
-
-When `ostree-push` is started, it first starts a local HTTP server
-providing the contents of the local ostree repo. It then connects to the
-remote host with `ssh` and tunnels the HTTP server port through the SSH
-connection. Finally, it runs `ostree-receive` on the remote host with
-the URL of the tunneled HTTP server. `ostree-receive` then creates a
-temporary remote using this URL and pulls the desired refs from it.
-
-In essence, `ostree-push` and `ostree-receive` coordinate to pull from
-the local repo to a remote repo while avoiding the limitations described
-above. Namely, no HTTP server needs to be running and no port needs to
-be exposed on the local host. Both resources are created temporarily and
-only exposed to the remote host through the secure SSH connection.
-
-## Installation
-
-Use `pip` to install the `otpush` package and the `ostree-push` and
-`ostree-receive` scripts. From a git checkout, run:
-
-```
-pip install .
-```
-
-If `ostree-receive` is not in a default `PATH` location, it may not be
-located when run in the environment spawned by the SSH server. As a
-workaround, make a symbolic link in a standard location:
-
-```
-sudo ln -s /path/to/ostree-receive /usr/bin/ostree-receive
-```
-
-In order to restrict SSH usage to only running `ostree-receive`, the
-`ostree-receive-shell` script can be used as a login shell. This way
-someone with SSH access to the remote machine cannot run arbitrary
-commands as the user owning the repositories. To use it, set the login
-shell of the repo owner to `ostree-receive-shell`:
-
-```
-sudo chsh -s /path/to/ostree-receive-shell <user>
-```
-
-`ostree-receive-shell` will also append the directory it's installed in
-to `PATH` to allow `ostree-receive` to be found in non-standard
-locations. In that scenario, the symbolic link to `ostree-receive`
-described above is not needed.
-
-Both `ostree-push` and `ostree-receive` require the OSTree GObject
-Introspection bindings. Typically these would be installed from the host
-distro. On Debian systems the package is `gir1.2-ostree-1.0` while on
-RedHat systems they are in the `ostree-libs` package.
-
-`ostree-push` relies on the connection sharing and port forwarding
-features of OpenSSH and is unlikely to work with another SSH client.
-Similarly, `ostree-receive` has only be tested with the OpenSSH server,
-but it might work correctly with other SSH servers.
-
-## Configuration
-
-`ostree-receive` can be configured from YAML formatted files. It will
-load `~/.config/ostree/ostree-receive.conf` and
-`/etc/ostree/ostree-receive.conf` or a file specified in the
-`OSTREE_RECEIVE_CONF` environment variable. See the example
-[`ostree-receive.conf`](ostree-receive.conf) file for available options.
-
-## Testing
-
-A test suite is provided using [pytest][pytest]. Most of the time simply
-running `pytest` from a git checkout will run it correctly. [tox][tox]
-can also be used to automate running the test suite in a prepared Python
-environment.
-
-In addition to the `ostree-push` dependencies, many of the tests depend
-on using OpenSSH `sshd` locally. On both Debian and RedHat systems this
-is available in the `openssh-server` package.
-
-[pytest]: https://docs.pytest.org/en/stable/
-[tox]: https://tox.readthedocs.io/en/stable/
diff --git a/ostree_push.egg-info/SOURCES.txt b/ostree_push.egg-info/SOURCES.txt
deleted file mode 100644
index e81d720..0000000
--- a/ostree_push.egg-info/SOURCES.txt
+++ /dev/null
@@ -1,41 +0,0 @@
-.flake8
-COPYING
-MANIFEST.in
-NEWS
-README.md
-TODO
-ostree-receive.conf
-pyproject.toml
-pytest.ini
-setup.cfg
-setup.py
-tox.ini
-ostree_push.egg-info/PKG-INFO
-ostree_push.egg-info/SOURCES.txt
-ostree_push.egg-info/dependency_links.txt
-ostree_push.egg-info/entry_points.txt
-ostree_push.egg-info/requires.txt
-ostree_push.egg-info/top_level.txt
-otpush/__init__.py
-otpush/push.py
-otpush/receive.py
-scripts/ostree-receive-shell
-tests/__init__.py
-tests/conftest.py
-tests/dumpenv
-tests/ostree-push
-tests/ostree-receive
-tests/test_full.py
-tests/test_push.py
-tests/test_receive.py
-tests/test_receive_shell.py
-tests/test_sshd.py
-tests/util.py
-tests/data/host_rsa_key
-tests/data/host_rsa_key.pub
-tests/data/id_rsa
-tests/data/id_rsa.pub
-tests/data/pgp-key.asc
-tests/data/pgp-pub.asc
-tests/data/pgp-pub.gpg
-tests/data/sshd_config
\ No newline at end of file
diff --git a/ostree_push.egg-info/dependency_links.txt b/ostree_push.egg-info/dependency_links.txt
deleted file mode 100644
index 8b13789..0000000
--- a/ostree_push.egg-info/dependency_links.txt
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/ostree_push.egg-info/entry_points.txt b/ostree_push.egg-info/entry_points.txt
deleted file mode 100644
index eb78bd6..0000000
--- a/ostree_push.egg-info/entry_points.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-[console_scripts]
-ostree-push = otpush.push:main
-ostree-receive = otpush.receive:main
diff --git a/ostree_push.egg-info/requires.txt b/ostree_push.egg-info/requires.txt
deleted file mode 100644
index 12e0019..0000000
--- a/ostree_push.egg-info/requires.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-PyGObject
-PyYAML
diff --git a/ostree_push.egg-info/top_level.txt b/ostree_push.egg-info/top_level.txt
deleted file mode 100644
index 9fab194..0000000
--- a/ostree_push.egg-info/top_level.txt
+++ /dev/null
@@ -1 +0,0 @@
-otpush
diff --git a/otpush/__init__.py b/otpush/__init__.py
index 1dea037..0ad4a58 100644
--- a/otpush/__init__.py
+++ b/otpush/__init__.py
@@ -1 +1 @@
-VERSION = '1.0.1'
+VERSION = '1.1.1'
diff --git a/otpush/receive.py b/otpush/receive.py
index 5475da0..b439c50 100755
--- a/otpush/receive.py
+++ b/otpush/receive.py
@@ -70,9 +70,30 @@ class OTReceiveConfigError(OTReceiveError):
 
 
 @dataclasses.dataclass
-class OTReceiveConfig:
+class OTReceiveRepoConfig:
     """OTReceiveRepo configuration
 
+    The path and url fields are required. See the OTReceiveConfig class for
+    details on the remaining optional fields.
+    """
+    path: Path
+    url: str
+    gpg_sign: list = dataclasses.field(default_factory=list)
+    gpg_homedir: str = None
+    gpg_verify: bool = False
+    gpg_trustedkeys: str = None
+    sign_type: str = 'ed25519'
+    sign_keyfiles: list = dataclasses.field(default_factory=list)
+    sign_verify: bool = False
+    sign_trustedkeyfile: str = None
+    update: bool = True
+    update_hook: str = None
+
+
+@dataclasses.dataclass
+class OTReceiveConfig:
+    """OTReceive configuration
+
     Configuration can be provided from a file or command line arguments using
     the load method. Config files are YAML mappings with the option names
     below using hypens instead of underscores. By default, the paths
@@ -107,6 +128,11 @@ class OTReceiveConfig:
       to the absolute path of the OSTree repository and the environment
       variable OSTREE_RECEIVE_REFS set to the set of refs received separated
       by whitespace.
+    repos: Optional per-repository configuration settings. All of the above
+      settings except for root can be set and will override the global value.
+      The value is a map of repository path to map of settings. The repository
+      path can be relative or absolute. If root is specified, relative paths
+      are resolved below it.
     log_level: Set the log level. See the logging module for available levels.
     force: Force receiving commits even if nothing changed or the remote
       commits are not newer than the current commits.
@@ -124,6 +150,7 @@ class OTReceiveConfig:
     sign_trustedkeyfile: str = None
     update: bool = True
     update_hook: str = None
+    repos: dict = dataclasses.field(default_factory=dict)
     log_level: str = 'INFO'
     force: bool = False
     dry_run: bool = False
@@ -217,8 +244,70 @@ class OTReceiveConfig:
             config_home / 'ostree/ostree-receive.conf',
         ]
 
+    def get_repo_config(self, path, url):
+        """Get OTReceiveRepoConfig instance for repo path and URL"""
+        repo_path = Path(path)
+        repo_root = (
+            Path(self.root).resolve() if self.root else None
+        )
+
+        if repo_root:
+            if not repo_path.is_absolute():
+                # Join the relative path to the root.
+                repo_path = repo_root.joinpath(repo_path)
+
+            # Make sure the path is below the root.
+            repo_path = repo_path.resolve()
+            try:
+                repo_path.relative_to(repo_root)
+            except ValueError:
+                raise OTReceiveError(f'repo {path} not found') from None
+
+        # Ensure the repository exists.
+        if not repo_path.exists():
+            raise OTReceiveError(f'repo {path} not found')
+
+        # See if there's a matching path in repos.
+        for key, values in self.repos.items():
+            config_path = Path(key)
+            if repo_root and not config_path.is_absolute():
+                config_path = repo_root.joinpath(config_path)
+            try:
+                matches = repo_path.samefile(config_path)
+            except FileNotFoundError:
+                matches = False
+
+            if matches:
+                logger.debug(f'Applying repos {key} configuration')
+                per_repo_config = values
+                break
+        else:
+            per_repo_config = {}
+
+        # Copy all but path and url from the per-repo or the global
+        # receive config.
+        repo_config_fields = {
+            field.name for field in dataclasses.fields(OTReceiveRepoConfig)
+        }
+        receive_config_fields = {
+            field.name for field in dataclasses.fields(self)
+        }
+        common_fields = repo_config_fields & receive_config_fields
+        repo_config_args = {
+            field: per_repo_config.get(field, getattr(self, field))
+            for field in common_fields
+        }
+        repo_config_args['path'] = repo_path
+        repo_config_args['url'] = url
+
+        return OTReceiveRepoConfig(**repo_config_args)
+
 
 class OTReceiveRepo(OSTree.Repo):
+    """OSTree repository receiving pushed commits
+
+    An OTReceiveRepoConfig instance is required.
+    """
     # The fake remote name
     REMOTE_NAME = '_receive'
 
@@ -229,32 +318,18 @@ class OTReceiveRepo(OSTree.Repo):
         OSTree.REPO_METADATA_REF,
     )
 
-    def __init__(self, path, url, config=None):
-        self.path = Path(path)
-        self.url = url
+    def __init__(self, config):
+        self.config = config
         self.remotes_dir = None
 
-        if config:
-            if not isinstance(config, OTReceiveConfig):
-                raise OTReceiveError(
-                    'config is not an OTReceiveConfig instance'
-                )
-            self.config = config
-        else:
-            self.config = OTReceiveConfig()
-
-        if self.config.root:
-            repo_root = Path(self.config.root).resolve()
-            if not self.path.is_absolute():
-                # Join the relative path to the root.
-                self.path = repo_root.joinpath(self.path)
+        if not isinstance(self.config, OTReceiveRepoConfig):
+            raise OTReceiveError(
+                'config is not an OTReceiveRepoConfig instance'
+            )
 
-            # Make sure the path is below the root.
-            self.path = self.path.resolve()
-            try:
-                self.path.relative_to(repo_root)
-            except ValueError:
-                raise OTReceiveError(f'repo {path} not found') from None
+        # Ensure the repository exists.
+        if not self.path.exists():
+            raise OTReceiveError(f'repo {self.path} not found')
 
         logger.debug('Using repo path %s', self.path)
 
@@ -292,6 +367,14 @@ class OTReceiveRepo(OSTree.Repo):
                          remotes_config_dir=self.remotes_dir.name)
         self.open()
 
+    @property
+    def path(self):
+        return self.config.path
+
+    @property
+    def url(self):
+        return self.config.url
+
     def __enter__(self):
         return self
 
@@ -533,10 +616,18 @@ class OTReceiveRepo(OSTree.Repo):
                     safe_sign_opts.append(f'--sign=<key #{i} from {keyfile}>')
 
         if self._is_flatpak_repo():
-            cmd_prefix = ['flatpak', 'build-update-repo', str(self.path)]
+            cmd_prefix = [
+                'flatpak',
+                'build-update-repo',
+                str(self.path),
+            ]
         else:
-            cmd_prefix = ['ostree', f'--repo={self.path}', 'summary',
-                          '--update']
+            cmd_prefix = [
+                'ostree',
+                f'--repo={self.path}',
+                'summary',
+                '--update',
+            ]
         logger.info('Updating repo metadata with %s',
                     ' '.join(cmd_prefix + safe_sign_opts))
         subprocess.check_call(cmd_prefix + sign_opts)
@@ -562,7 +653,7 @@ class OTReceiveRepo(OSTree.Repo):
         logger.debug('OSTREE_RECEIVE_REFS=%s', env['OSTREE_RECEIVE_REFS'])
         subprocess.check_call(cmd, env=env)
 
-    def receive(self, refs):
+    def receive(self, refs, force=False, dry_run=False):
         # See what revisions we're pulling.
         _, remote_refs = self.remote_list_refs(self.REMOTE_NAME)
         if len(refs) == 0:
@@ -596,7 +687,7 @@ class OTReceiveRepo(OSTree.Repo):
                 raise OTReceiveError(
                     f'Could not find ref {ref} in summary file')
 
-            if self.config.force or remote_rev != current_rev:
+            if force or remote_rev != current_rev:
                 logger.debug('Pulling %s', ref)
                 refs_to_pull[ref] = remote_rev
 
@@ -635,16 +726,17 @@ class OTReceiveRepo(OSTree.Repo):
                     else:
                         if remote_timestamp <= current_timestamp:
                             logger.warning(
-                                'ref %s remote rev %s is not newer than '
-                                'current rev %s',
-                                ref, remote_rev, current_rev
+                                'received %s commit %s is not newer than '
+                                'current %s commit %s',
+                                ref, remote_rev, ref, current_rev
                             )
                         if current_root.equal(remote_root):
                             logger.warning(
-                                'ref %s remote commit %s root equals %s',
-                                ref, remote_rev, current_rev
+                                'received %s commit %s has the same content '
+                                'as current %s commit %s',
+                                ref, remote_rev, ref, current_rev
                             )
-                        if self.config.force:
+                        if force:
                             logger.info('Forcing merge of ref %s', ref)
                             refs_to_merge[ref] = remote_rev
 
@@ -654,7 +746,7 @@ class OTReceiveRepo(OSTree.Repo):
                 return set()
 
             # For a dry run, exit now before creating the refs
-            if self.config.dry_run:
+            if dry_run:
                 self.abort_transaction()
                 return refs_to_merge.keys()
 
@@ -680,6 +772,30 @@ class OTReceiveRepo(OSTree.Repo):
         return refs_to_merge.keys()
 
 
+class OTReceiver:
+    """Pushed commit receiver
+
+    An OTReceiveConfig instance can be provided to configure the receiver.
+    """
+    def __init__(self, config=None):
+        self.config = config or OTReceiveConfig()
+
+        if not isinstance(self.config, OTReceiveConfig):
+            raise OTReceiveError(
+                'config is not an OTReceiveConfig instance'
+            )
+
+    def receive(self, path, url, refs):
+        """Receive pushed commits
+
+        Creates an OTReceiveRepo at path and receives commits on refs
+        from url.
+        """
+        repo_config = self.config.get_repo_config(path, url)
+        with OTReceiveRepo(repo_config) as repo:
+            return repo.receive(refs, self.config.force, self.config.dry_run)
+
+
 class OTReceiveArgParser(ArgumentParser):
     """ArgumentParse for ostree-receive"""
     def __init__(self):
@@ -725,8 +841,8 @@ def main():
 
     logging.basicConfig(level=config.log_level)
 
-    with OTReceiveRepo(args.repo, args.url, config) as repo:
-        repo.receive(args.refs)
+    receiver = OTReceiver(config)
+    receiver.receive(args.repo, args.url, args.refs)
 
 
 if __name__ == '__main__':
diff --git a/setup.cfg b/setup.cfg
index dcfc94d..7f572fc 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -8,33 +8,28 @@ long_description = file: README.md
 long_description_content_type = text/markdown
 url = https://github.com/dbnicholson/ostree-push
 license_file = COPYING
-classifiers = 
-	Programming Language :: Python :: 3
-	Programming Language :: Python :: 3.7
-	Programming Language :: Python :: 3.8
-	Programming Language :: Python :: 3.9
-	Development Status :: 5 - Production/Stable
-	License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)
-	Operating System :: POSIX
-	Topic :: Software Development :: Build Tools
-	Topic :: System :: Archiving :: Mirroring
-	Topic :: System :: Archiving :: Packaging
-	Topic :: System :: Software Distribution
+classifiers =
+  Programming Language :: Python :: 3
+  Programming Language :: Python :: 3.7
+  Programming Language :: Python :: 3.8
+  Programming Language :: Python :: 3.9
+  Development Status :: 5 - Production/Stable
+  License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)
+  Operating System :: POSIX
+  Topic :: Software Development :: Build Tools
+  Topic :: System :: Archiving :: Mirroring
+  Topic :: System :: Archiving :: Packaging
+  Topic :: System :: Software Distribution
 
 [options]
 packages = otpush
 scripts = scripts/ostree-receive-shell
-install_requires = 
-	PyGObject
-	PyYAML
+install_requires =
+  PyGObject
+  PyYAML
 python_requires = >=3.7
 
 [options.entry_points]
-console_scripts = 
-	ostree-push = otpush.push:main
-	ostree-receive = otpush.receive:main
-
-[egg_info]
-tag_build = 
-tag_date = 0
-
+console_scripts =
+  ostree-push = otpush.push:main
+  ostree-receive = otpush.receive:main
diff --git a/tests/conftest.py b/tests/conftest.py
index 1550f8b..e3dd607 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -120,10 +120,19 @@ def dest_repo(tmp_path):
 
 
 @pytest.fixture
-def receive_repo(dest_repo, source_server):
-    repo_path = str(dest_repo.path)
+def receiver():
     config = receive.OTReceiveConfig(update=False)
-    with receive.OTReceiveRepo(repo_path, source_server.url, config) as repo:
+    return receive.OTReceiver(config)
+
+
+@pytest.fixture
+def receive_repo(dest_repo, source_server):
+    config = receive.OTReceiveRepoConfig(
+        dest_repo.path,
+        source_server.url,
+        update=False,
+    )
+    with receive.OTReceiveRepo(config) as repo:
         yield repo
 
 
diff --git a/tests/test_receive.py b/tests/test_receive.py
index 9c4ded8..9ded4bc 100644
--- a/tests/test_receive.py
+++ b/tests/test_receive.py
@@ -29,7 +29,6 @@ from .util import (
     oneshot_transaction,
     random_commit,
     wipe_repo,
-    TmpRepo,
 )
 
 gi.require_version('OSTree', '1.0')
@@ -41,17 +40,26 @@ logger = logging.getLogger(__name__)
 class TestReceiveRepo:
     def test_cleanup(self, dest_repo):
         url = 'http://example.com'
-        repo = receive.OTReceiveRepo(dest_repo.path, url)
+        config = receive.OTReceiveRepoConfig(dest_repo.path, url)
+        repo = receive.OTReceiveRepo(config)
         remotes_dir = Path(repo.remotes_dir.name)
         assert remotes_dir.exists()
         del repo
         assert not remotes_dir.exists()
 
-        with receive.OTReceiveRepo(dest_repo.path, url) as repo:
+        with receive.OTReceiveRepo(config) as repo:
             remotes_dir = Path(repo.remotes_dir.name)
             assert remotes_dir.exists()
         assert not remotes_dir.exists()
 
+    def test_missing_repo(self, tmp_path):
+        repo_path = tmp_path / 'repo'
+        url = 'http://example.com'
+        config = receive.OTReceiveRepoConfig(repo_path, url)
+        with pytest.raises(receive.OTReceiveError) as excinfo:
+            receive.OTReceiveRepo(config)
+        assert str(excinfo.value) == f'repo {repo_path} not found'
+
     def test_get_commit_timestamp(self, tmp_files_path, receive_repo):
         with pytest.raises(GLib.Error) as excinfo:
             receive_repo._get_commit_timestamp('missing')
@@ -307,22 +315,29 @@ class TestReceiveRepo:
                                 monkeypatch):
         # Specifying a missing GPG keyring should fail
         keyring_path = str(tmp_path / 'missing.gpg')
-        config = receive.OTReceiveConfig(gpg_verify=True,
-                                         gpg_trustedkeys=keyring_path,
-                                         update=False)
-        repo_path = str(dest_repo.path)
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
+            gpg_verify=True,
+            gpg_trustedkeys=keyring_path,
+            update=False,
+        )
         with pytest.raises(receive.OTReceiveConfigError) as excinfo:
-            receive.OTReceiveRepo(repo_path, source_server.url, config)
+            receive.OTReceiveRepo(config)
         assert str(excinfo.value) == (
             f'gpg_trustedkeys keyring "{keyring_path}" does not exist'
         )
 
         # Receiving an unsigned commit should fail.
         random_commit(source_repo, tmp_files_path, 'ref1')
-        config = receive.OTReceiveConfig(gpg_verify=True,
-                                         gpg_trustedkeys=str(PGP_PUB_KEYRING),
-                                         update=False)
-        repo = receive.OTReceiveRepo(repo_path, source_server.url, config)
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
+            gpg_verify=True,
+            gpg_trustedkeys=str(PGP_PUB_KEYRING),
+            update=False,
+        )
+        repo = receive.OTReceiveRepo(config)
         with pytest.raises(GLib.Error) as excinfo:
             repo.receive(['ref1'])
         assert excinfo.value.matches(OSTree.gpg_error_quark(),
@@ -331,10 +346,14 @@ class TestReceiveRepo:
         # Receiving a signed commit should succeed.
         random_commit(source_repo, tmp_files_path, 'ref1',
                       gpg_key_id=PGP_KEY_ID, gpg_homedir=str(gpg_homedir))
-        config = receive.OTReceiveConfig(gpg_verify=True,
-                                         gpg_trustedkeys=str(PGP_PUB_KEYRING),
-                                         update=False)
-        repo = receive.OTReceiveRepo(repo_path, source_server.url, config)
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
+            gpg_verify=True,
+            gpg_trustedkeys=str(PGP_PUB_KEYRING),
+            update=False,
+        )
+        repo = receive.OTReceiveRepo(config)
         wipe_repo(repo)
         merged = repo.receive(['ref1'])
         assert merged == {'ref1'}
@@ -345,10 +364,14 @@ class TestReceiveRepo:
         # also work.
         random_commit(source_repo, tmp_files_path, 'ref1',
                       gpg_key_id=PGP_KEY_ID, gpg_homedir=str(gpg_homedir))
-        config = receive.OTReceiveConfig(gpg_verify=True,
-                                         gpg_trustedkeys=str(PGP_PUB),
-                                         update=False)
-        repo = receive.OTReceiveRepo(repo_path, source_server.url, config)
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
+            gpg_verify=True,
+            gpg_trustedkeys=str(PGP_PUB),
+            update=False,
+        )
+        repo = receive.OTReceiveRepo(config)
         wipe_repo(repo)
         merged = repo.receive(['ref1'])
         assert merged == {'ref1'}
@@ -362,8 +385,13 @@ class TestReceiveRepo:
         keyring = tmp_path / 'ostree/ostree-receive-trustedkeys.gpg'
         keyring.parent.mkdir(exist_ok=True)
         keyring.symlink_to(PGP_PUB_KEYRING)
-        config = receive.OTReceiveConfig(gpg_verify=True, update=False)
-        repo = receive.OTReceiveRepo(repo_path, source_server.url, config)
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
+            gpg_verify=True,
+            update=False,
+        )
+        repo = receive.OTReceiveRepo(config)
         wipe_repo(repo)
         merged = repo.receive(['ref1'])
         assert merged == {'ref1'}
@@ -403,23 +431,29 @@ class TestReceiveRepo:
                                     ed25519_public_keyfile, monkeypatch):
         # Specifying a missing keyfile should fail.
         keyfile_path = str(tmp_path / 'missing')
-        config = receive.OTReceiveConfig(sign_verify=True,
-                                         sign_trustedkeyfile=keyfile_path,
-                                         update=False)
-        repo_path = str(dest_repo.path)
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
+            sign_verify=True,
+            sign_trustedkeyfile=keyfile_path,
+            update=False,
+        )
         with pytest.raises(receive.OTReceiveConfigError,
                            match='sign_trustedkeyfile keyfile'
                                  + f' "{keyfile_path}" does not'
                                  + ' exist') as excinfo:
-            receive.OTReceiveRepo(repo_path, source_server.url, config)
+            receive.OTReceiveRepo(config)
 
         # Receiving an unsigned commit should fail.
         random_commit(source_repo, tmp_files_path, 'ref1')
-        config = receive.OTReceiveConfig(
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
             sign_verify=True,
             sign_trustedkeyfile=ed25519_public_keyfile,
-            update=False)
-        repo = receive.OTReceiveRepo(repo_path, source_server.url, config)
+            update=False,
+        )
+        repo = receive.OTReceiveRepo(config)
         with pytest.raises(GLib.Error, match="Can't verify commit") as excinfo:
             repo.receive(['ref1'])
         assert excinfo.value.matches(Gio.io_error_quark(),
@@ -428,11 +462,14 @@ class TestReceiveRepo:
         # Receiving a signed commit should succeed.
         random_commit(source_repo, tmp_files_path, 'ref1',
                       ed25519_key=ED25519_PRIVATE_KEY)
-        config = receive.OTReceiveConfig(
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
             sign_verify=True,
             sign_trustedkeyfile=ed25519_public_keyfile,
-            update=False)
-        repo = receive.OTReceiveRepo(repo_path, source_server.url, config)
+            update=False,
+        )
+        repo = receive.OTReceiveRepo(config)
         wipe_repo(repo)
         merged = repo.receive(['ref1'])
         assert merged == {'ref1'}
@@ -446,8 +483,13 @@ class TestReceiveRepo:
         keyring = tmp_path / 'ostree/ostree-receive-trustedkeyfile.ed25519'
         keyring.parent.mkdir(exist_ok=True)
         keyring.symlink_to(ed25519_public_keyfile)
-        config = receive.OTReceiveConfig(sign_verify=True, update=False)
-        repo = receive.OTReceiveRepo(repo_path, source_server.url, config)
+        config = receive.OTReceiveRepoConfig(
+            dest_repo.path,
+            source_server.url,
+            sign_verify=True,
+            update=False,
+        )
+        repo = receive.OTReceiveRepo(config)
         wipe_repo(repo)
         merged = repo.receive(['ref1'])
         assert merged == {'ref1'}
@@ -634,36 +676,198 @@ class TestReceiveRepo:
         refs = local_refs(receive_repo)
         assert refs.keys() == {'ref1', 'ref2'}
 
-    def test_root(self, tmp_path, tmp_files_path, source_server):
-        url = source_server.url
-        root = tmp_path / 'pub/repos'
-        root.mkdir(parents=True)
-        config = receive.OTReceiveConfig(root=str(root), update=False)
-        root_tmp_repo = TmpRepo(root / 'root-dest')
-        non_root_tmp_repo = TmpRepo(tmp_path / 'non-root-dest')
-
-        # Requesting a repo outside the root should fail
-        repo_path = non_root_tmp_repo.path
-        logger.debug('Repo path %s', repo_path)
-        with pytest.raises(receive.OTReceiveError) as excinfo:
-            receive.OTReceiveRepo(str(repo_path), url, config)
-        assert str(excinfo.value) == (
-            f'repo {non_root_tmp_repo.path} not found'
+    def test_receive_dry_run(self, tmp_files_path, receive_repo, source_repo,
+                             source_server):
+        random_commit(source_repo, tmp_files_path, 'ref1')
+        merged = receive_repo.receive(['ref1'], dry_run=True)
+        assert merged == {'ref1'}
+        refs = local_refs(receive_repo)
+        assert refs.keys() == set()
+
+    def test_receive_force(self, tmp_files_path, receive_repo, source_repo,
+                           source_server, caplog):
+        caplog.set_level(logging.WARNING, receive.logger.name)
+
+        # First make a commit and pull it directly so the destination
+        # has the exact same commit.
+        checksum = random_commit(
+            source_repo,
+            tmp_files_path,
+            'ref1',
+            timestamp=0,
+        )
+        opts = GLib.Variant('a{sv}', {
+            'refs': GLib.Variant('as', ['ref1']),
+        })
+        receive_repo.pull_with_options(source_repo.path.as_uri(), opts)
+        refs = local_refs(receive_repo)
+        assert refs == {'ref1': checksum}
+
+        # Non-forced receive will get nothing. There should be no
+        # warnings since the commits are exactly the same.
+        caplog.clear()
+        merged = receive_repo.receive(['ref1'])
+        assert merged == set()
+        refs = local_refs(receive_repo)
+        assert refs == {'ref1': checksum}
+        assert caplog.record_tuples == []
+
+        # Forced merge will make a new commit. This will have warnings
+        # about both timestamp and content.
+        caplog.clear()
+        merged = receive_repo.receive(['ref1'], force=True)
+        assert merged == {'ref1'}
+        refs = local_refs(receive_repo)
+        assert refs.keys() == {'ref1'}
+        assert refs['ref1'] != checksum
+        assert caplog.record_tuples == [
+            (
+                receive.logger.name, logging.WARNING,
+                f'received ref1 commit {checksum} is not newer than '
+                f'current ref1 commit {checksum}'
+            ),
+            (
+                receive.logger.name, logging.WARNING,
+                f'received ref1 commit {checksum} has the same content as '
+                f'current ref1 commit {checksum}'
+            ),
+        ]
+
+        # Make a new commit with the same content and set the
+        # destination repo back to the original commit.
+        with oneshot_transaction(source_repo):
+            mtree = OSTree.MutableTree.new()
+            _, root, _ = source_repo.read_commit(checksum)
+            _, commit, _ = source_repo.load_commit(checksum)
+            source_repo.write_directory_to_mtree(root, mtree, None)
+            _, new_root = source_repo.write_mtree(mtree)
+            metadata = commit.get_child_value(0)
+            _, new_checksum = source_repo.write_commit_with_time(
+                checksum,
+                'Test commit',
+                None,
+                metadata,
+                new_root,
+                1,
+            )
+            source_repo.transaction_set_ref(None, 'ref1', new_checksum)
+        receive_repo.set_ref_immediate(None, 'ref1', checksum)
+
+        # Non-forced receive will get nothing but there will be a
+        # warning about the content.
+        caplog.clear()
+        merged = receive_repo.receive(['ref1'])
+        assert merged == set()
+        refs = local_refs(receive_repo)
+        assert refs == {'ref1': checksum}
+        assert caplog.record_tuples == [
+            (
+                receive.logger.name, logging.WARNING,
+                f'received ref1 commit {new_checksum} has the same content '
+                f'as current ref1 commit {checksum}'
+            ),
+        ]
+
+        # Forced merge will make a new commit.
+        caplog.clear()
+        merged = receive_repo.receive(['ref1'], force=True)
+        assert merged == {'ref1'}
+        refs = local_refs(receive_repo)
+        assert refs.keys() == {'ref1'}
+        assert refs['ref1'] != checksum
+
+        # Make a random commit in the destination so it's newer and has
+        # different content.
+        dest_checksum = random_commit(
+            receive_repo,
+            tmp_files_path,
+            'ref1',
+            timestamp=2,
         )
 
-        # Absolute path under the root should work
-        repo_path = root_tmp_repo.path.resolve()
-        assert repo_path.is_absolute()
-        logger.debug('Repo path %s', repo_path)
-        with receive.OTReceiveRepo(str(repo_path), url, config):
-            pass
+        # Non-forced receive will get nothing but there will be a
+        # warning about the timestamp.
+        caplog.clear()
+        merged = receive_repo.receive(['ref1'])
+        assert merged == set()
+        refs = local_refs(receive_repo)
+        assert refs == {'ref1': dest_checksum}
+        assert caplog.record_tuples == [
+            (
+                receive.logger.name, logging.WARNING,
+                f'received ref1 commit {new_checksum} is not newer than '
+                f'current ref1 commit {dest_checksum}'
+            ),
+        ]
+
+        # Forced merge will make a new commit.
+        caplog.clear()
+        merged = receive_repo.receive(['ref1'], force=True)
+        assert merged == {'ref1'}
+        refs = local_refs(receive_repo)
+        assert refs.keys() == {'ref1'}
+        assert refs['ref1'] != dest_checksum
 
-        # Relative path under the root should work
-        repo_path = root_tmp_repo.path.relative_to(root)
-        assert not repo_path.is_absolute()
-        logger.debug('Repo path %s', repo_path)
-        with receive.OTReceiveRepo(str(repo_path), url, config):
-            pass
+
+class TestReceiver:
+    """Tests for OTReceiver class"""
+    def test_default_config(self):
+        receiver = receive.OTReceiver()
+        assert receiver.config == receive.OTReceiveConfig()
+
+    def test_receive(self, receiver, tmp_files_path, source_repo, dest_repo,
+                     source_server):
+        random_commit(source_repo, tmp_files_path, 'ref1')
+        source_refs = local_refs(source_repo)
+        assert source_refs.keys() == {'ref1'}
+
+        merged = receiver.receive(dest_repo.path, source_server.url, ['ref1'])
+        assert merged == {'ref1'}
+        dest_refs = local_refs(dest_repo)
+        assert dest_refs.keys() == {'ref1'}
+
+        merged = receiver.receive(dest_repo.path, source_server.url, ['ref1'])
+        assert merged == set()
+        dest_refs = local_refs(dest_repo)
+        assert dest_refs.keys() == {'ref1'}
+
+        # Test that repos override is applied.
+        summary_path = dest_repo.path / 'summary'
+        assert not summary_path.exists()
+        assert not receiver.config.update
+        receiver.config.repos = {str(dest_repo.path): {'update': True}}
+        random_commit(source_repo, tmp_files_path, 'ref2')
+        merged = receiver.receive(dest_repo.path, source_server.url, ['ref2'])
+        assert merged == {'ref2'}
+        assert summary_path.exists()
+
+
+class TestRepoConfig:
+    """Tests for OTReceiveRepoConfig"""
+    def test_defaults(self):
+        config = receive.OTReceiveRepoConfig(Path('foo'), 'http://bar')
+        assert dataclasses.asdict(config) == {
+            'path': Path('foo'),
+            'url': 'http://bar',
+            'gpg_sign': [],
+            'gpg_homedir': None,
+            'gpg_verify': False,
+            'gpg_trustedkeys': None,
+            'sign_type': 'ed25519',
+            'sign_keyfiles': [],
+            'sign_verify': False,
+            'sign_trustedkeyfile': None,
+            'update': True,
+            'update_hook': None,
+        }
+
+    def test_required(self):
+        with pytest.raises(TypeError):
+            receive.OTReceiveRepoConfig()
+        with pytest.raises(TypeError):
+            receive.OTReceiveRepoConfig(path=Path('foo'))
+        with pytest.raises(TypeError):
+            receive.OTReceiveRepoConfig(url='http://bar')
 
 
 class TestConfig:
@@ -682,6 +886,7 @@ class TestConfig:
             'sign_trustedkeyfile': None,
             'update': True,
             'update_hook': None,
+            'repos': {},
             'log_level': 'INFO',
             'force': False,
             'dry_run': False,
@@ -734,6 +939,14 @@ class TestConfig:
             'sign_trustedkeyfile': str(tmp_path / 'trustedkey'),
             'update': False,
             'update_hook': '/foo/bar baz',
+            'repos': {
+                'foo': {
+                    'gpg_sign': ['76543210'],
+                },
+                'bar': {
+                    'gpg_verify': False,
+                },
+            },
             'log_level': 'DEBUG',
             'force': True,
             'dry_run': True,
@@ -876,6 +1089,7 @@ class TestConfig:
             'sign_trustedkeyfile': None,
             'update': True,
             'update_hook': None,
+            'repos': {},
             'log_level': 'WARNING',
             'force': False,
             'dry_run': False,
@@ -917,6 +1131,117 @@ class TestConfig:
         config = receive.OTReceiveConfig.load(paths=[path], args=args)
         assert config.log_level == 'WARNING'
 
+    def test_repo_config(self, tmp_path, monkeypatch):
+        monkeypatch.chdir(tmp_path)
+        config = receive.OTReceiveConfig()
+        url = 'http://example.com'
+        rel_root = Path('root')
+        root = rel_root.resolve()
+        root.mkdir()
+        root_repo = root / 'repo'
+        rel_root_repo = root_repo.relative_to(root)
+        root_repo.mkdir()
+        rel_nonroot_repo = Path('repo')
+        nonroot_repo = rel_nonroot_repo.resolve()
+        nonroot_repo.mkdir()
+
+        # Non-existent repo should raise an exception.
+        repo_path = tmp_path / 'nonexistent'
+        with pytest.raises(receive.OTReceiveError) as excinfo:
+            config.get_repo_config(repo_path, url)
+        assert str(excinfo.value) == f'repo {repo_path} not found'
+
+        # Without root setup, the path should be passed back as is.
+        repo_config = config.get_repo_config(str(rel_nonroot_repo), url)
+        assert repo_config.path == rel_nonroot_repo
+        repo_config = config.get_repo_config(rel_nonroot_repo, url)
+        assert repo_config.path == rel_nonroot_repo
+        repo_config = config.get_repo_config(str(nonroot_repo), url)
+        assert repo_config.path == nonroot_repo
+        repo_config = config.get_repo_config(nonroot_repo, url)
+        assert repo_config.path == nonroot_repo
+
+        # Requesting a repo outside the root should fail.
+        config.root = str(root)
+        with pytest.raises(receive.OTReceiveError) as excinfo:
+            config.get_repo_config(nonroot_repo, url)
+        assert str(excinfo.value) == f'repo {nonroot_repo} not found'
+
+        # All combinations of root, repo path, and config override path.
+        base_expected_config = {
+            'path': nonroot_repo,
+            'url': url,
+            'gpg_sign': config.gpg_sign,
+            'gpg_homedir': config.gpg_homedir,
+            'gpg_verify': config.gpg_verify,
+            'gpg_trustedkeys': config.gpg_trustedkeys,
+            'sign_type': config.sign_type,
+            'sign_keyfiles': config.sign_keyfiles,
+            'sign_verify': config.sign_verify,
+            'sign_trustedkeyfile': config.sign_trustedkeyfile,
+            'update': config.update,
+            'update_hook': config.update_hook,
+        }
+        for root_path, repo_path, override_path, expected_repo_path in (
+            # Absolute repo path with no root and no override.
+            (None, nonroot_repo, None, nonroot_repo),
+            # Relative repo path with no root and no override.
+            (None, rel_nonroot_repo, None, rel_nonroot_repo),
+            # Absolute repo path with absolute root and no override.
+            (root, root_repo, None, root_repo),
+            # Relative repo path with absolute root and no override.
+            (root, rel_root_repo, None, root_repo),
+            # Absolute repo path with relative root and no override.
+            (rel_root, root_repo, None, root_repo),
+            # Relative repo path with relative root and no override.
+            (rel_root, rel_root_repo, None, root_repo),
+
+            # Absolute repo path with no root and absolute override.
+            (None, nonroot_repo, nonroot_repo, nonroot_repo),
+            # Relative repo path with no root and absolute override.
+            (None, rel_nonroot_repo, nonroot_repo, rel_nonroot_repo),
+            # Absolute repo path with absolute root and absolute override.
+            (root, root_repo, root_repo, root_repo),
+            # Relative repo path with absolute root and absolute override.
+            (root, rel_root_repo, root_repo, root_repo),
+            # Absolute repo path with relative root and absolute override.
+            (rel_root, root_repo, root_repo, root_repo),
+            # Relative repo path with relative root and absolute override.
+            (rel_root, rel_root_repo, root_repo, root_repo),
+
+            # Absolute repo path with no root and relative override.
+            (None, nonroot_repo, rel_nonroot_repo, nonroot_repo),
+            # Relative repo path with no root and relative override.
+            (None, rel_nonroot_repo, rel_nonroot_repo, rel_nonroot_repo),
+            # Absolute repo path with absolute root and relative override.
+            (root, root_repo, rel_root_repo, root_repo),
+            # Relative repo path with absolute root and relative override.
+            (root, rel_root_repo, rel_root_repo, root_repo),
+            # Absolute repo path with relative root and relative override.
+            (rel_root, root_repo, rel_root_repo, root_repo),
+            # Relative repo path with relative root and relative override.
+            (rel_root, rel_root_repo, rel_root_repo, root_repo),
+        ):
+            logger.debug(
+                f'Testing {root_path=}, {repo_path=}, {override_path=}, '
+                f'{expected_repo_path=}',
+            )
+
+            expected_config = base_expected_config.copy()
+            expected_config['path'] = expected_repo_path
+            config.root = str(root_path) if root_path else None
+            if override_path:
+                config.repos = {str(override_path): {'update': False}}
+                expected_config['update'] = False
+            else:
+                config.repos = {}
+                expected_config['update'] = True
+
+            repo_config = config.get_repo_config(repo_path, url)
+            assert dataclasses.asdict(repo_config) == expected_config
+            if override_path:
+                assert repo_config.update != config.update
+
 
 class TestArgParser:
     def test_no_repo(self, capsys):

More details

Full run details

Historical runs