Initial upload (#1)
* feat: minimal working version * chore: clean swap files * chore: amend gitignore to include swaps * chore: typecheck * wip: ignore git, support dir * wip: ignores, directory handling * wip: add prompting, better path management * refactor: centralize printing * wip: handle jsondecodeerror * docs: README * chore: add inquirer to dependencies * wip: error handling when not in git
This commit is contained in:
parent
099403cf7d
commit
02a7b2d5d8
16 changed files with 401 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -2,7 +2,10 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.sw[a-z]
|
||||
|
||||
#Temporarily
|
||||
tests/
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
|
|
24
README.md
24
README.md
|
@ -1 +1,23 @@
|
|||
# carboncopy
|
||||
# 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 <path>` 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.
|
||||
|
|
32
requirements.txt
Normal file
32
requirements.txt
Normal file
|
@ -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
|
9
script/bootstrap
Normal file
9
script/bootstrap
Normal file
|
@ -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
|
19
setup.py
Normal file
19
setup.py
Normal file
|
@ -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",
|
||||
)
|
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/carboncopy/__init__.py
Normal file
0
src/carboncopy/__init__.py
Normal file
20
src/carboncopy/cli_utils.py
Normal file
20
src/carboncopy/cli_utils.py
Normal file
|
@ -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)
|
6
src/carboncopy/config_defaults.py
Normal file
6
src/carboncopy/config_defaults.py
Normal file
|
@ -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": [],
|
||||
}
|
6
src/carboncopy/constants.py
Normal file
6
src/carboncopy/constants.py
Normal file
|
@ -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$"
|
77
src/carboncopy/fs_utils.py
Normal file
77
src/carboncopy/fs_utils.py
Normal file
|
@ -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)
|
52
src/carboncopy/git_utils.py
Normal file
52
src/carboncopy/git_utils.py
Normal file
|
@ -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
|
27
src/carboncopy/main.py
Normal file
27
src/carboncopy/main.py
Normal file
|
@ -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()
|
2
src/carboncopy/print_utils.py
Normal file
2
src/carboncopy/print_utils.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
def pretty_print(message: str):
|
||||
print(message)
|
109
src/carboncopy/use_cases.py
Normal file
109
src/carboncopy/use_cases.py
Normal file
|
@ -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"]))
|
16
tasks.py
Normal file
16
tasks.py
Normal file
|
@ -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)
|
Reference in a new issue