diff --git a/.github/workflows/alias_collision/.gitignore b/.github/workflows/alias_collision/.gitignore new file mode 100644 index 000000000..ee792c41f --- /dev/null +++ b/.github/workflows/alias_collision/.gitignore @@ -0,0 +1,163 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# default known collisions file +known_collisions.json diff --git a/.github/workflows/alias_collision/check_alias_collision.py b/.github/workflows/alias_collision/check_alias_collision.py new file mode 100644 index 000000000..fe465a4b7 --- /dev/null +++ b/.github/workflows/alias_collision/check_alias_collision.py @@ -0,0 +1,166 @@ +"""Check for alias collisions within the codebase""" + +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser +from pathlib import Path +from dataclasses import dataclass +from typing import List, Dict +import itertools +import re +import json + + +ERROR_MESSAGE_TEMPLATE = ( + "Alias `%s` defined in `%s` already exists as alias `%s` in `%s`." +) + +KNOWN_COLLISIONS_PATH = Path(__file__).resolve().parent / "known_collisions.json" + + +def dir_path(path_string: str) -> Path: + if Path(path_string).is_dir(): + return Path(path_string) + else: + raise NotADirectoryError(path_string) + + +def parse_arguments(): + parser = ArgumentParser( + description=__doc__, + formatter_class=ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "folder", + type=dir_path, + help="Folder to check", + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--known-collisions", + type=Path, + default=None, + help="Json-serialized list of known collision to compare to", + ) + group.add_argument( + "--known-collisions-output-path", + type=Path, + default=KNOWN_COLLISIONS_PATH, + help="Output path for a json-serialized list of known collisions", + ) + return parser.parse_args() + + +@dataclass(frozen=True) +class Alias: + alias: str + value: str + module: Path + + def to_dict(self) -> Dict: + return { + "alias": self.alias, + "value": self.value, + "module": str(self.module), + } + + +@dataclass(frozen=True) +class Collision: + existing_alias: Alias + new_alias: Alias + + def is_new_collision(self, known_collision_aliases: List[str]) -> bool: + return self.new_alias.alias not in known_collision_aliases + + @classmethod + def from_dict(cls, collision_dict: Dict) -> "Collision": + return cls( + Alias(**collision_dict["existing_alias"]), + Alias(**collision_dict["new_alias"]), + ) + + def to_dict(self) -> Dict: + return { + "existing_alias": self.existing_alias.to_dict(), + "new_alias": self.new_alias.to_dict(), + } + + +def find_aliases_in_file(file: Path) -> List[Alias]: + matches = re.findall(r"^alias (.*)='(.*)'", file.read_text(), re.M) + return [Alias(match[0], match[1], file) for match in matches] + + +def load_known_collisions(collision_file: Path) -> List[Collision]: + collision_list = json.loads(collision_file.read_text()) + return [Collision.from_dict(collision_dict) for collision_dict in collision_list] + + +def find_all_aliases(path: Path) -> list: + aliases = [find_aliases_in_file(file) for file in path.rglob("*.zsh")] + return list(itertools.chain(*aliases)) + + +def check_for_duplicates(aliases: List[Alias]) -> List[Collision]: + elements = {} + collisions = [] + for alias in aliases: + if alias.alias in elements: + existing = elements[alias.alias] + collisions.append(Collision(existing, alias)) + else: + elements[alias.alias] = alias + return collisions + + +def print_collisions(collisions: Dict[Alias, Alias]) -> None: + if collisions: + print(f"Found {len(collisions)} alias collisions:\n") + for collision in collisions: + print( + ERROR_MESSAGE_TEMPLATE + % ( + f"{collision.new_alias.alias}={collision.new_alias.value}", + collision.new_alias.module.name, + f"{collision.existing_alias.alias}={collision.existing_alias.value}", + collision.existing_alias.module.name, + ) + ) + print("\nConsider renaming your aliases.") + else: + print("Found no collisions") + + +def check_for_new_collisions( + known_collisions: Path, collisions: List[Collision] +) -> List[Collision]: + known_collisions = load_known_collisions(known_collisions) + known_collision_aliases = [ + collision.new_alias.alias for collision in known_collisions + ] + + return [ + collision + for collision in collisions + if collision.is_new_collision(known_collision_aliases) + ] + + +def main() -> int: + """main""" + args = parse_arguments() + aliases = find_all_aliases(args.folder) + collisions = check_for_duplicates(aliases) + + if args.known_collisions is not None: + new_collisions = check_for_new_collisions(args.known_collisions, collisions) + print_collisions(new_collisions) + return -1 if new_collisions else 0 + + args.known_collisions_output_path.write_text( + json.dumps([collision.to_dict() for collision in collisions]) + ) + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/.github/workflows/alias_collision/requirements.txt b/.github/workflows/alias_collision/requirements.txt new file mode 100644 index 000000000..edf3ab03b --- /dev/null +++ b/.github/workflows/alias_collision/requirements.txt @@ -0,0 +1,3 @@ +pyfakefs +pytest +pytest-sugar diff --git a/.github/workflows/alias_collision/tests/test_check_alias_collision.py b/.github/workflows/alias_collision/tests/test_check_alias_collision.py new file mode 100644 index 000000000..c6a3802ee --- /dev/null +++ b/.github/workflows/alias_collision/tests/test_check_alias_collision.py @@ -0,0 +1,166 @@ +from pathlib import Path + +from pyfakefs.fake_filesystem import FakeFilesystem +import pytest + +from check_alias_collision import ( + dir_path, + find_all_aliases, + find_aliases_in_file, + check_for_duplicates, + Alias, + Collision, + load_known_collisions, +) + + +THREE_ALIASES = """ +alias g='git' + +alias ga='git add' +alias gaa='git add --all' +""" + +CONDITIONAL_ALIAS = """ +is-at-least 2.8 "$git_version" \ + && alias gfa='git fetch --all --prune --jobs=10' \ + || alias gfa='git fetch --all --prune' +""" + +ONE_KNOWN_COLLISION = """ +[ + { + "existing_alias": { + "alias": "gcd", + "value": "git checkout $(git_develop_branch)", + "module": "plugins/git/git.plugin.zsh" + }, + "new_alias": { + "alias": "gcd", + "value": "git checkout $(git config gitflow.branch.develop)", + "module": "plugins/git-flow/git-flow.plugin.zsh" + } + } +] +""" + + +def test_dir_path__is_dir__input_path(fs: FakeFilesystem) -> None: + fs.create_dir("test") + assert Path("test") == dir_path("test") + + +def test_dir_path__is_file__raise_not_a_directory_error(fs: FakeFilesystem) -> None: + fs.create_file("test") + with pytest.raises(NotADirectoryError): + dir_path("test") + + +def test_dir_path__does_not_exist__raise_not_a_directory_error( + fs: FakeFilesystem, +) -> None: + with pytest.raises(NotADirectoryError): + dir_path("test") + + +def test_find_all_aliases__empty_folder_should_return_empty_list( + fs: FakeFilesystem, +) -> None: + fs.create_dir("test") + result = find_all_aliases(Path("test")) + assert [] == result + + +def test_find_aliases_in_file__empty_text_should_return_empty_list( + fs: FakeFilesystem, +) -> None: + fs.create_file("empty.zsh") + result = find_aliases_in_file(Path("empty.zsh")) + assert [] == result + + +def test_find_aliases_in_file__one_alias_should_find_one(fs: FakeFilesystem) -> None: + fs.create_file("one.zsh", contents="alias g='git'") + result = find_aliases_in_file(Path("one.zsh")) + assert [Alias("g", "git", Path("one.zsh"))] == result + + +def test_find_aliases_in_file__three_aliases_should_find_three( + fs: FakeFilesystem, +) -> None: + fs.create_file("three.zsh", contents=THREE_ALIASES) + result = find_aliases_in_file(Path("three.zsh")) + assert [ + Alias("g", "git", Path("three.zsh")), + Alias("ga", "git add", Path("three.zsh")), + Alias("gaa", "git add --all", Path("three.zsh")), + ] == result + + +def test_find_aliases_in_file__one_conditional_alias_should_find_none( + fs: FakeFilesystem, +) -> None: + fs.create_file("conditional.zsh", contents=CONDITIONAL_ALIAS) + result = find_aliases_in_file(Path("conditional.zsh")) + assert [] == result + + +def test_check_for_duplicates__no_duplicates_should_return_empty_dict() -> None: + result = check_for_duplicates( + [ + Alias("g", "git", Path("git.zsh")), + Alias("ga", "git add", Path("git.zsh")), + Alias("gaa", "git add --all", Path("git.zsh")), + ] + ) + assert result == [] + + +def test_check_for_duplicates__duplicates_should_have_one_collision() -> None: + result = check_for_duplicates( + [ + Alias("gc", "git commit", Path("git.zsh")), + Alias("gc", "git clone", Path("git.zsh")), + ] + ) + assert result == [ + Collision( + Alias("gc", "git commit", Path("git.zsh")), + Alias("gc", "git clone", Path("git.zsh")), + ) + ] + + +def test_is_new_collision__new_alias_not_in_known_collisions__should_return_true() -> ( + None +): + known_collisions = ["gc", "gd"] + new_alias = Alias("ga", "git add", Path("git.zsh")) + collision = Collision(Alias("gd", "git diff", Path("git.zsh")), new_alias) + assert collision.is_new_collision(known_collisions) is True + + +def test_is_new_collision__new_alias_in_known_collisions__should_return_false() -> None: + known_collisions = ["gc", "gd", "ga"] + new_alias = Alias("ga", "git add", Path("git.zsh")) + collision = Collision(Alias("gd", "git diff", Path("git.zsh")), new_alias) + assert collision.is_new_collision(known_collisions) is False + + +def test_load_known_collisions__empty_file__should_return_empty_list( + fs: FakeFilesystem, +) -> None: + empty_list = Path("empty.json") + fs.create_file(empty_list, contents="[]") + result = load_known_collisions(empty_list) + assert [] == result + + +def test_load_known_collisions__one_collision__should_return_one_collision( + fs: FakeFilesystem, +) -> None: + known_collisions_file = Path("known_collisions.json") + fs.create_file(known_collisions_file, contents=ONE_KNOWN_COLLISION) + result = load_known_collisions(known_collisions_file) + assert 1 == len(result) + assert "gcd" == result[0].existing_alias.alias diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 264ac31f3..048a09231 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -36,3 +36,31 @@ jobs: ./themes/*.zsh-theme; do zsh -n "$file" || return 1 done + + collisions: + name: Check alias collisions + runs-on: ubuntu-latest + steps: + - name: Set up git repository + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r .github/workflows/alias_collision/requirements.txt + - name: Run unit tests + run: | + cd .github/workflows/alias_collision/ + python -m pytest + - name: Checkout target branch + uses: actions/checkout@v4 + with: + path: ohmyzsh-target-branch + ref: master + - name: Check for alias collisions on target branch + run: python .github/workflows/alias_collision/check_alias_collision.py ohmyzsh-target-branch/plugins --known-collisions-output-path known_alias_collisions.json + - name: Compare known collisions to new collisions on source branch + run: python .github/workflows/alias_collision/check_alias_collision.py plugins --known-collisions known_alias_collisions.json