├── gg ├── __init__.py ├── builtins │ ├── __init__.py │ ├── pr │ │ ├── __init__.py │ │ ├── README.rst │ │ └── gg_pr.py │ ├── push │ │ ├── __init__.py │ │ ├── README.rst │ │ ├── tests │ │ │ └── test_gg_push.py │ │ └── gg_push.py │ ├── branches │ │ ├── __init__.py │ │ ├── README.rst │ │ ├── tests │ │ │ └── test_gg_branches.py │ │ ├── gg_fetchcheckout.py │ │ └── gg_branches.py │ ├── cleanup │ │ ├── __init__.py │ │ ├── README.rst │ │ ├── tests │ │ │ └── test_gg_cleanup.py │ │ └── gg_cleanup.py │ ├── commit │ │ ├── __init__.py │ │ ├── README.rst │ │ ├── tests │ │ │ └── test_gg_commit.py │ │ └── gg_commit.py │ ├── getback │ │ ├── __init__.py │ │ ├── README.rst │ │ ├── tests │ │ │ └── test_gg_getback.py │ │ └── gg_getback.py │ ├── merge │ │ ├── __init__.py │ │ ├── README.rst │ │ ├── gg_merge.py │ │ └── tests │ │ │ └── test_gg_merge.py │ ├── rebase │ │ ├── __init__.py │ │ ├── README.rst │ │ ├── gg_rebase.py │ │ └── tests │ │ │ └── test_gg_rebase.py │ ├── start │ │ ├── __init__.py │ │ ├── README.rst │ │ ├── gg_start.py │ │ └── tests │ │ │ └── test_gg_start.py │ ├── mastermerge │ │ ├── __init__.py │ │ ├── README.rst │ │ └── gg_mastermerge.py │ ├── config.py │ ├── local_config.py │ ├── bugzilla.py │ └── github.py ├── testing.py ├── state.py ├── utils.py └── main.py ├── setup.cfg ├── .gitignore ├── .therapist.yml ├── gg-complete.sh ├── PLUGINS.rst ├── tests ├── utils.py ├── test_config.py ├── conftest.py ├── test_state.py ├── test_utils.py ├── test_bugzilla.py └── test_github.py ├── .github └── workflows │ └── python.yml ├── tox.ini ├── LICENSE ├── setup.py └── README.rst /gg/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/pr/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/push/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/branches/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/cleanup/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/commit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/getback/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/merge/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/rebase/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/start/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gg/builtins/mastermerge/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | .cache/ 4 | .eggs/ 5 | .tox/ 6 | htmlcov/ 7 | .pytest_cache/ 8 | *.egg-info/ 9 | .vscode/tags 10 | -------------------------------------------------------------------------------- /.therapist.yml: -------------------------------------------------------------------------------- 1 | actions: 2 | black: 3 | run: black --check --diff {files} 4 | fix: black {files} 5 | include: "*.py" 6 | 7 | flake8: 8 | run: flake8 {files} 9 | include: "*.py" 10 | -------------------------------------------------------------------------------- /gg/testing.py: -------------------------------------------------------------------------------- 1 | class Response: 2 | def __init__(self, content, status_code=200): 3 | self.content = content 4 | self.status_code = status_code 5 | 6 | def json(self): 7 | return self.content 8 | -------------------------------------------------------------------------------- /gg/builtins/start/README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | gg-start 3 | ======== 4 | 5 | A plugin for `gg `_ for starting new 6 | branches. 7 | 8 | Version History 9 | =============== 10 | 11 | 0.1 12 | * Proof of concept 13 | -------------------------------------------------------------------------------- /gg/builtins/rebase/README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | rebase 3 | ====== 4 | 5 | A plugin for `gg `_ for rebasing the 6 | current branch. 7 | 8 | 9 | Version History 10 | =============== 11 | 12 | 0.1 13 | * Proof of concept 14 | -------------------------------------------------------------------------------- /gg-complete.sh: -------------------------------------------------------------------------------- 1 | _gg_completion() { 2 | COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \ 3 | COMP_CWORD=$COMP_CWORD \ 4 | _GG_COMPLETE=complete $1 ) ) 5 | return 0 6 | } 7 | 8 | complete -F _gg_completion -o default gg; 9 | -------------------------------------------------------------------------------- /gg/builtins/push/README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | gg-push 3 | ======= 4 | 5 | A plugin for `gg `_ for starting new 6 | pushing branches to remotes. 7 | 8 | 9 | Version History 10 | =============== 11 | 12 | 0.1 13 | * Proof of concept 14 | -------------------------------------------------------------------------------- /gg/builtins/getback/README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | getback 3 | ======= 4 | 5 | A plugin for `gg `_ for getting back to 6 | master and deleting the current branch. 7 | 8 | 9 | Version History 10 | =============== 11 | 12 | 0.1 13 | * Proof of concept 14 | -------------------------------------------------------------------------------- /gg/builtins/cleanup/README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | cleanup 3 | ======= 4 | 5 | A plugin for `gg `_ for deleting 6 | branches (locally and remotely) when you're in the $default_branch branch. 7 | 8 | 9 | Version History 10 | =============== 11 | 12 | 0.1 13 | * Proof of concept 14 | -------------------------------------------------------------------------------- /PLUGINS.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | gg Plugins 3 | ========== 4 | 5 | 6 | `gg-start `_ 7 | ================================================= 8 | 9 | Use this to create branches. Its utility will help you create the 10 | branch based on a Bugzilla bug ID or a GitHub Issue number. 11 | -------------------------------------------------------------------------------- /gg/builtins/branches/README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | branches 3 | ======== 4 | 5 | A plugin for `gg `_ for finding and 6 | possible checking out branches by search patterns. Also for listing 7 | branches order by recent activity. 8 | 9 | 10 | Version History 11 | =============== 12 | 13 | 0.1 14 | * Proof of concept 15 | -------------------------------------------------------------------------------- /gg/builtins/merge/README.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | merge 3 | ===== 4 | 5 | A plugin for `gg `_ for merging the 6 | current branch into master. 7 | 8 | 9 | Installation 10 | ============ 11 | 12 | Doesn't need to be installed. It's part of ``gg`` by default. 13 | 14 | 15 | Version History 16 | =============== 17 | 18 | 0.1 19 | * Proof of concept 20 | -------------------------------------------------------------------------------- /gg/builtins/mastermerge/README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | mastermerge 3 | =========== 4 | 5 | A plugin for `gg `_ for merging the 6 | master branch into this feature branch. 7 | 8 | 9 | Installation 10 | ============ 11 | 12 | Doesn't need to be installed. It's part of ``gg`` by default. 13 | 14 | 15 | Version History 16 | =============== 17 | 18 | 0.1 19 | * Proof of concept 20 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture() 9 | def temp_configfile(request): 10 | cf = os.path.join(tempfile.gettempdir(), "config.json") 11 | with open(cf, "w") as f: 12 | json.dump({}, f) 13 | 14 | def teardown(): 15 | os.remove(cf) 16 | 17 | request.addfinalizer(teardown) 18 | return cf 19 | 20 | 21 | class Response: 22 | def __init__(self, content, status_code=200): 23 | self.content = content 24 | self.status_code = status_code 25 | 26 | def json(self): 27 | return self.content 28 | -------------------------------------------------------------------------------- /gg/builtins/pr/README.rst: -------------------------------------------------------------------------------- 1 | == 2 | pr 3 | == 4 | 5 | A plugin for `gg `_ for finding pull requests. 6 | 7 | 8 | Installation 9 | ============ 10 | 11 | Doesn't need to be installed. It's part of ``gg`` by default. 12 | 13 | How to develop 14 | ============== 15 | 16 | To work on this, first run:: 17 | 18 | pip install -U --editable . 19 | 20 | That installs the package ``gg`` and its dependencies. It also 21 | installs the executable ``gg``. So now you should be able to run:: 22 | 23 | gg commit --help 24 | 25 | 26 | Version History 27 | =============== 28 | 29 | 0.1 30 | * Proof of concept 31 | -------------------------------------------------------------------------------- /gg/builtins/commit/README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | commit 3 | ====== 4 | 5 | A plugin for `gg `_ for committing branches. 6 | 7 | 8 | Installation 9 | ============ 10 | 11 | Doesn't need to be installed. It's part of ``gg`` by default. 12 | 13 | How to develop 14 | ============== 15 | 16 | To work on this, first run:: 17 | 18 | pip install -U --editable . 19 | 20 | That installs the package ``gg`` and its dependencies. It also 21 | installs the executable ``gg``. So now you should be able to run:: 22 | 23 | gg commit --help 24 | 25 | 26 | Version History 27 | =============== 28 | 29 | 0.1 30 | * Proof of concept 31 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | # Based on 2 | # https://pypi.org/project/tox-gh-actions/ 3 | 4 | name: Python 5 | 6 | on: [push] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: [3.6, 3.7, 3.8] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install tox tox-gh-actions 25 | - name: Test with tox 26 | run: tox 27 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from click.testing import CliRunner 4 | 5 | from gg.main import Config 6 | from gg.builtins import config 7 | 8 | 9 | def test_config(temp_configfile, mocker): 10 | # rget = mocker.patch('requests.get') 11 | getpass = mocker.patch("getpass.getpass") 12 | getpass.return_value = "somelongapitokenhere" 13 | 14 | runner = CliRunner() 15 | config_ = Config() 16 | config_.configfile = temp_configfile 17 | result = runner.invoke(config.config, ["-f", "myown"], obj=config_) 18 | assert result.exit_code == 0 19 | assert not result.exception 20 | 21 | with open(temp_configfile) as f: 22 | saved = json.load(f) 23 | assert "FORK_NAME" in saved 24 | assert saved["FORK_NAME"] == "myown" 25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | 5 | import pytest 6 | import requests_mock 7 | 8 | 9 | @pytest.yield_fixture 10 | def temp_configfile(): 11 | cf = os.path.join(tempfile.gettempdir(), "config.json") 12 | with open(cf, "w") as f: 13 | json.dump({}, f) 14 | yield cf 15 | os.remove(cf) 16 | 17 | 18 | @pytest.fixture(autouse=True) 19 | def requestsmock(): 20 | """Return a context where requests are all mocked. 21 | Usage:: 22 | 23 | def test_something(requestsmock): 24 | requestsmock.get( 25 | 'https://example.com/path' 26 | content=b'The content' 27 | ) 28 | # Do stuff that involves requests.get('http://example.com/path') 29 | """ 30 | with requests_mock.mock() as m: 31 | yield m 32 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py{36,37,38}, lint, end2end 8 | 9 | [gh-actions] 10 | python = 11 | 3.6: py36 12 | 3.7: py37 13 | 3.8: py38, lint, end2end 14 | 15 | [testenv:unit] 16 | usedevelop=True 17 | deps = 18 | pytest 19 | pytest-mock 20 | pytest-cov 21 | requests_mock 22 | commands= 23 | pytest --cov-report term-missing --cov gg {posargs} 24 | 25 | [testenv:lint] 26 | extras = dev 27 | commands=therapist run --use-tracked-files 28 | 29 | [testenv:end2end] 30 | commands = 31 | gg --help 32 | gg bugzilla --help 33 | gg github --help 34 | gg config --help 35 | gg start --help 36 | gg merge --help 37 | gg commit --help 38 | gg branches --help 39 | gg getback --help 40 | gg cleanup --help 41 | gg rebase --help 42 | gg mastermerge --help 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2016 Peter Bengtsson 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /tests/test_state.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from gg import state 4 | 5 | 6 | def test_save(temp_configfile): 7 | state.write(temp_configfile, {}) 8 | state.save(temp_configfile, "My description", "branch-name", foo="bar") 9 | with open(temp_configfile) as f: 10 | saved = json.load(f) 11 | key = "gg:branch-name" 12 | assert saved[key]["description"] == "My description" 13 | assert saved[key]["foo"] == "bar" 14 | assert saved[key]["date"] 15 | 16 | 17 | def test_update(temp_configfile): 18 | state.update(temp_configfile, {"any": "thing"}) 19 | with open(temp_configfile) as f: 20 | saved = json.load(f) 21 | assert saved["any"] == "thing" 22 | 23 | 24 | def test_remove(temp_configfile): 25 | state.update(temp_configfile, {"any": "thing", "other": "stuff"}) 26 | state.remove(temp_configfile, "any") 27 | with open(temp_configfile) as f: 28 | saved = json.load(f) 29 | assert "any" not in saved 30 | assert saved["other"] == "stuff" 31 | 32 | 33 | def test_load(temp_configfile): 34 | state.write(temp_configfile, {}) 35 | state.save(temp_configfile, "My description", "branch-name", foo="bar") 36 | saved = state.load(temp_configfile, "branch-name") 37 | assert saved["description"] == "My description" 38 | assert saved["foo"] == "bar" 39 | assert saved["date"] 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from setuptools import setup, find_packages 3 | 4 | _here = path.dirname(__file__) 5 | 6 | 7 | dev_requirements = [ 8 | "tox==3.14.2", 9 | "pytest==5.3.2", 10 | "pytest-mock==1.13.0", 11 | "pytest-cov==2.8.1", 12 | "pytest-sugar==0.9.2", 13 | "black==20.8b1", 14 | "flake8==3.8.3", 15 | "requests_mock==1.7.0", 16 | "therapist==2.1.0", 17 | ] 18 | 19 | setup( 20 | name="gg", 21 | version="0.0.21", 22 | author="Peter Bengtsson", 23 | author_email="mail@peterbe.com", 24 | url="https://github.com/peterbe/gg", 25 | description="Git and GitHub for the productivity addicted", 26 | long_description=open(path.join(_here, "README.rst")).read(), 27 | license="MIT", 28 | classifiers=[ 29 | "Programming Language :: Python", 30 | "Programming Language :: Python :: 3", 31 | "Programming Language :: Python :: Implementation :: CPython", 32 | "License :: OSI Approved :: MIT License", 33 | ], 34 | packages=find_packages(), 35 | include_package_data=True, 36 | zip_safe=False, 37 | install_requires=["click", "colorama", "requests", "GitPython"], 38 | extras_require={"dev": dev_requirements}, 39 | entry_points=""" 40 | [console_scripts] 41 | gg=gg.main:cli 42 | """, 43 | setup_requires=["pytest-runner"], 44 | tests_require=["pytest", "pytest-mock", "requests_mock"], 45 | keywords="git click github bugzilla", 46 | ) 47 | -------------------------------------------------------------------------------- /gg/builtins/mastermerge/gg_mastermerge.py: -------------------------------------------------------------------------------- 1 | from gg.main import cli, pass_config 2 | from gg.state import read 3 | from gg.utils import error_out, get_default_branch, success_out 4 | 5 | 6 | @cli.command() 7 | @pass_config 8 | def mastermerge(config): 9 | """Merge the origin_name/default_branch into the the current branch""" 10 | repo = config.repo 11 | 12 | state = read(config.configfile) 13 | origin_name = state.get("ORIGIN_NAME", "origin") 14 | default_branch = get_default_branch(repo, origin_name) 15 | 16 | active_branch = repo.active_branch 17 | if active_branch.name == default_branch: 18 | error_out(f"You're already on the {default_branch} branch.") 19 | active_branch_name = active_branch.name 20 | 21 | if repo.is_dirty(): 22 | error_out( 23 | 'Repo is "dirty". ({})'.format( 24 | ", ".join([repr(x.b_path) for x in repo.index.diff(None)]) 25 | ) 26 | ) 27 | 28 | upstream_remote = None 29 | for remote in repo.remotes: 30 | if remote.name == origin_name: 31 | upstream_remote = remote 32 | break 33 | if not upstream_remote: 34 | error_out(f"No remote called {origin_name!r} found") 35 | 36 | repo.heads[default_branch].checkout() 37 | repo.remotes[origin_name].pull(default_branch) 38 | 39 | repo.heads[active_branch_name].checkout() 40 | 41 | repo.git.merge(default_branch) 42 | success_out(f"Merged against {origin_name}/{default_branch}") 43 | -------------------------------------------------------------------------------- /gg/builtins/config.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from gg.utils import success_out, info_out 4 | from gg.state import update, read 5 | from gg.main import cli, pass_config 6 | 7 | 8 | @cli.command() 9 | @click.option( 10 | "-f", 11 | "--fork-name", 12 | default="", 13 | help="Name of the remote which is the fork where you push your branches.", 14 | ) 15 | @click.option( 16 | "-o", 17 | "--origin-name", 18 | default="", 19 | help="Name of the remote which is the origin remote.", 20 | ) 21 | @click.option( 22 | "-b", 23 | "--default-branch", 24 | default="", 25 | help="Name of the default branch.", 26 | ) 27 | @pass_config 28 | def config(config, fork_name="", origin_name="", default_branch=""): 29 | """Setting various configuration options""" 30 | state = read(config.configfile) 31 | if fork_name: 32 | update(config.configfile, {"FORK_NAME": fork_name}) 33 | success_out(f"fork-name set to: {fork_name}") 34 | else: 35 | info_out(f"fork-name: {state['FORK_NAME']}") 36 | 37 | if origin_name: 38 | update(config.configfile, {"ORIGIN_NAME": origin_name}) 39 | success_out(f"origin-name set to: {origin_name}") 40 | else: 41 | info_out(f"origin-name: {state.get('ORIGIN_NAME', '*not set*')}") 42 | 43 | if default_branch: 44 | update(config.configfile, {"DEFAULT_BRANCH": default_branch}) 45 | success_out(f"default-branch set to: {default_branch}") 46 | else: 47 | info_out(f"default-branch: {state.get('DEFAULT_BRANCH', '*not set*')}") 48 | -------------------------------------------------------------------------------- /gg/state.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | 4 | from .utils import get_repo_name 5 | 6 | 7 | def read(configfile): 8 | with open(configfile, "r") as f: 9 | return json.load(f) 10 | 11 | 12 | def write(configfile, data): 13 | with open(configfile, "w") as f: 14 | json.dump(data, f, indent=2, sort_keys=True) 15 | 16 | 17 | def save(configfile, description, branch_name, **extra): 18 | repo_name = get_repo_name() 19 | key = f"{repo_name}:{branch_name}" 20 | new = {key: extra} 21 | new[key].update( 22 | {"description": description, "date": datetime.datetime.now().isoformat()} 23 | ) 24 | update(configfile, new) 25 | 26 | 27 | def load(configfile, branch_name): 28 | # like read() but returning specifically the state for the current repo 29 | key = f"{get_repo_name()}:{branch_name}" 30 | return read(configfile)[key] 31 | 32 | 33 | def load_config(configfile, key): 34 | # like read() but returning specifically the state for the current repo 35 | key = f"{get_repo_name()}:_config:{key}" 36 | return read(configfile)[key] 37 | 38 | 39 | def update(configfile, data): 40 | state = read(configfile) 41 | state.update(data) 42 | write(configfile, state) 43 | 44 | 45 | def update_config(configfile, **kwargs): 46 | state = read(configfile) 47 | for key, value in kwargs.items(): 48 | state[f"{get_repo_name()}:_config:{key}"] = value 49 | write(configfile, state) 50 | 51 | 52 | def remove(configfile, key): 53 | state = read(configfile) 54 | del state[key] 55 | write(configfile, state) 56 | -------------------------------------------------------------------------------- /gg/builtins/rebase/gg_rebase.py: -------------------------------------------------------------------------------- 1 | from gg.utils import error_out, success_out, info_out 2 | from gg.state import read 3 | from gg.main import cli, pass_config 4 | 5 | 6 | @cli.command() 7 | @pass_config 8 | def rebase(config): 9 | """Rebase the current branch against $origin/$branch""" 10 | repo = config.repo 11 | 12 | state = read(config.configfile) 13 | default_branch = state.get("DEFAULT_BRANCH", "master") 14 | 15 | active_branch = repo.active_branch 16 | if active_branch.name == default_branch: 17 | error_out(f"You're already on the {default_branch} branch.") 18 | active_branch_name = active_branch.name 19 | 20 | if repo.is_dirty(): 21 | error_out( 22 | 'Repo is "dirty". ({})'.format( 23 | ", ".join([repr(x.b_path) for x in repo.index.diff(None)]) 24 | ) 25 | ) 26 | 27 | origin_name = state.get("ORIGIN_NAME", "origin") 28 | upstream_remote = None 29 | for remote in repo.remotes: 30 | if remote.name == origin_name: 31 | upstream_remote = remote 32 | break 33 | if not upstream_remote: 34 | error_out("No remote called {!r} found".format(origin_name)) 35 | 36 | repo.heads[default_branch].checkout() 37 | repo.remotes[origin_name].pull(default_branch) 38 | 39 | repo.heads[active_branch_name].checkout() 40 | 41 | print(repo.git.rebase(default_branch)) 42 | success_out(f"Rebased against {origin_name}/{default_branch}") 43 | info_out( 44 | f"If you want to start interactive rebase " 45 | f"run:\n\n\tgit rebase -i {default_branch}\n" 46 | ) 47 | -------------------------------------------------------------------------------- /gg/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from urllib.parse import urlparse 4 | 5 | import click 6 | import git 7 | 8 | 9 | def get_repo(here="."): 10 | if os.path.abspath(here) == "/": # hit rock bottom 11 | raise git.InvalidGitRepositoryError("Unable to find repo root") 12 | if os.path.isdir(os.path.join(here, ".git")): 13 | return git.Repo(here) 14 | return get_repo(os.path.join(here, "..")) 15 | 16 | 17 | def get_repo_name(): 18 | repo = get_repo() 19 | return os.path.basename(repo.working_dir) 20 | 21 | 22 | def error_out(msg, raise_abort=True): 23 | click.echo(click.style(msg, fg="red")) 24 | if raise_abort: 25 | raise click.Abort 26 | 27 | 28 | def success_out(msg): 29 | click.echo(click.style(msg, fg="green")) 30 | 31 | 32 | def warning_out(msg): 33 | click.echo(click.style(msg, fg="yellow")) 34 | 35 | 36 | def info_out(msg): 37 | click.echo(msg) 38 | 39 | 40 | def is_github(data): 41 | if data.get("bugnumber") and data.get("url"): 42 | return "github" in urlparse(data["url"]).netloc 43 | return False 44 | 45 | 46 | def is_bugzilla(data): 47 | if data.get("bugnumber") and data.get("url"): 48 | return "bugzilla" in urlparse(data["url"]).netloc 49 | return False 50 | 51 | 52 | def get_default_branch(repo, origin_name): 53 | # TODO: Figure out how to do this with GitPython 54 | res = subprocess.run( 55 | f"git remote show {origin_name}".split(), 56 | check=True, 57 | capture_output=True, 58 | cwd=repo.working_tree_dir, 59 | ) 60 | for line in res.stdout.decode("utf-8").splitlines(): 61 | if line.strip().startswith("HEAD branch:"): 62 | return line.replace("HEAD branch:", "").strip() 63 | 64 | raise NotImplementedError(f"No remote called {origin_name!r}") 65 | -------------------------------------------------------------------------------- /gg/builtins/merge/gg_merge.py: -------------------------------------------------------------------------------- 1 | from gg.utils import error_out, info_out, success_out 2 | from gg.state import read 3 | from gg.main import cli, pass_config 4 | 5 | 6 | @cli.command() 7 | @pass_config 8 | def merge(config): 9 | """Merge the current branch into $default_branch.""" 10 | repo = config.repo 11 | 12 | state = read(config.configfile) 13 | default_branch = state.get("DEFAULT_BRANCH", "master") 14 | 15 | active_branch = repo.active_branch 16 | if active_branch.name == default_branch: 17 | error_out(f"You're already on the {default_branch} branch.") 18 | 19 | if repo.is_dirty(): 20 | error_out( 21 | 'Repo is "dirty". ({})'.format( 22 | ", ".join([repr(x.b_path) for x in repo.index.diff(None)]) 23 | ) 24 | ) 25 | 26 | branch_name = active_branch.name 27 | 28 | origin_name = state.get("ORIGIN_NAME", "origin") 29 | upstream_remote = None 30 | for remote in repo.remotes: 31 | if remote.name == origin_name: 32 | upstream_remote = remote 33 | break 34 | if not upstream_remote: 35 | error_out(f"No remote called {origin_name!r} found") 36 | 37 | repo.heads[default_branch].checkout() 38 | upstream_remote.pull(repo.heads[default_branch]) 39 | 40 | repo.git.merge(branch_name) 41 | repo.git.branch("-d", branch_name) 42 | success_out("Branch {!r} deleted.".format(branch_name)) 43 | 44 | info_out("NOW, you might want to run:\n") 45 | info_out(f"git push {origin_name} {default_branch}\n\n") 46 | 47 | push_for_you = input("Run that push? [Y/n] ").lower().strip() != "n" 48 | if push_for_you: 49 | upstream_remote.push(default_branch) 50 | success_out(f"Current {default_branch} pushed to {upstream_remote.name}") 51 | 52 | # XXX perhaps we should delete the branch off the fork remote if it exists 53 | -------------------------------------------------------------------------------- /gg/builtins/rebase/tests/test_gg_rebase.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | import pytest 7 | from click.testing import CliRunner 8 | 9 | # By doing this import we make sure that the plugin is made available 10 | # but the entry points loading inside gg.main. 11 | # An alternative would we to set `PYTHONPATH=. py.test` (or something) 12 | # but then that wouldn't test the entry point loading. 13 | from gg.main import Config 14 | 15 | from gg.builtins.rebase.gg_rebase import rebase 16 | 17 | 18 | @pytest.yield_fixture 19 | def temp_configfile(): 20 | tmp_dir = tempfile.mkdtemp("gg-rebase") 21 | fp = os.path.join(tmp_dir, "state.json") 22 | with open(fp, "w") as f: 23 | json.dump({}, f) 24 | yield fp 25 | shutil.rmtree(tmp_dir) 26 | 27 | 28 | def test_rebase(temp_configfile, mocker): 29 | mocked_git = mocker.patch("git.Repo") 30 | mocked_git().working_dir = "gg-start-test" 31 | active_branch = mocker.MagicMock() 32 | mocked_git().active_branch = active_branch 33 | mocked_remote = mocker.MagicMock() 34 | mocked_remote.name = "origin" 35 | mocked_git().remotes.__iter__.return_value = [mocked_remote] 36 | mocked_git().is_dirty.return_value = False 37 | 38 | state = json.load(open(temp_configfile)) 39 | state["FORK_NAME"] = "peterbe" 40 | with open(temp_configfile, "w") as f: 41 | json.dump(state, f) 42 | 43 | runner = CliRunner() 44 | config = Config() 45 | config.configfile = temp_configfile 46 | result = runner.invoke(rebase, [], input="\n", obj=config) 47 | # if result.exception: 48 | # print(mocked_git.mock_calls) 49 | # print(result.output) 50 | # print(result.exception) 51 | # raise result.exception 52 | assert result.exit_code == 0 53 | assert not result.exception 54 | 55 | mocked_git().git.rebase.assert_called_with("master") 56 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | import click 4 | import pytest 5 | import git 6 | 7 | from gg import utils 8 | 9 | 10 | def test_get_repo(mocker): 11 | this_repo = utils.get_repo() 12 | assert isinstance(this_repo, git.Repo) 13 | 14 | 15 | def test_get_repo_never_found(mocker): 16 | with pytest.raises(git.InvalidGitRepositoryError): 17 | utils.get_repo(tempfile.gettempdir()) 18 | 19 | 20 | def test_get_repo_name(): 21 | this_repo_name = utils.get_repo_name() 22 | assert this_repo_name == "gg" 23 | 24 | 25 | def test_error_out(capsys): 26 | with pytest.raises(click.Abort): 27 | utils.error_out("Blarg") 28 | out, err = capsys.readouterr() 29 | assert not err 30 | assert out == "Blarg\n" 31 | 32 | 33 | def test_error_out_no_raise(capsys): 34 | utils.error_out("Blarg", False) 35 | out, err = capsys.readouterr() 36 | assert not err 37 | assert out == "Blarg\n" 38 | 39 | 40 | def test_is_github(): 41 | good_data = {"bugnumber": 123, "url": "https://github.com/peterbe/gg/issues/1"} 42 | assert utils.is_github(good_data) 43 | 44 | not_good_data = {"bugnumber": 123, "url": "https://issuetracker.example.com/1234"} 45 | assert not utils.is_github(not_good_data) 46 | 47 | not_good_data = {"bugnumber": None, "url": "https://github.com/peterbe/gg/issues/1"} 48 | assert not utils.is_github(not_good_data) 49 | 50 | 51 | def test_is_bugzilla(): 52 | good_data = { 53 | "bugnumber": 123, 54 | "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=12345678", 55 | } 56 | assert utils.is_bugzilla(good_data) 57 | 58 | good_data = { 59 | "bugnumber": 123, 60 | "url": "https://bugzilla.redhat.com/show_bug.cgi?id=12345", 61 | } 62 | assert utils.is_bugzilla(good_data) 63 | 64 | not_good_data = {"bugnumber": 123, "url": "https://issuetracker.example.com/1234"} 65 | assert not utils.is_bugzilla(not_good_data) 66 | 67 | not_good_data = { 68 | "bugnumber": None, 69 | "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=12345678", 70 | } 71 | assert not utils.is_bugzilla(not_good_data) 72 | -------------------------------------------------------------------------------- /gg/builtins/push/tests/test_gg_push.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | from git.remote import PushInfo 7 | import pytest 8 | import requests_mock 9 | from click.testing import CliRunner 10 | 11 | # By doing this import we make sure that the plugin is made available 12 | # but the entry points loading inside gg.main. 13 | # An alternative would we to set `PYTHONPATH=. py.test` (or something) 14 | # but then that wouldn't test the entry point loading. 15 | from gg.main import Config 16 | 17 | from gg.builtins.push.gg_push import push 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def requestsmock(): 22 | """Return a context where requests are all mocked. 23 | Usage:: 24 | 25 | def test_something(requestsmock): 26 | requestsmock.get( 27 | 'https://example.com/path' 28 | content=b'The content' 29 | ) 30 | # Do stuff that involves requests.get('http://example.com/path') 31 | """ 32 | with requests_mock.mock() as m: 33 | yield m 34 | 35 | 36 | @pytest.yield_fixture 37 | def temp_configfile(): 38 | tmp_dir = tempfile.mkdtemp("gg-start") 39 | fp = os.path.join(tmp_dir, "state.json") 40 | with open(fp, "w") as f: 41 | json.dump({}, f) 42 | yield fp 43 | shutil.rmtree(tmp_dir) 44 | 45 | 46 | def test_push(temp_configfile, mocker): 47 | mocked_git = mocker.patch("git.Repo") 48 | mocked_git().working_dir = "gg-start-test" 49 | mocked_git().remotes.__getitem__().push.return_value = [ 50 | PushInfo(0, "All is well", None, None, "origin") 51 | ] 52 | 53 | state = json.load(open(temp_configfile)) 54 | state["FORK_NAME"] = "peterbe" 55 | with open(temp_configfile, "w") as f: 56 | json.dump(state, f) 57 | 58 | runner = CliRunner() 59 | config = Config() 60 | config.configfile = temp_configfile 61 | result = runner.invoke(push, [], obj=config) 62 | if result.exception: 63 | raise result.exception 64 | assert result.exit_code == 0 65 | assert not result.exception 66 | 67 | mocked_git().remotes.__getitem__().push.assert_called_with() 68 | -------------------------------------------------------------------------------- /gg/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pkg_resources import iter_entry_points 3 | 4 | import click 5 | import git 6 | 7 | from . import state 8 | from .utils import error_out, get_repo 9 | 10 | 11 | DEFAULT_CONFIGFILE = os.path.expanduser("~/.gg.json") 12 | 13 | 14 | class Config: 15 | def __init__(self): 16 | self.verbose = False # default 17 | self.configfile = DEFAULT_CONFIGFILE 18 | try: 19 | self.repo = get_repo() 20 | except git.InvalidGitRepositoryError as exception: 21 | error_out(f"{exception.args[0]} is not a git repository") 22 | 23 | 24 | pass_config = click.make_pass_decorator(Config, ensure=True) 25 | 26 | 27 | @click.group() 28 | @click.option("-v", "--verbose", is_flag=True) 29 | @click.option( 30 | "-c", 31 | "--configfile", 32 | default=DEFAULT_CONFIGFILE, 33 | help=f"Path to the config file (default: {DEFAULT_CONFIGFILE})", 34 | ) 35 | @pass_config 36 | def cli(config, configfile, verbose): 37 | """A glorious command line tool to make your life with git, GitHub 38 | and Bugzilla much easier.""" 39 | config.verbose = verbose 40 | config.configfile = configfile 41 | if not os.path.isfile(configfile): 42 | state.write(configfile, {}) 43 | 44 | 45 | # load in the builtin apps 46 | from .builtins import bugzilla # noqa 47 | from .builtins import github # noqa 48 | from .builtins import config as _ # noqa 49 | from .builtins import local_config as _ # noqa 50 | from .builtins.commit import gg_commit # noqa 51 | from .builtins.pr import gg_pr # noqa 52 | from .builtins.merge import gg_merge # noqa 53 | from .builtins.mastermerge import gg_mastermerge # noqa 54 | from .builtins.start import gg_start # noqa 55 | from .builtins.push import gg_push # noqa 56 | from .builtins.getback import gg_getback # noqa 57 | from .builtins.rebase import gg_rebase # noqa 58 | from .builtins.branches import gg_branches # noqa 59 | from .builtins.cleanup import gg_cleanup # noqa 60 | 61 | # Simply loading all installed packages that have this entry_point 62 | # will be enough. Each plugin automatically registers itself with the 63 | # cli click group. 64 | for entry_point in iter_entry_points(group="gg.plugin", name=None): 65 | entry_point.load() 66 | -------------------------------------------------------------------------------- /gg/builtins/local_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import click 5 | 6 | from gg.state import load_config, update_config 7 | from gg.main import cli, pass_config 8 | 9 | 10 | @cli.command() 11 | @click.option( 12 | "--push-to-origin", 13 | default="", 14 | help="Push to the origin instead of your fork (true or false)", 15 | ) 16 | @click.option( 17 | "--toggle-fixes-message", 18 | is_flag=True, 19 | default=False, 20 | help="Enable or disable the 'fixes' functionality in commits", 21 | ) 22 | @click.option( 23 | "--toggle-username-branches", 24 | is_flag=True, 25 | default=None, 26 | help=f"Toggles if branches should prefix by your username ({os.getlogin()})", 27 | ) 28 | @pass_config 29 | def local_config( 30 | config, 31 | push_to_origin="", 32 | toggle_fixes_message=False, 33 | toggle_username_branches=None, 34 | ): 35 | """Setting configuration options per repo name""" 36 | if push_to_origin: 37 | try: 38 | before = load_config(config.configfile, "push_to_origin") 39 | print(f"push_to_origin before: {before}") 40 | except KeyError: 41 | print("push_to_origin before: not set") 42 | new_value = json.loads(push_to_origin) 43 | update_config(config.configfile, push_to_origin=new_value) 44 | print(f"push_to_origin after: {new_value}") 45 | 46 | if toggle_fixes_message: 47 | try: 48 | before = load_config(config.configfile, "fixes_message") 49 | print(f"toggle_fixes_message before: {before}") 50 | except KeyError: 51 | print("toggle_fixes_message before: not set") 52 | before = True # the default 53 | new_value = not before 54 | update_config(config.configfile, fixes_message=new_value) 55 | print(f"fixes_message after: {new_value}") 56 | 57 | if toggle_username_branches is not None: 58 | try: 59 | before = load_config(config.configfile, "username_branches") 60 | print(f"username_branches before: {before}") 61 | except KeyError: 62 | print("username_branches before: not set") 63 | before = False # the default 64 | new_value = not before 65 | update_config(config.configfile, username_branches=new_value) 66 | print(f"username_branches after: {new_value}") 67 | -------------------------------------------------------------------------------- /gg/builtins/getback/tests/test_gg_getback.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | import pytest 7 | import requests_mock 8 | from click.testing import CliRunner 9 | 10 | # By doing this import we make sure that the plugin is made available 11 | # but the entry points loading inside gg.main. 12 | # An alternative would we to set `PYTHONPATH=. py.test` (or something) 13 | # but then that wouldn't test the entry point loading. 14 | from gg.main import Config 15 | 16 | from gg.builtins.getback.gg_getback import getback 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def requestsmock(): 21 | """Return a context where requests are all mocked. 22 | Usage:: 23 | 24 | def test_something(requestsmock): 25 | requestsmock.get( 26 | 'https://example.com/path' 27 | content=b'The content' 28 | ) 29 | # Do stuff that involves requests.get('http://example.com/path') 30 | """ 31 | with requests_mock.mock() as m: 32 | yield m 33 | 34 | 35 | @pytest.yield_fixture 36 | def temp_configfile(): 37 | tmp_dir = tempfile.mkdtemp("gg-start") 38 | fp = os.path.join(tmp_dir, "state.json") 39 | with open(fp, "w") as f: 40 | json.dump({}, f) 41 | yield fp 42 | shutil.rmtree(tmp_dir) 43 | 44 | 45 | def test_getback(temp_configfile, mocker): 46 | mocked_git = mocker.patch("git.Repo") 47 | mocked_git().working_dir = "gg-start-test" 48 | active_branch = mocker.MagicMock() 49 | mocked_git().active_branch = active_branch 50 | mocked_remote = mocker.MagicMock() 51 | mocked_remote.name = "origin" 52 | mocked_git().remotes.__iter__.return_value = [mocked_remote] 53 | mocked_git().is_dirty.return_value = False 54 | 55 | state = json.load(open(temp_configfile)) 56 | state["FORK_NAME"] = "peterbe" 57 | with open(temp_configfile, "w") as f: 58 | json.dump(state, f) 59 | 60 | runner = CliRunner() 61 | config = Config() 62 | config.configfile = temp_configfile 63 | result = runner.invoke(getback, [], input="\n", obj=config) 64 | if result.exception: 65 | # print(mocked_git.mock_calls) 66 | # print(result.output) 67 | # print(result.exception) 68 | raise result.exception 69 | assert result.exit_code == 0 70 | assert not result.exception 71 | 72 | mocked_git().git.branch.assert_called_with("-D", active_branch.name) 73 | -------------------------------------------------------------------------------- /gg/builtins/branches/tests/test_gg_branches.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | import pytest 7 | import requests_mock 8 | from click.testing import CliRunner 9 | 10 | # By doing this import we make sure that the plugin is made available 11 | # but the entry points loading inside gg.main. 12 | # An alternative would we to set `PYTHONPATH=. py.test` (or something) 13 | # but then that wouldn't test the entry point loading. 14 | from gg.main import Config 15 | 16 | from gg.builtins.branches.gg_branches import branches 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def requestsmock(): 21 | """Return a context where requests are all mocked. 22 | Usage:: 23 | 24 | def test_something(requestsmock): 25 | requestsmock.get( 26 | 'https://example.com/path' 27 | content=b'The content' 28 | ) 29 | # Do stuff that involves requests.get('http://example.com/path') 30 | """ 31 | with requests_mock.mock() as m: 32 | yield m 33 | 34 | 35 | @pytest.yield_fixture 36 | def temp_configfile(): 37 | tmp_dir = tempfile.mkdtemp("gg-start") 38 | fp = os.path.join(tmp_dir, "state.json") 39 | with open(fp, "w") as f: 40 | json.dump({}, f) 41 | yield fp 42 | shutil.rmtree(tmp_dir) 43 | 44 | 45 | def test_branches(temp_configfile, mocker): 46 | mocked_git = mocker.patch("git.Repo") 47 | mocked_git().working_dir = "gg-start-test" 48 | mocked_git().git.branch.return_value = """ 49 | * this-branch 50 | other-branch 51 | """ 52 | branch1 = mocker.MagicMock() 53 | branch1.name = "this-branch" 54 | 55 | branch2 = mocker.MagicMock() 56 | branch2.name = "other-branch" 57 | 58 | branch3 = mocker.MagicMock() 59 | branch3.name = "not-merged-branch" 60 | mocked_git().heads.__iter__.return_value = [branch1, branch2, branch3] 61 | 62 | state = json.load(open(temp_configfile)) 63 | state["FORK_NAME"] = "peterbe" 64 | with open(temp_configfile, "w") as f: 65 | json.dump(state, f) 66 | 67 | runner = CliRunner() 68 | config = Config() 69 | config.configfile = temp_configfile 70 | result = runner.invoke(branches, ["other"], input="\n", obj=config) 71 | if result.exception: 72 | # print(mocked_git.mock_calls) 73 | # print(result.output) 74 | # print(result.exception) 75 | raise result.exception 76 | # print(result.output) 77 | assert "other-branch" in result.output 78 | assert "this-branch" not in result.output 79 | assert result.exit_code == 0 80 | assert not result.exception 81 | 82 | # .assert_called_once() is new only in 3.6 83 | # branch2.checkout.assert_called_once() 84 | branch2.checkout.assert_called_with() 85 | -------------------------------------------------------------------------------- /gg/builtins/cleanup/tests/test_gg_cleanup.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | import pytest 7 | import requests_mock 8 | from click.testing import CliRunner 9 | 10 | # By doing this import we make sure that the plugin is made available 11 | # but the entry points loading inside gg.main. 12 | # An alternative would we to set `PYTHONPATH=. py.test` (or something) 13 | # but then that wouldn't test the entry point loading. 14 | from gg.main import Config 15 | 16 | from gg.builtins.cleanup.gg_cleanup import cleanup 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def requestsmock(): 21 | """Return a context where requests are all mocked. 22 | Usage:: 23 | 24 | def test_something(requestsmock): 25 | requestsmock.get( 26 | 'https://example.com/path' 27 | content=b'The content' 28 | ) 29 | # Do stuff that involves requests.get('http://example.com/path') 30 | """ 31 | with requests_mock.mock() as m: 32 | yield m 33 | 34 | 35 | @pytest.yield_fixture 36 | def temp_configfile(): 37 | tmp_dir = tempfile.mkdtemp("gg-start") 38 | fp = os.path.join(tmp_dir, "state.json") 39 | with open(fp, "w") as f: 40 | json.dump({}, f) 41 | yield fp 42 | shutil.rmtree(tmp_dir) 43 | 44 | 45 | def test_cleanup(temp_configfile, mocker): 46 | mocked_git = mocker.patch("git.Repo") 47 | mocked_git().working_dir = "gg-start-test" 48 | mocked_git().git.branch.return_value = """ 49 | this-branch 50 | * other-branch 51 | """ 52 | branch1 = mocker.MagicMock() 53 | branch1.name = "this-branch" 54 | 55 | branch2 = mocker.MagicMock() 56 | branch2.name = "other-branch" 57 | 58 | branch3 = mocker.MagicMock() 59 | branch3.name = "not-merged-branch" 60 | mocked_git().heads.__iter__.return_value = [branch1, branch2, branch3] 61 | 62 | active_branch = mocker.MagicMock() 63 | mocked_git().active_branch = active_branch 64 | 65 | mocked_remote = mocker.MagicMock() 66 | mocked_remote.name = "origin" 67 | mocked_git().remotes.__iter__.return_value = [mocked_remote] 68 | 69 | state = json.load(open(temp_configfile)) 70 | state["FORK_NAME"] = "peterbe" 71 | with open(temp_configfile, "w") as f: 72 | json.dump(state, f) 73 | 74 | runner = CliRunner() 75 | config = Config() 76 | config.configfile = temp_configfile 77 | result = runner.invoke(cleanup, ["other"], input="\n", obj=config) 78 | if result.exception: 79 | # print(mocked_git.mock_calls) 80 | # print(result.output) 81 | # print(result.exception) 82 | raise result.exception 83 | assert result.exit_code == 0 84 | assert not result.exception 85 | 86 | mocked_git().git.branch.assert_called_with("-D", branch2.name) 87 | -------------------------------------------------------------------------------- /gg/builtins/push/gg_push.py: -------------------------------------------------------------------------------- 1 | import git 2 | import click 3 | 4 | from gg.utils import error_out, info_out, success_out 5 | from gg.state import read, load_config 6 | from gg.main import cli, pass_config 7 | 8 | 9 | @cli.command() 10 | @click.option("-f", "--force", is_flag=True, default=False) 11 | @pass_config 12 | def push(config, force=False): 13 | """Create push the current branch.""" 14 | repo = config.repo 15 | 16 | state = read(config.configfile) 17 | default_branch = state.get("DEFAULT_BRANCH", "master") 18 | 19 | active_branch = repo.active_branch 20 | if active_branch.name == default_branch: 21 | error_out( 22 | f"Can't commit when on the {default_branch} branch. " 23 | "You really ought to do work in branches." 24 | ) 25 | 26 | if not state.get("FORK_NAME"): 27 | info_out("Can't help you push the commit. Please run: gg config --help") 28 | return 0 29 | 30 | try: 31 | push_to_origin = load_config(config.configfile, "push_to_origin") 32 | except KeyError: 33 | push_to_origin = False 34 | 35 | try: 36 | repo.remotes[state["FORK_NAME"]] 37 | except IndexError: 38 | error_out("There is no remote called '{}'".format(state["FORK_NAME"])) 39 | 40 | origin_name = state.get("ORIGIN_NAME", "origin") 41 | destination = repo.remotes[origin_name if push_to_origin else state["FORK_NAME"]] 42 | if force: 43 | (pushed,) = destination.push(force=True) 44 | info_out(pushed.summary) 45 | else: 46 | (pushed,) = destination.push() 47 | # print("PUSHED...") 48 | # for enum_name in [ 49 | # "DELETED", 50 | # "ERROR", 51 | # "FAST_FORWARD", 52 | # "NEW_HEAD", 53 | # "NEW_TAG", 54 | # "NO_MATCH", 55 | # "REMOTE_FAILURE", 56 | # ]: 57 | # print( 58 | # f"{enum_name}?:", pushed.flags & getattr(git.remote.PushInfo, enum_name) 59 | # ) 60 | 61 | if pushed.flags & git.remote.PushInfo.FORCED_UPDATE: 62 | success_out(f"Successfully force pushed to {destination}") 63 | elif ( 64 | pushed.flags & git.remote.PushInfo.REJECTED 65 | or pushed.flags & git.remote.PushInfo.REMOTE_REJECTED 66 | ): 67 | error_out('The push was rejected ("{}")'.format(pushed.summary), False) 68 | 69 | try_force_push = input("Try to force push? [Y/n] ").lower().strip() 70 | if try_force_push not in ("no", "n"): 71 | (pushed,) = destination.push(force=True) 72 | info_out(pushed.summary) 73 | else: 74 | return 0 75 | elif pushed.flags & git.remote.PushInfo.UP_TO_DATE: 76 | info_out(f"{destination} already up-to-date") 77 | else: 78 | success_out(f"Successfully pushed to {destination}") 79 | 80 | return 0 81 | -------------------------------------------------------------------------------- /gg/builtins/merge/tests/test_gg_merge.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | import pytest 7 | from click.testing import CliRunner 8 | 9 | # By doing this import we make sure that the plugin is made available 10 | # but the entry points loading inside gg.main. 11 | # An alternative would we to set `PYTHONPATH=. py.test` (or something) 12 | # but then that wouldn't test the entry point loading. 13 | from gg.main import Config 14 | from gg.testing import Response 15 | 16 | from gg.builtins.merge.gg_merge import merge 17 | 18 | 19 | @pytest.yield_fixture 20 | def temp_configfile(): 21 | tmp_dir = tempfile.mkdtemp("gg-merge") 22 | fp = os.path.join(tmp_dir, "state.json") 23 | with open(fp, "w") as f: 24 | json.dump({}, f) 25 | yield fp 26 | shutil.rmtree(tmp_dir) 27 | 28 | 29 | class MockDiff: 30 | def __init__(self, path, deleted_file=False): 31 | self.b_path = path 32 | self.deleted_file = deleted_file 33 | 34 | 35 | def test_merge(temp_configfile, mocker): 36 | rget = mocker.patch("requests.get") 37 | 38 | def mocked_get(url, params, headers): 39 | assert url.endswith("/peterbe/gg-example/pulls") 40 | assert headers["Authorization"] == "token somelongapitokenhere" 41 | return Response([]) 42 | 43 | rget.side_effect = mocked_get 44 | 45 | mocked_git = mocker.patch("git.Repo") 46 | mocked_git().working_dir = "gg-commit-test" 47 | branch1 = mocker.MagicMock() 48 | branch1.name = "this-branch" 49 | 50 | mocked_git().active_branch.name = branch1.name 51 | 52 | mocked_remote = mocker.MagicMock() 53 | mocked_remote.name = "origin" 54 | mocked_git().remotes.__iter__.return_value = [mocked_remote] 55 | mocked_git().is_dirty.return_value = False 56 | 57 | mocked_git().heads.__iter__.return_value = [branch1] 58 | 59 | my_remote = mocker.MagicMock() 60 | origin_remote = mocker.MagicMock() 61 | origin_remote.url = "git@github.com:peterbe/gg-example.git" 62 | my_pushinfo = mocker.MagicMock() 63 | my_pushinfo.flags = 0 64 | my_remote.push.return_value = [my_pushinfo] 65 | # first we have to fake some previous information 66 | state = json.load(open(temp_configfile)) 67 | state["gg-commit-test:my-topic-branch"] = { 68 | "description": "Some description", 69 | "bugnumber": None, 70 | } 71 | # state["FORK_NAME"] = "myusername" 72 | state["GITHUB"] = { 73 | "token": "somelongapitokenhere", 74 | "github_url": "https://example.com", 75 | } 76 | with open(temp_configfile, "w") as f: 77 | json.dump(state, f) 78 | 79 | runner = CliRunner() 80 | config = Config() 81 | config.configfile = temp_configfile 82 | result = runner.invoke(merge, [], input="\n\n", obj=config) 83 | if result.exception: 84 | # print(mocked_git.mock_calls) 85 | # print(result.output) 86 | # print(result.exception) 87 | raise result.exception 88 | assert result.exit_code == 0 89 | 90 | mocked_git().git.merge.assert_called_with("this-branch") 91 | mocked_git().git.branch.assert_called_with("-d", "this-branch") 92 | -------------------------------------------------------------------------------- /gg/builtins/getback/gg_getback.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from gg.utils import error_out, info_out, get_default_branch, warning_out 4 | from gg.state import read, load_config 5 | from gg.main import cli, pass_config 6 | 7 | 8 | @cli.command() 9 | @click.option("-f", "--force", is_flag=True, default=False) 10 | @pass_config 11 | def getback(config, force=False): 12 | """Goes back to the default branch, deletes the current branch locally 13 | and remotely.""" 14 | repo = config.repo 15 | 16 | state = read(config.configfile) 17 | origin_name = state.get("ORIGIN_NAME", "origin") 18 | default_branch = get_default_branch(repo, origin_name) 19 | active_branch = repo.active_branch 20 | if active_branch.name == default_branch: 21 | error_out(f"You're already on the {default_branch} branch.") 22 | 23 | if repo.is_dirty(): 24 | dirty_paths = ", ".join([repr(x.b_path) for x in repo.index.diff(None)]) 25 | error_out(f'Repo is "dirty". ({dirty_paths})') 26 | 27 | branch_name = active_branch.name 28 | 29 | upstream_remote = None 30 | fork_remote = None 31 | for remote in repo.remotes: 32 | if remote.name == origin_name: 33 | # remote.pull() 34 | upstream_remote = remote 35 | break 36 | if not upstream_remote: 37 | error_out(f"No remote called {origin_name!r} found") 38 | 39 | # Check out default branch 40 | repo.heads[default_branch].checkout() 41 | upstream_remote.pull(repo.heads[default_branch]) 42 | 43 | # Is this one of the merged branches?! 44 | # XXX I don't know how to do this "natively" with GitPython. 45 | merged_branches = [ 46 | x.strip() 47 | for x in repo.git.branch("--merged").splitlines() 48 | if x.strip() and not x.strip().startswith("*") 49 | ] 50 | was_merged = branch_name in merged_branches 51 | certain = was_merged or force 52 | if not certain: 53 | # Need to ask the user. 54 | # XXX This is where we could get smart and compare this branch 55 | # with the default branch. 56 | certain = ( 57 | input("Are you certain {} is actually merged? [Y/n] ".format(branch_name)) 58 | .lower() 59 | .strip() 60 | != "n" 61 | ) 62 | if not certain: 63 | return 1 64 | 65 | if was_merged: 66 | repo.git.branch("-d", branch_name) 67 | else: 68 | repo.git.branch("-D", branch_name) 69 | 70 | try: 71 | push_to_origin = load_config(config.configfile, "push_to_origin") 72 | except KeyError: 73 | push_to_origin = False 74 | remote_name = origin_name if push_to_origin else state["FORK_NAME"] 75 | 76 | fork_remote = None 77 | for remote in repo.remotes: 78 | if remote.name == remote_name: 79 | fork_remote = remote 80 | break 81 | else: 82 | info_out(f"Never found the remote {remote_name}") 83 | 84 | if fork_remote: 85 | try: 86 | fork_remote.push(":" + branch_name) 87 | info_out("Remote branch on fork deleted too.") 88 | except Exception as e: 89 | warning_out(f"Error deleting remote branch: {e.stderr or e.stdout or e}") 90 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | == 2 | gg 3 | == 4 | 5 | .. image:: https://github.com/peterbe/gg/workflows/Python/badge.svg 6 | :target: https://github.com/peterbe/gg/actions 7 | 8 | .. image:: https://badge.fury.io/py/gg.svg 9 | :target: https://pypi.python.org/pypi/gg 10 | 11 | Git and GitHub command line swiss army knife for the productivity addicted. 12 | 13 | ``gg`` is a base command, and all the work to create branches, list branches, 14 | clean up branches, connect to Bugzilla etc. are done by 15 | `plugins `_. 16 | 17 | ``gg`` is stateful. Meaning, plugins (not all!) need to store additional 18 | information that is re-used for other commands. For example, to 19 | connect to your GitHub account might need to store a GitHub Access Token. 20 | 21 | 22 | Installation 23 | ============ 24 | 25 | ``gg`` requires Python 3. 26 | 27 | The idea is that you install ``gg`` globally:: 28 | 29 | sudo pip install gg 30 | 31 | But that's optional, you can also just install it in your current 32 | virtual environment:: 33 | 34 | pip install gg 35 | 36 | If you don't want to install ``gg`` and its dependencies in either the 37 | current working virtual environment *or* in your global system Python, 38 | you can first install `pipx `_ 39 | then once you've installed and set that up:: 40 | 41 | pipx install gg 42 | 43 | Next, you need to install some plugins. See 44 | `PLUGINS.rst `_ 45 | for a list of available plugins. 46 | 47 | Bash completion 48 | =============== 49 | 50 | First download 51 | `gg-complete.sh `_ 52 | and save it somewhere on your computer. Then put this line into your `.bashrc` 53 | (or `.bash_profile` if you're on OSX):: 54 | 55 | source /path/to/gg-complete.sh 56 | 57 | 58 | How to develop 59 | ============== 60 | 61 | To work on this, first run:: 62 | 63 | pip install -U --editable . 64 | 65 | Now you can type:: 66 | 67 | gg --help 68 | 69 | If you have install more plugins they will be listed under the same 70 | ``--help`` command. 71 | 72 | Linting 73 | ======= 74 | 75 | This project tracks `black `_ and expects 76 | all files to be as per how ``black`` wants them. Please see its repo for how to 77 | set up automatic formatting. 78 | 79 | All code needs to be ``flake8`` conformant. See ``setup.cfg`` for the rules. 80 | 81 | To test both, run:: 82 | 83 | tox -e lint 84 | 85 | 86 | How to write a plugin 87 | ===================== 88 | 89 | To write your own custom plugin, (similar to ``gg/builtins/commands/commit``) 90 | these are the critical lines you need to you have in your ``setup.py``:: 91 | 92 | setup( 93 | ... 94 | install_requires=['gg'], 95 | entry_points=""" 96 | [gg.plugin] 97 | cli=gg_myplugin:start 98 | """, 99 | ... 100 | ) 101 | 102 | This assumes you have a file called ``gg_myplugin.py`` that has a function 103 | called ``start``. 104 | 105 | Version History 106 | =============== 107 | 108 | 0.1 109 | * Proof of concept 110 | -------------------------------------------------------------------------------- /gg/builtins/cleanup/gg_cleanup.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from gg.utils import error_out, info_out, get_default_branch, warning_out 4 | from gg.state import read 5 | from gg.main import cli, pass_config 6 | from gg.builtins.branches.gg_branches import find 7 | 8 | 9 | @cli.command() 10 | @click.option("-f", "--force", is_flag=True, default=False) 11 | @click.argument("searchstring") 12 | @pass_config 13 | def cleanup(config, searchstring, force=False): 14 | """Deletes a found branch locally and remotely.""" 15 | repo = config.repo 16 | 17 | state = read(config.configfile) 18 | origin_name = state.get("ORIGIN_NAME", "origin") 19 | # default_branch = state.get("DEFAULT_BRANCH", "master") 20 | default_branch = get_default_branch(repo, origin_name) 21 | 22 | branches_ = list(find(repo, searchstring)) 23 | if not branches_: 24 | error_out("No branches found") 25 | elif len(branches_) > 1: 26 | error_out( 27 | "More than one branch found.{}".format( 28 | "\n\t".join([""] + [x.name for x in branches_]) 29 | ) 30 | ) 31 | 32 | assert len(branches_) == 1 33 | branch_name = branches_[0].name 34 | active_branch = repo.active_branch 35 | if branch_name == active_branch.name: 36 | error_out("Can't clean up the current active branch.") 37 | # branch_name = active_branch.name 38 | upstream_remote = None 39 | fork_remote = None 40 | 41 | origin_name = state.get("ORIGIN_NAME", "origin") 42 | for remote in repo.remotes: 43 | if remote.name == origin_name: 44 | # remote.pull() 45 | upstream_remote = remote 46 | break 47 | if not upstream_remote: 48 | error_out("No remote called {!r} found".format(origin_name)) 49 | 50 | # Check out default branch 51 | repo.heads[default_branch].checkout() 52 | upstream_remote.pull(repo.heads[default_branch]) 53 | 54 | # Is this one of the merged branches?! 55 | # XXX I don't know how to do this "nativly" with GitPython. 56 | merged_branches = [ 57 | x.strip() 58 | for x in repo.git.branch("--merged").splitlines() 59 | if x.strip() and not x.strip().startswith("*") 60 | ] 61 | was_merged = branch_name in merged_branches 62 | certain = was_merged or force 63 | if not certain: 64 | # Need to ask the user. 65 | # XXX This is where we could get smart and compare this branch 66 | # with the default branch. 67 | certain = ( 68 | input("Are you certain {} is actually merged? [Y/n] ".format(branch_name)) 69 | .lower() 70 | .strip() 71 | != "n" 72 | ) 73 | if not certain: 74 | return 1 75 | 76 | if was_merged: 77 | repo.git.branch("-d", branch_name) 78 | else: 79 | repo.git.branch("-D", branch_name) 80 | 81 | fork_remote = None 82 | state = read(config.configfile) 83 | for remote in repo.remotes: 84 | if remote.name == state.get("FORK_NAME"): 85 | fork_remote = remote 86 | break 87 | if fork_remote: 88 | try: 89 | fork_remote.push(":" + branch_name) 90 | info_out("Remote branch on fork deleted too.") 91 | except Exception as e: 92 | warning_out(f"Error deleting remote branch: {e.stderr or e.stdout or e}") 93 | -------------------------------------------------------------------------------- /gg/builtins/pr/gg_pr.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | 5 | from gg.builtins import github 6 | from gg.main import cli, pass_config 7 | from gg.state import read, load_config 8 | from gg.utils import ( 9 | error_out, 10 | get_default_branch, 11 | info_out, 12 | ) 13 | 14 | 15 | @cli.command() 16 | @pass_config 17 | def pr(config): 18 | """Find PRs based on the current branch""" 19 | repo = config.repo 20 | 21 | state = read(config.configfile) 22 | origin_name = state.get("ORIGIN_NAME", "origin") 23 | default_branch = get_default_branch(repo, origin_name) 24 | 25 | active_branch = repo.active_branch 26 | if active_branch.name == default_branch: 27 | error_out(f"You can't find PRs from the {default_branch} branch. ") 28 | 29 | state = read(config.configfile) 30 | 31 | try: 32 | push_to_origin = load_config(config.configfile, "push_to_origin") 33 | except KeyError: 34 | push_to_origin = False 35 | 36 | # try: 37 | # data = load(config.configfile, active_branch.name) 38 | # except KeyError: 39 | # error_out( 40 | # "You're in a branch that was not created with gg.\n" 41 | # "No branch information available." 42 | # ) 43 | 44 | if not state.get("GITHUB"): 45 | if config.verbose: 46 | info_out( 47 | "Can't help create a GitHub Pull Request.\n" 48 | "Consider running: gg github --help" 49 | ) 50 | return 0 51 | 52 | origin = repo.remotes[state.get("ORIGIN_NAME", "origin")] 53 | rest = re.split(r"github\.com[:/]", origin.url)[1] 54 | org, repo = rest.split(".git")[0].split("/", 1) 55 | 56 | # Search for an existing open pull request, and remind us of the link 57 | # to it. 58 | if push_to_origin: 59 | head = f"{org}:{active_branch.name}" 60 | else: 61 | head = f"{state['FORK_NAME']}:{active_branch.name}" 62 | 63 | search = { 64 | "head": head, 65 | "state": "all", 66 | } 67 | for pull_request in github.find_pull_requests(config, org, repo, **search): 68 | print("Pull Request:") 69 | print("") 70 | print( 71 | "\t", 72 | pull_request["html_url"], 73 | " ", 74 | "(DRAFT)" 75 | if pull_request["draft"] 76 | else f"({pull_request['state'].upper()})", 77 | ) 78 | print("") 79 | 80 | full_pull_request = github.get_pull_request( 81 | config, org, repo, pull_request["number"] 82 | ) 83 | 84 | print("Mergeable?", full_pull_request.get("mergeable", "*not known*")) 85 | print("Updated", full_pull_request["updated_at"]) 86 | 87 | print("") 88 | break 89 | else: 90 | info_out("Sorry, can't find a PR") 91 | 92 | return 0 93 | 94 | 95 | # def _humanize_time(amount, units): 96 | # """Chopped and changed from http://stackoverflow.com/a/6574789/205832""" 97 | # intervals = (1, 60, 60 * 60, 60 * 60 * 24, 604800, 2419200, 29030400) 98 | # names = ( 99 | # ("second", "seconds"), 100 | # ("minute", "minutes"), 101 | # ("hour", "hours"), 102 | # ("day", "days"), 103 | # ("week", "weeks"), 104 | # ("month", "months"), 105 | # ("year", "years"), 106 | # ) 107 | 108 | # result = [] 109 | # unit = [x[1] for x in names].index(units) 110 | # # Convert to seconds 111 | # amount = amount * intervals[unit] 112 | # for i in range(len(names) - 1, -1, -1): 113 | # a = int(amount) // intervals[i] 114 | # if a > 0: 115 | # result.append((a, names[i][1 % a])) 116 | # amount -= a * intervals[i] 117 | # return result 118 | 119 | 120 | # def humanize_seconds(seconds): 121 | # return "{} {}".format(*_humanize_time(seconds, "seconds")[0]) 122 | -------------------------------------------------------------------------------- /gg/builtins/bugzilla.py: -------------------------------------------------------------------------------- 1 | import json 2 | import getpass 3 | import urllib.parse 4 | 5 | import click 6 | import requests 7 | 8 | from gg.utils import error_out, success_out, info_out 9 | from gg.state import read, update, remove 10 | from gg.main import cli, pass_config 11 | 12 | BUGZILLA_URL = "https://bugzilla.mozilla.org" 13 | 14 | 15 | @cli.group() 16 | @click.option( 17 | "-u", 18 | "--bugzilla-url", 19 | default=BUGZILLA_URL, 20 | help=f"URL to Bugzilla instance (default: {BUGZILLA_URL})", 21 | ) 22 | @pass_config 23 | def bugzilla(config, bugzilla_url): 24 | """General tool for connecting to Bugzilla. The default URL 25 | is that for bugzilla.mozilla.org but you can override that. 26 | Once you're signed in you can use those credentials to automatically 27 | fetch bug summaries even of private bugs. 28 | """ 29 | config.bugzilla_url = bugzilla_url 30 | 31 | 32 | @bugzilla.command() 33 | @click.argument("api_key", default="") 34 | @pass_config 35 | def login(config, api_key=""): 36 | """Store your Bugzilla API Key""" 37 | if not api_key: 38 | info_out( 39 | "If you don't have an API Key, go to:\n" 40 | "https://bugzilla.mozilla.org/userprefs.cgi?tab=apikey\n" 41 | ) 42 | api_key = getpass.getpass("API Key: ") 43 | 44 | # Before we store it, let's test it. 45 | url = urllib.parse.urljoin(config.bugzilla_url, "/rest/whoami") 46 | assert url.startswith("https://"), url 47 | response = requests.get(url, params={"api_key": api_key}) 48 | if response.status_code == 200: 49 | if response.json().get("error"): 50 | error_out(f"Failed - {response.json()}") 51 | else: 52 | update( 53 | config.configfile, 54 | { 55 | "BUGZILLA": { 56 | "bugzilla_url": config.bugzilla_url, 57 | "api_key": api_key, 58 | # "login": login, 59 | } 60 | }, 61 | ) 62 | success_out("Yay! It worked!") 63 | else: 64 | error_out(f"Failed - {response.status_code} ({response.json()})") 65 | 66 | 67 | @bugzilla.command() 68 | @pass_config 69 | def logout(config): 70 | """Remove and forget your Bugzilla credentials""" 71 | state = read(config.configfile) 72 | if state.get("BUGZILLA"): 73 | remove(config.configfile, "BUGZILLA") 74 | success_out("Forgotten") 75 | else: 76 | error_out("No stored Bugzilla credentials") 77 | 78 | 79 | def get_summary(config, bugnumber): 80 | params = {"ids": bugnumber, "include_fields": "summary,id"} 81 | # If this function is called from a plugin, we don't have 82 | # config.bugzilla_url this time. 83 | base_url = getattr(config, "bugzilla_url", BUGZILLA_URL) 84 | state = read(config.configfile) 85 | 86 | credentials = state.get("BUGZILLA") 87 | if credentials: 88 | # cool! let's use that 89 | base_url = credentials["bugzilla_url"] 90 | params["api_key"] = credentials["api_key"] 91 | 92 | url = urllib.parse.urljoin(base_url, "/rest/bug/") 93 | assert url.startswith("https://"), url 94 | response = requests.get(url, params=params) 95 | response.raise_for_status() 96 | if response.status_code == 200: 97 | data = response.json() 98 | bug = data["bugs"][0] 99 | 100 | bug_url = urllib.parse.urljoin(base_url, f"/show_bug.cgi?id={bug['id']}") 101 | return bug["summary"], bug_url 102 | return None, None 103 | 104 | 105 | @bugzilla.command() 106 | @click.option( 107 | "-b", "--bugnumber", type=int, help="Optionally test fetching a specific bug" 108 | ) 109 | @pass_config 110 | def test(config, bugnumber): 111 | """Test your saved Bugzilla API Key.""" 112 | state = read(config.configfile) 113 | credentials = state.get("BUGZILLA") 114 | if not credentials: 115 | error_out("No API Key saved. Run: gg bugzilla login") 116 | if config.verbose: 117 | info_out(f"Using: {credentials['bugzilla_url']}") 118 | 119 | if bugnumber: 120 | summary, _ = get_summary(config, bugnumber) 121 | if summary: 122 | info_out("It worked!") 123 | success_out(summary) 124 | else: 125 | error_out("Unable to fetch") 126 | else: 127 | url = urllib.parse.urljoin(credentials["bugzilla_url"], "/rest/whoami") 128 | assert url.startswith("https://"), url 129 | 130 | response = requests.get(url, params={"api_key": credentials["api_key"]}) 131 | if response.status_code == 200: 132 | if response.json().get("error"): 133 | error_out(f"Failed! - {response.json()}") 134 | else: 135 | success_out(json.dumps(response.json(), indent=2)) 136 | else: 137 | error_out(f"Failed to query - {response.status_code} ({response.json()})") 138 | -------------------------------------------------------------------------------- /tests/test_bugzilla.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from click.testing import CliRunner 4 | 5 | from gg.main import Config 6 | from gg.builtins import bugzilla 7 | 8 | 9 | def test_login(temp_configfile, mocker, requestsmock): 10 | getpass = mocker.patch("getpass.getpass") 11 | getpass.return_value = "secret" 12 | 13 | requestsmock.get( 14 | "https://example.com/rest/whoami?api_key=secret", 15 | content=json.dumps( 16 | { 17 | "real_name": "Peter Bengtsson [:peterbe]", 18 | "name": "peterbe@example.com", 19 | "id": 1, 20 | } 21 | ).encode("utf-8"), 22 | ) 23 | 24 | runner = CliRunner() 25 | config = Config() 26 | config.configfile = temp_configfile 27 | config.bugzilla_url = "https://example.com" 28 | result = runner.invoke(bugzilla.login, [], input="myusername", obj=config) 29 | if result.exception: 30 | raise result.exception 31 | assert result.exit_code == 0 32 | assert not result.exception 33 | 34 | with open(temp_configfile) as f: 35 | saved = json.load(f) 36 | assert "BUGZILLA" in saved 37 | assert saved["BUGZILLA"]["api_key"] == "secret" 38 | assert saved["BUGZILLA"]["bugzilla_url"] == "https://example.com" 39 | 40 | 41 | def test_test(temp_configfile, mocker, requestsmock): 42 | with open(temp_configfile, "w") as f: 43 | saved = { 44 | "BUGZILLA": {"api_key": "secret", "bugzilla_url": "https://example.com"} 45 | } 46 | json.dump(saved, f) 47 | 48 | requestsmock.get( 49 | "https://example.com/rest/whoami?api_key=secret", 50 | content=json.dumps( 51 | {"real_name": "John Doe", "name": "peterbe@example.com", "id": 1} 52 | ).encode("utf-8"), 53 | ) 54 | 55 | runner = CliRunner() 56 | config = Config() 57 | config.configfile = temp_configfile 58 | config.bugzilla_url = "https://example.com" 59 | result = runner.invoke(bugzilla.test, [], obj=config) 60 | if result.exception: 61 | raise result.exception 62 | assert result.exit_code == 0 63 | assert not result.exception 64 | assert "John Doe" in result.output 65 | 66 | 67 | def test_logout(temp_configfile, mocker): 68 | 69 | with open(temp_configfile, "w") as f: 70 | saved = {"BUGZILLA": {"api_key": "secret"}} 71 | json.dump(saved, f) 72 | 73 | runner = CliRunner() 74 | config = Config() 75 | config.configfile = temp_configfile 76 | config.bugzilla_url = "https://example.com" 77 | result = runner.invoke(bugzilla.logout, [], obj=config) 78 | assert result.exit_code == 0 79 | assert not result.exception 80 | 81 | with open(temp_configfile) as f: 82 | saved = json.load(f) 83 | assert "BUGZILLA" not in saved 84 | 85 | 86 | def test_get_summary(temp_configfile, mocker, requestsmock): 87 | 88 | requestsmock.get( 89 | "https://bugs.example.com/rest/bug/", 90 | content=json.dumps( 91 | { 92 | "bugs": [ 93 | { 94 | "assigned_to": "nobody@mozilla.org", 95 | "assigned_to_detail": { 96 | "email": "nobody@mozilla.org", 97 | "id": 1, 98 | "name": "nobody@mozilla.org", 99 | "real_name": "Nobody; OK to take it and work on it", 100 | }, 101 | "id": 123456789, 102 | "status": "RESOLVED", 103 | "summary": "This is the summary", 104 | } 105 | ], 106 | "faults": [], 107 | } 108 | ).encode("utf-8"), 109 | ) 110 | 111 | config = Config() 112 | config.configfile = temp_configfile 113 | config.bugzilla_url = "https://bugs.example.com" 114 | 115 | summary, url = bugzilla.get_summary(config, "123456789") 116 | assert summary == "This is the summary" 117 | assert url == "https://bugs.example.com/show_bug.cgi?id=123456789" 118 | 119 | 120 | def test_get_summary_with_token(temp_configfile, requestsmock): 121 | requestsmock.get( 122 | "https://privatebugs.example.com/rest/bug/", 123 | content=json.dumps( 124 | { 125 | "bugs": [ 126 | { 127 | "assigned_to": "nobody@mozilla.org", 128 | "assigned_to_detail": { 129 | "email": "nobody@mozilla.org", 130 | "id": 1, 131 | "name": "nobody@mozilla.org", 132 | "real_name": "Nobody; OK to take it and work on it", 133 | }, 134 | "id": 123456789, 135 | "status": "NEW", 136 | "summary": "This is a SECRET!", 137 | } 138 | ], 139 | "faults": [], 140 | } 141 | ).encode("utf-8"), 142 | ) 143 | 144 | with open(temp_configfile, "w") as f: 145 | saved = { 146 | "BUGZILLA": { 147 | "api_key": "secret", 148 | "bugzilla_url": "https://privatebugs.example.com", 149 | } 150 | } 151 | json.dump(saved, f) 152 | config = Config() 153 | config.configfile = temp_configfile 154 | config.bugzilla_url = "https://bugs.example.com" 155 | 156 | summary, url = bugzilla.get_summary(config, "123456789") 157 | assert summary == "This is a SECRET!" 158 | assert url == "https://privatebugs.example.com/show_bug.cgi?id=123456789" 159 | -------------------------------------------------------------------------------- /gg/builtins/github.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import getpass 4 | import urllib.parse 5 | 6 | import click 7 | import requests 8 | 9 | from gg.utils import error_out, success_out, info_out 10 | from gg.state import read, update, remove 11 | from gg.main import cli, pass_config 12 | 13 | GITHUB_URL = "https://api.github.com" 14 | 15 | 16 | @cli.group() 17 | @click.option( 18 | "-u", 19 | "--github-url", 20 | default=GITHUB_URL, 21 | help=f"URL to GitHub instance (default: {GITHUB_URL})", 22 | ) 23 | @pass_config 24 | def github(config, github_url): 25 | """For setting up a GitHub API token.""" 26 | config.github_url = github_url 27 | 28 | 29 | @github.command() 30 | @click.argument("token", default="") 31 | @pass_config 32 | def token(config, token): 33 | """Store and fetch a GitHub access token""" 34 | if not token: 35 | info_out( 36 | "To generate a personal API token, go to:\n\n\t" 37 | "https://github.com/settings/tokens\n\n" 38 | "To read more about it, go to:\n\n\t" 39 | "https://help.github.com/articles/creating-an-access" 40 | "-token-for-command-line-use/\n\n" 41 | 'Remember to enable "repo" in the scopes.' 42 | ) 43 | token = getpass.getpass("GitHub API Token: ").strip() 44 | url = urllib.parse.urljoin(config.github_url, "/user") 45 | assert url.startswith("https://"), url 46 | response = requests.get(url, headers={"Authorization": f"token {token}"}) 47 | if response.status_code == 200: 48 | update( 49 | config.configfile, 50 | { 51 | "GITHUB": { 52 | "github_url": config.github_url, 53 | "token": token, 54 | "login": response.json()["login"], 55 | } 56 | }, 57 | ) 58 | name = response.json()["name"] or response.json()["login"] 59 | success_out(f"Hi! {name}") 60 | else: 61 | error_out(f"Failed - {response.status_code} ({response.content})") 62 | 63 | 64 | @github.command() 65 | @pass_config 66 | def burn(config): 67 | """Remove and forget your GitHub credentials""" 68 | state = read(config.configfile) 69 | if state.get("GITHUB"): 70 | remove(config.configfile, "GITHUB") 71 | success_out("Forgotten") 72 | else: 73 | error_out("No stored GitHub credentials") 74 | 75 | 76 | def get_title(config, org, repo, number): 77 | base_url = GITHUB_URL 78 | headers = {} 79 | state = read(config.configfile) 80 | credentials = state.get("GITHUB") 81 | if credentials: 82 | base_url = state["GITHUB"]["github_url"] 83 | headers["Authorization"] = f"token {credentials['token']}" 84 | if config.verbose: 85 | info_out(f'Using API token: {credentials["token"][:10] + "…"}') 86 | url = urllib.parse.urljoin(base_url, f"/repos/{org}/{repo}/issues/{number}") 87 | if config.verbose: 88 | info_out(f"GitHub URL: {url}") 89 | assert url.startswith("https://"), url 90 | response = requests.get(url, headers=headers) 91 | response.raise_for_status() 92 | if response.status_code == 200: 93 | data = response.json() 94 | return data["title"], data["html_url"] 95 | if config.verbose: 96 | info_out(f"GitHub Response: {response}") 97 | return None, None 98 | 99 | 100 | @github.command() 101 | @click.option("-i", "--issue-url", help="Optionally test fetching a specific issue") 102 | @pass_config 103 | def test(config, issue_url): 104 | """Test your saved GitHub API Token.""" 105 | state = read(config.configfile) 106 | credentials = state.get("GITHUB") 107 | if not credentials: 108 | error_out("No credentials saved. Run: gg github token") 109 | if config.verbose: 110 | info_out(f"Using: {credentials['github_url']}") 111 | 112 | if issue_url: 113 | github_url_regex = re.compile( 114 | r"https://github.com/([^/]+)/([^/]+)/issues/(\d+)" 115 | ) 116 | org, repo, number = github_url_regex.search(issue_url).groups() 117 | title, _ = get_title(config, org, repo, number) 118 | if title: 119 | info_out("It worked!") 120 | success_out(title) 121 | else: 122 | error_out("Unable to fetch") 123 | else: 124 | url = urllib.parse.urljoin(credentials["github_url"], "/user") 125 | assert url.startswith("https://"), url 126 | response = requests.get( 127 | url, headers={"Authorization": f"token {credentials['token']}"} 128 | ) 129 | if response.status_code == 200: 130 | success_out(json.dumps(response.json(), indent=2)) 131 | else: 132 | error_out(f"Failed to query - {response.status_code} ({response.json()})") 133 | 134 | 135 | def find_pull_requests(config, org, repo, **params): 136 | base_url = GITHUB_URL 137 | headers = {} 138 | state = read(config.configfile) 139 | credentials = state.get("GITHUB") 140 | if credentials: 141 | # base_url = state['GITHUB']['github_url'] 142 | headers["Authorization"] = f"token {credentials['token']}" 143 | if config.verbose: 144 | info_out(f'Using API token: {credentials["token"][:10] + "…"}') 145 | url = urllib.parse.urljoin(base_url, f"/repos/{org}/{repo}/pulls") 146 | if config.verbose: 147 | info_out(f"GitHub URL: {url}") 148 | assert url.startswith("https://"), url 149 | response = requests.get(url, params, headers=headers) 150 | if response.status_code == 200: 151 | return response.json() 152 | 153 | 154 | def get_pull_request(config, org, repo, number): 155 | base_url = GITHUB_URL 156 | headers = {} 157 | state = read(config.configfile) 158 | credentials = state.get("GITHUB") 159 | if credentials: 160 | headers["Authorization"] = f"token {credentials['token']}" 161 | if config.verbose: 162 | info_out(f'Using API token: {credentials["token"][:10] + "…"}') 163 | url = urllib.parse.urljoin(base_url, f"/repos/{org}/{repo}/pulls/{number}") 164 | if config.verbose: 165 | info_out(f"GitHub URL: {url}") 166 | assert url.startswith("https://"), url 167 | response = requests.get(url, headers=headers) 168 | if response.status_code == 200: 169 | return response.json() 170 | -------------------------------------------------------------------------------- /gg/builtins/start/gg_start.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import getpass 4 | import urllib 5 | 6 | import click 7 | 8 | from gg.utils import error_out, info_out, is_github 9 | from gg.state import save, read, load_config 10 | from gg.main import cli, pass_config 11 | from gg.builtins import bugzilla 12 | from gg.builtins import github 13 | from gg.builtins.branches.gg_branches import find 14 | 15 | 16 | @cli.command() 17 | @click.argument("bugnumber", default="") 18 | @pass_config 19 | def start(config, bugnumber=""): 20 | """Create a new topic branch.""" 21 | repo = config.repo 22 | 23 | try: 24 | username_branches = load_config(config.configfile, "username_branches") 25 | except KeyError: 26 | username_branches = False 27 | 28 | if bugnumber: 29 | summary, bugnumber, url = get_summary(config, bugnumber) 30 | else: 31 | url = None 32 | summary = None 33 | 34 | if summary: 35 | summary = input('Summary ["{}"]: '.format(summary)).strip() or summary 36 | else: 37 | summary = input("Summary: ").strip() 38 | 39 | branch_name = "" 40 | if username_branches: 41 | branch_name += f"{os.getlogin()}-" 42 | if bugnumber: 43 | if is_github({"bugnumber": bugnumber, "url": url}): 44 | branch_name += f"{bugnumber}-" 45 | else: 46 | branch_name += f"{bugnumber}-" 47 | 48 | def clean_branch_name(string): 49 | string = re.sub(r"\s+", " ", string) 50 | for each in " |": 51 | string = string.replace(each, "-") 52 | for each in ("->", "=>"): 53 | string = string.replace(each, "-") 54 | for each in "@%^&:'\"/(),[]{}!.?`$<>#*;=": 55 | string = string.replace(each, "") 56 | string = re.sub("-+", "-", string) 57 | string = string.strip("-") 58 | return string.lower().strip() 59 | 60 | branch_name += clean_branch_name(summary) 61 | 62 | if not branch_name: 63 | error_out("Must provide a branch name") 64 | 65 | # Check that the branch doesn't already exist 66 | found = list(find(repo, branch_name, exact=True)) 67 | if found: 68 | error_out("There is already a branch called {!r}".format(found[0].name)) 69 | 70 | new_branch = repo.create_head(branch_name) 71 | new_branch.checkout() 72 | if config.verbose: 73 | click.echo("Checkout out new branch: {}".format(branch_name)) 74 | 75 | save(config.configfile, summary, branch_name, bugnumber=bugnumber, url=url) 76 | 77 | 78 | def get_summary(config, bugnumber): 79 | """return a summary for this bug/issue. If it can't be found, 80 | return None.""" 81 | 82 | bugzilla_url_regex = re.compile( 83 | re.escape("https://bugzilla.mozilla.org/show_bug.cgi?id=") + r"(\d+)$" 84 | ) 85 | 86 | # The user could have pasted in a bugzilla ID or a bugzilla URL 87 | if bugzilla_url_regex.search(bugnumber.split("#")[0]): 88 | # that's easy then! 89 | (bugzilla_id,) = bugzilla_url_regex.search(bugnumber.split("#")[0]).groups() 90 | bugzilla_id = int(bugzilla_id) 91 | summary, url = bugzilla.get_summary(config, bugzilla_id) 92 | return summary, bugzilla_id, url 93 | 94 | # The user could have pasted in a GitHub issue URL 95 | github_url_regex = re.compile(r"https://github.com/([^/]+)/([^/]+)/issues/(\d+)") 96 | if github_url_regex.search(bugnumber.split("#")[0]): 97 | # that's also easy 98 | ( 99 | org, 100 | repo, 101 | id_, 102 | ) = github_url_regex.search(bugnumber.split("#")[0]).groups() 103 | id_ = int(id_) 104 | title, url = github.get_title(config, org, repo, id_) 105 | if title: 106 | return title.strip(), id_, url 107 | else: 108 | return None, None, None 109 | 110 | # If it's a number it can be either a github issue or a bugzilla bug 111 | if bugnumber.isdigit(): 112 | # try both and see if one of them turns up something interesting 113 | 114 | repo = config.repo 115 | state = read(config.configfile) 116 | fork_name = state.get("FORK_NAME", getpass.getuser()) 117 | if config.verbose: 118 | info_out("Using fork name: {}".format(fork_name)) 119 | candidates = [] 120 | # Looping over the remotes, let's figure out which one 121 | # is the one that has issues. Let's try every one that isn't 122 | # your fork remote. 123 | for origin in repo.remotes: 124 | if origin.name == fork_name: 125 | continue 126 | url = origin.url 127 | org, repo = parse_remote_url(origin.url) 128 | github_title, github_url = github.get_title( 129 | config, org, repo, int(bugnumber) 130 | ) 131 | if github_title: 132 | candidates.append((github_title, int(bugnumber), github_url)) 133 | 134 | bugzilla_summary, bugzilla_url = bugzilla.get_summary(config, bugnumber) 135 | if bugzilla_summary: 136 | candidates.append((bugzilla_summary, int(bugnumber), bugzilla_url)) 137 | 138 | if len(candidates) > 1: 139 | info_out( 140 | "Input is ambiguous. Multiple possibilities found. " 141 | "Please re-run with the full URL:" 142 | ) 143 | for title, _, url in candidates: 144 | info_out("\t{}".format(url)) 145 | info_out("\t{}\n".format(title)) 146 | error_out("Awaiting your choice") 147 | elif len(candidates) == 1: 148 | return candidates[0] 149 | else: 150 | error_out("ID could not be found on GitHub or Bugzilla") 151 | raise Exception(bugnumber) 152 | 153 | return bugnumber, None, None 154 | 155 | 156 | def parse_remote_url(url): 157 | """return a tuple of (org, repo) from the remote git URL""" 158 | # The URL will either be git@github.com:org/repo.git or 159 | # https://github.com/org/repo.git and in both cases 160 | # it's not guarantee that the domain is github.com. 161 | # FIXME: Make it work non-github.com domains 162 | if url.startswith("git@"): 163 | path = url.split(":", 1)[1] 164 | else: 165 | parsed = urllib.parse.urlparse(url) 166 | path = parsed.path[1:] 167 | 168 | assert path.endswith(".git"), path 169 | path = path[:-4] 170 | return path.split("/") 171 | -------------------------------------------------------------------------------- /gg/builtins/commit/tests/test_gg_commit.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | import pytest 7 | from click.testing import CliRunner 8 | 9 | # By doing this import we make sure that the plugin is made available 10 | # but the entry points loading inside gg.main. 11 | # An alternative would we to set `PYTHONPATH=. py.test` (or something) 12 | # but then that wouldn't test the entry point loading. 13 | from gg.main import Config 14 | from gg.testing import Response 15 | 16 | from gg.builtins.commit.gg_commit import commit, humanize_seconds 17 | 18 | 19 | @pytest.yield_fixture 20 | def temp_configfile(): 21 | tmp_dir = tempfile.mkdtemp("gg-commit") 22 | fp = os.path.join(tmp_dir, "state.json") 23 | with open(fp, "w") as f: 24 | json.dump({}, f) 25 | yield fp 26 | shutil.rmtree(tmp_dir) 27 | 28 | 29 | class MockDiff: 30 | def __init__(self, path, deleted_file=False): 31 | self.b_path = path 32 | self.deleted_file = deleted_file 33 | 34 | 35 | def test_commit(temp_configfile, mocker): 36 | rget = mocker.patch("requests.get") 37 | 38 | def mocked_get(url, params, headers): 39 | assert url.endswith("/peterbe/gg-example/pulls") 40 | assert headers["Authorization"] == "token somelongapitokenhere" 41 | return Response([]) 42 | 43 | rget.side_effect = mocked_get 44 | 45 | mocked_git = mocker.patch("git.Repo") 46 | mocked_git().working_dir = "gg-commit-test" 47 | mocked_git().active_branch.name = "my-topic-branch" 48 | mocked_git().index.diff.return_value = [MockDiff("some/path.txt")] 49 | 50 | my_remote = mocker.MagicMock() 51 | origin_remote = mocker.MagicMock() 52 | origin_remote.url = "git@github.com:peterbe/gg-example.git" 53 | mocked_git().remotes = {"myusername": my_remote, "origin": origin_remote} 54 | my_pushinfo = mocker.MagicMock() 55 | my_pushinfo.flags = 0 56 | my_remote.push.return_value = [my_pushinfo] 57 | # first we have to fake some previous information 58 | state = json.load(open(temp_configfile)) 59 | state["gg-commit-test:my-topic-branch"] = { 60 | "description": "Some description", 61 | "bugnumber": None, 62 | } 63 | state["FORK_NAME"] = "myusername" 64 | state["GITHUB"] = { 65 | "token": "somelongapitokenhere", 66 | "github_url": "https://example.com", 67 | } 68 | with open(temp_configfile, "w") as f: 69 | json.dump(state, f) 70 | 71 | runner = CliRunner() 72 | config = Config() 73 | config.configfile = temp_configfile 74 | result = runner.invoke(commit, [], input="\n\n", obj=config) 75 | if result.exception: 76 | raise result.exception 77 | assert result.exit_code == 0 78 | assert not result.exception 79 | pr_url = ( 80 | "https://github.com/peterbe/gg-example/compare/peterbe:master..." 81 | "myusername:my-topic-branch?expand=1" 82 | ) 83 | assert pr_url in result.output 84 | 85 | 86 | def test_commit_without_github(temp_configfile, mocker): 87 | mocked_git = mocker.patch("git.Repo") 88 | mocked_git().working_dir = "gg-commit-test" 89 | mocked_git().active_branch.name = "my-topic-branch" 90 | mocked_git().index.diff.return_value = [MockDiff("foo.txt")] 91 | 92 | # first we have to fake some previous information 93 | state = json.load(open(temp_configfile)) 94 | state["gg-commit-test:my-topic-branch"] = { 95 | "description": "Some description", 96 | "bugnumber": None, 97 | } 98 | state["FORK_NAME"] = "myusername" 99 | with open(temp_configfile, "w") as f: 100 | json.dump(state, f) 101 | 102 | my_remote = mocker.MagicMock() 103 | origin_remote = mocker.MagicMock() 104 | origin_remote.url = "git@github.com:peterbe/gg-example.git" 105 | mocked_git().remotes = {"myusername": my_remote, "origin": origin_remote} 106 | my_pushinfo = mocker.MagicMock() 107 | my_pushinfo.flags = 0 108 | my_remote.push.return_value = [my_pushinfo] 109 | 110 | runner = CliRunner() 111 | config = Config() 112 | config.configfile = temp_configfile 113 | result = runner.invoke(commit, [], input="\n\n", obj=config) 114 | if result.exception: 115 | # print(result.exception) 116 | # print(result.output) 117 | raise result.exception 118 | assert result.exit_code == 0 119 | assert not result.exception 120 | 121 | 122 | def test_commit_no_files_to_add(temp_configfile, mocker): 123 | mocked_git = mocker.patch("git.Repo") 124 | mocked_git().working_dir = "gg-commit-test" 125 | mocked_git().active_branch.name = "my-topic-branch" 126 | mocked_git().index.entries.keys.return_value = [] 127 | 128 | # first we have to fake some previous information 129 | # print(repr(temp_configfile)) 130 | state = json.load(open(temp_configfile)) 131 | state["gg-commit-test:my-topic-branch"] = { 132 | "description": "Some description", 133 | "bugnumber": None, 134 | } 135 | with open(temp_configfile, "w") as f: 136 | json.dump(state, f) 137 | 138 | runner = CliRunner() 139 | config = Config() 140 | config.configfile = temp_configfile 141 | result = runner.invoke(commit, [], input="\n", obj=config) 142 | assert result.exit_code == 0 143 | assert not result.exception 144 | assert "No files to add" in result.output 145 | 146 | 147 | def test_commit_without_start(temp_configfile, mocker): 148 | mocked_git = mocker.patch("git.Repo") 149 | mocked_git().working_dir = "gg-commit-test" 150 | 151 | runner = CliRunner() 152 | config = Config() 153 | config.configfile = temp_configfile 154 | result = runner.invoke(commit, [], input='foo "bar"\n', obj=config) 155 | assert result.exit_code > 0 156 | assert result.exception 157 | assert "You're in a branch that was not created with gg." in result.output 158 | 159 | 160 | def test_humanize_seconds(): 161 | assert humanize_seconds(1) == "1 second" 162 | assert humanize_seconds(45) == "45 seconds" 163 | # assert humanize_seconds(45 + 60) == "1 minute 45 seconds" 164 | assert humanize_seconds(45 + 60) == "1 minute" 165 | # assert humanize_seconds(45 + 60 * 2) == "2 minutes 45 seconds" 166 | assert humanize_seconds(45 + 60 * 2) == "2 minutes" 167 | assert humanize_seconds(60 * 60) == "1 hour" 168 | assert humanize_seconds(60 * 60 * 2) == "2 hours" 169 | assert humanize_seconds(60 * 60 * 24) == "1 day" 170 | assert humanize_seconds(60 * 60 * 24 * 2) == "2 days" 171 | assert humanize_seconds(60 * 60 * 24 * 7) == "1 week" 172 | assert humanize_seconds(60 * 60 * 24 * 14) == "2 weeks" 173 | assert humanize_seconds(60 * 60 * 24 * 28) == "1 month" 174 | assert humanize_seconds(60 * 60 * 24 * 28 * 2) == "2 months" 175 | assert humanize_seconds(60 * 60 * 24 * 28 * 12) == "1 year" 176 | assert humanize_seconds(60 * 60 * 24 * 28 * 12 * 2) == "2 years" 177 | -------------------------------------------------------------------------------- /gg/builtins/branches/gg_fetchcheckout.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import click 4 | import git 5 | from gg.main import cli, pass_config 6 | from gg.utils import error_out, info_out, success_out, warning_out 7 | 8 | 9 | class InvalidRemoteName(Exception): 10 | """when a remote doesn't exist""" 11 | 12 | 13 | DEFAULT_CUTOFF = 10 14 | 15 | 16 | @cli.command() 17 | @click.option( 18 | "-y", 19 | "--yes", 20 | default=False, 21 | is_flag=True, 22 | help="Immediately say yes to any questions", 23 | ) 24 | @click.argument("searchstring") 25 | @pass_config 26 | def fetchcheckout(config, yes=False, searchstring="", cutoff=DEFAULT_CUTOFF): 27 | """Fetch from origin. And if exactly 1 found, offer to check it out.""" 28 | repo = config.repo 29 | 30 | try: 31 | branches_ = list(find(repo, searchstring)) 32 | except InvalidRemoteName as exception: 33 | remote_search_name = searchstring.split(":")[0] 34 | if remote_search_name in [x.name for x in repo.remotes]: 35 | error_out(f"Invalid remote name {exception!r}") 36 | 37 | (repo_name,) = [ 38 | x.url.split("/")[-1].split(".git")[0] 39 | for x in repo.remotes 40 | if x.name == "origin" 41 | ] 42 | # Add it and start again 43 | remote_url = f"https://github.com/{remote_search_name}/{repo_name}.git" 44 | if not click.confirm( 45 | f"Add remote {remote_search_name!r} ({remote_url})", default=True 46 | ): 47 | error_out("Unable to find or create remote") 48 | 49 | repo.create_remote(remote_search_name, remote_url) 50 | branches_ = list(find(repo, searchstring)) 51 | 52 | if branches_: 53 | merged = get_merged_branches(repo) 54 | info_out("Found existing branches...") 55 | print_list(branches_, merged, cutoff=cutoff) 56 | if len(branches_) == 1 and searchstring: 57 | # If the found branch is the current one, error 58 | active_branch = repo.active_branch 59 | if active_branch == branches_[0]: 60 | error_out(f"You're already on {branches_[0].name!r}") 61 | branch_name = branches_[0].name 62 | if len(branch_name) > 50: 63 | branch_name = branch_name[:47] + "…" 64 | 65 | if not yes: 66 | check_it_out = ( 67 | input(f"Check out {branch_name!r}? [Y/n] ").lower().strip() != "n" 68 | ) 69 | if yes or check_it_out: 70 | branches_[0].checkout() 71 | elif searchstring: 72 | warning_out(f"Found no branches matching {searchstring!r}.") 73 | else: 74 | warning_out("Found no branches.") 75 | 76 | 77 | def find(repo, searchstring, exact=False): 78 | # When you copy-to-clipboard from GitHub you get something like 79 | # 'peterbe:1545809-urllib3-1242' for example. 80 | # But first, it exists as a local branch, use that. 81 | if searchstring and ":" in searchstring: 82 | remote_name = searchstring.split(":")[0] 83 | for remote in repo.remotes: 84 | if remote.name == remote_name: 85 | # remote.pull() 86 | found_remote = remote 87 | break 88 | else: 89 | raise InvalidRemoteName(remote_name) 90 | 91 | for head in repo.heads: 92 | if exact: 93 | if searchstring.split(":", 1)[1].lower() == head.name.lower(): 94 | yield head 95 | return 96 | else: 97 | if searchstring.split(":", 1)[1].lower() in head.name.lower(): 98 | yield head 99 | return 100 | 101 | info_out(f"Fetching the latest from {found_remote}") 102 | for fetchinfo in found_remote.fetch(): 103 | if fetchinfo.flags & git.remote.FetchInfo.HEAD_UPTODATE: 104 | # Most boring 105 | pass 106 | else: 107 | msg = "updated" 108 | if fetchinfo.flags & git.remote.FetchInfo.FORCED_UPDATE: 109 | msg += " (force updated)" 110 | print(fetchinfo.ref, msg) 111 | 112 | if str(fetchinfo.ref) == searchstring.replace(":", "/", 1): 113 | yield fetchinfo.ref 114 | 115 | for head in repo.heads: 116 | if searchstring: 117 | if exact: 118 | if searchstring.lower() != head.name.lower(): 119 | continue 120 | else: 121 | if searchstring.lower() not in head.name.lower(): 122 | continue 123 | yield head 124 | 125 | 126 | def get_merged_branches(repo): 127 | # XXX I wish I could dedude from a git.refs.head.Head object if it was 128 | # merged. Then I wouldn't have to do this string splitting crap. 129 | output = repo.git.branch("--merged") 130 | return [x.split()[-1] for x in output.splitlines() if x.strip()] 131 | 132 | 133 | def print_list(heads, merged_names, cutoff=10): 134 | def wrap(head): 135 | commit = head.commit 136 | return { 137 | "head": head, 138 | "info": {"date": commit.committed_datetime, "message": commit.message}, 139 | } 140 | 141 | def format_age(dt): 142 | # This `dt` is timezone aware. So cheat, so we don't need to figure out 143 | # our timezone is. 144 | delta = datetime.timedelta( 145 | seconds=datetime.datetime.utcnow().timestamp() - dt.timestamp() 146 | ) 147 | if delta.days < 1: 148 | seconds = delta.total_seconds() 149 | minutes = seconds / 60 150 | if seconds > 60 * 60: 151 | hours = minutes / 60 152 | return f"{hours:.0f} hours" 153 | elif seconds > 60: 154 | return f"{minutes:.0f} minutes" 155 | return f"{seconds:.0f} seconds" 156 | return str(delta) 157 | 158 | def format_msg(message): 159 | message = message.strip().replace("\n", "\\n") 160 | if len(message) > 80: 161 | return message[:76] + "…" 162 | return message 163 | 164 | wrapped = sorted( 165 | [wrap(head) for head in heads], 166 | key=lambda x: x["info"].get("date"), 167 | reverse=True, 168 | ) 169 | 170 | for each in wrapped[:cutoff]: 171 | info_out("".center(80, "-")) 172 | success_out( 173 | each["head"].name 174 | + (each["head"].name in merged_names and " (MERGED ALREADY)" or "") 175 | ) 176 | if each.get("error"): 177 | info_out(f"\tError getting ref log ({each['error']!r})") 178 | info_out("\t" + each["info"]["date"].isoformat()) 179 | info_out("\t" + format_age(each["info"]["date"])) 180 | info_out("\t" + format_msg(each["info"].get("message", "*no commit yet*"))) 181 | info_out("") 182 | 183 | if len(heads) > cutoff: 184 | warning_out( 185 | f"Note! Found total of {len(heads)} but only showing {cutoff} most recent." 186 | ) 187 | -------------------------------------------------------------------------------- /tests/test_github.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from click.testing import CliRunner 4 | 5 | from gg.main import Config 6 | from gg.builtins import github 7 | 8 | from gg.testing import Response 9 | 10 | 11 | def test_token(temp_configfile, mocker): 12 | rget = mocker.patch("requests.get") 13 | getpass = mocker.patch("getpass.getpass") 14 | getpass.return_value = "somelongapitokenhere" 15 | 16 | def mocked_get(url, headers): 17 | assert url == "https://example.com/user" 18 | assert headers["Authorization"] == "token somelongapitokenhere" 19 | return Response({"name": "Peter", "login": "peterbe"}) 20 | 21 | rget.side_effect = mocked_get 22 | 23 | runner = CliRunner() 24 | config = Config() 25 | config.configfile = temp_configfile 26 | config.github_url = "https://example.com" 27 | result = runner.invoke(github.token, [], obj=config) 28 | assert result.exit_code == 0 29 | assert not result.exception 30 | 31 | with open(temp_configfile) as f: 32 | saved = json.load(f) 33 | assert "GITHUB" in saved 34 | assert saved["GITHUB"]["login"] == "peterbe" 35 | assert saved["GITHUB"]["token"] == "somelongapitokenhere" 36 | assert saved["GITHUB"]["github_url"] == "https://example.com" 37 | 38 | 39 | def test_token_argument(temp_configfile, mocker): 40 | rget = mocker.patch("requests.get") 41 | 42 | def mocked_get(url, headers): 43 | assert url == "https://example.com/user" 44 | assert headers["Authorization"] == "token somelongapitokenhere" 45 | return Response({"name": "Peter", "login": "peterbe"}) 46 | 47 | rget.side_effect = mocked_get 48 | 49 | runner = CliRunner() 50 | config = Config() 51 | config.configfile = temp_configfile 52 | config.github_url = "https://example.com" 53 | result = runner.invoke(github.token, ["somelongapitokenhere"], obj=config) 54 | assert result.exit_code == 0 55 | assert not result.exception 56 | 57 | with open(temp_configfile) as f: 58 | saved = json.load(f) 59 | assert "GITHUB" in saved 60 | assert saved["GITHUB"]["login"] == "peterbe" 61 | assert saved["GITHUB"]["token"] == "somelongapitokenhere" 62 | assert saved["GITHUB"]["github_url"] == "https://example.com" 63 | 64 | 65 | def test_test(temp_configfile, mocker): 66 | with open(temp_configfile, "w") as f: 67 | saved = { 68 | "GITHUB": { 69 | "token": "somelongapitokenhere", 70 | "login": "peterbe", 71 | "github_url": "https://example.com", 72 | } 73 | } 74 | json.dump(saved, f) 75 | rget = mocker.patch("requests.get") 76 | 77 | def mocked_get(url, headers): 78 | assert url == "https://example.com/user" 79 | assert headers["Authorization"] == "token somelongapitokenhere" 80 | return Response({"id": 123456, "name": "Peter Bengtsson", "login": "peterbe"}) 81 | 82 | rget.side_effect = mocked_get 83 | 84 | runner = CliRunner() 85 | config = Config() 86 | config.configfile = temp_configfile 87 | config.github_url = "https://example.com" 88 | result = runner.invoke(github.test, [], obj=config) 89 | assert result.exit_code == 0 90 | assert not result.exception 91 | assert "Peter Bengtsson" in result.output 92 | 93 | 94 | def test_test_issue_url(temp_configfile, mocker): 95 | with open(temp_configfile, "w") as f: 96 | saved = { 97 | "GITHUB": { 98 | "token": "somelongapitokenhere", 99 | "login": "peterbe", 100 | "github_url": "https://example.com", 101 | } 102 | } 103 | json.dump(saved, f) 104 | rget = mocker.patch("requests.get") 105 | 106 | def mocked_get(url, headers): 107 | assert url == "https://example.com/repos/peterbe/gg/issues/123" 108 | assert headers["Authorization"] == "token somelongapitokenhere" 109 | return Response( 110 | { 111 | "id": 123456, 112 | "title": "Issue Title Here", 113 | "html_url": "https://api.github.com/repos/peterbe/gg/issues/123", 114 | } 115 | ) 116 | 117 | rget.side_effect = mocked_get 118 | 119 | runner = CliRunner() 120 | config = Config() 121 | config.configfile = temp_configfile 122 | config.github_url = "https://example.com" 123 | result = runner.invoke( 124 | github.test, ["-i", "https://github.com/peterbe/gg/issues/123"], obj=config 125 | ) 126 | assert result.exit_code == 0 127 | assert not result.exception 128 | assert "Issue Title Here" in result.output 129 | 130 | 131 | def test_burn(temp_configfile, mocker): 132 | 133 | with open(temp_configfile, "w") as f: 134 | saved = {"GITHUB": {"token": "somelongapitokenhere", "login": "peterbe"}} 135 | json.dump(saved, f) 136 | 137 | runner = CliRunner() 138 | config = Config() 139 | config.configfile = temp_configfile 140 | config.github_url = "https://example.com" 141 | result = runner.invoke(github.burn, [], obj=config) 142 | assert result.exit_code == 0 143 | assert not result.exception 144 | 145 | with open(temp_configfile) as f: 146 | saved = json.load(f) 147 | assert "GITHUB" not in saved 148 | 149 | 150 | def test_get_title(temp_configfile, mocker): 151 | rget = mocker.patch("requests.get") 152 | 153 | def mocked_get(url, headers): 154 | assert url == "https://api.github.com/repos/peterbe/gg/issues/1" 155 | # assert 'token' not in params 156 | return Response( 157 | { 158 | "html_url": "https://github.com/peterbe/gg/issues/1", 159 | "id": 85565047, 160 | "number": 1, 161 | "title": "This is the title", 162 | } 163 | ) 164 | 165 | rget.side_effect = mocked_get 166 | 167 | config = Config() 168 | config.configfile = temp_configfile 169 | # config.bugzilla_url = 'https://bugs.example.com' 170 | 171 | title, url = github.get_title(config, "peterbe", "gg", 1) 172 | assert title == "This is the title" 173 | assert url == "https://github.com/peterbe/gg/issues/1" 174 | 175 | 176 | def test_find_pull_requests(temp_configfile, mocker): 177 | rget = mocker.patch("requests.get") 178 | getpass = mocker.patch("getpass.getpass") 179 | getpass.return_value = "somelongapitokenhere" 180 | 181 | with open(temp_configfile, "w") as f: 182 | saved = { 183 | "GITHUB": { 184 | "token": "somelongapitokenhere", 185 | "login": "peterbe", 186 | "github_url": "https://enterprise.github.com", 187 | } 188 | } 189 | json.dump(saved, f) 190 | 191 | def mocked_get(url, params, headers): 192 | assert url == "https://api.github.com/repos/myorg/myrepo/pulls" 193 | assert headers["Authorization"] == "token somelongapitokenhere" 194 | return Response([]) 195 | 196 | rget.side_effect = mocked_get 197 | config = Config() 198 | config.configfile = temp_configfile 199 | config.github_url = "https://example.com" 200 | found = github.find_pull_requests(config, "myorg", "myrepo") 201 | assert found == [] 202 | -------------------------------------------------------------------------------- /gg/builtins/start/tests/test_gg_start.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import shutil 5 | 6 | import pytest 7 | import requests_mock 8 | from click.testing import CliRunner 9 | 10 | # By doing this import we make sure that the plugin is made available 11 | # but the entry points loading inside gg.main. 12 | # An alternative would we to set `PYTHONPATH=. py.test` (or something) 13 | # but then that wouldn't test the entry point loading. 14 | from gg.main import Config 15 | 16 | from gg.builtins.start.gg_start import start, parse_remote_url 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def requestsmock(): 21 | """Return a context where requests are all mocked. 22 | Usage:: 23 | 24 | def test_something(requestsmock): 25 | requestsmock.get( 26 | 'https://example.com/path' 27 | content=b'The content' 28 | ) 29 | # Do stuff that involves requests.get('http://example.com/path') 30 | """ 31 | with requests_mock.mock() as m: 32 | yield m 33 | 34 | 35 | @pytest.yield_fixture 36 | def temp_configfile(): 37 | tmp_dir = tempfile.mkdtemp("gg-start") 38 | fp = os.path.join(tmp_dir, "state.json") 39 | with open(fp, "w") as f: 40 | json.dump({}, f) 41 | yield fp 42 | shutil.rmtree(tmp_dir) 43 | 44 | 45 | def test_start(temp_configfile, mocker): 46 | mocked_git = mocker.patch("git.Repo") 47 | mocked_git().working_dir = "gg-start-test" 48 | 49 | runner = CliRunner() 50 | config = Config() 51 | config.configfile = temp_configfile 52 | result = runner.invoke(start, [""], input='foo "bar"\n', obj=config) 53 | assert result.exit_code == 0 54 | assert not result.exception 55 | 56 | mocked_git().create_head.assert_called_with("foo-bar") 57 | mocked_git().create_head().checkout.assert_called_with() 58 | 59 | with open(temp_configfile) as f: 60 | saved = json.load(f) 61 | 62 | assert "gg-start-test:foo-bar" in saved 63 | assert saved["gg-start-test:foo-bar"]["description"] == 'foo "bar"' 64 | assert saved["gg-start-test:foo-bar"]["date"] 65 | 66 | 67 | def test_start_weird_description(temp_configfile, mocker): 68 | mocked_git = mocker.patch("git.Repo") 69 | mocked_git().working_dir = "gg-start-test" 70 | 71 | runner = CliRunner() 72 | config = Config() 73 | config.configfile = temp_configfile 74 | summary = " a!@#$%^&*()_+{}[/]-= ;: --> ==> --- `foo` ,. " 75 | result = runner.invoke(start, [""], input=summary + "\n", obj=config) 76 | assert result.exit_code == 0 77 | assert not result.exception 78 | 79 | expected_branchname = "a_+-foo-bar" 80 | mocked_git().create_head.assert_called_with(expected_branchname) 81 | mocked_git().create_head().checkout.assert_called_with() 82 | 83 | with open(temp_configfile) as f: 84 | saved = json.load(f) 85 | 86 | key = "gg-start-test:" + expected_branchname 87 | assert key in saved 88 | assert saved[key]["description"] == summary.strip() 89 | 90 | 91 | def test_start_a_digit(temp_configfile, mocker, requestsmock): 92 | mocked_git = mocker.patch("git.Repo") 93 | mocked_git().working_dir = "gg-start-test" 94 | 95 | remotes = [] 96 | 97 | class Remote: 98 | def __init__(self, name, url): 99 | self.name = name 100 | self.url = url 101 | 102 | remotes.append(Remote("origin", "git@github.com:myorg/myrepo.git")) 103 | remotes.append(Remote("other", "https://github.com/o/ther.git")) 104 | mocked_git().remotes.__iter__.return_value = remotes 105 | 106 | # rget = mocker.patch("requests.get") 107 | requestsmock.get( 108 | "https://bugzilla.mozilla.org/rest/bug/", 109 | content=json.dumps( 110 | { 111 | "bugs": [ 112 | { 113 | "assigned_to": "nobody@mozilla.org", 114 | "assigned_to_detail": { 115 | "email": "nobody@mozilla.org", 116 | "id": 1, 117 | "name": "nobody@mozilla.org", 118 | "real_name": "Nobody; OK to take it and work on it", 119 | }, 120 | "id": 1234, 121 | "status": "NEW", 122 | "summary": "This is the summary", 123 | } 124 | ], 125 | "faults": [], 126 | } 127 | ).encode("utf-8"), 128 | ) 129 | requestsmock.get( 130 | "https://api.github.com/repos/myorg/myrepo/issues/1234", 131 | content=json.dumps( 132 | { 133 | "id": 1234, 134 | "title": "Issue Title Here", 135 | "html_url": ("https://api.github.com/repos/myorg/myrepo/issues/123"), 136 | } 137 | ).encode("utf-8"), 138 | ) 139 | requestsmock.get( 140 | "https://api.github.com/repos/o/ther/issues/1234", 141 | status_code=404, 142 | content=json.dumps({"not": "found"}).encode("utf-8"), 143 | ) 144 | 145 | runner = CliRunner() 146 | config = Config() 147 | config.configfile = temp_configfile 148 | result = runner.invoke(start, ["1234"], obj=config) 149 | assert "Input is ambiguous" in result.output 150 | assert "Issue Title Here" in result.output 151 | assert "This is the summary" in result.output 152 | assert result.exit_code == 1 153 | 154 | 155 | def test_start_github_issue(temp_configfile, mocker, requestsmock): 156 | 157 | requestsmock.get( 158 | "https://api.github.com/repos/peterbe/gg-start/issues/7", 159 | content=json.dumps( 160 | { 161 | "title": "prefix branch name differently for github issues", 162 | "html_url": "https://github.com/peterbe/gg-start/issues/7", 163 | } 164 | ).encode("utf-8"), 165 | ) 166 | mocked_git = mocker.patch("git.Repo") 167 | mocked_git().working_dir = "gg-start-test" 168 | 169 | runner = CliRunner() 170 | config = Config() 171 | config.configfile = temp_configfile 172 | result = runner.invoke( 173 | start, 174 | ["https://github.com/peterbe/gg-start/issues/7"], 175 | input='foo "bar"\n', 176 | obj=config, 177 | ) 178 | if result.exception: 179 | raise result.exception 180 | assert result.exit_code == 0 181 | assert not result.exception 182 | 183 | mocked_git().create_head.assert_called_with("7-foo-bar") 184 | mocked_git().create_head().checkout.assert_called_with() 185 | 186 | with open(temp_configfile) as f: 187 | saved = json.load(f) 188 | 189 | key = "gg-start-test:7-foo-bar" 190 | assert key in saved 191 | assert saved[key]["description"] == 'foo "bar"' 192 | assert saved[key]["date"] 193 | 194 | 195 | def test_start_bugzilla_url(temp_configfile, mocker, requestsmock): 196 | requestsmock.get( 197 | "https://bugzilla.mozilla.org/rest/bug/?ids=123456&include_fields=summary%2Cid", 198 | content=json.dumps( 199 | { 200 | "bugs": [ 201 | { 202 | "assigned_to": "nobody@mozilla.org", 203 | "id": 1234, 204 | "status": "NEW", 205 | "summary": "This is the summary", 206 | } 207 | ], 208 | "faults": [], 209 | } 210 | ).encode("utf-8"), 211 | ) 212 | mocked_git = mocker.patch("git.Repo") 213 | mocked_git().working_dir = "gg-start-test" 214 | 215 | runner = CliRunner() 216 | config = Config() 217 | config.configfile = temp_configfile 218 | result = runner.invoke( 219 | start, 220 | ["https://bugzilla.mozilla.org/show_bug.cgi?id=123456"], 221 | input='foo "bar"\n', 222 | obj=config, 223 | ) 224 | if result.exception: 225 | raise result.exception 226 | assert result.exit_code == 0 227 | assert not result.exception 228 | 229 | mocked_git().create_head.assert_called_with("123456-foo-bar") 230 | mocked_git().create_head().checkout.assert_called_with() 231 | 232 | with open(temp_configfile) as f: 233 | saved = json.load(f) 234 | 235 | key = "gg-start-test:123456-foo-bar" 236 | assert key in saved 237 | assert saved[key]["description"] == 'foo "bar"' 238 | assert saved[key]["date"] 239 | 240 | 241 | def test_parse_remote_url(): 242 | org, repo = parse_remote_url("git@github.com:org/repo.git") 243 | assert org == "org" 244 | assert repo == "repo" 245 | org, repo = parse_remote_url("https://github.com/org/repo.git") 246 | assert org == "org" 247 | assert repo == "repo" 248 | -------------------------------------------------------------------------------- /gg/builtins/branches/gg_branches.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import click 4 | import git 5 | from gg.main import cli, pass_config 6 | from gg.state import read 7 | from gg.utils import error_out, info_out, success_out, warning_out 8 | 9 | 10 | class InvalidRemoteName(Exception): 11 | """when a remote doesn't exist""" 12 | 13 | 14 | DEFAULT_CUTOFF = 10 15 | 16 | 17 | @cli.command() 18 | @click.option( 19 | "-y", 20 | "--yes", 21 | default=False, 22 | is_flag=True, 23 | help="Immediately say yes to any questions", 24 | ) 25 | @click.option( 26 | "-n", 27 | "--cutoff", 28 | default=DEFAULT_CUTOFF, 29 | help="Max. number of branches to show", 30 | show_default=True, 31 | ) 32 | @click.argument("searchstring", default="") 33 | @pass_config 34 | def branches(config, yes=False, searchstring="", cutoff=DEFAULT_CUTOFF): 35 | """List all branches. And if exactly 1 found, offer to check it out.""" 36 | repo = config.repo 37 | 38 | # state = read(config.configfile) 39 | # origin_name = state.get("ORIGIN_NAME", "origin") 40 | 41 | try: 42 | branches_ = list(find(repo, searchstring)) 43 | if not branches_: 44 | # Fetch from origin and try again 45 | fetch_origin(config) 46 | state = read(config.configfile) 47 | origin_name = state.get("ORIGIN_NAME", "origin") 48 | branches_ = list(find(repo, searchstring, search_remote=origin_name)) 49 | except InvalidRemoteName as exception: 50 | remote_search_name = searchstring.split(":")[0] 51 | if remote_search_name in [x.name for x in repo.remotes]: 52 | error_out(f"Invalid remote name {exception!r}") 53 | 54 | (repo_name,) = [ 55 | x.url.split("/")[-1].split(".git")[0] 56 | for x in repo.remotes 57 | if x.name == "origin" 58 | ] 59 | # Add it and start again 60 | remote_url = f"https://github.com/{remote_search_name}/{repo_name}.git" 61 | if not click.confirm( 62 | f"Add remote {remote_search_name!r} ({remote_url})", default=True 63 | ): 64 | error_out("Unable to find or create remote") 65 | 66 | repo.create_remote(remote_search_name, remote_url) 67 | branches_ = list(find(repo, searchstring)) 68 | 69 | if branches_: 70 | merged = get_merged_branches(repo) 71 | info_out("Found existing branches...") 72 | print_list(branches_, merged, cutoff=cutoff) 73 | if len(branches_) == 1 and searchstring: 74 | # If the found branch is the current one, error 75 | active_branch = repo.active_branch 76 | if active_branch == branches_[0]: 77 | error_out(f"You're already on {branches_[0].name!r}") 78 | branch_name = branches_[0].name 79 | if len(branch_name) > 50: 80 | branch_name = branch_name[:47] + "…" 81 | 82 | if not yes: 83 | check_it_out = ( 84 | input(f"Check out {branch_name!r}? [Y/n] ").lower().strip() != "n" 85 | ) 86 | if yes or check_it_out: 87 | if isinstance(branches_[0], git.RemoteReference): 88 | # print(dir(branches_[0])) 89 | # print(branches_[0].name) 90 | branches_[0].checkout() 91 | # raise Exception("??") 92 | else: 93 | branches_[0].checkout() 94 | elif searchstring: 95 | warning_out(f"Found no branches matching {searchstring!r}.") 96 | else: 97 | warning_out("Found no branches.") 98 | 99 | 100 | def fetch_origin(config): 101 | repo = config.repo 102 | state = read(config.configfile) 103 | origin_name = state.get("ORIGIN_NAME", "origin") 104 | upstream_remote = None 105 | for remote in repo.remotes: 106 | if remote.name == origin_name: 107 | upstream_remote = remote 108 | break 109 | if not upstream_remote: 110 | error_out(f"No remote called {origin_name!r} found") 111 | 112 | info_out(f"Fetching origin {origin_name!r}") 113 | upstream_remote.fetch() 114 | 115 | 116 | def find(repo, searchstring, exact=False, search_remote=None): 117 | # When you copy-to-clipboard from GitHub you get something like 118 | # 'peterbe:1545809-urllib3-1242' for example. 119 | # But first, it exists as a local branch, use that. 120 | if searchstring and ":" in searchstring: 121 | remote_name = searchstring.split(":")[0] 122 | for remote in repo.remotes: 123 | if remote.name == remote_name: 124 | # remote.pull() 125 | found_remote = remote 126 | break 127 | else: 128 | raise InvalidRemoteName(remote_name) 129 | 130 | for head in repo.heads: 131 | if exact: 132 | if searchstring.split(":", 1)[1].lower() == head.name.lower(): 133 | yield head 134 | return 135 | else: 136 | if searchstring.split(":", 1)[1].lower() in head.name.lower(): 137 | yield head 138 | return 139 | 140 | info_out(f"Fetching the latest from {found_remote}") 141 | for fetchinfo in found_remote.fetch(): 142 | if fetchinfo.flags & git.remote.FetchInfo.HEAD_UPTODATE: 143 | # Most boring 144 | pass 145 | else: 146 | msg = "updated" 147 | if fetchinfo.flags & git.remote.FetchInfo.FORCED_UPDATE: 148 | msg += " (force updated)" 149 | print(fetchinfo.ref, msg) 150 | 151 | if str(fetchinfo.ref) == searchstring.replace(":", "/", 1): 152 | yield fetchinfo.ref 153 | 154 | for head in repo.heads: 155 | if searchstring: 156 | if exact: 157 | if searchstring.lower() != head.name.lower(): 158 | continue 159 | else: 160 | if searchstring.lower() not in head.name.lower(): 161 | continue 162 | yield head 163 | 164 | if search_remote: 165 | remote_refs = repo.remote().refs 166 | for ref in remote_refs: 167 | _, rest = ref.name.split(f"{search_remote}/") 168 | if exact: 169 | print((rest, searchstring)) 170 | if searchstring.lower() == rest.lower(): 171 | yield ref 172 | elif searchstring.lower() in rest.lower(): 173 | yield ref 174 | 175 | 176 | def get_merged_branches(repo): 177 | # XXX I wish I could dedude from a git.refs.head.Head object if it was 178 | # merged. Then I wouldn't have to do this string splitting crap. 179 | output = repo.git.branch("--merged") 180 | return [x.split()[-1] for x in output.splitlines() if x.strip()] 181 | 182 | 183 | def print_list(heads, merged_names, cutoff=10): 184 | def wrap(head): 185 | commit = head.commit 186 | return { 187 | "head": head, 188 | "info": {"date": commit.committed_datetime, "message": commit.message}, 189 | } 190 | 191 | def format_age(dt): 192 | # This `dt` is timezone aware. So cheat, so we don't need to figure out 193 | # our timezone is. 194 | delta = datetime.timedelta( 195 | seconds=datetime.datetime.utcnow().timestamp() - dt.timestamp() 196 | ) 197 | if delta.days < 1: 198 | seconds = delta.total_seconds() 199 | minutes = seconds / 60 200 | if seconds > 60 * 60: 201 | hours = minutes / 60 202 | return f"{hours:.0f} hours" 203 | elif seconds > 60: 204 | return f"{minutes:.0f} minutes" 205 | return f"{seconds:.0f} seconds" 206 | return str(delta) 207 | 208 | def format_msg(message): 209 | message = message.strip().replace("\n", "\\n") 210 | if len(message) > 80: 211 | return message[:76] + "…" 212 | return message 213 | 214 | wrapped = sorted( 215 | [wrap(head) for head in heads], 216 | key=lambda x: x["info"].get("date"), 217 | reverse=True, 218 | ) 219 | 220 | for each in wrapped[:cutoff]: 221 | info_out("".center(80, "-")) 222 | success_out( 223 | each["head"].name 224 | + (each["head"].name in merged_names and " (MERGED ALREADY)" or "") 225 | ) 226 | if each.get("error"): 227 | info_out(f"\tError getting ref log ({each['error']!r})") 228 | info_out("\t" + each["info"]["date"].isoformat()) 229 | info_out("\t" + format_age(each["info"]["date"])) 230 | info_out("\t" + format_msg(each["info"].get("message", "*no commit yet*"))) 231 | info_out("") 232 | 233 | if len(heads) > cutoff: 234 | warning_out( 235 | f"Note! Found total of {len(heads)} but only showing {cutoff} most recent." 236 | ) 237 | -------------------------------------------------------------------------------- /gg/builtins/commit/gg_commit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import re 5 | import time 6 | 7 | import click 8 | import git 9 | from gg.builtins import github 10 | from gg.main import cli, pass_config 11 | from gg.state import load, read, load_config 12 | from gg.utils import ( 13 | error_out, 14 | get_default_branch, 15 | info_out, 16 | is_bugzilla, 17 | is_github, 18 | success_out, 19 | ) 20 | 21 | 22 | @cli.command() 23 | @click.option( 24 | "-n", 25 | "--no-verify", 26 | default=False, 27 | is_flag=True, 28 | help="This option bypasses the pre-commit and commit-msg hooks.", 29 | ) 30 | @click.option( 31 | "-y", 32 | "--yes", 33 | default=False, 34 | is_flag=True, 35 | help="Immediately say yes to any questions", 36 | ) 37 | @pass_config 38 | def commit(config, no_verify, yes): 39 | """Commit the current branch with all files.""" 40 | repo = config.repo 41 | 42 | state = read(config.configfile) 43 | origin_name = state.get("ORIGIN_NAME", "origin") 44 | default_branch = get_default_branch(repo, origin_name) 45 | 46 | active_branch = repo.active_branch 47 | if active_branch.name == default_branch: 48 | error_out( 49 | f"Can't commit when on the {default_branch} branch. " 50 | f"You really ought to do work in branches." 51 | ) 52 | 53 | now = time.time() 54 | 55 | def count_files_in_directory(directory): 56 | count = 0 57 | for root, _, files in os.walk(directory): 58 | # We COULD crosscheck these files against the .gitignore 59 | # if we ever felt overachievious. 60 | count += len(files) 61 | return count 62 | 63 | # First group all untracked files by root folder 64 | all_untracked_files = {} 65 | for path in repo.untracked_files: 66 | root = path.split(os.path.sep)[0] 67 | if root not in all_untracked_files: 68 | all_untracked_files[root] = { 69 | "files": [], 70 | "total_count": count_files_in_directory(root), 71 | } 72 | all_untracked_files[root]["files"].append(path) 73 | 74 | # Now filter this based on it being single files or a bunch 75 | untracked_files = {} 76 | for root, info in all_untracked_files.items(): 77 | for path in info["files"]: 78 | age = now - os.stat(path).st_mtime 79 | # If there's fewer untracked files in its directory, suggest 80 | # the directory instead. 81 | if info["total_count"] == 1: 82 | path = root 83 | if path in untracked_files: 84 | if age < untracked_files[path]: 85 | # youngest file in that directory 86 | untracked_files[path] = age 87 | else: 88 | untracked_files[path] = age 89 | 90 | if untracked_files: 91 | ordered = sorted(untracked_files.items(), key=lambda x: x[1], reverse=True) 92 | info_out("NOTE! There are untracked files:") 93 | for path, age in ordered: 94 | if os.path.isdir(path): 95 | path = path + "/" 96 | print("\t", path.ljust(60), humanize_seconds(age), "old") 97 | 98 | # But only put up this input question if one the files is 99 | # younger than 12 hours. 100 | young_ones = [x for x in untracked_files.values() if x < 60 * 60 * 12] 101 | if young_ones: 102 | ignore = input("Ignore untracked files? [Y/n] ").lower().strip() 103 | if ignore.lower().strip() == "n": 104 | error_out( 105 | "\n\tLeaving it up to you to figure out what to do " 106 | "with those untracked files." 107 | ) 108 | return 1 109 | print("") 110 | 111 | state = read(config.configfile) 112 | 113 | try: 114 | push_to_origin = load_config(config.configfile, "push_to_origin") 115 | except KeyError: 116 | push_to_origin = False 117 | 118 | try: 119 | fixes_message = load_config(config.configfile, "fixes_message") 120 | except KeyError: 121 | fixes_message = True 122 | 123 | try: 124 | data = load(config.configfile, active_branch.name) 125 | except KeyError: 126 | error_out( 127 | "You're in a branch that was not created with gg.\n" 128 | "No branch information available." 129 | ) 130 | 131 | print("Commit message: (type a new one if you want to override)") 132 | msg = data["description"] 133 | if data.get("bugnumber"): 134 | if is_bugzilla(data): 135 | msg = "bug {} - {}".format(data["bugnumber"], data["description"]) 136 | msg = not yes and input(f'"{msg}" ').strip() or msg 137 | elif is_github(data): 138 | msg = not yes and input(f'"{msg}" ').strip() or msg 139 | if fixes_message: 140 | msg += "\n\nPart of #{}".format(data["bugnumber"]) 141 | if yes: 142 | print(msg) 143 | 144 | if data["bugnumber"] and fixes_message: 145 | question = 'Add the "fixes" mention? [N/y] ' 146 | fixes = yes or input(question).lower().strip() 147 | if fixes in ("y", "yes") or yes: 148 | if is_bugzilla(data): 149 | msg = "fixes " + msg 150 | elif is_github(data): 151 | msg = msg.replace("Part of ", "Fixes ") 152 | else: 153 | raise NotImplementedError 154 | 155 | # Now we're going to do the equivalent of `git commit -a -m "..."` 156 | index = repo.index 157 | files_added = [] 158 | files_removed = [] 159 | for x in repo.index.diff(None): 160 | if x.deleted_file: 161 | files_removed.append(x.b_path) 162 | else: 163 | files_added.append(x.b_path) 164 | files_new = [] 165 | for x in repo.index.diff(repo.head.commit): 166 | files_new.append(x.b_path) 167 | 168 | proceed = True 169 | if not (files_added or files_removed or files_new): 170 | info_out("No files to add or remove.") 171 | proceed = False 172 | if input("Proceed anyway? [Y/n] ").lower().strip() == "n": 173 | proceed = True 174 | 175 | if "Peterbe" in msg: 176 | print(f"data={data}") 177 | print(f"msg={msg!r}") 178 | raise Exception("HOW DID THAT HAPPEN!?") 179 | if proceed: 180 | if not repo.is_dirty(): 181 | error_out("Branch is not dirty. There is nothing to commit.") 182 | if files_added: 183 | index.add(files_added) 184 | if files_removed: 185 | index.remove(files_removed) 186 | try: 187 | # Do it like this (instead of `repo.git.commit(msg)`) 188 | # so that git signing works. 189 | # commit = repo.git.commit(["-m", msg]) 190 | args = ["-m", msg] 191 | if no_verify: 192 | args.append("--no-verify") 193 | commit = repo.git.commit(args) 194 | except git.exc.HookExecutionError as exception: 195 | if not no_verify: 196 | info_out( 197 | f"Commit hook failed ({exception.command}, " 198 | f"exit code {exception.status})" 199 | ) 200 | if exception.stdout: 201 | error_out(exception.stdout) 202 | elif exception.stderr: 203 | error_out(exception.stderr) 204 | else: 205 | error_out("Commit hook failed.") 206 | else: 207 | commit = index.commit(msg, skip_hooks=True) 208 | 209 | success_out(f"Commit created {commit}") 210 | 211 | if not state.get("FORK_NAME"): 212 | info_out("Can't help you push the commit. Please run: gg config --help") 213 | return 0 214 | 215 | if push_to_origin: 216 | try: 217 | repo.remotes[origin_name] 218 | except IndexError: 219 | error_out(f"There is no remote called {origin_name!r}") 220 | else: 221 | try: 222 | repo.remotes[state["FORK_NAME"]] 223 | except IndexError: 224 | error_out(f"There is no remote called {state['FORK_NAME']!r}") 225 | 226 | remote_name = origin_name if push_to_origin else state["FORK_NAME"] 227 | 228 | if yes: 229 | push_for_you = "yes" 230 | else: 231 | push_for_you = input(f"Push branch to {remote_name!r}? [Y/n] ").lower().strip() 232 | if push_for_you not in ("n", "no"): 233 | push_output = repo.git.push("--set-upstream", remote_name, active_branch.name) 234 | print(push_output) 235 | 236 | else: 237 | # If you don't want to push, then don't bother with GitHub 238 | # Pull Request stuff. 239 | return 0 240 | 241 | if not state.get("GITHUB"): 242 | if config.verbose: 243 | info_out( 244 | "Can't help create a GitHub Pull Request.\n" 245 | "Consider running: gg github --help" 246 | ) 247 | return 0 248 | 249 | origin = repo.remotes[state.get("ORIGIN_NAME", "origin")] 250 | rest = re.split(r"github\.com[:/]", origin.url)[1] 251 | org, repo = rest.split(".git")[0].split("/", 1) 252 | 253 | # Search for an existing open pull request, and remind us of the link 254 | # to it. 255 | search = { 256 | "head": f"{remote_name}:{active_branch.name}", 257 | "state": "open", 258 | } 259 | prs = github.find_pull_requests(config, org, repo, **search) 260 | if prs is None: 261 | error_out("Can't iterate over pull requests") 262 | for pull_request in prs: 263 | print("Pull Request already created:") 264 | print("") 265 | print("\t", pull_request["html_url"]) 266 | print("") 267 | break 268 | else: 269 | # If no known Pull Request exists, make a link to create a new one. 270 | if remote_name == origin.name: 271 | github_url = "https://github.com/{}/{}/compare/{}...{}?expand=1".format( 272 | org, repo, default_branch, active_branch.name 273 | ) 274 | else: 275 | github_url = ( 276 | "https://github.com/{}/{}/compare/{}:{}...{}:{}?expand=1".format( 277 | org, 278 | repo, 279 | org, 280 | default_branch, 281 | remote_name, 282 | active_branch.name, 283 | ) 284 | ) 285 | print("Now, to make a Pull Request, go to:") 286 | print("") 287 | success_out(github_url) 288 | print("(⌘-click to open URLs)") 289 | 290 | return 0 291 | 292 | 293 | def _humanize_time(amount, units): 294 | """Chopped and changed from http://stackoverflow.com/a/6574789/205832""" 295 | intervals = (1, 60, 60 * 60, 60 * 60 * 24, 604800, 2419200, 29030400) 296 | names = ( 297 | ("second", "seconds"), 298 | ("minute", "minutes"), 299 | ("hour", "hours"), 300 | ("day", "days"), 301 | ("week", "weeks"), 302 | ("month", "months"), 303 | ("year", "years"), 304 | ) 305 | 306 | result = [] 307 | unit = [x[1] for x in names].index(units) 308 | # Convert to seconds 309 | amount = amount * intervals[unit] 310 | for i in range(len(names) - 1, -1, -1): 311 | a = int(amount) // intervals[i] 312 | if a > 0: 313 | result.append((a, names[i][1 % a])) 314 | amount -= a * intervals[i] 315 | return result 316 | 317 | 318 | def humanize_seconds(seconds): 319 | return "{} {}".format(*_humanize_time(seconds, "seconds")[0]) 320 | --------------------------------------------------------------------------------