From 2ef6c25a024061c4a77c91bfaaf3c951a12dc6db Mon Sep 17 00:00:00 2001 From: Douglas Lassance Date: Sun, 20 Mar 2022 12:00:00 -0700 Subject: [PATCH] Implement initial features (#9) --- .editorconfig | 25 + .flake8 | 3 + .github/FUNDING.yml | 1 + .github/workflows/cd.yaml | 27 + .github/workflows/ci.yaml | 37 ++ .gitignore | 9 + .pylintrc | 14 + LICENSE | 21 + README.md | 72 +++ docs/Makefile | 20 + docs/make.bat | 35 + docs/source/conf.py | 167 +++++ docs/source/index.rst | 14 + gitalong-python.code-workspace | 7 + gitalong-python.sublime-project | 61 ++ gitalong/__info__.py | 5 + gitalong/__init__.py | 16 + gitalong/enums.py | 27 + gitalong/exceptions.py | 8 + gitalong/functions.py | 122 ++++ gitalong/gitalong.py | 787 +++++++++++++++++++++++ gitalong/resources/gitignore | 3 + gitalong/resources/hooks/post-applypatch | 8 + gitalong/resources/hooks/post-checkout | 19 + gitalong/resources/hooks/post-commit | 12 + gitalong/resources/hooks/post-rewrite | 8 + pyrightconfig.json | 5 + requirements-ci.txt | 1 + setup.py | 53 ++ tests/__init__.py | 0 tests/functions.py | 6 + tests/test_functions.py | 57 ++ tests/test_gitalong.py | 143 ++++ 33 files changed, 1793 insertions(+) create mode 100644 .editorconfig create mode 100644 .flake8 create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/cd.yaml create mode 100644 .github/workflows/ci.yaml create mode 100644 .pylintrc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 gitalong-python.code-workspace create mode 100644 gitalong-python.sublime-project create mode 100644 gitalong/__info__.py create mode 100644 gitalong/__init__.py create mode 100644 gitalong/enums.py create mode 100644 gitalong/exceptions.py create mode 100644 gitalong/functions.py create mode 100644 gitalong/gitalong.py create mode 100644 gitalong/resources/gitignore create mode 100644 gitalong/resources/hooks/post-applypatch create mode 100644 gitalong/resources/hooks/post-checkout create mode 100644 gitalong/resources/hooks/post-commit create mode 100644 gitalong/resources/hooks/post-rewrite create mode 100644 pyrightconfig.json create mode 100644 requirements-ci.txt create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/functions.py create mode 100644 tests/test_functions.py create mode 100644 tests/test_gitalong.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..965e91b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Python +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 88 + +# YAML +[*.yaml] +indent_style = space +indent_size = 2 + +# JSON +[*.json] +indent_style = space +indent_size = 4 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..06a8ccc --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +exclude = .git,__pycache__,docs/source/conf.py,old,build,dist \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d80a2f8 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: ["douglaslassance"] diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml new file mode 100644 index 0000000..4d79f23 --- /dev/null +++ b/.github/workflows/cd.yaml @@ -0,0 +1,27 @@ +name: CD + +on: + release: + types: [created] + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Publish on PyPI + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload --verbose dist/* diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..75cbe6c --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.7, 3.8, 3.9] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install requirements + run: | + python -m pip install --upgrade pip + pip install --editable ".[ci]" + - name: Lint with flake8 + run: | + flake8 . --count --show-source --statistics + - name: Test with pytest + run: | + pytest --cov=gitalong + - name: Document with sphinx + run: | + sphinx-build ./docs/source ./docs/build + - name: Upload report on CodeCov + run: | + bash <(curl -s https://codecov.io/bash) diff --git a/.gitignore b/.gitignore index 1bc4271..2719a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,12 @@ dmypy.json # macOS .DS_Store + +# VS Code +.vscode/ + +# IntelliJ IDEA +.idea/ + +# pytest-profiling +/prof/ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..c357d6d --- /dev/null +++ b/.pylintrc @@ -0,0 +1,14 @@ +[MASTER] + +ignore=docs, __pycache__ +disable= + no-member, + too-many-arguments, + too-few-public-methods, + logging-format-interpolation, + missing-module-docstring + + +[FORMAT] + +max-line-length=88 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f106050 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Playsthetic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c678a30 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# gitalong-python + +[![PyPI version](https://badge.fury.io/py/gitalong-python.svg)](https://badge.fury.io/py/gitalong-python) +[![Documentation Status](https://readthedocs.org/projects/gitalong-python/badge/?version=latest)](https://gitalong-python.readthedocs.io/en/latest) +[![codecov](https://codecov.io/gh/douglaslassance/gitalong-python/branch/main/graph/badge.svg?token=5267NA3EQQ)](https://codecov.io/gh/douglaslassance/gitalong-python) + +A Python API allowing to interact with Gitalong features on a Git repository. +More about Gitalong in this [medium article](). + +## Pre-requisites + +- [Git >=2.35.1](https://git-scm.com/downloads) + +## Installation + +``` +pip install gitalong +``` + +## Usage + +```python +from pprint import pprint + +from gitalong import Gitalong, GitalongNotInstalled + +try: + gitalong = Gitalong(managed_repository_path) +except GitalongNotInstalled: + # Gitalong stores its data in its own repository therefore we need to pass that repository URL. + gitalong = Gitalong.install(managed_repository_path, data_repository_url) + +# Now we'll get the last commit for a given file. +# This could return a dummy commit representing uncommitted changes. +last_commit = gitalong.get_file_last_commit(filename) +pprint(last_commit) + +spread = gitalong.get_commit_spread(commit) +if commit_spread & CommitSpread.LOCAL_UNCOMMITTED == CommitSpread.LOCAL_UNCOMMITTED: + print("Commit represents our local uncommitted changes." +if commit_spread & CommitSpread.LOCAL_ACTIVE_BRANCH == CommitSpread.LOCAL_ACTIVE_BRANCH: + print("Commit is on our local active branch." +if commit_spread & CommitSpread.LOCAL_OTHER_BRANCH == CommitSpread.LOCAL_OTHER_BRANCH: + print("Commit is in one ore more of our other local branches." +if commit_spread & CommitSpread.REMOTE_MATCHING_BRANCH == CommitSpread.REMOTE_MATCHING_BRANCH: + print("Commit is on the matching remote branch." +if commit_spread & CommitSpread.REMOTE_OTHER_BRANCH == CommitSpread.REMOTE_OTHER_BRANCH: + print("Commit is one ore more other remote branches." +if commit_spread & CommitSpread.CLONE_OTHER_BRANCH == CommitSpread.CLONE_OTHER_BRANCH: + print("Commit is on someone else's clone non-matching branch." +if commit_spread & CommitSpread.CLONE_MATCHING_BRANCH == CommitSpread.CLONE_MATCHING_BRANCH: + print("Commit is on another clone's matching branch." +if commit_spread & CommitSpread.CLONE_UNCOMMITTED == CommitSpread.CLONE_UNCOMMITTED: + print("Commit represents someone else's uncommitted changes." + +# To update tracked commit with the ones based on local changes. +gitalong.update_tracked_commits() + +# To update permissions of tracked files. +gitalong.update_binary_permissions() +``` + +# Development + +This projects requires the following: + +- [Python >=3.7](https://www.python.org/downloads/) +- [virtualenwrapper](https://pypi.org/project/virtualenvwrapper/) (macOS/Linux) +- [virtualenwrapper-win](https://pypi.org/project/virtualenvwrapper-win/) (Windows) + +Make sure your `WORKON_HOME` environment variable is set on Windows, and create a `gitalong-python` virtual environment with `mkvirtualenv`. +Build systems for installing requirements and running tests are on board of the SublimeText project. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..1ecd382 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..9534b01 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..5b31416 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,167 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# pylint: skip-file + +import gitalong + + +# -- Project information ----------------------------------------------------- + +project = gitalong.__name__ +copyright = gitalong.__copyright__ +author = gitalong.__author__ + +# The full version, including alpha/beta/rc tags +release = gitalong.__version__ + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autodoc", + "sphinx.ext.autosectionlabel", + "sphinx.ext.autosummary", + "sphinx.ext.coverage", + "sphinx.ext.doctest", + "sphinx.ext.githubpages", + "sphinx.ext.ifconfig", + "sphinx.ext.intersphinx", + "sphinx.ext.mathjax", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx_markdown_tables", + "sphinx_rtd_theme", + "sphinxcontrib.apidoc", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [ + "./docs", + "./tests", + "./setup.py", +] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +source_suffix = [".rst", ".md"] +# source_parsers = { +# ".md": "recommonmark.parser.CommonMarkParser", +# } + +add_module_names = False + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# import sphinx_rtd_theme +html_theme = "sphinx_rtd_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [] + +html_theme_options = { + # "canonical_url": "", + # "logo_only": False, + # "display_version": True, + # "prev_next_buttons_location": "bottom", + # "style_external_links": False, + "style_nav_header_background": "#29BAF4", + # "collapse_navigation": True, + # "sticky_navigation": True, + # "navigation_depth": 4, + # "includehidden": True, + # "titles_only": False, +} + +# -- apidoc --------------------------------------------------- + +apidoc_module_dir = "../.." +apidoc_output_dir = "./generated" +apidoc_excluded_paths = exclude_patterns +apidoc_separate_modules = True +apidoc_toc_file = False +apidoc_module_first = False + + +# -- autodoc ----------------------------------------------------- + +autoclass_content = "class" +autodoc_member_order = "bysource" +autodoc_default_flags = ["members"] + + +# -- napoleon -------------------------------------------- + +# Parse Google style docstrings. +# See http://google-styleguide.googlecode.com/svn/trunk/pyguide.html +napoleon_google_docstring = True + +# Parse NumPy style docstrings. +# See https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt +napoleon_numpy_docstring = True + +# Should special members (like __membername__) and private members +# (like _membername) members be included in the documentation if they +# have docstrings. +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True + +# If True, docstring sections will use the ".. admonition::" directive. +# If False, docstring sections will use the ".. rubric::" directive. +# One may look better than the other depending on what HTML theme is used. +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False + +# If True, use Sphinx :ivar: directive for instance variables: +# :ivar attr1: Description of attr1. +# :type attr1: type +# If False, use Sphinx .. attribute:: directive for instance variables: +# .. attribute:: attr1 +# +# Description of attr1. +# +# :type: type +napoleon_use_ivar = False + +# If True, use Sphinx :param: directive for function parameters: +# :param arg1: Description of arg1. +# :type arg1: type +# If False, output function parameters using the :parameters: field: +# :parameters: **arg1** (*type*) -- Description of arg1. +napoleon_use_param = False + +# If True, use Sphinx :rtype: directive for the return type: +# :returns: Description of return value. +# :rtype: type +# If False, output the return type inline with the return description: +# :returns: *type* -- Description of return value. +napoleon_use_rtype = False + + +# -- autosectionlabel -------------------------------------------- + +# Prefix document path to section labels, otherwise autogenerated labels would look like +# 'heading' rather than 'path/to/file:heading' +autosectionlabel_prefix_document = True diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..2a7d7bd --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,14 @@ +Content +======= + +.. toctree:: + :maxdepth: 4 + + generated/gitalong + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/gitalong-python.code-workspace b/gitalong-python.code-workspace new file mode 100644 index 0000000..362d7c2 --- /dev/null +++ b/gitalong-python.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/gitalong-python.sublime-project b/gitalong-python.sublime-project new file mode 100644 index 0000000..a9cc384 --- /dev/null +++ b/gitalong-python.sublime-project @@ -0,0 +1,61 @@ +{ + "build_systems": [ + { + "cmd": [ + "pip", + "install", + "--editable", + ".[ci]" + ], + "name": "Install requirements with PyPI", + "path": "$WORKON_HOME/gitalong-python/bin:$PATH", + "windows": { + "path": "$WORKON_HOME/gitalong-python/Scripts;$PATH", + }, + "working_dir": "$project_path", + }, + { + "cmd": [ + "pytest", + "--cov-report=html", + "--cov=gitalong", + "--profile-svg" + ], + "name": "Test with pytest", + "path": "$WORKON_HOME/gitalong-python/bin:$PATH", + "windows": { + "path": "$WORKON_HOME/gitalong-python/Scripts;$PATH", + }, + "working_dir": "$project_path", + }, + { + "cmd": [ + "sphinx-build", + "./docs/source", + "./docs/build" + ], + "name": "Document with Sphinx", + "path": "$WORKON_HOME/gitalong-python/bin:$PATH", + "windows": { + "path": "$WORKON_HOME/gitalong-python/Scripts;$PATH", + }, + "working_dir": "$project_path", + } + ], + "folders": [ + { + "file_exclude_patterns": [ + ".coverage" + ], + "folder_exclude_patterns": [ + "__pycache__", + "htmlcov", + "*.egg-info", + ".pytest_cache", + "build" + ], + "path": ".", + }, + ], + "virtualenv": "$WORKON_HOME/gitalong-python", +} diff --git a/gitalong/__info__.py b/gitalong/__info__.py new file mode 100644 index 0000000..54690bf --- /dev/null +++ b/gitalong/__info__.py @@ -0,0 +1,5 @@ +__author__ = "Douglas Lassance" +__copyright__ = "2020, Douglas Lassance" +__email__ = "douglassance@gmail.com" +__license__ = "MIT" +__version__ = "0.1.0.dev1" diff --git a/gitalong/__init__.py b/gitalong/__init__.py new file mode 100644 index 0000000..3436641 --- /dev/null +++ b/gitalong/__init__.py @@ -0,0 +1,16 @@ +"""API to perform Gitalong operations on a Git repository.""" + +import os +import logging + +from dotenv import load_dotenv + +from .__info__ import __version__, __copyright__, __email__, __author__ # noqa: F401 +from .gitalong import Gitalong # noqa: F401 +from .enums import CommitSpread # noqa: F401 +from .exceptions import GitalongNotInstalled # noqa: F401 + + +# Performing global setup. +load_dotenv() +logging.getLogger().setLevel(os.environ.get("GITARMONY_PYTHON_DEBUG_LEVEL", "INFO")) diff --git a/gitalong/enums.py b/gitalong/enums.py new file mode 100644 index 0000000..011e597 --- /dev/null +++ b/gitalong/enums.py @@ -0,0 +1,27 @@ +from enum import IntFlag, auto + + +class CommitSpread(IntFlag): + + """A combinable enumerator to represent where the commit spreads across branches + and clones. + + Attributes: + LOCAL_UNCOMITTED (int): Commit represent our local uncommitted changes. + LOCAL_ACTIVE_BRANCH (int): Commit is on our local active branch. + LOCAL_OTHER_BRANCH (int): Commit is in one ore more of our other local branches. + REMOTE_MATCHING_BRANCH (int): Commit is on matching remote branch. + REMOTE_OTHER_BRANCH (int): Commit is on other remote branch. + CLONE_OTHER_BRANCH (int): Commit is on someone else's clone non-matching branch. + CLONE_MATCHING_BRANCH (int): Commit is on someone else's clone matching branch. + CLONE_UNCOMMITED (int): Commit is on someone else's clone uncommitted changes. + """ + + LOCAL_UNCOMMITTED = auto() + LOCAL_ACTIVE_BRANCH = auto() + LOCAL_OTHER_BRANCH = auto() + REMOTE_MATCHING_BRANCH = auto() + REMOTE_OTHER_BRANCH = auto() + CLONE_OTHER_BRANCH = auto() + CLONE_MATCHING_BRANCH = auto() + CLONE_UNCOMMITTED = auto() diff --git a/gitalong/exceptions.py b/gitalong/exceptions.py new file mode 100644 index 0000000..0787b2a --- /dev/null +++ b/gitalong/exceptions.py @@ -0,0 +1,8 @@ +class GitalongError(Exception): + + """Base error for gitalong.""" + + +class GitalongNotInstalled(GitalongError): + + """Error for when gitalong is not installed in the managed repository.""" diff --git a/gitalong/functions.py b/gitalong/functions.py new file mode 100644 index 0000000..7850cad --- /dev/null +++ b/gitalong/functions.py @@ -0,0 +1,122 @@ +import os +import time +import stat +import pathlib +import re + +import git + + +MOVE_STRING_REGEX = re.compile("{(.*)}") + + +def is_binary_file(filename: str) -> bool: + """ + Args: + filename (str): The path to the file to analyze. + + Returns: + bool: Whether the file is a binary. + """ + with open(filename, "rb") as fle: + return is_binary_string(fle.read(1024)) + return False + + +def is_binary_string(string: str) -> bool: + """ + Args: + string (str): A string to analyze. + + Returns: + bool: Whether the string is a binary string. + """ + textchars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7F}) + return bool(string.translate(None, textchars)) + + +def is_read_only(filename: str) -> bool: + """TODO: Make sure this works on other operating system than Windows. + + Args: + filename (str): The absolute filename of the file to check. + + Returns: + bool: Whether the file is read only. + """ + _stat = os.stat(filename) + return not bool(_stat.st_mode & stat.S_IWRITE) + + +def set_read_only( + filename: str, read_only: bool = True, check_exists: bool = True +) -> bool: + """Sets the file read-only state. + + Args: + filename (str): The absolute filename of the file we want to set. + read_only (bool, optional): Whether read-only should be true of false. + check_exists (bool, optional): Whether we are guarding from non existing files. + + Returns: + str: Whether the file was set to the provided permission. + """ + if check_exists and not os.path.exists(filename): + return False + if read_only: + os.chmod(filename, stat.S_IREAD) + return True + os.chmod(filename, stat.S_IWRITE) + return True + + +def get_real_path(filename: str) -> str: + """ + Args: + filename (str): The filemame for which we want the real path. + + Returns: + str: Real path in case this path goes through Windows subst. + """ + # On Windows, this private function is available and will return the real path + # for a subst location. + if hasattr(os.path, "_getfinalpathname"): + filename = os.path._getfinalpathname( # pylint: disable=protected-access + filename + ) + filename = str(pathlib.Path(filename).resolve()) + return filename + + +def pulled_within(repository: git.Repo, seconds: float) -> bool: + """Summary + + Args: + repository (git.Repo): The repository to check for. + seconds (float): Time in seconds since last push. + + Returns: + TYPE: Whether the repository pulled within the time provided. + """ + fetch_head = os.path.join(repository.git_dir, "FETCH_HEAD") + if not os.path.exists(fetch_head): + return False + since_last = time.time() - os.path.getmtime(fetch_head) + return seconds > since_last + + +def get_filenames_from_move_string(move_string: str) -> tuple: + arrow = " => " + if arrow not in move_string: + return (move_string,) + lefts = [] + rights = [] + match = MOVE_STRING_REGEX.search(move_string) + if match: + for group in match.groups(): + move_string = move_string.replace(group, "") + splits = group.split(arrow) + lefts.append(splits[0]) + rights.append(splits[-1]) + pair = {move_string.format(*lefts), move_string.format(*rights)} + return tuple(sorted(pair)) diff --git a/gitalong/gitalong.py b/gitalong/gitalong.py new file mode 100644 index 0000000..99b21f0 --- /dev/null +++ b/gitalong/gitalong.py @@ -0,0 +1,787 @@ +import os +import shutil +import logging +import typing +import json +import socket +import datetime +import getpass +import configparser + +import dictdiffer +import git + +from git.repo import Repo +from gitdb.util import hex_to_bin + +from .enums import CommitSpread +from .functions import get_real_path, is_binary_file +from .exceptions import GitalongNotInstalled +from .functions import set_read_only, pulled_within, get_filenames_from_move_string + + +class Gitalong: + """The Gitalong class aggregates all the Gitalong actions that can happen on a + repository. + """ + + _instances = {} + _config_basename = ".gitalong.json" + + def __new__( + cls, + managed_repository: str = "", + use_cached_instances=False, + ): + managed_repo = Repo(managed_repository, search_parent_directories=True) + working_dir = managed_repo.working_dir + if use_cached_instances: + return cls._instances.setdefault(working_dir, super().__new__(cls)) + return super().__new__(cls) + + def __init__( + self, + managed_repository: str = "", + use_cached_instances=False, # pylint: disable=unused-argument + ): + """ + Args: + managed_repository (str): + The managed repository exact absolute path. + use_cached_instances (bool): + If true, the class will return "singleton" cached per clone. + + Raises: + GitalongNotInstalled: Description + """ + self._config = None + self._submodules = None + + self._managed_repository = Repo( + managed_repository, search_parent_directories=True + ) + self._gitalong_repository = self._clone_gitalong_repository() + + if self.config.get( + "modify_permissions", False + ) and self._managed_repository.config_reader().get_value( + "core", "fileMode", True + ): + config_writer = self._managed_repository.config_writer() + config_writer.set_value("core", "fileMode", "false") + config_writer.release() + + def _clone_gitalong_repository(self): + """ + Returns: + git.Repo: Clones the Gitalong repository if not done already. + """ + try: + return Repo(os.path.join(self.managed_repository_root, ".gitalong")) + except (git.exc.NoSuchPathError, git.exc.InvalidGitRepositoryError): + remote = self.config.get("remote_url") + return Repo.clone_from( + remote, + os.path.join(self.managed_repository_root, ".gitalong"), + ) + + @classmethod + def install( + cls, + gitalong_repository: str, + managed_repository: str = "", + modify_permissions=False, + pull_treshold: float = 60.0, + track_binaries: bool = False, + track_uncommitted: bool = False, + tracked_extensions: dict = None, + update_gitignore: bool = False, + update_hooks: bool = False, + ): + """Install Gitalong on a repository. + + Args: + gitalong_repository (str): + The URL of the repository that will store Gitalong data. + managed_repository (str, optional): + The repository in which we install Gitalong. Defaults to current + working directory. Current working directory if not passed. + modify_permissions (bool, optional): + Whether Gitalong should managed permissions of binary files. + track_binaries (bool, optional): + Track all binary files by automatically detecting them. + track_uncommitted (bool, optional): + Track uncommitted changes. Better for collaboration but requires to push + tracked commits after each file system operation. + tracked_extensions (list, optional): + List of extensions to track. + pull_treshold (list, optional): + Time in seconds that need to pass before Gitalong pulls again. Defaults + to 10 seconds. This is for optimization sake as pull and fetch operation + are expensive. Defaults to 60 seconds. + update_gitignore (bool, optional): + Whether .gitignore should be modified in the managed repository to + ignore Gitalong files. + update_hooks (bool, optional): + Whether hooks should be updated with Gitalong logic. + + Returns: + Gitalong: + The Gitalong management class corresponding to the repository in + which we just installed. + """ + tracked_extensions = tracked_extensions or [] + managed_repository = Repo(managed_repository, search_parent_directories=True) + config_path = os.path.join(managed_repository.working_dir, cls._config_basename) + with open(config_path, "w", encoding="utf8") as _config_file: + config_settings = { + "remote_url": gitalong_repository, + "modify_permissions": modify_permissions, + "track_binaries": track_binaries, + "tracked_extensions": ",".join(tracked_extensions), + "pull_treshold": pull_treshold, + "track_uncommitted": track_uncommitted, + } + dump = json.dumps(config_settings, indent=4, sort_keys=True) + _config_file.write(dump) + gitalong = cls(managed_repository=managed_repository.working_dir) + gitalong._clone_gitalong_repository() + if update_gitignore: + gitalong.update_gitignore() + if update_hooks: + gitalong.install_hooks() + return gitalong + + def update_gitignore(self): + """Update the .gitignore of the managed repository with Gitalong directives. + + TODO: Improve update by considering what is already ignored. + """ + gitignore_path = os.path.join(self.managed_repository_root, ".gitignore") + content = "" + if os.path.exists(gitignore_path): + with open(gitignore_path, encoding="utf8") as gitignore: + content = gitignore.read() + with open(gitignore_path, "w", encoding="utf8") as gitignore: + with open( + # Reading our .gitignore template. + os.path.join(os.path.dirname(__file__), "resources", "gitignore"), + encoding="utf8", + ) as patch: + patch_content = patch.read() + if patch_content not in content: + gitignore.write(content + patch_content) + + @property + def managed_repository(self) -> Repo: + """ + Returns: + git.Repo: The repository we are managing with Gitalong. + """ + return self._managed_repository + + @property + def config_path(self) -> str: + """ + Returns: + dict: The content of `.gitalong.json` as a dictionary. + """ + return os.path.join(self.managed_repository_root, self._config_basename) + + @property + def config(self) -> dict: + """ + Returns: + dict: The content of `.gitalong.json` as a dictionary. + """ + if self._config is None: + try: + with open(self.config_path, encoding="utf8") as _config_file: + self._config = json.loads(_config_file.read()) + except FileNotFoundError as error: + raise GitalongNotInstalled( + "Gitalong is not installed on this repository." + ) from error + return self._config + + @property + def hooks_path(self) -> str: + """ + Returns: + str: The hook path of the managed repository. + """ + try: + basename = self._managed_repository.config_reader().get_value( + "core", "hooksPath" + ) + except configparser.NoOptionError: + basename = os.path.join(".git", "hooks") + return os.path.normpath(os.path.join(self.managed_repository_root, basename)) + + def install_hooks(self): + """Installs Gitalong hooks in managed repository. + + TODO: Implement non-destructive version of these hooks. Currently we don't have + any consideration for preexisting content. + """ + hooks = os.path.join(os.path.dirname(__file__), "resources", "hooks") + destination_dir = self.hooks_path + for (dirname, _, basenames) in os.walk(hooks): + for basename in basenames: + filename = os.path.join(dirname, basename) + destination = os.path.join(destination_dir, basename) + msg = f"Copying hook from {filename} to {destination}" + logging.info(msg) + shutil.copyfile(filename, destination) + + def get_relative_path(self, filename: str) -> str: + """ + Args: + filename (str): The absolute path. + + Returns: + str: The path relative to the managed repository. + """ + if os.path.exists(filename): + filename = os.path.relpath(filename, self.managed_repository_root) + return filename + + def get_absolute_path(self, filename: str) -> str: + """ + Args: + filename (str): The path relative to the managed repository. + + Returns: + str: The absolute path. + """ + if os.path.exists(filename): + return filename + return os.path.join(self.managed_repository_root, filename) + + def get_file_last_commit(self, filename: str, prune: bool = True) -> dict: + """ + Args: + filename (str): Absolute or relative filename to get the last commit for. + prune (bool, optional): Prune branches if a fetch is necessary. + + Returns: + dict: The last commit for the provided filename across all branches local or + remote. + """ + # We are checking the tracked commit first as they represented local changes. + # They are in nature always more recent. If we find a relevant commit here we + # can skip looking elsewhere. + tracked_commits = self.tracked_commits + relevant_tracked_commits = [] + filename = self.get_relative_path(filename) + remote = self._managed_repository.remote().url + last_commit = {} + track_uncommitted = self.config.get("track_uncommitted", False) + for tracked_commit in tracked_commits: + if ( + # We ignore uncommitted tracked commits if configuration says so. + (not track_uncommitted and "sha" not in tracked_commit) + # We ignore commits from other remotes. + or tracked_commit.get("remote") != remote + ): + continue + for change in tracked_commit.get("changes", []): + if os.path.normpath(change) == os.path.normpath(filename): + relevant_tracked_commits.append(tracked_commit) + continue + if relevant_tracked_commits: + relevant_tracked_commits.sort(key=lambda commit: commit.get("date")) + last_commit = relevant_tracked_commits[-1] + # Because there is no post-push hook a local commit that got pushed could + # have never been removed from our tracked commits. To cover for this case + # we are checking if this commit is on remote and modify it so it's + # conform to a remote commit. + if "sha" in last_commit and self.get_commit_branches( + last_commit["sha"], remote=True + ): + tracked_commits.remove(last_commit) + self.update_tracked_commits(tracked_commits) + for key in self.context_dict: + if key in last_commit: + del last_commit[key] + if not last_commit: + pull_treshold = self.config.get("pull_treshold", 60) + if not pulled_within(self._managed_repository, pull_treshold): + try: + self._managed_repository.remote().fetch(prune=prune) + except git.exc.GitCommandError: + pass + + # TODO: Maybe there is a way to get this information using pure Python. + args = ["--all", "--remotes", '--pretty=format:"%H"', "--", filename] + output = self._managed_repository.git.log(*args) + file_commits = output.replace('"', "").split("\n") if output else [] + last_commit = ( + self.get_commit_dict( + git.objects.Commit( + self._managed_repository, hex_to_bin(file_commits[0]) + ) + ) + if file_commits + else {} + ) + if last_commit and "sha" in last_commit: + # We are only evaluating branch information here because it's expensive. + last_commit["branches"] = { + "local": self.get_commit_branches(last_commit["sha"]), + "remote": self.get_commit_branches(last_commit["sha"], remote=True), + } + return last_commit + + @property + def active_branch_commits(self) -> list: + """ + Returns: + list: List of all local commits for active branch. + """ + active_branch = self._managed_repository.active_branch + return list( + git.objects.Commit.iter_items(self._managed_repository, active_branch) + ) + + def get_commit_spread(self, commit: dict) -> dict: + """ + Args: + commit (dict): The commit to check for. + + Returns: + dict: + A dictionary of commit spread information containing all + information about where this commit lives across branches and clones. + """ + commit_spread = 0 + active_branch = self._managed_repository.active_branch.name + if commit.get("user", ""): + is_issued = self.is_issued_commit(commit) + if "sha" in commit: + if active_branch in commit.get("branches", {}).get("local", []): + commit_spread |= ( + CommitSpread.LOCAL_ACTIVE_BRANCH + if is_issued + else CommitSpread.CLONE_MATCHING_BRANCH + ) + else: + commit_spread |= ( + CommitSpread.LOCAL_OTHER_BRANCH + if is_issued + else CommitSpread.CLONE_OTHER_BRANCH + ) + else: + commit_spread |= ( + CommitSpread.LOCAL_UNCOMMITTED + if is_issued + else CommitSpread.CLONE_UNCOMMITTED + ) + else: + remote_branches = commit.get("branches", {}).get("remote", []) + if active_branch in remote_branches: + commit_spread |= CommitSpread.REMOTE_MATCHING_BRANCH + if active_branch in commit.get("branches", {}).get("local", []): + commit_spread |= CommitSpread.LOCAL_ACTIVE_BRANCH + if active_branch in remote_branches: + remote_branches.remove(active_branch) + if remote_branches: + commit_spread |= CommitSpread.REMOTE_OTHER_BRANCH + return commit_spread + + @staticmethod + def is_uncommitted_changes_commit(commit: dict) -> bool: + """ + Args: + commit (dict): The commit dictionary. + + Returns: + bool: Whether the commit dictionary represents uncommitted changes. + """ + return "user" in commit.keys() + + @property + def uncommitted_changes_commit(self) -> dict: + """ + Returns: + dict: Returns a commit dictionary representing uncommitted changes. + """ + uncommitted_changes = self.uncommitted_changes + if not uncommitted_changes: + return {} + commit = { + "remote": self._managed_repository.remote().url, + "changes": self.uncommitted_changes, + "date": str(datetime.datetime.now()), + } + commit.update(self.context_dict) + return commit + + def is_issued_commit(self, commit: dict) -> bool: + """ + Args: + commit (dict): The commit dictionary to check for. + + Returns: + bool: Whether the commit was issued by the current context. + """ + context_dict = self.context_dict + diff_keys = set() + for diff in dictdiffer.diff(context_dict, commit): + if diff[0] == "change": + diff_keys.add(diff[1]) + elif diff[0] in ("add", "remove"): + diff_keys = diff_keys.union([key[0] for key in diff[2]]) + intersection = set(context_dict.keys()).intersection(diff_keys) + return not intersection + + def is_issued_uncommitted_changes_commit(self, commit: dict) -> bool: + """ + Args: + commit (dict): Description + + Returns: + bool: + Whether the commit represents uncommitted changes and is issued by the + current context. + """ + if not self.is_uncommitted_changes_commit(commit): + return False + return self.is_issued_commit(commit) + + def accumulate_local_only_commits( + self, start: git.objects.Commit, local_commits: list + ): + """Accumulates a list of local only commit starting from the provided commit. + + Args: + local_commits (list): The accumulated local commits. + start (git.objects.Commit): + The commit that we start peeling from last commit. + """ + # TODO: Maybe there is a way to get this information using pure Python. + if self._managed_repository.git.branch("--remotes", "--contains", start.hexsha): + return + commit_dict = self.get_commit_dict(start) + commit_dict.update(self.context_dict) + # TODO: Maybe we should compare the SHA here. + if commit_dict not in local_commits: + local_commits.append(commit_dict) + for parent in start.parents: + self.accumulate_local_only_commits(parent, local_commits) + + @property + def context_dict(self) -> dict: + """ + Returns: + dict: A dict of contextual values that we attached to tracked commits. + """ + return { + "host": socket.gethostname(), + "user": getpass.getuser(), + "clone": get_real_path(self.managed_repository_root), + } + + @property + def local_only_commits(self) -> list: + """ + Returns: + list: + Commits that are not on remote branches. Includes a commit that + represents uncommitted changes. + """ + local_commits = [] + # We are collecting local commit for all local branches. + for branch in self._managed_repository.branches: + self.accumulate_local_only_commits(branch.commit, local_commits) + if self.config.get("track_uncommitted"): + uncommitted_changes_commit = self.uncommitted_changes_commit + if uncommitted_changes_commit: + local_commits.insert(0, uncommitted_changes_commit) + local_commits.sort(key=lambda commit: commit.get("date"), reverse=True) + return local_commits + + @property + def uncommitted_changes(self) -> list: + """ + Returns: + list: A list of unique relative filenames that feature uncommitted changes. + """ + # TODO: Maybe there is a way to get this information using pure Python. + git_cmd = self._managed_repository.git + output = git_cmd.ls_files("--exclude-standard", "--others") + untracked_changes = output.split("\n") if output else [] + output = git_cmd.diff("--cached", "--name-only") + staged_changes = output.split("\n") if output else [] + # A file can be in both in untracked and staged changes. The set fixes that. + return list(set(untracked_changes + staged_changes)) + + def get_commit_dict(self, commit: git.objects.Commit) -> dict: + """ + Args: + commit (git.objects.Commit): The commit to get as a dict. + + Returns: + dict: A simplified JSON serializable dict that represents the commit. + """ + changes = [] + for change in list(commit.stats.files.keys()): + changes += get_filenames_from_move_string(change) + return { + "sha": commit.hexsha, + "remote": self._managed_repository.remote().url, + "changes": changes, + "date": str(commit.committed_datetime), + "author": commit.author.name, + } + + def get_commit_branches(self, hexsha: str, remote: bool = False) -> list: + """ + Args: + hexsha (str): The hexsha of the commit to check for. + remote (bool, optional): Whether we should return local or remote branches. + + Returns: + list: A list of branch names that this commit is living on. + """ + args = ["--remote" if remote else []] + args += ["--contains", hexsha] + branches = self._managed_repository.git.branch(*args) + branches = branches.replace("*", "") + branches = branches.replace(" ", "") + branches = branches.split("\n") if branches else [] + branch_names = set() + for branch in branches: + branch_names.add(branch.split("/")[-1]) + return list(branch_names) + + def is_ignored(self, filename: str) -> bool: + """ + Args: + filename (str): The filename to check for. + + Returns: + bool: Whether a file is ignored by the managed repository .gitignore file. + """ + filename = self.get_relative_path(filename) + try: + self._managed_repository.git.check_ignore(filename) + return True + except git.exc.GitCommandError: + return False + + @property + def submodules(self) -> list: + """ + Returns: + TYPE: A list of submodule relative filenames. + """ + if self._submodules is None: + self._submodules = [_.name for _ in self._managed_repository.submodules] + return self._submodules + + def is_submodule_file(self, filename) -> bool: + """ + Args: + filename (TYPE): Description + + Returns: + TYPE: Whether a an absolute or relative filename belongs to a submodule. + """ + for submodule in self.submodules: + if self.get_relative_path(filename).startswith(submodule): + return True + return False + + @property + def tracked_commits_json_path(self): + """ + Returns: + TYPE: The path to the JSON file that tracks the local commits. + """ + return os.path.join(self._gitalong_repository.working_dir, "commits.json") + + @property + def managed_repository_files(self) -> list: + """ + Returns: + list: + The relative filenames that are tracked by the managed repository. Not + to be confused with the files tracked by Gitalong. + """ + git_cmd = self._managed_repository.git + filenames = git_cmd.ls_tree(full_tree=True, name_only=True, r="HEAD") + return filenames.split("\n") + + @property + def locally_changed_files(self) -> list: + """ + Returns: + list: + The relative filenames that have been changed by local commits or + uncommitted changes. + """ + local_changes = set() + for commit in self.local_only_commits: + local_changes = local_changes.union(commit.get("changes", [])) + return local_changes + + def update_file_permissions( + self, filename: str, locally_changed_files: list = None + ) -> tuple: + """Updates the permissions of a file based on whether or not it was locally + changed. + + Args: + filename (str): The relative or absolute filename to update permissions for. + locally_changed_files (list, optional): + For optimization sake you can pass the locally changed files if you + already have them. Default will compute them. + + Returns: + tuple: A tuple featuring the permission and the filename. + """ + locally_changed_files = locally_changed_files or self.locally_changed_files + if self.is_file_tracked(filename): + read_only = self.get_relative_path(filename) not in locally_changed_files + if set_read_only( + self.get_absolute_path(filename), + read_only=read_only, + check_exists=False, + ): + return ("R" if read_only else "W", filename) + return () + + def is_file_tracked(self, filename: str) -> bool: + """ + Args: + filename (str): The absolute or relative file or folder path to check for. + + Returns: + bool: Whether the file is tracked by Gitalong. + """ + if self.is_ignored(filename): + return False + tracked_extensions = self.config.get("tracked_extensions", []) + if os.path.splitext(filename)[-1] in tracked_extensions: + return True + # The binary check is expensive so we are doing it last. + return self.config.get("track_binaries", False) and is_binary_file( + self.get_absolute_path(filename) + ) + + @property + def updated_tracked_commits(self) -> list: + """ + Returns: + list: + Local commits for all clones with local commits and uncommitted changes + from this clone. + """ + # Removing any matching contextual commits from tracked commits. + # We are re-evaluating those. + tracked_commits = [] + for commit in self.tracked_commits: + remote = self._managed_repository.remote().url + is_other_remote = commit.get("remote") != remote + if is_other_remote or not self.is_issued_commit(commit): + tracked_commits.append(commit) + continue + # Adding all local commit to the list of tracked commits. + # Will include uncommitted changes as a "fake" commit. + for commit in self.local_only_commits: + tracked_commits.append(commit) + return tracked_commits + + def update_tracked_commits(self, commits: list = None, push: bool = True): + """Write and pushes JSON file that tracks the local commits from all clones + using the passed commits. + + Args: + commits (list, optional): + The tracked commits to update with. Default to evaluating updated + tracked commits. + push (bool, optional): + Whether we are pushing the update JSON file to the Gitalong repository + remote. + """ + commits = commits or self.updated_tracked_commits + json_path = self.tracked_commits_json_path + with open(json_path, "w") as _file: + dump = json.dumps(commits, indent=4, sort_keys=True) + _file.write(dump) + if push: + self._gitalong_repository.index.add(json_path) + basename = os.path.basename(json_path) + self._gitalong_repository.index.commit(message=f"Update {basename}") + self._gitalong_repository.remote().push() + + @property + def tracked_commits(self) -> typing.List[dict]: + """ + Returns: + typing.List[dict]: + A list of commits that haven't been pushed to remote. Also includes + commits representing uncommitted changes. + """ + gitalong_repository = self._gitalong_repository + remote = gitalong_repository.remote() + pull_treshold = self.config.get("pull_treshold", 60) + if not pulled_within(gitalong_repository, pull_treshold) and remote.refs: + # TODO: If we could check that a pull is already happening then we could + # avoid this try except and save time. + try: + remote.pull( + ff=True, + quiet=True, + rebase=True, + autostash=True, + verify=False, + summary=False, + ) + except git.exc.GitCommandError: + pass + serializable_commits = [] + if os.path.exists(self.tracked_commits_json_path): + with open(self.tracked_commits_json_path, "r") as _file: + serializable_commits = json.loads(_file.read()) + return serializable_commits + + def make_file_writable( + self, + filename: str, + prune: bool = True, + ) -> dict: + """Make a file writable if it's not missing with other tracked commits that + aren't present locally. + + Args: + filename (str): + The file to make writable. Takes a path that's absolute or relative to + the managed repository. + + Returns: + dict: The missing commit that we are missing. + """ + last_commit = self.get_file_last_commit(filename, prune=prune) + spread = self.get_commit_spread(last_commit) + is_local_commit = ( + spread & CommitSpread.LOCAL_ACTIVE_BRANCH + == CommitSpread.LOCAL_ACTIVE_BRANCH + ) + is_uncommitted = ( + spread & CommitSpread.LOCAL_UNCOMMITTED == CommitSpread.LOCAL_UNCOMMITTED + ) + missing_commit = {} if is_local_commit or is_uncommitted else last_commit + if os.path.exists(filename): + if not missing_commit: + set_read_only(filename, missing_commit) + return missing_commit + + @property + def managed_repository_root(self) -> str: + """ + Returns: + str: The managed repository dirname. + """ + return self._managed_repository.working_dir diff --git a/gitalong/resources/gitignore b/gitalong/resources/gitignore new file mode 100644 index 0000000..b5fd2cf --- /dev/null +++ b/gitalong/resources/gitignore @@ -0,0 +1,3 @@ +# Gitalong +/.gitalong/ +!/.gitalong.cfg diff --git a/gitalong/resources/hooks/post-applypatch b/gitalong/resources/hooks/post-applypatch new file mode 100644 index 0000000..b47f828 --- /dev/null +++ b/gitalong/resources/hooks/post-applypatch @@ -0,0 +1,8 @@ +#!/bin/sh + +# Gitalong +if (where gitalong) then + gitalong sync +else + echo "Gitalong CLI is not installed on this system." +fi diff --git a/gitalong/resources/hooks/post-checkout b/gitalong/resources/hooks/post-checkout new file mode 100644 index 0000000..79941c0 --- /dev/null +++ b/gitalong/resources/hooks/post-checkout @@ -0,0 +1,19 @@ +#!/bin/sh + +# Git LFS +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-checkout.\n"; exit 2; } +git lfs post-checkout "$@" + +# Gitalong +NEW_HEAD=$1 +PREVIOUS_HEAD=$2 +# The goal of this check is to detect when the user is dropping uncommitted changes. +# It should trigger this post-checkout with the previous and new heads being the same. +if [ "$NEW_HEAD" = "$PREVIOUS_HEAD" ]; +then + if (where gitalong) then + gitalong sync + else + echo "Gitalong CLI is not installed on this system." + fi +fi diff --git a/gitalong/resources/hooks/post-commit b/gitalong/resources/hooks/post-commit new file mode 100644 index 0000000..6261b44 --- /dev/null +++ b/gitalong/resources/hooks/post-commit @@ -0,0 +1,12 @@ +#!/bin/sh + +# Git LFS +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-commit.\n"; exit 2; } +git lfs post-commit "$@" + +# Gitalong +if (where gitalong) then + gitalong sync +else + echo "Gitalong CLI is not installed on this system." +fi diff --git a/gitalong/resources/hooks/post-rewrite b/gitalong/resources/hooks/post-rewrite new file mode 100644 index 0000000..b47f828 --- /dev/null +++ b/gitalong/resources/hooks/post-rewrite @@ -0,0 +1,8 @@ +#!/bin/sh + +# Gitalong +if (where gitalong) then + gitalong sync +else + echo "Gitalong CLI is not installed on this system." +fi diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..e0d33ea --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,5 @@ +{ + "venvPath": "$WORKON_HOME", + "venv": "gitalong", + "reportGeneralTypeIssues": false +} diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 0000000..42628c6 --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1 @@ +.[ci] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5f630d0 --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +"""Setup for gitalong. +""" + +import os + +from setuptools import setup + +dirname = os.path.dirname(__file__) +info = {} +with open(os.path.join(dirname, "gitalong", "__info__.py"), mode="r") as f: + exec(f.read(), info) # pylint: disable=W0122 + +# Get the long description from the README file. +with open(os.path.join(dirname, "README.md"), encoding="utf-8") as fle: + long_description = fle.read() + +setup( + name="gitalong", + version=info.get("__version__", ""), + description="An API to perform gitalong operation on Git repositories.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/douglaslassance/gitalong-python", + author=info.get("__author__", ""), + author_email=info.get("__email__", ""), + license=info.get("__license__", ""), + packages=["gitalong"], + install_requires=[ + "GitPython~=3.1", + "dictdiffer~=0.9", + "python-dotenv~=0.19", + ], + extras_require={ + "ci": [ + "black", + "flake8-print~=3.1", + "flake8~=3.9", + "pep8-naming~=0.11", + "Pillow~=8.4", + "pylint~=2.9", + "pytest-cov~=2.12", + "pytest-html~=2.1", + "pytest-pep8~=1.0", + "pytest-profiling~=1.7", + "requests-mock~=1.8", + "sphinx-markdown-tables~=0.0", + "sphinx-rtd-theme~=0.5", + "sphinxcontrib-apidoc~=0.3", + "Sphinx~=3.2", + ], + }, + include_package_data=True, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/functions.py b/tests/functions.py new file mode 100644 index 0000000..b7b4954 --- /dev/null +++ b/tests/functions.py @@ -0,0 +1,6 @@ +from PIL import Image + + +def save_image(filename: str, image_format: str = "JPEG") -> bool: + image = Image.new(mode="RGB", size=(256, 256)) + image.save(filename, image_format) diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 0000000..0f2c0bf --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,57 @@ +import os +import shutil +import tempfile +import unittest +import logging + +from gitalong.functions import ( + is_binary_file, + set_read_only, + is_read_only, + get_filenames_from_move_string, +) + +from .functions import save_image + + +class FunctionsTestCase(unittest.TestCase): + """Sets up a temporary git repository for each test""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + try: + shutil.rmtree(self.temp_dir) + except PermissionError as error: + logging.error(error) + + def test_is_binary_file(self): + self.assertEqual(False, is_binary_file(__file__)) + image_path = os.path.join(self.temp_dir, "image.jpg") + save_image(image_path) + self.assertEqual(True, is_binary_file(image_path)) + + def test_set_read_only(self): + image_path = os.path.join(self.temp_dir, "set_read_only.jpg") + save_image(image_path) + self.assertEqual(False, is_read_only(image_path)) + set_read_only(image_path, True) + self.assertEqual(True, is_read_only(image_path)) + set_read_only(image_path, False) + self.assertEqual(False, is_read_only(image_path)) + # This one is for coverage of non-existing files. + set_read_only(os.path.join(self.temp_dir, "non_existing.jpg"), True) + + def test_get_filenames_from_move_string(self): + move_string = get_filenames_from_move_string("A/B/C.abc") + self.assertEqual(("A/B/C.abc",), move_string) + + move_string = get_filenames_from_move_string("A/B/{C.abc => D.abc}") + self.assertEqual(("A/B/C.abc", "A/B/D.abc"), move_string) + + move_string = get_filenames_from_move_string("A/B/{C..abc => C.abc}") + self.assertEqual(("A/B/C..abc", "A/B/C.abc"), move_string) + + move_string = get_filenames_from_move_string("A/B/{C/D.abc => E/F.abc}") + self.assertEqual(("A/B/C/D.abc", "A/B/E/F.abc"), move_string) diff --git a/tests/test_gitalong.py b/tests/test_gitalong.py new file mode 100644 index 0000000..d11aaa3 --- /dev/null +++ b/tests/test_gitalong.py @@ -0,0 +1,143 @@ +import os +import shutil +import tempfile +import unittest +import logging + +from git.repo import Repo + +from gitalong import Gitalong, CommitSpread +from gitalong.functions import is_read_only + +from .functions import save_image + + +class GitalongTestCase(unittest.TestCase): + """Sets up a temporary git repository for each test""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + logging.info("{} was created.".format(self.temp_dir)) + self.managed_remote = Repo.init( + path=os.path.join(self.temp_dir, "managed.git"), bare=True + ) + self.managed_clone = self.managed_remote.clone( + os.path.join(self.temp_dir, "managed") + ) + self.gitalong_remote_url = os.path.join(self.temp_dir, "gitalong.git") + self.gitalong_remote = Repo.init(path=self.gitalong_remote_url, bare=True) + self.gitalong = Gitalong.install( + self.gitalong_remote_url, + self.managed_clone.working_dir, + modify_permissions=True, + track_binaries=True, + track_uncommitted=True, + update_gitignore=True, + # Hooks are turned off because we would have to install Gitalong CLI as + # part of that test. Instead we are simulating the hooks operations below. + update_hooks=False, + ) + + def tearDown(self): + if hasattr(self, "_outcome"): + result = self.defaultTestResult() + self._feedErrorsToResult(result, self._outcome.errors) + error = self.list_to_reason(result.errors) + failure = self.list_to_reason(result.failures) + if not error and not failure: + try: + shutil.rmtree(self.temp_dir) + except PermissionError as error: + logging.error(error) + + def list_to_reason(self, exc_list): + if exc_list and exc_list[-1][0] is self: + return exc_list[-1][1] + + def test_config(self): + config = self.gitalong.config + self.assertEqual( + os.path.normpath(self.gitalong_remote_url), + os.path.normpath(config.get("remote_url")), + ) + + def test_worfklow(self): + local_only_commits = self.gitalong.local_only_commits + working_dir = self.managed_clone.working_dir + self.assertEqual(1, len(local_only_commits)) + self.assertEqual(2, len(local_only_commits[0]["changes"])) + + # Testing detecting un-tracked files. + save_image(os.path.join(working_dir, "untracked_image_01.jpg")) + + # Testing detecting staged files. + staged_image_01_path = os.path.join(working_dir, "staged_image_01.jpg") + save_image(staged_image_01_path) + self.managed_clone.index.add(staged_image_01_path) + self.assertEqual(4, len(self.gitalong.local_only_commits[0]["changes"])) + + commit = self.managed_clone.index.commit(message="Add staged_image.jpg") + local_only_commits = self.gitalong.local_only_commits + self.assertEqual(2, len(local_only_commits)) + self.assertEqual(3, len(local_only_commits[0]["changes"])) + self.assertEqual(1, len(local_only_commits[1]["changes"])) + + self.managed_clone.remote().push() + local_only_commits = self.gitalong.local_only_commits + self.assertEqual(1, len(local_only_commits)) + self.assertEqual(3, len(local_only_commits[0]["changes"])) + + image_path = os.path.join(working_dir, "staged_image_02.jpg") + save_image(image_path) + # Simulating the application syncing when saving the file. + self.gitalong.update_tracked_commits() + # print("POST-SAVE TRACKED COMMITS") + # pprint(self.gitalong.get_tracked_commits()) + + self.managed_clone.index.add(image_path) + self.managed_clone.index.commit(message="Add staged_image_02.jpg") + # Simulating the post-commit hook. + self.gitalong.update_tracked_commits() + # print("POST-COMMIT TRACKED COMMITS") + # pprint(self.gitalong.get_tracked_commits()) + + self.managed_clone.remote().push() + # Simulating a post-push hook. + # It could only be implemented server-side as it's not an actual Git hook. + self.gitalong.update_tracked_commits() + # print("POST-PUSH TRACKED COMMITS") + # pprint(self.gitalong.get_tracked_commits()) + + # We just pushed the changes therefore there should be no missing commit. + last_commit = self.gitalong.get_file_last_commit("staged_image_02.jpg") + spread = self.gitalong.get_commit_spread(last_commit) + self.assertEqual( + CommitSpread.LOCAL_ACTIVE_BRANCH | CommitSpread.REMOTE_MATCHING_BRANCH, + spread, + ) + + # We are dropping the last commit locally. + self.managed_clone.git.reset("--hard", commit.hexsha) + # Simulating the post-checkout hook. + self.gitalong.update_tracked_commits() + # print("POST-CHECKOUT TRACKED COMMITS") + # pprint(self.gitalong.get_tracked_commits()) + + # As a result it should be a commit we do no have locally. + last_commit = self.gitalong.get_file_last_commit("staged_image_02.jpg") + spread = self.gitalong.get_commit_spread(last_commit) + self.assertEqual(CommitSpread.REMOTE_MATCHING_BRANCH, spread) + + self.assertEqual(False, is_read_only(staged_image_01_path)) + self.assertEqual(False, is_read_only(self.gitalong.config_path)) + self.gitalong.update_file_permissions(staged_image_01_path) + self.assertEqual(True, is_read_only(staged_image_01_path)) + self.gitalong.update_file_permissions(self.gitalong.config_path) + self.assertEqual(False, is_read_only(self.gitalong.config_path)) + + self.gitalong.update_tracked_commits() + + missing_commit = self.gitalong.make_file_writable(staged_image_01_path) + self.assertEqual(False, bool(missing_commit)) + missing_commit = self.gitalong.make_file_writable(image_path) + self.assertEqual(True, bool(missing_commit))