├── gitutor ├── branch │ ├── __init__.py │ ├── utils.py │ ├── commands.py │ ├── create.py │ ├── change.py │ └── delete.py ├── compare │ ├── __init__.py │ ├── color_diff.py │ └── commands.py ├── goBack │ ├── __init__.py │ ├── test_commands.py │ └── commands.py ├── ignore │ ├── __init__.py │ ├── commands.py │ └── tests.py ├── init │ ├── __init__.py │ ├── test_commands.py │ └── commands.py ├── lesson │ ├── __init__.py │ ├── commands.py │ └── lessons.py ├── save │ ├── __init__.py │ ├── test_commands.py │ └── commands.py ├── .gitutor_config ├── __init__.py ├── cli.py └── util.py ├── .gitignore ├── .github └── workflows │ └── python-publish.yml ├── pyproject.toml ├── LICENCE └── README.md /gitutor/branch/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gitutor/compare/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gitutor/goBack/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gitutor/ignore/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gitutor/init/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gitutor/lesson/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gitutor/save/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gitutor/.gitutor_config: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /gitutor/__init__.py: -------------------------------------------------------------------------------- 1 | import os, sys; sys.path.append(os.path.dirname(os.path.realpath(__file__))) 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | dist/ 3 | **.ipynb 4 | **.egg-info 5 | .DS_Store 6 | .python-version 7 | poetry.lock -------------------------------------------------------------------------------- /gitutor/compare/color_diff.py: -------------------------------------------------------------------------------- 1 | try: 2 | from colorama import Fore, Back, Style, init 3 | init() 4 | except ImportError: # fallback so that the imported classes always exist 5 | class ColorFallback(): 6 | __getattr__ = lambda self, name: '' 7 | Fore = Back = Style = ColorFallback() 8 | 9 | def color_diff(diff): 10 | for line in diff: 11 | if line.startswith('+'): 12 | yield Fore.GREEN + line + Fore.RESET 13 | elif line.startswith('-'): 14 | yield Fore.RED + line + Fore.RESET 15 | elif line.startswith('@'): 16 | yield Fore.BLUE + line + Fore.RESET 17 | else: 18 | yield line -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Upload Python Package 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | deploy: 11 | name: deploy 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2.3.1 16 | - uses: actions/setup-python@v2 17 | with: 18 | python-version: '3.9.7' 19 | - name: Install Poetry 20 | uses: snok/install-poetry@v1.2.1 21 | - name: setup .gitconfig 22 | run: echo "1" > ./gitutor/.gitutor_config 23 | - name: Build and publish 24 | env: 25 | passwordPy: ${{ secrets.PYPIKEY }} 26 | run: | 27 | poetry publish --username __token__ --password "$passwordPy" --build 28 | -------------------------------------------------------------------------------- /gitutor/branch/utils.py: -------------------------------------------------------------------------------- 1 | def list_branches(repo, get_all=True): 2 | local_branches = set(branch.name for branch in repo.branches) 3 | 4 | if get_all: 5 | remote_branches = [] 6 | remote_refs = repo.remote().refs 7 | for refs in remote_refs: 8 | branch = refs.name 9 | branch = branch.replace("origin/", "") 10 | 11 | if branch == "HEAD": 12 | continue 13 | 14 | remote_branches.append(branch) 15 | 16 | branches = set(remote_branches).union(local_branches) 17 | else: 18 | branches = local_branches 19 | remote_branches = [] 20 | 21 | branches = sorted(list(branches)) 22 | 23 | return branches, remote_branches, local_branches 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "gitutor" 3 | version = "0.6.5" 4 | description = "A command line app that makes Git easy." 5 | readme = "README.md" 6 | authors = ["AMAI"] 7 | license = "MIT" 8 | homepage = "https://gitutor.io" 9 | repository = "https://github.com/artemisa-mx/gitutor" 10 | keywords = ["git", "python", "cli"] 11 | classifiers = [ 12 | "Topic :: Software Development :: Libraries :: Python Modules" 13 | ] 14 | packages = [ 15 | { include = "gitutor"} 16 | ] 17 | 18 | [tool.poetry.dependencies] 19 | python="^3.6" 20 | click="7.1.2" 21 | GitPython="3.1.3" 22 | gitdb="4.0.5" 23 | smmap="3.0.4" 24 | pyinquirer="1.0.3" 25 | pygithub="1.51" 26 | colorama="0.4.3" 27 | requests="^2.20.0" 28 | 29 | [tool.poetry.dev-dependencies] 30 | 31 | [tool.poetry.scripts] 32 | gt = 'gitutor.cli:main' 33 | gtb = 'gitutor.branch.commands:main' 34 | 35 | [build-system] 36 | requires = ["poetry>=0.12"] 37 | build-backend = "poetry.masonry.api" 38 | -------------------------------------------------------------------------------- /gitutor/branch/commands.py: -------------------------------------------------------------------------------- 1 | import git 2 | import click 3 | 4 | from .change import change 5 | from .create import create 6 | from .delete import delete 7 | 8 | class CustomGroup(click.Group): 9 | def invoke(self, ctx): 10 | ctx.obj['args'] = tuple(ctx.args) 11 | super(CustomGroup, self).invoke(ctx) 12 | 13 | 14 | @click.group(cls=CustomGroup) 15 | @click.pass_context 16 | def branch(ctx): 17 | """ 18 | Manage your repo branches 19 | """ 20 | # Check ctx was initialized 21 | ctx.ensure_object(dict) 22 | 23 | if '--help' not in ctx.obj['args'] and 'REPO' not in ctx.obj: 24 | try: 25 | repo = git.Repo(".", search_parent_directories=True) 26 | ctx.obj['REPO'] = repo 27 | except Exception: 28 | click.echo('Ups! You\'re not inside a git repo') 29 | exit() 30 | 31 | 32 | branch.add_command(create) 33 | branch.add_command(change) 34 | branch.add_command(delete) 35 | 36 | 37 | def main(): 38 | branch(obj={}) 39 | -------------------------------------------------------------------------------- /gitutor/lesson/commands.py: -------------------------------------------------------------------------------- 1 | import click 2 | from PyInquirer import prompt 3 | from .lessons import lessons, welcome_message 4 | 5 | @click.command() 6 | @click.pass_context 7 | def lesson(ctx): 8 | """ 9 | Gitutor lessons right on your terminal ! 10 | """ 11 | click.echo(welcome_message) 12 | selection = prompt_for_lesson() 13 | if selection: 14 | lesson_name = extract_lesson_name(selection['lesson']) 15 | click.echo(lessons[lesson_name]['content']) 16 | 17 | 18 | def create_choices_from_lessons(): 19 | return [f'{lesson} - {lessons[lesson]["description"]}' for lesson in lessons] 20 | 21 | def extract_lesson_name(selection): 22 | if selection: 23 | return selection.split('-')[0].strip() 24 | 25 | def prompt_for_lesson(): 26 | message = 'Select a lesson!' 27 | choices = create_choices_from_lessons() 28 | question = [ 29 | { 30 | 'type': 'list', 31 | 'message': message, 32 | 'name': 'lesson', 33 | 'choices': choices 34 | } 35 | ] 36 | return prompt(question) -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mónica Alba, Andrea Marín, Isaí Gonzalez, Andrés Cruz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /gitutor/branch/create.py: -------------------------------------------------------------------------------- 1 | import re 2 | import click 3 | from git import GitCommandError 4 | 5 | 6 | def parse_branch(branch_name: str): 7 | branch_name = re.sub(r"\s{2,}", " ", branch_name) 8 | branch_name = branch_name.replace(" ", "-") 9 | return branch_name 10 | 11 | 12 | @click.command() 13 | @click.pass_context 14 | @click.option('-l', '--local', 'is_local', is_flag=True, help="create local branch") 15 | @click.option('-b', '--branch', 'branch_name', help="branch name") 16 | def create(ctx, is_local: bool, branch_name: str): 17 | """ 18 | Create new branch in the repo 19 | """ 20 | # recover repository's object from context 21 | repo = ctx.obj['REPO'] 22 | 23 | if not branch_name: 24 | branch_name = click.prompt("Branch name") 25 | 26 | branch_name = parse_branch(branch_name) 27 | 28 | # create new branch 29 | try: 30 | repo.git.checkout(b=branch_name) 31 | except GitCommandError: 32 | error = f"fatal: A branch named '{branch_name}' already exists." 33 | click.echo(click.style(error, fg="red")) 34 | else: 35 | # create branch in remote repo 36 | if not is_local: 37 | repo.git.execute(["git", "push", "--set-upstream", "origin", branch_name]) 38 | click.echo(f"Branch {branch_name} created successfully!") 39 | else: 40 | click.echo(f"Local branch {branch_name} created successfully!") 41 | -------------------------------------------------------------------------------- /gitutor/cli.py: -------------------------------------------------------------------------------- 1 | import git 2 | import click 3 | 4 | from goBack.commands import goBack 5 | from init.commands import init 6 | from save.commands import save 7 | from ignore.commands import ignore 8 | from compare.commands import compare 9 | from lesson.commands import lesson 10 | from branch.commands import branch 11 | 12 | 13 | class CustomGroup(click.Group): 14 | def invoke(self, ctx): 15 | ctx.obj['args'] = tuple(ctx.args) 16 | super(CustomGroup, self).invoke(ctx) 17 | 18 | 19 | @click.group(cls=CustomGroup) 20 | @click.pass_context 21 | def cli(ctx): 22 | """ 23 | Git the easy way. 24 | 25 | If you want to access gitutor tutorials run 26 | 27 | $ gt lesson 28 | 29 | Any issues, questions or bugs you can reach us at support@gitutor.io 30 | """ 31 | 32 | # Check ctx was initialized 33 | ctx.ensure_object(dict) 34 | 35 | if ('--help' not in ctx.obj['args']) and (ctx.invoked_subcommand != 'init') and ctx.invoked_subcommand != 'lesson': 36 | try: 37 | repo = git.Repo(".", search_parent_directories=True) 38 | ctx.obj['REPO'] = repo 39 | except Exception: 40 | click.echo('Ups! You\'re not inside a git repo') 41 | exit() 42 | 43 | 44 | cli.add_command(init) 45 | cli.add_command(goBack) 46 | cli.add_command(save) 47 | cli.add_command(ignore) 48 | cli.add_command(compare) 49 | cli.add_command(branch) 50 | cli.add_command(lesson) 51 | 52 | 53 | def main(): 54 | cli(obj={}) 55 | 56 | 57 | if __name__ == '__main__': 58 | main() 59 | -------------------------------------------------------------------------------- /gitutor/compare/commands.py: -------------------------------------------------------------------------------- 1 | import git 2 | import click 3 | from git import GitCommandError 4 | from .color_diff import color_diff 5 | from util import commits_full_list, prompt_for_commit_selection 6 | 7 | @click.command(short_help = "Compare current status with selected commit") 8 | @click.pass_context 9 | @click.option('-h', '--hash', 'commit', help='Hash of commit to compare') 10 | def compare(ctx,commit): 11 | """ 12 | Compare current status with selected commit. 13 | 14 | To display a list with all the commits run: 15 | 16 | $ gt compare 17 | 18 | If you know the hash of the commit you want to compare your repo with, then run 19 | 20 | $ gt compare --hash 21 | 22 | """ 23 | #Recover repo from context 24 | repo = ctx.obj['REPO'] 25 | 26 | if commit: 27 | try: 28 | click.echo(repo.git.diff(commit)) 29 | except GitCommandError: 30 | click.echo('Hash not found') 31 | return 32 | 33 | 34 | full_list_of_commits = commits_full_list(repo) 35 | click.echo('\n'.join(color_diff("-Red lines are from prior version. \n+Green lines are from current version".splitlines()))) 36 | click.echo("Learn how to read diff output here: https://gitutor.io/guide/gt-compare.html#understanding-the-output \n") 37 | answer = prompt_for_commit_selection(full_list_of_commits, 'Select commit to compare') 38 | if answer: 39 | answer_hash = answer['commit'][:7] 40 | diff_string = repo.git.diff(answer_hash) 41 | click.echo('\n'.join(color_diff(diff_string.splitlines()))) 42 | -------------------------------------------------------------------------------- /gitutor/branch/change.py: -------------------------------------------------------------------------------- 1 | import click 2 | from git import GitCommandError 3 | from PyInquirer import prompt 4 | 5 | from .utils import list_branches 6 | 7 | 8 | def get_new_branch(repo, get_all): 9 | repo_branches, _, _ = list_branches(repo, get_all) 10 | 11 | message = f"Select the branch you want to use. Current branch is {repo.active_branch.name}" 12 | 13 | questions = [ 14 | { 15 | 'type': 'list', 16 | 'message': message, 17 | 'name': 'selected_branch', 18 | 'default': 1, 19 | 'choices': repo_branches 20 | } 21 | ] 22 | answers = prompt(questions) 23 | 24 | if "selected_branch" in answers: 25 | selected_branch = answers["selected_branch"] 26 | else: 27 | selected_branch = None 28 | 29 | return selected_branch 30 | 31 | 32 | @click.command() 33 | @click.pass_context 34 | @click.option('-a', '--all', 'get_all', is_flag=True, help="show remote and local branches", default=False) 35 | @click.option('-b', '--branch', 'branch_name', help="branch name") 36 | def change(ctx, get_all: bool, branch_name: str): 37 | """ 38 | Change current branch 39 | """ 40 | # recover repository's object from context 41 | repo = ctx.obj['REPO'] 42 | 43 | if not branch_name: 44 | # prompt to get new branch 45 | branch_name = get_new_branch(repo, get_all) 46 | 47 | if branch_name: 48 | try: 49 | repo.git.checkout(branch_name) 50 | except GitCommandError: 51 | error = "Please commit your changes or stash them before you switch branches.\nAborting" 52 | click.echo(click.style(error, fg="yellow")) 53 | -------------------------------------------------------------------------------- /gitutor/util.py: -------------------------------------------------------------------------------- 1 | from PyInquirer import prompt 2 | 3 | 4 | def prompt_for_commit_selection(list_of_commits, message): 5 | commit_page_index = 0 6 | choices = get_commit_page(commit_page_index, list_of_commits) 7 | question = [ 8 | { 9 | 'type': 'list', 10 | 'message': message, 11 | 'name': 'commit', 12 | 'choices': choices 13 | } 14 | ] 15 | answer = prompt(question) 16 | if answer: 17 | while answer['commit'] == '...Show previous commits' or answer['commit'] == 'Show more commits...': 18 | if answer['commit'] == '...Show previous commits': 19 | commit_page_index -= 1 20 | commits_to_show = get_commit_page(commit_page_index, list_of_commits) 21 | else: 22 | commit_page_index += 1 23 | commits_to_show = get_commit_page(commit_page_index, list_of_commits) 24 | 25 | question[0].update({'choices': commits_to_show}) 26 | answer = prompt(question) 27 | return answer 28 | 29 | 30 | def commits_full_list(repo, first_parent=True): 31 | 32 | if first_parent: 33 | log_params = ['--first-parent'] 34 | else: 35 | log_params = [] 36 | 37 | log_params.append("--pretty=format:'%h - %s: %an, %ar'") 38 | full_list_of_commits = repo.git.log(log_params).replace("'", "").splitlines() 39 | return full_list_of_commits 40 | 41 | 42 | def get_commit_page(page_index, full_list_of_commits): 43 | if page_index == 0: 44 | commits_to_show = full_list_of_commits[0:20] 45 | else: 46 | commits_to_show = full_list_of_commits[page_index * 20:page_index * 20 + 20] 47 | commits_to_show.insert(0, '...Show previous commits') 48 | if len(full_list_of_commits) > page_index * 20 + 20: 49 | commits_to_show.append('Show more commits...') 50 | return commits_to_show 51 | -------------------------------------------------------------------------------- /gitutor/save/test_commands.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | import commands 4 | from click.testing import CliRunner 5 | 6 | class TestSaveCommand(unittest.TestCase): 7 | 8 | @mock.patch('commands.repo.git.commit') 9 | @mock.patch('commands.get_conflict_files') 10 | @mock.patch('commands.repo.remotes.origin.exists') 11 | @mock.patch('commands.repo.git.pull') 12 | @mock.patch('commands.conflicts_from_merge') 13 | @mock.patch('commands.repo.git.push') 14 | def test_save_commit_exception( 15 | self, 16 | mock_get_conflict_files, 17 | mock_commit, 18 | mock_exists, 19 | mock_pull, 20 | mock_conflicts_from_merge, 21 | mock_push 22 | ): 23 | mock_get_conflict_files.return_value = False 24 | mock_commit.side_effect = Exception() 25 | mock_exists.return_value = True 26 | mock_pull.return_value = True 27 | runner = CliRunner() 28 | result = runner.invoke(commands.save, 29 | ['-m', 'message']) 30 | self.assertTrue(mock_push.called) 31 | 32 | @mock.patch('commands.repo.git.commit') 33 | @mock.patch('commands.get_conflict_files') 34 | @mock.patch('commands.repo.remotes.origin.exists') 35 | @mock.patch('commands.repo.git.pull') 36 | @mock.patch('commands.conflicts_from_merge') 37 | @mock.patch('commands.repo.git.push') 38 | @mock.patch('commands.click.echo') 39 | def test_save_conflicted_files( 40 | self, 41 | mock_get_conflict_files, 42 | mock_commit, 43 | mock_exists, 44 | mock_pull, 45 | mock_conflicts_from_merge, 46 | mock_echo 47 | ): 48 | mock_get_conflict_files.return_value = True 49 | mock_commit.side_effect = Exception() 50 | mock_exists.return_value = True 51 | mock_pull.return_value = True 52 | runner = CliRunner() 53 | result = runner.invoke(commands.save, 54 | ['-m', 'message']) 55 | mock_echo.assert_called_with('Please fix the following conflicts then use "gt save" again') -------------------------------------------------------------------------------- /gitutor/init/test_commands.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | import commands 4 | from click.testing import CliRunner 5 | 6 | class TestInitCommand(unittest.TestCase): 7 | 8 | @mock.patch('commands.git') 9 | def test_is_initialized_true(self, mock_git): 10 | is_initialized = commands.is_initialized_repo() 11 | self.assertTrue(is_initialized) 12 | 13 | @mock.patch('commands.git') 14 | def test_is_initialized_false(self, mock_git): 15 | mock_git.Repo.side_effect = Exception() 16 | is_initialized = commands.is_initialized_repo() 17 | self.assertFalse(is_initialized) 18 | 19 | @mock.patch('commands.Github') 20 | @mock.patch('commands.init_local_repo') 21 | @mock.patch('commands.is_initialized_repo') 22 | def test_init_github_exception( 23 | self, 24 | mock_is_initialized_repo, 25 | mock_init_local_repo, 26 | mock_Github 27 | ): 28 | mock_is_initialized_repo.return_value = False 29 | mock_Github.side_effect = Exception() 30 | runner = CliRunner() 31 | result = runner.invoke(commands.init, 32 | ['-u', 'user', '-p', 'passwrod', '-r', 'repo_name']) 33 | self.assertFalse(mock_init_local_repo.called) 34 | 35 | @mock.patch('commands.Github') 36 | @mock.patch('commands.init_local_repo') 37 | @mock.patch('commands.is_initialized_repo') 38 | def test_init_already_initialized( 39 | self, 40 | mock_is_initialized_repo, 41 | mock_init_local_repo, 42 | mock_Github 43 | ): 44 | mock_is_initialized_repo.return_value = True 45 | runner = CliRunner() 46 | result = runner.invoke(commands.init, 47 | ['-u', 'user', '-p', 'passwrod', '-r', 'repo_name']) 48 | self.assertFalse(mock_Github.called) 49 | self.assertFalse(mock_init_local_repo.called) 50 | 51 | @mock.patch('commands.Github') 52 | @mock.patch('commands.init_local_repo') 53 | @mock.patch('commands.is_initialized_repo') 54 | def test_init_local_only( 55 | self, 56 | mock_is_initialized_repo, 57 | mock_init_local_repo, 58 | mock_Github 59 | ): 60 | mock_is_initialized_repo.return_value = False 61 | runner = CliRunner() 62 | result = runner.invoke(commands.init, 63 | ['-l']) 64 | self.assertFalse(mock_Github.called) 65 | self.assertTrue(mock_init_local_repo.called) 66 | -------------------------------------------------------------------------------- /gitutor/goBack/test_commands.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | import commands 4 | from click.testing import CliRunner 5 | from git import Repo 6 | 7 | class TestGoBackCommand(unittest.TestCase): 8 | 9 | # @mock.patch('commands.git') 10 | # def test_is_initialized_true(self, mock_git): 11 | # is_initialized = commands.is_initialized_repo() 12 | # self.assertTrue(is_initialized) 13 | 14 | @mock.patch('commands.revert_hash_sequence') 15 | @mock.patch('commands.hash_sequence_to_target') 16 | def test_revert_to_target_empty_hash_sequence( 17 | self, 18 | mock_hash_seq_to_target, 19 | mock_revert_hash_seq 20 | ): 21 | repo = mock.Mock(spec=Repo) 22 | mock_hash_seq_to_target.side_effect = [ 23 | [1], 24 | [] 25 | ] 26 | number_of_reverts = commands.revert_to_target(repo, 1, []) 27 | self.assertFalse(mock_revert_hash_seq.called) 28 | self.assertFalse(repo.git.called) 29 | self.assertTrue(mock_hash_seq_to_target.called) 30 | self.assertEqual(number_of_reverts, 0) 31 | 32 | @mock.patch('commands.revert_hash_sequence') 33 | @mock.patch('commands.hash_sequence_to_target') 34 | def test_revert_to_target_succesfull( 35 | self, 36 | mock_hash_seq_to_target, 37 | mock_revert_hash_seq 38 | ): 39 | repo = mock.Mock(spec=Repo) 40 | mock_hash_seq_to_target.side_effect = [ 41 | [1, 2, 3], 42 | [1, 2, 3] 43 | ] 44 | number_of_reverts = commands.revert_to_target(repo, 1, []) 45 | self.assertTrue(mock_revert_hash_seq.called) 46 | self.assertFalse(repo.git.called) 47 | self.assertTrue(mock_hash_seq_to_target.called) 48 | self.assertEqual(number_of_reverts, 3) 49 | 50 | @mock.patch('commands.revert_hash_sequence') 51 | @mock.patch('commands.hash_sequence_to_target') 52 | def test_revert_to_target_fails( 53 | self, 54 | mock_hash_seq_to_target, 55 | mock_revert_hash_seq 56 | ): 57 | repo = mock.Mock(spec=Repo) 58 | mock_hash_seq_to_target.side_effect = [ 59 | [1, 2, 3], 60 | [1, 2, 3] 61 | ] 62 | mock_revert_hash_seq.side_effect = Exception 63 | number_of_reverts = commands.revert_to_target(repo, 1, []) 64 | self.assertTrue(mock_revert_hash_seq.called) 65 | self.assertTrue(mock_hash_seq_to_target.called) 66 | repo.git.reset.assert_called_with('--hard', 1) 67 | self.assertEqual(number_of_reverts, 0) 68 | 69 | -------------------------------------------------------------------------------- /gitutor/init/commands.py: -------------------------------------------------------------------------------- 1 | import click 2 | from github import Github 3 | from github import GithubException 4 | from git import GitCommandError 5 | import git 6 | import os 7 | 8 | 9 | @click.command(short_help = "Create Git repo and GitHub remote") 10 | @click.pass_context 11 | @click.option('-u', '--user', 'user_name', help = "GitHub username") 12 | @click.option('-p', '--password', 'password', hide_input=True, help = "GitHub password") 13 | @click.option('-l', '--local', is_flag=True, help = "Init repo locally") 14 | @click.option('-n', '--name', help = "Repository's name") 15 | @click.option('--ssh', is_flag=True, help = "Use ssh authentication") 16 | def init(ctx, user_name, password,local, name, ssh): 17 | """ 18 | Create git repo and github remote 19 | 20 | If you don't want to create the repo in your GitHub account run: 21 | 22 | $ gt init -l 23 | 24 | You can use ssh authentication with 25 | 26 | $ gt init --ssh 27 | 28 | (To enable ssh authentication follow this tutorial https://docs.github.com/en/github/authenticating-to-github/connecting-to-github-with-ssh) 29 | 30 | You can also provide your repo's information in one step instead of waiting for the prompt with: 31 | 32 | $ gt init --name --user --password 33 | 34 | """ 35 | if is_initialized_repo(): 36 | click.echo('This is already a git repo!') 37 | return 38 | 39 | if local: 40 | init_local_repo() 41 | return 42 | if not user_name: 43 | user_name = click.prompt('Username') 44 | 45 | if not password: 46 | password = click.prompt('Password', hide_input=True) 47 | 48 | if not name: 49 | name = click.prompt('Repo name') 50 | 51 | try: 52 | g = Github(user_name, password) 53 | user = g.get_user() 54 | click.echo('Creating github repo...') 55 | remote_repo = user.create_repo(name) 56 | except GithubException as e: 57 | error_code = e.args[0] 58 | if error_code == 401 : 59 | click.echo('Problem with credentials!') 60 | else: 61 | if error_code == 422 : 62 | click.echo('Repo name already used!') 63 | else: 64 | local_repo = init_local_repo() 65 | if ssh: 66 | local_repo.create_remote('origin', remote_repo.ssh_url) 67 | else: 68 | local_repo.create_remote('origin', remote_repo.clone_url) 69 | click.echo('Push to origin...') 70 | local_repo.git.push('-u', 'origin', 'master') 71 | click.echo('Ready!') 72 | 73 | def init_local_repo(): 74 | repo_dir = os.getcwd() 75 | file_name = os.path.join(repo_dir, 'README.md') 76 | 77 | repo = git.Repo.init(repo_dir) 78 | #Create empty README.md 79 | open(file_name, 'wb').close() 80 | repo.index.add([file_name]) 81 | repo.index.commit('first commit') 82 | return repo 83 | 84 | def is_initialized_repo(): 85 | try: 86 | git.Repo('.', search_parent_directories=True) 87 | except: 88 | return False 89 | else: 90 | return True 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pypiv](https://img.shields.io/pypi/v/gitutor.svg)](https://pypi.python.org/pypi/gitutor) 2 | [![pyv](https://img.shields.io/pypi/pyversions/gitutor.svg)](https://pypi.python.org/pypi/gitutor) 3 | 4 | # Gitutor 5 | 6 | Welcome to Gitutor. This tool is meant to get you up and running using gitmin the shortest time possible while learning on the go. 7 | 8 | Gitutor is a command line application that wraps git and provides beginner friendly versions of git's commands. It's Git the easy way. 9 | 10 | You can check out the tutorial and a further explanation of the commands in the [docs](https://gitutor.io/guide). And don't worry if you forget how to use a command you can always run 11 | 12 | $ gt --help 13 | 14 | If you have any problems please send us an email at support@gitutor.io or open an issue in our [repo](https://github.com/artemisa-mx/gitutor/issues), we usually answer in less than a day. 15 | 16 | ## Available commands 17 | 18 | 1. gt init - Initialize your local and remote repository. 19 | 2. gt save - Save you changes in the local and remote repository. 20 | 3. gt goback - Return to a previous commit. 21 | 4. gt compare - Compare the current state with a previous commit. 22 | 5. gt ignore - Make git ignore selected files. 23 | 6. gt lesson - See gitutor lessons and documentation. 24 | 25 | ## Installation guide 26 | 27 | > **NOTE**: pipx and gitutor work with Python3.6+ 28 | 29 | In order to use gitutor without any dependencies version conflicts we recommend installing it using pipx. Pipx creates a virtual environment for your package and exposes its entry point so you can run gitutor from anywhere. 30 | 31 | To install pipx and configure the $PATH run the following commands 32 | 33 | For Windows: 34 | 35 | $ python -m pip install pipx 36 | $ python -m pipx ensurepath 37 | 38 | For MacOS use: 39 | 40 | $ brew install pipx 41 | 42 | For Linux use: 43 | 44 | $ python3 -m pip install pipx 45 | $ python3 -m pipx ensurepath 46 | 47 | > **NOTE**: You may need to restart your terminal for the path updates to take effect. 48 | 49 | Once pipx is installed, run the following to install gitutor: 50 | 51 | $ pipx install gitutor 52 | 53 | And to upgrade gitutor to its latest version you only need to run: 54 | 55 | $ pipx upgrade gitutor 56 | 57 | To install gitutor without using pipx just run: 58 | 59 | $ pip install gitutor 60 | 61 | ## Additional notes 62 | 63 | Before using gitutor you need to have Git available in your computer. You can check the installation guide [here](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). 64 | 65 | It's also recommended to store your GitHub credentials so you won't have to authenticate everytime you realize a push or pull. You can do this by running 66 | 67 | $ git config --global credential.helper store 68 | 69 | This will store your credentials in a plain-text file (.git-gredentials) under your project directory. If you don't like this you can use any of the following approaches: 70 | 71 | On Mac OS X you can use its native keystore with 72 | 73 | $ git config --global credential.helper oskeychain 74 | 75 | For Windows you can install a helper called [Git Credential Manager for Windows](https://github.com/Microsoft/Git-Credential-Manager-for-Windows) and then run 76 | 77 | $ git config --global credential.helper manager 78 | 79 | 80 | If you like what we're doing you can buy as a [coffee](https://ko-fi.com/artemisamx) 81 | -------------------------------------------------------------------------------- /gitutor/branch/delete.py: -------------------------------------------------------------------------------- 1 | import click 2 | from git import GitCommandError 3 | from PyInquirer import prompt 4 | 5 | from .utils import list_branches 6 | 7 | 8 | def get_branches(repo): 9 | _, remote_branches, local_branches= list_branches(repo, get_all=True) 10 | 11 | current_branch = repo.active_branch.name 12 | local_branches.remove(current_branch) 13 | 14 | if current_branch != "master": 15 | local_branches.remove("master") 16 | 17 | questions = [ 18 | { 19 | 'type': 'checkbox', 20 | 'message': "Select the branches you want to delete.", 21 | 'name': 'branches', 22 | 'choices': [{"name": b} for b in local_branches] 23 | } 24 | ] 25 | answers = prompt(questions) 26 | 27 | branches = [] 28 | if 'branches' in answers: 29 | branches = answers["branches"] 30 | 31 | return branches, remote_branches 32 | 33 | 34 | def get_merged_branches(repo): 35 | merged = repo.git.branch('--merged').split("\n") 36 | merged = [b.replace("*", "").strip() for b in merged] 37 | return merged 38 | 39 | 40 | def can_delete(branch_name, merged_branches, remote_branches, force, is_local): 41 | delete = False 42 | error = "" 43 | 44 | if force: 45 | delete = True 46 | elif not is_local and branch_name not in remote_branches: 47 | error = f"Unable to delete '{branch_name}': remote ref does not exist.\nTo delete locally run 'gt branch delete --local'." 48 | elif branch_name not in merged_branches: 49 | error = f"The branch '{branch_name}' is not fully merged.\nIf you are sure you want to delete it, run 'gt branch delete --force'." 50 | else: 51 | delete = True 52 | 53 | return delete, error 54 | 55 | 56 | @click.command() 57 | @click.pass_context 58 | @click.option('-f', '--force', 'force', is_flag=True, help="force deletion (unmerged branches)", default=False) 59 | @click.option('-l', '--local', 'is_local', is_flag=True, help="only delete branches locally", default=False) 60 | @click.option('-b', '--branch', 'branch_name', help="branch to delete") 61 | def delete(ctx, is_local: bool, force: bool, branch_name: str): 62 | """ 63 | Delete branches remote or only locally 64 | """ 65 | # recover repository's object from context 66 | repo = ctx.obj['REPO'] 67 | 68 | if not branch_name: 69 | # prompt to get new branch 70 | delete_branches, remote_branches = get_branches(repo) 71 | else: 72 | _, remote_branches, _ = list_branches(repo, get_all=True) 73 | 74 | # get branches that have already been merged 75 | merged_branches = get_merged_branches(repo) 76 | 77 | remote = repo.remote(name="origin") 78 | deleted = [] 79 | for branch in delete_branches: 80 | # check if branch can be deleted 81 | delete, error = can_delete(branch, merged_branches, remote_branches, force, is_local) 82 | 83 | if not delete: 84 | click.echo(click.style(error, fg="yellow")) 85 | else: 86 | # delete remote branch 87 | if not is_local: 88 | try: 89 | remote.push(refspec=(f":{branch}")) 90 | except: 91 | pass 92 | 93 | # delete branch locally 94 | delete_str = "-D" if force else "-d" 95 | repo.git.branch(delete_str, branch) 96 | 97 | deleted.append(branch) 98 | 99 | if deleted: 100 | message = f"Successfully deleted {len(deleted)} branches: {', '.join(deleted)}" 101 | click.echo(click.style(message, fg="green")) -------------------------------------------------------------------------------- /gitutor/goBack/commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | import click 3 | from util import prompt_for_commit_selection, commits_full_list 4 | from git import GitCommandError 5 | from collections import deque 6 | 7 | 8 | @click.command(short_help='Returns repo version to a specific commit') 9 | @click.pass_context 10 | @click.option('-h', '--hash', 'commit_hash', help="hash of the commit to go back") 11 | def goBack(ctx, commit_hash): 12 | """ 13 | Returns version to a specific commit via hash or selected commit 14 | 15 | To display a list with all the commits run: 16 | 17 | $ gt goback 18 | 19 | If you know the hash of the commit you want to go back to, then run 20 | 21 | $ gt goback --hash 22 | """ 23 | repo = ctx.obj['REPO'] 24 | 25 | full_list_of_commits = commits_full_list(repo) 26 | if commit_hash: 27 | commit_hash = commit_hash[0:7] 28 | commit_info = find_commit_info(full_list_of_commits, commit_hash) 29 | else: 30 | selected_log_entry = prompt_for_commit_selection(full_list_of_commits, 'Select the commit you want to return') 31 | if 'commit' not in selected_log_entry: #handle PyInquirer bug that returns None on user click on list 32 | return 33 | selected_log_entry = selected_log_entry['commit'] 34 | commit_info = extract_info_from_log_entry(selected_log_entry) 35 | 36 | if not commit_info: 37 | click.echo('There is no commit with the hash provided') 38 | return 39 | 40 | click.echo(f'Going back to "{commit_info["comment"]}"...') 41 | click.echo('Saving changes before revert...') 42 | commit_before_revert(repo) 43 | number_of_reverts = revert_to_target(repo, commit_info['hash'], full_list_of_commits) 44 | if number_of_reverts > 0: 45 | combine_reverts_into_single_commit(repo, number_of_reverts, commit_info['comment']) 46 | click.echo('Done!') 47 | else: 48 | click.echo('Nothing to revert!') 49 | 50 | 51 | def combine_reverts_into_single_commit(repo, number_of_reverts, commit_comment): 52 | index_of_first_revert = number_of_reverts 53 | repo.git.reset('--soft', f'HEAD~{index_of_first_revert}') 54 | repo.git.commit('-m', f'Going back to "{commit_comment}"') 55 | 56 | def hash_sequence_to_target(target_hash, list_of_commits): 57 | list_of_commit_dicts = [ extract_info_from_log_entry(log_entry) for log_entry in list_of_commits ] 58 | list_of_hashes = [ commit_info['hash'] for commit_info in list_of_commit_dicts ] 59 | return list_of_hashes[:list_of_hashes.index(target_hash)] 60 | 61 | def revert_to_target(repo, target_hash, list_of_commits): 62 | hash_sequence = hash_sequence_to_target(target_hash, list_of_commits) 63 | head_sha = hash_sequence[0] if len(hash_sequence) > 0 else None 64 | #We need to recalculate list_of_commits in case there was a new commit created by commit_before_revert 65 | list_of_commits = commits_full_list(repo) 66 | hash_sequence = hash_sequence_to_target(target_hash, list_of_commits) 67 | if len(hash_sequence) > 0: 68 | try: 69 | revert_hash_sequence(repo, hash_sequence) 70 | except Exception as e: 71 | print(e) 72 | repo.git.reset('--hard', head_sha) 73 | return 0 74 | 75 | return len(hash_sequence) 76 | 77 | def revert_hash_sequence(repo, hash_sequence): 78 | 79 | for commit_hash in hash_sequence: 80 | try: 81 | if is_merge_commit(repo, commit_hash): 82 | repo.git.revert('-m', '1', commit_hash, '--no-edit') 83 | else: 84 | repo.git.revert(commit_hash, '--no-edit') 85 | except Exception as e: 86 | raise e 87 | 88 | def is_merge_commit(repo, commit_hash): 89 | return len(repo.commit(commit_hash).parents) > 1 90 | 91 | def find_commit_info(list_of_commits, commit_hash): 92 | list_of_commits = [ extract_info_from_log_entry(log_entry) for log_entry in list_of_commits] 93 | for commit_info in list_of_commits: 94 | if commit_info['hash'] == commit_hash: 95 | return commit_info 96 | 97 | return None 98 | 99 | def extract_info_from_log_entry(log_entry): 100 | log_entry_parts = log_entry.split('-') 101 | return { 102 | 'hash': log_entry_parts[0].strip(), 103 | 'comment': log_entry_parts[1].split(':')[0] 104 | } 105 | 106 | def commit_before_revert(repo): 107 | repo.git.add(A=True) 108 | try: 109 | repo.git.commit("-m", 'Saving before revert') 110 | return 1 111 | except GitCommandError: 112 | return 0 -------------------------------------------------------------------------------- /gitutor/save/commands.py: -------------------------------------------------------------------------------- 1 | import git 2 | import click 3 | from git import GitCommandError 4 | 5 | @click.command(short_help = "Creates a checkpoint of your project") 6 | @click.pass_context 7 | @click.option('-m', '--message', 'message', help='Commit message') 8 | @click.option('-l', '--local', is_flag=True, help='Save locally only') 9 | @click.option('--defer-conflicts', is_flag=True, help='Defer conflict resolution') 10 | def save(ctx, message, local, defer_conflicts): 11 | """ 12 | Creates a checkpoint of your project, i.e., saves all of your current changes and new files. 13 | 14 | You can pass the commit message without the prompt with: 15 | 16 | $ gt save --message "This is a commit" 17 | 18 | If you don't want to push your changes to the remote repository run: 19 | 20 | $ gt save --local 21 | 22 | If you have files with conflicts but don't want to resolve them right away run: 23 | 24 | $ gt save --defer-conflicts 25 | 26 | This will abort the attempt to sync with the remote repo. Your changes will be saved on your local machine only. 27 | You will have to solve the merge conflict later on. 28 | """ 29 | # Recover repo from context 30 | repo = ctx.obj['REPO'] 31 | 32 | if defer_conflicts: 33 | try: 34 | repo.git.merge('--abort') 35 | except Exception as e: 36 | if e.args[1] == 128: 37 | click.echo('No merge to abort!') 38 | else: 39 | click.echo('Merge aborted!') 40 | return 41 | 42 | conflicted_files = get_conflict_files(repo) 43 | 44 | if not conflicted_files: 45 | 46 | #git add . 47 | if not message: 48 | message = click.prompt('Commit message') 49 | 50 | repo.git.add(A=True) 51 | 52 | #git commit -m "" 53 | try: 54 | repo.git.commit("-m", message) 55 | except GitCommandError: 56 | click.echo("No changes in local repository since last commit") 57 | 58 | if not local: 59 | try: 60 | repo.remotes.origin.exists() 61 | except: 62 | click.echo('Remote repository does not exist!') 63 | else: 64 | try: 65 | repo.git.pull() 66 | except GitCommandError as e: 67 | if "no tracking" in e.stderr: 68 | click.echo("There is no corresponding branch in the remote repository") 69 | click.echo("Changes were saved only in local repository") 70 | else: 71 | conflicted_files_merge = conflicts_from_merge(repo) 72 | if conflicted_files_merge: 73 | click.echo('Merge conflicts in:') 74 | for conflict_file in conflicted_files_merge: 75 | click.echo("- " + conflict_file) 76 | click.echo('Please fix conflicts then use "gt save" again') 77 | print_abort_merge_instructions() 78 | else: 79 | click.echo('Pushing to remote repository...') 80 | repo.git.push() 81 | click.echo('Done!') 82 | else: 83 | click.echo('Please fix the following conflicts then use "gt save" again') 84 | for conflicted_file in conflicted_files: 85 | click.echo(conflicted_file) 86 | print_abort_merge_instructions() 87 | 88 | def print_abort_merge_instructions(): 89 | click.echo('\nLearn how to resolve conflicts here: https://gitutor.io/guide/multiple-users.html#merge-conflicts') 90 | click.echo('\nDon\'t want conflicts right now ?') 91 | click.echo('Defer conflict resolution: gt save --defer-conflicts ') 92 | click.echo('Dont worry, your work will still be saved on your local repo:)') 93 | click.echo('\nSave only on local repo until you want to resolve conflict: gt save -l ') 94 | 95 | def conflicts_from_merge(repo): 96 | unmerged_blobs = repo.index.unmerged_blobs() 97 | error_array = [] 98 | # We're really interested in the stage each blob is associated with. 99 | # So we'll iterate through all of the paths and the entries in each value 100 | # list, but we won't do anything with most of the values. 101 | for path in unmerged_blobs: 102 | list_of_blobs = unmerged_blobs[path] 103 | for (stage, blob) in list_of_blobs: 104 | # Now we can check each stage to see whether there were any conflicts 105 | if stage != 0: 106 | if path not in error_array: 107 | error_array.append(path) 108 | return error_array 109 | 110 | def get_conflict_files(repo): 111 | matched_lines=[] 112 | try: 113 | repo.git.diff("--check") 114 | except GitCommandError as e: 115 | matched_lines = [line for line in e.stdout.split("'")[1].split('\n') if (": leftover conflict marker" in line) ] 116 | return matched_lines 117 | 118 | -------------------------------------------------------------------------------- /gitutor/ignore/commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import click 4 | import difflib 5 | from PyInquirer import prompt 6 | from git import GitCommandError 7 | 8 | 9 | def get_files(path: str, git_path: str): 10 | ''' 11 | Get all files in path, including hidden files 12 | ''' 13 | files = os.listdir(path) 14 | 15 | # remove git files from list 16 | for f in ['.git', '.gitignore', '.github']: 17 | try: 18 | files.remove(f) 19 | except Exception: 20 | pass 21 | 22 | # get dir relative path to git's repo 23 | relative_path = ''.join([x[-1] for x in difflib.ndiff(path, git_path) if x[0] == '-']) 24 | relative_path = re.sub('^/', '', relative_path) 25 | 26 | # create dictionary with files (key is the plain file name) 27 | dir_files = {} 28 | for key in files: 29 | full_path = '{}/{}'.format(path, key) 30 | folder = os.path.isdir(full_path) 31 | 32 | if folder: 33 | key = key + '/' 34 | 35 | dir_files[key] = { 36 | 'file_name': '{}/{}'.format(relative_path, key) if len(relative_path) > 1 else key, 37 | 'is_folder': folder 38 | } 39 | 40 | return dir_files 41 | 42 | 43 | def get_gitignore_files(git_path: str): 44 | ''' 45 | Get the files that already are in the .gitignore file 46 | ''' 47 | gitignore_path = git_path + '/.gitignore' 48 | files = [] 49 | 50 | if os.path.exists(gitignore_path): 51 | with open(gitignore_path, 'r') as _: 52 | files = re.sub(r'(\n+$)|(^\n)', '', _.read()) 53 | 54 | files = re.sub(r'(\n\s*){2,}', '\n', files) 55 | files = files.split("\n") 56 | 57 | return files 58 | 59 | 60 | def create_choices(dir_files: dict, gitignore_files: list): 61 | ''' 62 | Create choices list for the interactive menu. 63 | Files in .gitignore are checked 64 | ''' 65 | choices = [] 66 | 67 | for file in dir_files: 68 | choice = { 69 | 'name': file 70 | } 71 | 72 | if dir_files[file]['file_name'] in gitignore_files: 73 | choice['checked'] = True 74 | dir_files[file]['is_ignored'] = True 75 | else: 76 | dir_files[file]['is_ignored'] = False 77 | 78 | choices.append(choice) 79 | 80 | return choices 81 | 82 | 83 | def update_gitignore(answers: list, dir_files: dict, gitignore_files: list, git_path: str): 84 | ''' 85 | Update gitignore file 86 | ''' 87 | new_files = [] 88 | 89 | # add new files to gitignore 90 | for file in answers: 91 | if dir_files[file]['file_name'] not in gitignore_files: 92 | gitignore_files.append(dir_files[file]['file_name']) 93 | new_files.append(dir_files[file]['file_name']) 94 | 95 | # remove files 96 | for key, file_info in filter(lambda d: d[1]['is_ignored'], dir_files.items()): 97 | if key not in answers: 98 | gitignore_files.remove(file_info['file_name']) 99 | 100 | gitignore = '\n'.join(gitignore_files) 101 | 102 | # write gitignore file 103 | gitignore_path = '{}/.gitignore'.format(git_path) 104 | with open(gitignore_path, 'w') as _: 105 | _.write(gitignore) 106 | 107 | return new_files 108 | 109 | 110 | def remove_tracked_files(new_files: list, repo): 111 | ''' 112 | Remove tracked files in gitignore from repo 113 | ''' 114 | tracked_files = repo.git.execute(["git", "ls-files", "-i", "--exclude-standard"]) 115 | tracked_files = tracked_files.split("\n") 116 | 117 | new_tracked_files = [] 118 | for file in new_files: 119 | if '/' in file: 120 | regex = re.compile('^' + file + ".*") 121 | new_tracked_files.extend(filter(regex.match, tracked_files)) 122 | elif file in tracked_files: 123 | new_tracked_files.append(file) 124 | 125 | if len(new_tracked_files) > 0: 126 | repo.git.rm(new_tracked_files, "-r", "--cached") 127 | try: 128 | repo.git.commit("-m", "Remove ignored files from repo") 129 | except GitCommandError: 130 | pass 131 | 132 | 133 | @click.command() 134 | @click.pass_context 135 | def ignore(ctx): 136 | """ 137 | Add files to .gitignore with an interactive menu 138 | """ 139 | # recover repository's object from context 140 | repo = ctx.obj['REPO'] 141 | 142 | # get current ignored files 143 | git_path = repo.working_tree_dir 144 | ignored_files = get_gitignore_files(git_path) 145 | 146 | # get files in current directory 147 | full_path = os.getcwd() 148 | dir_files = get_files(full_path, git_path) 149 | 150 | questions = [ 151 | { 152 | 'type': 'checkbox', 153 | 'message': 'Select files to ignore', 154 | 'name': 'files', 155 | 'choices': create_choices(dir_files, ignored_files) 156 | } 157 | ] 158 | 159 | answers = prompt(questions) 160 | 161 | if 'files' in answers: 162 | new_ignored_files = update_gitignore(answers['files'], dir_files, ignored_files, git_path) 163 | remove_tracked_files(new_ignored_files, repo) 164 | -------------------------------------------------------------------------------- /gitutor/ignore/tests.py: -------------------------------------------------------------------------------- 1 | import commands 2 | import unittest 3 | from unittest import mock 4 | from click.testing import CliRunner 5 | 6 | class TestIgnoreCommand(unittest.TestCase): 7 | 8 | @mock.patch("commands.os") 9 | def test_repo_without_gitignore(self, os_mock): 10 | os_mock.path.exists.return_value = False 11 | gitignore_files = commands.get_gitignore_files("git path") 12 | self.assertEqual(len(gitignore_files), 0) 13 | self.assertIsInstance(gitignore_files, list) 14 | 15 | @mock.patch("commands.os") 16 | @mock.patch("builtins.open", new_callable = mock.mock_open, read_data="\na1\n \n\na2\na3\n") 17 | def test_repo_with_gitignore(self, open_mock, os_mock): 18 | os_mock.path.exists.return_value = True 19 | 20 | gitignore_files = commands.get_gitignore_files("git path") 21 | self.assertEqual(len(gitignore_files), 3) 22 | 23 | @mock.patch("commands.os") 24 | def test_get_dir_files(self, os_mock): 25 | files = ["archivo1", "archivo2", "archivo3"] 26 | output = {k:{"file_name": f"inside path/{k}", "is_folder": False} for k in files} 27 | 28 | os_mock.listdir.return_value = files 29 | os_mock.path.isdir.return_value = False 30 | 31 | dir_files = commands.get_files("git path/inside path", "git path") 32 | 33 | self.assertDictEqual(dir_files, output) 34 | 35 | @mock.patch("commands.os") 36 | def test_get_dir_folders(self, os_mock): 37 | files = ["folder1", "folder2", "folder3"] 38 | output = {f"{k}/":{"file_name": f"inside path/{k}/", "is_folder": True} for k in files} 39 | 40 | os_mock.listdir.return_value = files 41 | os_mock.path.isdir.return_value = True 42 | 43 | dir_files = commands.get_files("git path/inside path", "git path") 44 | 45 | self.assertDictEqual(dir_files, output) 46 | 47 | @mock.patch("commands.os") 48 | def test_get_dir_files_top(self, os_mock): 49 | files = ["archivo1", "archivo2", "archivo3"] 50 | output = {k:{"file_name": k, "is_folder": False} for k in files} 51 | 52 | os_mock.listdir.return_value = files 53 | os_mock.path.isdir.return_value = False 54 | 55 | dir_files = commands.get_files("git path", "git path") 56 | 57 | self.assertDictEqual(dir_files, output) 58 | 59 | def test_choices(self): 60 | dir_files = { 61 | "a1":{ 62 | "file_name": "inside path/a1", 63 | "is_folder": False 64 | }, 65 | "a2":{ 66 | "file_name": "inside path/a2", 67 | "is_folder": False 68 | }, 69 | "a3":{ 70 | "file_name": "inside path/a3", 71 | "is_folder": False 72 | }, 73 | "f1/":{ 74 | "file_name": "inside path/f1/", 75 | "is_folder": True 76 | }, 77 | "f2/":{ 78 | "file_name": "inside path/f2/", 79 | "is_folder": True 80 | }, 81 | "f3/":{ 82 | "file_name": "inside path/f3/", 83 | "is_folder": True 84 | } 85 | } 86 | 87 | gitignore_files = ["a1", "inside path/a2","inside path/inside/f1/", "inside path/f2/"] 88 | 89 | expected_output = [ 90 | {"name": "a1"}, 91 | {"name": "a2", "checked": True}, 92 | {"name": "a3"}, 93 | {"name": "f1/"}, 94 | {"name": "f2/", "checked": True}, 95 | {"name": "f3/"}, 96 | ] 97 | 98 | choices = commands.create_choices(dir_files, gitignore_files) 99 | self.assertListEqual(choices, expected_output) 100 | 101 | for key in ["a2", "f2/"]: 102 | self.assertTrue(dir_files[key]["is_ignored"]) 103 | 104 | for key in ["a1", "f1/", "a3", "f3/"]: 105 | self.assertFalse(dir_files[key]["is_ignored"]) 106 | 107 | @mock.patch("builtins.open") 108 | def test_update_gitignore_add_files(self, open_mock): 109 | dir_files = { 110 | "a1":{ 111 | "file_name": "inside path/a1", 112 | "is_folder": False, 113 | "is_ignored": False 114 | }, 115 | "a2":{ 116 | "file_name": "inside path/a2", 117 | "is_folder": False, 118 | "is_ignored": True 119 | }, 120 | "a3":{ 121 | "file_name": "inside path/a3", 122 | "is_folder": False, 123 | "is_ignored": False 124 | }, 125 | "f1/":{ 126 | "file_name": "inside path/f1/", 127 | "is_folder": True, 128 | "is_ignored": False 129 | }, 130 | "f2/":{ 131 | "file_name": "inside path/f2/", 132 | "is_folder": True, 133 | "is_ignored": True 134 | }, 135 | "f3/":{ 136 | "file_name": "inside path/f3/", 137 | "is_folder": True, 138 | "is_ignored": False 139 | } 140 | } 141 | gitignore_files = ["a1", "inside path/a2","inside path/inside/f1/", "inside path/f2/"] 142 | answers = ["a1", "a2", "f2/", "f3/"] 143 | 144 | ignored_files = gitignore_files + ["a1", "f3/"] 145 | ignored_files = "\n".join(ignored_files) 146 | 147 | open_mock = mock.mock_open() 148 | 149 | new_files = commands.update_gitignore(answers, dir_files, gitignore_files, "git path") 150 | 151 | self.assertListEqual(new_files, ["a1", "f3/"]) 152 | 153 | open_mock.assert_called_once_with('git path/.gitignore', 'w') 154 | handle = open_mock() 155 | handle.write.assert_called_once_with(ignored_files) 156 | 157 | 158 | 159 | if __name__ == '__main__': 160 | unittest.main() -------------------------------------------------------------------------------- /gitutor/lesson/lessons.py: -------------------------------------------------------------------------------- 1 | welcome_message ='''\ 2 | Here is a list of short lessons to get you going. You can read 3 | the extended version of this tutorial on https://gitutor.io/guide/ 4 | ''' 5 | lessons = { 6 | 'Introduction': { 7 | 'description': 'Learn the basic concepts', 8 | 'content': '''\ 9 | The basic concepts of git: 10 | 11 | Git is a free and open source distributed version control system 12 | 13 | A version control system is a tool that saves checkpoints (called commits) of 14 | a file or a set of files over time. A collection of files tracked by git is 15 | called a repository (or repo). 16 | 17 | Git is distributed because it's possible to have copies of a repository on 18 | different machines in which checkpoints are saved independently. 19 | Github is a service for storing repositories that act as a source of truth. 20 | 21 | On the next lesson you'll learn the basic workflows for git 22 | Gitutor provides beginner friendly commands that wrap git. 23 | Start by using the simple commands. 24 | On the extended tutorial you'll be shown what happens behind the scenes and 25 | what are the actual git commands being used. 26 | ''' 27 | }, 28 | 'Single user workflow': { 29 | 'description': 'One person works on the repo', 30 | 'content': '''\ 31 | The first and simplest workflow is a single person working on the repository. 32 | 33 | 1. Change directory into the root folder of your project 34 | 35 | $cd myProject 36 | 37 | 2. Initialize your project's folder as a git repo 38 | 39 | $gt init 40 | 41 | 3. Work on your project 42 | 43 | 4. Create checkpoint to save your changes 44 | 45 | $gt save 46 | 47 | Repeat from step 3. 48 | 49 | Extended lesson: https://gitutor.io/guide/one-branch.html 50 | ''' 51 | 52 | }, 53 | 'Multiple users workflow': { 54 | 'description': 'Multiple people on the repo', 55 | 'content': '''\ 56 | The simplest workflow for colaboration is the following: 57 | 58 | 1. Initialize a repository as shown in Single User Workflow 59 | or download a copy of a repo with the url from the repo's github page 60 | with the following git command: 61 | 62 | $git clone url-of-github-repo 63 | 64 | For example: 65 | 66 | $git clone https://github.com/artemisa-mx/demoRepository.git 67 | 68 | 2. Grant or ask for write permisions to the github repo 69 | 70 | 3. Work on your project 71 | 72 | 4. Create checkpoint to save changes. 73 | 74 | $gt save 75 | 76 | 5. If no conflict occurs go to step 3. 77 | 78 | 6. If a merge conflict occurs, gitutor will output the names of the 79 | conflicted files. Solve the conflicts and then create a checkpoint to save 80 | the conflict resolution: 81 | 82 | $gt save 83 | 84 | Repeat from step 3 85 | 86 | Else, if you don't want to resolve the conflict right away, defer conflict resolution: 87 | 88 | $gt save --defer-conflicts 89 | 90 | Your checkpoint will only be saved locally. 91 | 92 | When creating a checkpoint on step 4, you can alteratively use: 93 | 94 | $gt save -l 95 | 96 | This will save the checkpoint only on your local machine and wont download 97 | changes from github, avoiding any conflict. When you are ready to sync your 98 | local and remote repo use the command without the -l flag. 99 | 100 | 101 | Learn how to grant write permisions and how to resolve conflicts on the 102 | extended version of the lesson : https://gitutor.io/guide/one-branch.html 103 | ''' 104 | 105 | }, 106 | 'More than a save button': { 107 | 'description': 'Learn the gitutor funcionality', 108 | 'content': '''\ 109 | Besides saving your project's development history, gitutor offers more funcionality. 110 | 111 | - You can compare your current version with any previous commit with: 112 | 113 | $ gt compare 114 | 115 | An interactive menu will appear with a list of the repository’s commits. Select one 116 | of them to display the differences between your current version and that particular commit. 117 | 118 | Documentation for this command can be found here: https://gitutor.io/guide/gt-compare.html 119 | 120 | - You can go back to a previous version of your project with: 121 | 122 | $ gt goback 123 | 124 | An interactive menu will again show a list of the repo's commits. Select one to 125 | return to that previous version. Doing this will create a new commit so you can undo 126 | the going back action and return to where you were before. 127 | 128 | Documentation for this command can be found here: https://gitutor.io/guide/gt-goback.html 129 | 130 | - You can tell gitutor to ignore certain files and don't keep track of them. 131 | To achieve this use: 132 | 133 | $ gt ignore 134 | 135 | An interactive menu will display all the files and folders inside your current 136 | location, check the ones you don't want tracked by git. If you select a folder, 137 | git won’t track any file or folder inside of it. Uncheck files or folders so git 138 | starts tracking them. 139 | 140 | Any ignored files will not be uploaded to the remote repository. Gitutor will automaticaly 141 | remove any file alredy in the remote repository if it was ignored in your local repository. 142 | 143 | Documentation for this command can be found here: https://gitutor.io/guide/gt-ignore.html 144 | ''' 145 | 146 | }, 147 | 'gt init': { 148 | 'description': 'Learn how the gt init command works', 149 | 'content': '''\ 150 | # gt init 151 | 152 | When the 'gt init' command is called, gitutor runs the following commands under the hood: 153 | 154 | $ git init 155 | 156 | This initializes a hidden folder with the name ".git". Git stores in this folder all the 157 | information concerning commits and the different versions of your project. 158 | 159 | Afterwards a README.md file is created and the first commit is done with: 160 | 161 | $ git add . 162 | $ git commit -m 'First commit' 163 | 164 | These git commands will be explained in the 'gt save' lesson. 165 | 166 | Next, gitutor creates a new empty repository on your github account. This can be done from 167 | ithub's website. If the repository is created with the default README.md option, it won’t 168 | be possible to push your local repo. 169 | 170 | Once the repo has it's first commit and the github repo is created, a remote is added with: 171 | 172 | $ git add remote origin https://github.com/user/repo.git 173 | 174 | A remote is an url that points to a repository on another machine (in this case on github's 175 | servers). 176 | 177 | "Origin" refers to the name assigned to that particular remote repository, it could be a 178 | name of your choosing. 179 | 180 | Once the remote is added, the commit is pushed with the -u flag to set this remote as the 181 | upstream: 182 | 183 | $ git push -u 184 | 185 | This will set your remote repository as the default place where commits should be pushed 186 | and where you’ll pull the changes from. 187 | 188 | ## Options 189 | 190 | This commands offers an optional flags: 191 | 192 | -l 193 | $ gt init -l 194 | This flag tells gitutor to start only a local repository, so no remote repository will 195 | be created. 196 | ''' 197 | 198 | }, 199 | 'gt save': { 200 | 'description': 'Learn how the gt save command works', 201 | 'content': '''\ 202 | # gt save 203 | 204 | This command will execute the following actions under the hood: 205 | 206 | First it runs: 207 | 208 | $ git add . 209 | 210 | This tells git to include the changes within all the files in your repository to your 211 | next commit. These changes include any modifications to your previously saved files and 212 | any new (untracked) files. 213 | 214 | Next, in order to actually save the changes to the project’s history gitutor runs: 215 | 216 | $ git commit -m “your message” 217 | 218 | You can think of a commit as a checkpoint or a snapshot of your repository’s current 219 | state. It’s important to notice that this command only saves the changes to your local 220 | repository. 221 | 222 | Then we need to check if there are any changes in the remote repository with: 223 | 224 | $ git pull 225 | 226 | This updates your local repository with the remote changes. Git will try to merge the 227 | files but if there is a conflict a message will be prompted indicating which files have 228 | the conflict. You’ll need to solve all the conflicts manually and then run gt save again. 229 | 230 | Finally, if there are no conflicts, you’ll be able to upload your local commits, or 231 | checkpoints, to your remote repository. To do this, gitutor runs: 232 | 233 | $ git push 234 | 235 | As you can see, if you wanted to use git to save your changes in the local and remote 236 | repository, you would have to run these 4 commands every time. Instead “gt save” does 237 | all of this for you with only 1 command. 238 | 239 | ## Options 240 | 241 | This commands offers two optional flags: 242 | 243 | -m 244 | gt save -m "your commit message" 245 | This flag tells gitutor to use the message introduced in the command directly instead 246 | of prompting the user for one. 247 | 248 | -l 249 | gt save -l 250 | This flag tells gitutor to only save the changes in your local repository. This way 251 | nothing is modified in your remote repository. 252 | 253 | ''' 254 | }, 255 | 'gt compare': { 256 | 'description': 'Learn how the gt compare command works', 257 | 'content': '''\ 258 | # gt compare 259 | 260 | The "gt compare" command will let you compare the current state of your files with a 261 | commit you previously made with the "gt save" command. 262 | 263 | After executing "gt compare", you are prompted with a list of all the previous commits 264 | done to your project. You can navigate the list with the arrow keys and select the 265 | commit you want to compare with the enter key. 266 | 267 | When a commit is selected, gitutor runs the following: 268 | 269 | $ git diff 270 | 271 | Gitutor will then show you a string containing all the differences between the current 272 | state of your project and the selected commit. The green text is the one that was added 273 | after the selected commit and the red text is the one deleted after the selected commit. 274 | 275 | If you wanted to do the same using only git, you would have to know the hash of the 276 | commit you want to compare or specify the number of commits behind to compare. Instead 277 | "gt compare" offers a way to easily select the commit you want to compare your project with. 278 | 279 | ## Options 280 | 281 | This commands offers an optional flags: 282 | 283 | -h 284 | $ gt compare -h 285 | This flag tells gitutor to compare the current state of your project to the commit with 286 | the inputted hash. This command flag is usefull if you already know the hash of the commit 287 | you want to compare instead of going through the whole list of commits. 288 | ''' 289 | }, 290 | 'gt goback': { 291 | 'description': 'Learn how the gt goback command works', 292 | 'content': '''\ 293 | # gt goback 294 | 295 | The "gt goback" command will let you return the state of your files to a commit you 296 | previously made with the "gt save" command. 297 | 298 | After executing "gt goback", you are prompted with a list of all the previous commits 299 | done to your project. You can navigate the list with the arrow keys and select the commit 300 | you want your project to go back to. 301 | 302 | When a commit is selected, gitutor runs the following: 303 | 304 | $ git diff hashOfSelectedCommit 305 | 306 | Gitutor will then show you a string containing all the differences between the current 307 | state of your project and the selected commit. The green text is the one that was added 308 | after the selected commit and the red text is the one deleted after the selected commit. 309 | 310 | If you wanted to do the same using only git, you would have to know the hash of the commit 311 | you want to compare or specify the number of commits behind to compare. Instead "gt compare" 312 | offers a way to easily select the commit you want to compare your project with. 313 | 314 | ## Options 315 | 316 | This commands offers an optional flags: 317 | 318 | -c 319 | $ gt compare hashOfSelectedCommit 320 | This flag tells gitutor to compare the current state of your project to the commit with 321 | the inputted commit. This command flag is usefull if you already know the hash of the 322 | commit you want to compare instead of going through the whole list of commits. 323 | ''' 324 | }, 325 | 'gt ignore': { 326 | 'description': 'Learn how the gt ignore command works', 327 | 'content': '''\ 328 | # gt ignore 329 | 330 | Sometimes there are files inside our local repository that we don't want to upload to 331 | the remote repository, such as keys, passwords or log files. The *.gitignore* file tells 332 | Git which files or folders to ignore. This means that Git won't track any changes made to 333 | those files and that they won't be uploaded to your remote repository. 334 | 335 | It's recommended to have a .gitignore file so that when you run 336 | 337 | $ git add . 338 | 339 | Git won't upload any unwanted files by mistake. .gitignore is a plain-text file in which 340 | each line specifies a pattern to ignore. For example: 341 | 342 | * 'hello.*' will match any file or folder whose name begins with hello ('hello.txt', 343 | 'hello.log', 'hello.py') 344 | 345 | * 'doc/'will match a folder named 'doc' but not a file named 'doc'. When you ignore 346 | a folder, Git will ignore any files or folders inside of it. 347 | 348 | * 'main.py' will match any file named exactly 'main.py'. 349 | 350 | * '*.csv' will match every csv file in your repository 351 | 352 | It's important to note that the .gitignore file specifies intentionally **untracked** files 353 | that Git should ignore. Files already tracked by Git are not affected so if, for example, 354 | you committed a file named 'keys.py' by mistake and later you add this pattern to the 355 | .gitignore file, nothing will happen. That is, the 'keys.py' file will remain in the 356 | remote repo and Git will still track any modifications. 357 | 358 | Gitutor provides an easy way to add files and folders to your .gitignore file so that you 359 | don't have to do it by hand! When you run 360 | 361 | $ gt ignore 362 | 363 | Gitutor will display a list with all the files and folders in your current directory. You 364 | only need to check all the files or folders you want to ignore, or uncheck any previously 365 | ignored file so that Git will begin to track it. 366 | 367 | If you check a file or folder which was previously tracked by Git, Gitutor will run: 368 | 369 | $ git rm --cached 370 | 371 | This will remove said file form Git's tracked files and it will also remove the file from 372 | your remote repository, but not from your local one. 373 | 374 | ''' 375 | }, 376 | } 377 | --------------------------------------------------------------------------------