diff --git a/.gitignore b/.gitignore index b6e4761..784cd51 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,10 @@ __pycache__/ *.py[cod] *$py.class +*.sw[a-z] +#Temporarily +tests/ # C extensions *.so diff --git a/README.md b/README.md index a9a61df..dd6c3c6 100644 --- a/README.md +++ b/README.md @@ -1 +1,23 @@ -# carboncopy \ No newline at end of file +# carboncopy + +Keep your repositories up-to-date with their templates in a few keystrokes. + +## :question: Why `carboncopy`? + +[Github Template Repositories](https://github.blog/2019-06-06-generate-new-repositories-with-repository-templates/) made it really easy to skip project boilerplate setup steps and to produce "new project kits" that ensure that all your (or your organization's) new projects have all the must-haves. Problem is, templates aren't set in stone and it's likely that templates get updated after some projects have been spawned from it. + +Because template repositories are different than forks, you can't simply rebase your project to gulp in the latest templated goodies -- leaving you with the gnarly task of manually moving files over. No more. + +With `carboncopy`, you are one command away from pulling in the latest changes from your template repositories as if it were a regular base branch. You can configure it via its RC file to ignore certain files from the template, and more! + +## :package: Installation + +As it is not yet published on `PyPi`, you can simply clone this repository and use `pip install ` to install it in your local environment. + +## :hammer: Usage + +From your repository, simply type `carboncopy` in your terminal to bring up a prompt asking you what to pull from your template repository. __Any change made is left as an unstaged change so you can commit and merge it however you want.__ + +## :wrench: Configuration + +You can configure the way `carboncopy` handles your template's contents by creating a `.carboncopyrc` file at the root of your repository. Documentation TBD. See `src/carboncopy/config_defaults.py` for general layout. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4dec578 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,32 @@ +appdirs==1.4.3 +attrs==19.3.0 +black==19.10b0 +blessings==1.7 +certifi==2019.11.28 +chardet==3.0.4 +Click==7.0 +idna==2.8 +importlib-metadata==1.3.0 +inquirer==2.6.3 +invoke==1.4.0 +more-itertools==8.0.2 +mypy==0.761 +mypy-extensions==0.4.3 +packaging==19.2 +pathspec==0.7.0 +pluggy==0.13.1 +py==1.8.1 +pyparsing==2.4.6 +pytest==5.3.2 +python-editor==1.0.4 +readchar==2.0.1 +regex==2019.12.20 +requests==2.22.0 +six==1.13.0 +syrupy==0.0.12 +toml==0.10.0 +typed-ast==1.4.0 +typing-extensions==3.7.4.1 +urllib3==1.25.7 +wcwidth==0.1.8 +zipp==0.6.0 diff --git a/script/bootstrap b/script/bootstrap new file mode 100644 index 0000000..73f2c65 --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,9 @@ +VENV_NAME="carboncopy.venv" +REQ_FILE="./requirements.txt" + +pyenv uninstall -f $VENV_NAME +pyenv install -s +pyenv virtualenv $VENV_NAME +pyenv activate $VENV_NAME + +pip install -r $REQ_FILE diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e2c322d --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +import setuptools + +with open("README.md", "r") as fh: + long_description = fh.read() + +setuptools.setup( + entry_points={"console_scripts": ["carboncopy = src.carboncopy.main:run"]}, + name="carboncopy", + version="0.0.1", + author="Marc Cataford", + author_email="c.marcandre@gmail.com", + description="A small CLI utility to keep your repositories up-to-date with their templates", + long_description=long_description, + url="", + packages=setuptools.find_packages(), + classifiers=[], + install_requires=["requests>=2.22.0", "inquirer==2.6.3"], + python_requires=">=3.6", +) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/carboncopy/__init__.py b/src/carboncopy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/carboncopy/cli_utils.py b/src/carboncopy/cli_utils.py new file mode 100644 index 0000000..6c3ffc1 --- /dev/null +++ b/src/carboncopy/cli_utils.py @@ -0,0 +1,20 @@ +import inquirer + +from typing import List +from pathlib import Path + + +def prompt_staging_files_confirmation( + staged: List[Path], template_repository_name: str +): + questions = [ + inquirer.Checkbox( + "suggested_changes", + message="The following files can be pulled from {}. Select the ones to be merged in:".format( + template_repository_name + ), + choices=[str(staged_file) for staged_file in staged], + ) + ] + + return inquirer.prompt(questions) diff --git a/src/carboncopy/config_defaults.py b/src/carboncopy/config_defaults.py new file mode 100644 index 0000000..1b8063f --- /dev/null +++ b/src/carboncopy/config_defaults.py @@ -0,0 +1,6 @@ +CONFIG_DEFAULTS = { + # This is used as the scratch space where the template is cloned. + # It is cleaned once done. + "temp_directory": ".carboncopy", + "ignore": [], +} diff --git a/src/carboncopy/constants.py b/src/carboncopy/constants.py new file mode 100644 index 0000000..80c1dfd --- /dev/null +++ b/src/carboncopy/constants.py @@ -0,0 +1,6 @@ +RCFILE_PATH = ".carboncopyrc" + +FORCED_IGNORE_PATTERNS = r"^.git/*" +FETCH_URL_PATTERN = r"Fetch URL" +GIT_LINK_PATTERN = r"(?<=git@github.com:)[A-Za-z0-9_\-\.]+/[A-Za-z0-9_\-\.]+" +GIT_EXT_PATTERN = r"\.git$" diff --git a/src/carboncopy/fs_utils.py b/src/carboncopy/fs_utils.py new file mode 100644 index 0000000..5aee153 --- /dev/null +++ b/src/carboncopy/fs_utils.py @@ -0,0 +1,77 @@ +import shutil +from pathlib import Path +from typing import Union, List, Dict, Any +import os + +from .print_utils import pretty_print + + +class Transform: + def __init__(self, source: Path, destination: Path): + self.source = source + self.destination = destination + + def get_source(self, as_str: bool = False): + return str(self.source) if as_str else self.source + + def get_destination(self, as_str: bool = False): + return str(self.destination) if as_str else self.destination + + def __repr__(self): + return "<{classname} {source} -> {destination}>".format( + classname=self.__class__, + source=str(self.source), + destination=str(self.destination), + ) + + +def clean_temp_files(path: Path) -> None: + if path: + shutil.rmtree(path, True) + + +def get_template_transforms(path: Path) -> List[Transform]: + file_paths = [] + + stack = [path] + + while stack: + current_path = stack.pop() + + if not current_path.is_dir(): + file_paths.append(current_path) + continue + + for child in current_path.iterdir(): + child_path = Path(child) + stack.append(child_path) + + return [ + Transform(source=filename, destination=filename.relative_to(path)) + for filename in file_paths + ] + + +def squash(transform: Transform) -> None: + destination = transform.get_destination() + source = transform.get_source() + + if not destination.parent.exists(): + os.makedirs(destination.parent) + + try: + shutil.copy(source, destination) + pretty_print( + "Copied {source} -> {destination}".format( + source=source, destination=destination + ) + ) + except IsADirectoryError: + pretty_print( + "Failed to copy {source} -> {destination}".format( + source=source, destination=destination + ) + ) + except Exception as e: + pretty_print(e.__class__) + pretty_print(e) diff --git a/src/carboncopy/git_utils.py b/src/carboncopy/git_utils.py new file mode 100644 index 0000000..6281c81 --- /dev/null +++ b/src/carboncopy/git_utils.py @@ -0,0 +1,52 @@ +from pathlib import Path +import subprocess +import re + +import requests + +from .constants import FETCH_URL_PATTERN, GIT_EXT_PATTERN, GIT_LINK_PATTERN + + +class NotInAGitRepositoryError(Exception): + pass + + +class NoTemplateError(Exception): + pass + + +def clone_template_head(url: str, destination: Path) -> None: + _run( + "git clone {url} {location}".format(url=url, location=destination.resolve()), + stdout=subprocess.DEVNULL, + ) + + +def get_local_repository_meta(): + stdout = _run("git remote show origin") + stdout_split = stdout.decode().split("\n") + + for line in stdout_split: + if re.search(FETCH_URL_PATTERN, line): + match = re.search(GIT_LINK_PATTERN, line) + org, repo = match.group(0).split("/") + + return org, re.sub(GIT_EXT_PATTERN, "", repo) + + return None, None + + +def get_repo_metadata(owner, repo): + headers = {"Accept": "application/vnd.github.baptiste-preview+json"} + r = requests.get( + "https://api.github.com/repos/{owner}/{repo}".format(owner=owner, repo=repo), + headers=headers, + ) + repo_data = r.json() + + template_repo = repo_data.get("template_repository") + return template_repo + + +def _run(command, stdout=subprocess.PIPE): + return subprocess.run(command.split(" "), stdout=stdout, stderr=stdout).stdout diff --git a/src/carboncopy/main.py b/src/carboncopy/main.py new file mode 100644 index 0000000..1fa6e69 --- /dev/null +++ b/src/carboncopy/main.py @@ -0,0 +1,27 @@ +from .use_cases import get_local_config, UseCases +from .print_utils import pretty_print +from .git_utils import NoTemplateError, NotInAGitRepositoryError + + +def run(): + config = get_local_config() + use_cases = UseCases(config) + + try: + use_cases.fetch_template_repository_details() + except NotInAGitRepositoryError: + pretty_print("Not in a git repository.") + return 1 + except NoTemplateError: + pretty_print("This repository does not have a template associated with it.") + return 1 + + try: + use_cases.clone_template_repository() + paths = use_cases.stage_changes() + use_cases.apply_changes(paths) + except Exception as e: + pretty_print(e.__class__) + pretty_print(e) + finally: + use_cases.clean_up() diff --git a/src/carboncopy/print_utils.py b/src/carboncopy/print_utils.py new file mode 100644 index 0000000..e66602a --- /dev/null +++ b/src/carboncopy/print_utils.py @@ -0,0 +1,2 @@ +def pretty_print(message: str): + print(message) diff --git a/src/carboncopy/use_cases.py b/src/carboncopy/use_cases.py new file mode 100644 index 0000000..191ca22 --- /dev/null +++ b/src/carboncopy/use_cases.py @@ -0,0 +1,109 @@ +import subprocess +import re +from pathlib import Path +import requests +import shutil +import os +import json +import uuid +from typing import List, Dict, Any + +from .config_defaults import CONFIG_DEFAULTS +from .constants import RCFILE_PATH, FORCED_IGNORE_PATTERNS +from .fs_utils import Transform, squash, clean_temp_files, get_template_transforms +from .git_utils import ( + NoTemplateError, + NotInAGitRepositoryError, + get_local_repository_meta, + get_repo_metadata, + clone_template_head, +) +from .cli_utils import prompt_staging_files_confirmation +from .print_utils import pretty_print + + +def get_local_config(root_path: Path = Path(".")) -> Dict[str, Any]: + config_path = root_path.joinpath(RCFILE_PATH) + try: + with open(config_path, "r") as config_file: + loaded_config = json.load(config_file) + + merged_config = CONFIG_DEFAULTS.copy() + + for key in loaded_config: + merged_config[key] = loaded_config.get(key) + + return merged_config + + except FileNotFoundError: + pretty_print( + "No config file found in current directory! Proceeding with defaults." + ) + except ValueError: + pretty_print("Invalid RC file!") + + return CONFIG_DEFAULTS + + +class UseCases: + def __init__(self, config: Dict[str, Any]) -> None: + self.config = config + self.template_repo: Dict[str, str] = {} + self.org = None + self.repo = None + + def fetch_template_repository_details(self) -> None: + org, repo = get_local_repository_meta() + template_repo_data = get_repo_metadata(org, repo) + + if not (org and repo): + raise NotInAGitRepositoryError() + + if not template_repo_data: + raise NoTemplateError() + + self.org = org + self.repo = repo + self.template_repo = template_repo_data + + def clone_template_repository(self) -> None: + clone_template_head( + self.template_repo["clone_url"], Path(self.config["temp_directory"]) + ) + + def stage_changes(self) -> List[Transform]: + path = Path(self.config["temp_directory"]) + available_transforms = get_template_transforms(path) + + def can_stage(path_str: str) -> bool: + return not re.match(FORCED_IGNORE_PATTERNS, path_str) and all( + [re.match(patt, path_str) for patt in self.config["ignore"]] + ) + + allowed_transforms = [ + transform + for transform in available_transforms + if can_stage(transform.get_destination(as_str=True)) + ] + + destinations = [ + transform.get_destination(as_str=True) for transform in allowed_transforms + ] + chosen_files = prompt_staging_files_confirmation( + destinations, "{}/{}".format(self.org, self.repo) + ) + chosen_transforms = [ + transform + for transform in allowed_transforms + if transform.get_destination(as_str=True) + in chosen_files["suggested_changes"] + ] + + return chosen_transforms + + def apply_changes(self, paths: List[Transform]) -> None: + for path in paths: + squash(path) + + def clean_up(self) -> None: + clean_temp_files(Path(self.config["temp_directory"])) diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..ecdcdad --- /dev/null +++ b/tasks.py @@ -0,0 +1,16 @@ +from invoke import Collection, task + + +@task +def format_all(ctx): + ctx.run("black src *.py") + + +@task +def typecheck(ctx): + ctx.run("mypy src") + + +ns = Collection() +ns.add_task(format_all, name="format") +ns.add_task(typecheck)