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__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
*.sw[a-z]
|
||||||
|
|
||||||
|
#Temporarily
|
||||||
|
tests/
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
|
22
README.md
22
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