Skip to content

Commit

Permalink
Updated the update command to use git diff and git apply
Browse files Browse the repository at this point in the history
  • Loading branch information
coordt committed Oct 8, 2022
1 parent 9402a33 commit d4bc14f
Show file tree
Hide file tree
Showing 7 changed files with 342 additions and 93 deletions.
234 changes: 147 additions & 87 deletions cookie_composer/commands/update.py
Original file line number Diff line number Diff line change
@@ -1,121 +1,112 @@
"""The implementation of the update command."""
from typing import List, Optional
from typing import List, Optional, Set

import os
import stat
from pathlib import Path

from cookiecutter.config import get_user_config
from cookiecutter.repository import determine_repo_dir
from tempfile import TemporaryDirectory

from cookie_composer.composition import (
Composition,
RenderedComposition,
RenderedLayer,
read_rendered_composition,
write_rendered_composition,
)
from cookie_composer.exceptions import GitError
from cookie_composer.git_commands import (
branch_exists,
branch_from_first_commit,
checkout_branch,
get_repo,
remote_branch_exists,
)
from cookie_composer.diff import get_diff
from cookie_composer.git_commands import apply_patch, checkout_branch, get_repo
from cookie_composer.layers import render_layers
from cookie_composer.utils import echo, get_context_for_layer


def update_cmd(destination_dir: Optional[Path] = None, no_input: bool = False):
def update_cmd(project_dir: Optional[Path] = None, no_input: bool = False):
"""
Update the project with the latest versions of each layer.
Args:
destination_dir: The project directory to add the layer to
project_dir: The project directory to update. Defaults to current directory.
no_input: If ``True`` force each layer's ``no_input`` attribute to ``True``
Raises:
GitError: If the destination_dir is not a git repository
ValueError: If there is not a .composition.yaml file in the destination directory
"""
destination_dir = Path(destination_dir).resolve() or Path().cwd().resolve()
output_dir = destination_dir.parent
repo = get_repo(destination_dir)
project_dir = Path(project_dir).resolve() or Path().cwd().resolve()
repo = get_repo(project_dir)
previously_untracked_files = set(repo.untracked_files)

# Read the project composition file
proj_composition_path = destination_dir / ".composition.yaml"
proj_composition_path = project_dir / ".composition.yaml"
if not proj_composition_path.exists():
raise ValueError(f"There is no .composition.yaml file in {destination_dir}")
raise ValueError(f"There is no .composition.yaml file in {project_dir}")

proj_composition = read_rendered_composition(proj_composition_path)

# Get the merged context for all layers
initial_context = get_context_for_layer(proj_composition)

update_composition = Composition(layers=[])
update_layers = []
requires_updating = False
for rendered_layer in proj_composition.layers:
if not layer_needs_rendering(rendered_layer):
echo(f"{rendered_layer.layer.layer_name} is already up-to-date.")
continue
update_composition.layers.append(rendered_layer.layer)

if not update_composition.layers:
echo("Done.")
latest_template_sha = rendered_layer.latest_template_sha()
if latest_template_sha is None or latest_template_sha != rendered_layer.layer.commit:
requires_updating = True
new_layer = rendered_layer.layer.copy(deep=True, update={"commit": latest_template_sha})
update_layers.append(new_layer)

if not requires_updating:
echo("All layers are up-to-date.")
return

branch_name = "update_composition"
if branch_exists(repo, branch_name) or remote_branch_exists(repo, branch_name):
checkout_branch(repo, branch_name)
else:
branch_from_first_commit(repo, branch_name)

rendered_layers = render_layers(
update_composition.layers, output_dir, initial_context=initial_context, no_input=no_input, accept_hooks=False
)
new_composition = update_rendered_composition_layers(proj_composition, rendered_layers)
write_rendered_composition(new_composition)

# Commit changed files and newly created files
changed_files = [item.a_path for item in repo.index.diff(None)]
untracked_files = set(repo.untracked_files)
new_untracked_files = untracked_files - previously_untracked_files
changed_files.extend(list(new_untracked_files))

if changed_files:
repo.index.add(changed_files)
repo.index.commit(message="Updating composition layers")

echo("Done.")


def layer_needs_rendering(rendered_layer: RenderedLayer) -> bool:
"""
Determine if a rendered layer is out of date or otherwise should be rendered.
If the template is not a git repository, it will always return ``True``.
Args:
rendered_layer: The rendered layer configuration
Returns:
``True`` if the rendered layer requires rendering
"""
user_config = get_user_config(config_file=None, default_config=False)
repo_dir, _ = determine_repo_dir(
template=rendered_layer.layer.template,
abbreviations=user_config["abbreviations"],
clone_to_dir=user_config["cookiecutters_dir"],
checkout=rendered_layer.layer.commit or rendered_layer.layer.checkout,
no_input=rendered_layer.layer.no_input,
password=rendered_layer.layer.password,
directory=rendered_layer.layer.directory,
)
try:
template_repo = get_repo(repo_dir, search_parent_directories=True)
return rendered_layer.latest_commit != template_repo.head.object.hexsha
except GitError:
# It probably isn't a git repository
return True
with TemporaryDirectory() as tempdir:
current_state_dir = Path(tempdir) / "current_state"
current_state_dir.mkdir(exist_ok=True)
updated_state_dir = Path(tempdir) / "update_state"
updated_state_dir.mkdir(exist_ok=True)

current_layers = [layer.layer for layer in proj_composition.layers]
current_rendered_layers = render_layers(
current_layers,
current_state_dir,
initial_context=initial_context,
no_input=no_input,
accept_hooks=False,
)
remove_paths(current_state_dir, {Path(".git")}) # don't want the .git dir, if it exists
current_composition = update_rendered_composition_layers(proj_composition, current_rendered_layers)

deleted_paths = get_deleted_files(current_state_dir, project_dir.parent)
deleted_paths.add(Path(".git")) # don't want the .git dir, if it exists

updated_rendered_layers = render_layers(
update_layers,
updated_state_dir,
initial_context=initial_context,
no_input=no_input,
accept_hooks=False,
)
remove_paths(updated_state_dir, deleted_paths)
updated_composition = update_rendered_composition_layers(proj_composition, updated_rendered_layers)

# Generate diff
current_project_dir = current_state_dir / current_composition.rendered_name
updated_project_dir = updated_state_dir / updated_composition.rendered_name
diff = get_diff(current_project_dir, updated_project_dir)

# Create new or checkout branch "update_composition"
checkout_branch(repo, "update_composition")
# Apply patch to branch
apply_patch(repo, diff)
write_rendered_composition(updated_composition)

# Commit changed files and newly created files
changed_files = [item.a_path for item in repo.index.diff(None)]
untracked_files = set(repo.untracked_files)
new_untracked_files = untracked_files - previously_untracked_files
changed_files.extend(list(new_untracked_files))

if changed_files:
repo.index.add(changed_files)
repo.index.commit(message="Updating composition layers")


def update_rendered_composition_layers(
Expand Down Expand Up @@ -152,14 +143,83 @@ def update_rendered_composition_layers(
f"file for layer {updated_layer.layer.layer_name}."
)

if base.render_dir != updated_layer.location:
raise RuntimeError(
f"The base rendered location is {base.render_dir}, but the update rendered location is "
f"{updated_layer.location}. Something very strange happened and the update failed."
)

new_layers.append(updated_layer)
else:
new_layers.append(rendered_layer)

return RenderedComposition(layers=new_layers, render_dir=base.render_dir, rendered_name=base.rendered_name)


def get_deleted_files(template_dir: Path, project_dir: Path) -> Set[Path]:
"""
Get a list of files in the rendered template that do not exist in the project.
This is to avoid introducing changes that won't apply cleanly to the current project.
Nabbed from Cruft: https://github.com/cruft/cruft/
Args:
template_dir: The path to the directory rendered with the same context as the project
project_dir: The path to the current project
Returns:
A set of paths that are missing
"""
cwd = Path.cwd()
os.chdir(template_dir)
template_paths = set(Path(".").glob("**/*"))
os.chdir(cwd)
os.chdir(project_dir)
deleted_paths = set(filter(lambda path: not path.exists(), template_paths))
os.chdir(cwd)
return deleted_paths


def remove_paths(root: Path, paths_to_remove: Set[Path]):
"""
Remove all paths in ``paths_to_remove`` from ``root``.
Nabbed from Cruft: https://github.com/cruft/cruft/
Args:
root: The absolute path of the directory requiring path removal
paths_to_remove: The set of relative paths to remove from ``root``
"""
# There is some redundancy here in chmod-ing dirs and/or files differently.
abs_paths_to_remove = [root / path_to_remove for path_to_remove in paths_to_remove]

for path in abs_paths_to_remove:
remove_single_path(path)


def remove_readonly_bit(func, path, _): # pragma: no-coverage
"""Clear the readonly bit and reattempt the removal."""
os.chmod(path, stat.S_IWRITE) # WINDOWS
func(path)


def remove_single_path(path: Path):
"""
Remove a path with extra error handling for Windows.
Args:
path: The path to remove
Raises:
IOError: If the file could not be removed
"""
from shutil import rmtree

if path.is_dir():
try:
rmtree(path, ignore_errors=False, onerror=remove_readonly_bit)
except Exception as e: # pragma: no-coverage
raise IOError("Failed to remove directory.") from e
elif path.is_file():
try:
path.unlink()
except PermissionError: # pragma: no-coverage
path.chmod(stat.S_IWRITE)
path.unlink()
except Exception as exc: # pragma: no-coverage
raise IOError("Failed to remove file.") from exc
33 changes: 32 additions & 1 deletion cookie_composer/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from pydantic import AnyHttpUrl, BaseModel, DirectoryPath, Field, root_validator

from cookie_composer.exceptions import MissingCompositionFileError
from cookie_composer.exceptions import GitError, MissingCompositionFileError
from cookie_composer.matching import rel_fnmatch

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -131,6 +131,37 @@ def set_rendered_name(cls, values):
values["rendered_name"] = dirs[0].name
return values

def latest_template_sha(self) -> Optional[str]:
"""
Return the latest SHA of this rendered layer's template.
If the template is not a git repository, it will always return ``None``.
Returns:
The latest hexsha of the template or ``None`` if the template isn't a git repo
"""
from cookiecutter.config import get_user_config
from cookiecutter.repository import determine_repo_dir

from cookie_composer.git_commands import get_repo

user_config = get_user_config(config_file=None, default_config=False)
repo_dir, _ = determine_repo_dir(
template=self.layer.template,
abbreviations=user_config["abbreviations"],
clone_to_dir=user_config["cookiecutters_dir"],
checkout=self.layer.commit or self.layer.checkout,
no_input=self.layer.no_input,
password=self.layer.password,
directory=self.layer.directory,
)
try:
template_repo = get_repo(repo_dir, search_parent_directories=True)
return template_repo.head.object.hexsha
except GitError:
# It probably isn't a git repository
return None


class Composition(BaseModel):
"""Composition of templates for a project."""
Expand Down
Loading

0 comments on commit d4bc14f

Please sign in to comment.