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
18 changes: 15 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,12 @@ the aggregated directory.
- echo 'a first command'
- echo 'a second command'

A real life example: applying a patch
Patches
-------

Patches can be applied with ``git am`` after the merges (and before the
``shell_command_after`` commands). Each entry is a local patch file, a
directory of patch files (applied in sorted order) or a URL.

.. code-block:: yaml

Expand All @@ -203,8 +208,13 @@ A real life example: applying a patch
merges:
- oca 9.0
target: acsone aggregated_branch_name
shell_command_after:
- git am "$(git format-patch -1 XXXXXX -o ../patches)"
patches:
- ../patches/0001-fix.patch
- ../patches/odoo
- https://github.com/OCA/OCB/pull/1234.patch

Relative paths are resolved from the directory where ``gitaggregate`` is run.
A missing, unreachable or empty patch stops the aggregation with an error.

Command line Usage
==================
Expand Down Expand Up @@ -413,11 +423,13 @@ Contributors
* Simone Orsi (camptocamp_)
* Artem Kostyuk
* Jan Verbeek
* Michael Tietz (MT_Software_)

.. _ACSONE: https://www.acsone.eu
.. _Tecnativa: https://www.tecnativa.com
.. _camptocamp: https://www.camptocamp.com
.. _LasLabs: https://laslabs.com
.. _MT_Software: https://github.com/mt-software-de

Maintainer
----------
Expand Down
31 changes: 31 additions & 0 deletions git_aggregator/command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# © 2015 ACSONE SA/NV
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPLv3 (http://www.gnu.org/licenses/agpl-3.0-standalone.html)
# Parts of the code comes from ANYBOX
# https://github.com/anybox/anybox.recipe.odoo
import logging
import subprocess

from ._compat import console_to_str

logger = logging.getLogger(__name__)


class CommandExecutor:
def __init__(self, cwd):
self.cwd = cwd

def log_call(self, cmd, callwith=subprocess.check_call,
log_level=logging.DEBUG, **kw):
"""Wrap a subprocess call with logging
:param meth: the calling method to use.
"""
logger.log(log_level, "%s> call %r", self.cwd, cmd)
try:
ret = callwith(cmd, **kw)
except Exception:
logger.error("%s> error calling %r", self.cwd, cmd)
raise
if callwith == subprocess.check_output:
ret = console_to_str(ret)
return ret
13 changes: 13 additions & 0 deletions git_aggregator/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# © 2015 ACSONE SA/NV
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPLv3 (http://www.gnu.org/licenses/agpl-3.0-standalone.html)

import logging
Expand All @@ -9,10 +10,21 @@

from ._compat import string_types
from .exception import ConfigException
from .patch import Patches

log = logging.getLogger(__name__)


def update_patches(repo_dict, repo_data):
"""Check and update repo_dict with patch files"""
patches_data = repo_data.get("patches")
patches = repo_dict.setdefault("patches", Patches())
if not patches_data:
return
for patch in patches_data:
patches += Patches.prepare_patches(patch, repo_dict.get("cwd"))


def get_repos(config, force=False):
"""Return a :py:obj:`list` list of repos from config file.
:param config: the repos config in :py:class:`dict` format.
Expand Down Expand Up @@ -126,6 +138,7 @@ def get_repos(config, force=False):
cmds = [cmds]
commands = cmds
repo_dict['shell_command_after'] = commands
update_patches(repo_dict, repo_data)
repo_list.append(repo_dict)
return repo_list

Expand Down
75 changes: 75 additions & 0 deletions git_aggregator/patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPLv3 (http://www.gnu.org/licenses/agpl-3.0-standalone.html)
import logging
import subprocess
from pathlib import Path

from .command import CommandExecutor
from .exception import ConfigException, GitAggregatorException

logger = logging.getLogger(__name__)


class Patch(CommandExecutor):
is_local = False

def __init__(self, path, cwd):
super().__init__(cwd)
self.path = path
if "://" not in path:
if not Path(path).is_file():
raise ConfigException(f"Patch file not found: {path}")
self.is_local = True

def retrive_data(self):
if self.is_local:
data = Path(self.path).read_bytes()
else:
cmd = [
"curl",
"--fail",
"--location",
self.path,
]
if logger.getEffectiveLevel() != logging.DEBUG:
cmd.append('--silent')
cmd.append('--show-error')
data = self.log_call(
cmd,
callwith=subprocess.run,
check=True,
stdout=subprocess.PIPE,
).stdout
if not data.strip():
raise GitAggregatorException(f"Patch is empty: {self.path}")
return data

def apply(self):
data = self.retrive_data()
cmd = [
"git",
"am",
]
if logger.getEffectiveLevel() != logging.DEBUG:
cmd.append('--quiet')
self.log_call(cmd, callwith=subprocess.run, check=True,
cwd=self.cwd, input=data)


class Patches(list):
"""List of patches"""
@staticmethod
def prepare_patches(path, cwd):
_path = Path(path)
patches = Patches()
if not _path.exists() or _path.is_file():
patches.append(Patch(path, cwd))
elif _path.is_dir():
for fpath in sorted(_path.iterdir()):
if fpath.is_file():
patches.append(Patch(str(fpath), cwd))
return patches

def apply(self):
for patch in self:
patch.apply()
26 changes: 8 additions & 18 deletions git_aggregator/repo.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# © 2015 ACSONE SA/NV
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPLv3 (http://www.gnu.org/licenses/agpl-3.0-standalone.html)
# Parts of the code comes from ANYBOX
# https://github.com/anybox/anybox.recipe.odoo
Expand All @@ -10,7 +11,9 @@
import requests

from ._compat import console_to_str
from .command import CommandExecutor
from .exception import DirtyException, GitAggregatorException
from .patch import Patches

FETCH_DEFAULTS = ("depth", "shallow-since", "shallow-exclude")
logger = logging.getLogger(__name__)
Expand All @@ -30,13 +33,13 @@ def ishex(s):
return True


class Repo:
class Repo(CommandExecutor):

_git_version = None

def __init__(self, cwd, remotes, merges, target,
shell_command_after=None, fetch_all=False, defaults=None,
force=False):
force=False, patches=None):
"""Initialize a git repository aggregator

:param cwd: path to the directory where to initialize the repository
Expand All @@ -56,7 +59,7 @@ def __init__(self, cwd, remotes, merges, target,
:param bool force:
When ``False``, it will stop if repo is dirty.
"""
self.cwd = cwd
super().__init__(cwd)
self.remotes = remotes
if fetch_all is True:
self.fetch_all = frozenset(r["name"] for r in remotes)
Expand All @@ -67,6 +70,7 @@ def __init__(self, cwd, remotes, merges, target,
self.shell_command_after = shell_command_after or []
self.defaults = defaults or dict()
self.force = force
self.patches = patches or Patches()

@property
def git_version(self):
Expand Down Expand Up @@ -148,21 +152,6 @@ def query_remote_ref(self, remote, ref):
return 'HEAD', sha
return None, ref

def log_call(self, cmd, callwith=subprocess.check_call,
log_level=logging.DEBUG, **kw):
"""Wrap a subprocess call with logging
:param meth: the calling method to use.
"""
logger.log(log_level, "%s> call %r", self.cwd, cmd)
try:
ret = callwith(cmd, **kw)
except Exception:
logger.error("%s> error calling %r", self.cwd, cmd)
raise
if callwith == subprocess.check_output:
ret = console_to_str(ret)
return ret

def aggregate(self):
""" Aggregate all merges into the target branch
If the target_dir doesn't exist, create an empty git repo otherwise
Expand All @@ -187,6 +176,7 @@ def aggregate(self):
self._reset_to(origin["remote"], origin["ref"])
for merge in merges:
self._merge(merge)
self.patches.apply()
self._execute_shell_command_after()
logger.info('End aggregation of %s', self.cwd)

Expand Down
38 changes: 38 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# © 2015 ACSONE SA/NV
# Copyright 2025 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPLv3 (http://www.gnu.org/licenses/agpl-3.0-standalone.html)
import os
import tempfile
Expand All @@ -10,6 +11,7 @@
from git_aggregator import config
from git_aggregator._compat import PY2
from git_aggregator.exception import ConfigException
from git_aggregator.patch import Patches


class TestConfig(unittest.TestCase):
Expand Down Expand Up @@ -44,6 +46,7 @@ def test_load(self):
'merges': [{'ref': '8.0', 'remote': 'oca'},
{'ref': 'refs/pull/105/head', 'remote': 'oca'},
{'ref': 'refs/pull/106/head', 'remote': 'oca'}],
'patches': [],
'remotes': [],
'shell_command_after': [],
'target': {'branch': 'aggregated_branch_name',
Expand All @@ -57,6 +60,40 @@ def test_load(self):
'url':
'git+ssh://git@github.com/acsone/product-attribute.git'}])

def test_load_patches(self):
with tempfile.TemporaryDirectory() as tmpdir:
patch_file = os.path.join(tmpdir, 'single.patch')
patch_dir = os.path.join(tmpdir, 'patches')
os.mkdir(patch_dir)
os.mkdir(os.path.join(patch_dir, 'subdir'))
for path in (patch_file,
os.path.join(patch_dir, '0002-b.patch'),
os.path.join(patch_dir, '0001-a.patch')):
with open(path, 'w') as f:
f.write('')
url = 'https://github.com/OCA/web/pull/1.patch'
config_yaml = dedent(f"""
/web:
remotes:
oca: https://github.com/OCA/web.git
merges:
- oca 8.0
target: oca aggregated_branch_name
patches:
- {patch_file}
- {patch_dir}
- {url}
""")
repos = config.get_repos(self._parse_config(config_yaml))
patches = repos[0]['patches']
self.assertIsInstance(patches, Patches)
self.assertEqual(
[(p.path, p.is_local, p.cwd) for p in patches],
[(patch_file, True, '/web'),
(os.path.join(patch_dir, '0001-a.patch'), True, '/web'),
(os.path.join(patch_dir, '0002-b.patch'), True, '/web'),
(url, False, '/web')])

def test_load_defaults(self):
config_yaml = dedent("""
/web:
Expand Down Expand Up @@ -91,6 +128,7 @@ def test_load_defaults(self):
'merges': [{'ref': '8.0', 'remote': 'oca', 'depth': 1000},
{'ref': 'refs/pull/105/head', 'remote': 'oca'},
{'ref': 'refs/pull/106/head', 'remote': 'oca'}],
'patches': [],
'remotes': [],
'shell_command_after': [],
'target': {'branch': 'aggregated_branch_name',
Expand Down
Loading