Compare commits

...

20 Commits

Author SHA1 Message Date
Markus Hofbauer b6cd9ccbc7
Merge 21f33c476c into 30e516a3aa 2025-03-03 17:23:21 +01:00
nasso 30e516a3aa
feat(jj): add jujutsu plugin (#12292) 2025-03-03 17:15:43 +01:00
Markus Hofbauer 21f33c476c move tool to .github workflows 2024-02-19 18:39:33 +01:00
Markus Hofbauer cdf32e5c04 fix plugin path 2024-02-19 15:39:55 +01:00
Markus Hofbauer e46423299e explicitly use master 2024-02-19 15:37:05 +01:00
Markus Hofbauer 4595a6ffe4 better use checkout 2024-02-19 15:31:48 +01:00
Markus Hofbauer c9282c5613 remove known collisions 2024-02-19 15:19:49 +01:00
Markus Hofbauer 7875e78af7 removed resolved collision 2024-02-19 12:54:59 +01:00
Markus Hofbauer a157b80a72 read known collisions from file 2024-02-19 12:54:59 +01:00
Markus Hofbauer 68f45fe079 add filter for known collisions 2024-02-19 12:54:59 +01:00
Markus Hofbauer b4a077fdd8 refactor to pytest 2024-02-19 12:54:59 +01:00
Markus Hofbauer 4880743ab7 fix CI 2024-02-19 12:54:59 +01:00
Markus Hofbauer 114240d9fa disable failing of collision checker temporarily 2024-02-19 12:54:59 +01:00
Markus Hofbauer b456dc5342 print all collisions found before failing 2024-02-19 12:54:59 +01:00
Markus Hofbauer ccfdc8dd97 improve error message 2024-02-19 12:54:59 +01:00
Markus Hofbauer 821ee1ebec rename test to tests 2024-02-19 12:54:59 +01:00
Markus Hofbauer d0c2053a1d update actions to latest major version 2024-02-19 12:54:59 +01:00
Markus Hofbauer 0d13d8b5fb run python unit tests in CI 2024-02-19 12:54:59 +01:00
Markus Hofbauer de10614369 add collision check to github actions 2024-02-19 12:54:59 +01:00
Markus Hofbauer d05b5b194e first draft alias collision 2024-02-19 12:54:59 +01:00
7 changed files with 668 additions and 0 deletions

View File

@ -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

View File

@ -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())

View File

@ -0,0 +1,3 @@
pyfakefs
pytest
pytest-sugar

View File

@ -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

View File

@ -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

89
plugins/jj/README.md Normal file
View File

@ -0,0 +1,89 @@
# jj - Jujutsu CLI
This plugin provides autocompletion for [jj](https://martinvonz.github.io/jj).
To use it, add `jj` to the plugins array of your zshrc file:
```zsh
plugins=(... jj)
```
## Aliases
| Alias | Command |
| ------ | ----------------------------- |
| jjc | `jj commit` |
| jjcmsg | `jj commit --message` |
| jjd | `jj diff` |
| jjdmsg | `jj desc --message` |
| jjds | `jj desc` |
| jje | `jj edit` |
| jjgcl | `jj git clone` |
| jjgf | `jj git fetch` |
| jjgp | `jj git push` |
| jjl | `jj log` |
| jjla | `jj log -r "all()"` |
| jjn | `jj new` |
| jjrb | `jj rebase` |
| jjrs | `jj restore` |
| jjrt | `cd "$(jj root \|\| echo .)"` |
| jjsp | `jj split` |
| jjsq | `jj squash` |
## Prompt usage
Because `jj` has a very powerful [template syntax](https://martinvonz.github.io/jj/latest/templates/), this
plugin only exposes a convenience function `jj_prompt_template` to read information from the current change.
It is basically the same as `jj log --no-graph -r @ -T $1`:
```sh
_my_theme_jj_info() {
jj_prompt_template 'self.change_id().shortest(3)'
}
PROMPT='$(_my_theme_jj_info) $'
```
`jj_prompt_template` escapes `%` signs in the output. Use `jj_prompt_template_raw` if you don't want that
(e.g. to colorize the output).
However, because `jj` can be used inside a Git repository, some themes might clash with it. Generally, you can
fix it with a wrapper function that tries `jj` first and then falls back to `git` if it didn't work:
```sh
_my_theme_vcs_info() {
jj_prompt_template 'self.change_id().shortest(3)' \
|| git_prompt_info
}
PROMPT='$(_my_theme_vcs_info) $'
```
You can find an example
[here](https://github.com/nasso/omzsh/blob/e439e494f22f4fd4ef1b6cb64626255f4b341c1b/themes/sunakayu.zsh-theme).
### Performance
Sometimes `jj` can be slower than `git`.
If you feel slowdowns, consider using the following:
```
zstyle :omz:plugins:jj ignore-working-copy yes
```
This will add `--ignore-working-copy` to all `jj` commands executed by your prompt. The downside here is that
your prompt might be out-of-sync until the next time `jj` gets a chance to _not_ ignore the working copy (i.e.
you manually run a `jj` command).
If you prefer to keep your prompt always up-to-date but still don't want to _feel_ the slowdown, you can make
your prompt asynchronous. This plugin doesn't do this automatically so you'd have to hack your theme a bit for
that.
## See Also
- [martinvonz/jj](https://github.com/martinvonz/jj)
## Contributors
- [nasso](https://github.com/nasso) - Plugin Author

53
plugins/jj/jj.plugin.zsh Normal file
View File

@ -0,0 +1,53 @@
# if jj is not found, don't do the rest of the script
if (( ! $+commands[jj] )); then
return
fi
# If the completion file doesn't exist yet, we need to autoload it and
# bind it to `jj`. Otherwise, compinit will have already done that.
if [[ ! -f "$ZSH_CACHE_DIR/completions/_jj" ]]; then
typeset -g -A _comps
autoload -Uz _jj
_comps[jj]=_jj
fi
jj util completion zsh >| "$ZSH_CACHE_DIR/completions/_jj" &|
function __jj_prompt_jj() {
local -a flags
flags=("--no-pager")
if zstyle -t ':omz:plugins:jj' ignore-working-copy; then
flags+=("--ignore-working-copy")
fi
command jj $flags "$@"
}
# convenience functions for themes
function jj_prompt_template_raw() {
__jj_prompt_jj log --no-graph -r @ -T "$@" 2> /dev/null
}
function jj_prompt_template() {
local out
out=$(jj_prompt_template_raw "$@") || return 1
echo "${out:gs/%/%%}"
}
# Aliases (sorted alphabetically)
alias jjc='jj commit'
alias jjcmsg='jj commit --message'
alias jjd='jj diff'
alias jjdmsg='jj desc --message'
alias jjds='jj desc'
alias jje='jj edit'
alias jjgcl='jj git clone'
alias jjgf='jj git fetch'
alias jjgp='jj git push'
alias jjl='jj log'
alias jjla='jj log -r "all()"'
alias jjn='jj new'
alias jjrb='jj rebase'
alias jjrs='jj restore'
alias jjrt='cd "$(jj root || echo .)"'
alias jjsp='jj split'
alias jjsq='jj squash'