Skip to content

Commit

Permalink
Merge pull request #556 from projectsyn/feat/dependency-new-worktree
Browse files Browse the repository at this point in the history
Create new components and packages as Git worktree checkouts
  • Loading branch information
simu authored Jul 19, 2022
2 parents ac18565 + 8d8b8c0 commit bb4d058
Show file tree
Hide file tree
Showing 10 changed files with 234 additions and 45 deletions.
11 changes: 11 additions & 0 deletions commodore/component/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ def delete(self):
abort=True,
)
rmtree(component.target_directory)
# We check for other checkouts here, because our MultiDependency doesn't
# know if there's other dependencies which would be registered on it.
if not cdep.has_checkouts():
# Also delete bare copy of component repo, if there's no other
# worktree checkouts for the same dependency repo.
rmtree(cdep.repo_directory)
else:
click.echo(
f" > Not deleting bare copy of repository {cdep.url}. "
+ "Other worktrees refer to the same reposiotry."
)

click.secho(f"Component {self.slug} successfully deleted 🎉", bold=True)
else:
Expand Down
35 changes: 26 additions & 9 deletions commodore/dependency_templater.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import datetime
import re
import tempfile
import shutil

from abc import ABC, abstractmethod
from pathlib import Path
Expand All @@ -11,6 +13,7 @@

from commodore.config import Config
from commodore.gitrepo import GitRepo
from commodore.multi_dependency import MultiDependency

SLUG_REGEX = re.compile("^[a-z][a-z0-9-]+[a-z0-9]$")

Expand Down Expand Up @@ -132,20 +135,34 @@ def create(self) -> None:
+ f"{self.target_dir} already exists."
)

self.template_renderer(
self.template,
no_input=True,
output_dir=self.target_dir.parent,
extra_context=self.cookiecutter_args,
want_worktree = (
self.config.inventory.dependencies_dir in self.target_dir.parents
)
if want_worktree:
md = MultiDependency(self.repo_url, self.config.inventory.dependencies_dir)
md.initialize_worktree(self.target_dir)

with tempfile.TemporaryDirectory() as tmpdir:
self.template_renderer(
self.template,
no_input=True,
output_dir=Path(tmpdir),
extra_context=self.cookiecutter_args,
)
shutil.copytree(
Path(tmpdir) / self.slug, self.target_dir, dirs_exist_ok=True
)

self.commit("Initial commit")
self.commit("Initial commit", amend=want_worktree)
click.secho(
f"{self.deptype.capitalize()} {self.name} successfully added 🎉", bold=True
)

def commit(self, msg: str) -> None:
repo = GitRepo(self.repo_url, targetdir=self.target_dir, force_init=True)
def commit(self, msg: str, amend=False, init=True) -> None:
# If we're amending an existing commit, we don't want to force initialize the
# repo.
repo = GitRepo(self.repo_url, self.target_dir, force_init=not amend and init)

repo.stage_all()
repo.stage_files(self.additional_files)
repo.commit(msg)
repo.commit(msg, amend=amend)
73 changes: 71 additions & 2 deletions commodore/gitrepo.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,15 @@ def head_short_sha(self) -> str:
sha = self._repo.head.commit.hexsha
return self._repo.git.rev_parse(sha, short=6)

@property
def _author_env(self) -> dict[str, str]:
return {
Actor.env_author_name: self._author.name or "",
Actor.env_author_email: self._author.email or "",
Actor.env_committer_name: self._author.name or "",
Actor.env_committer_email: self._author.email or "",
}

@property
def _null_tree(self) -> Tree:
"""Generate empty Tree for the repo.
Expand Down Expand Up @@ -404,6 +413,51 @@ def checkout_worktree(self, worktree: Path, version: Optional[str]):
# If the worktree directory doesn't exist yet, create the worktree
self._create_worktree(worktree, version)

def initialize_worktree(
self, worktree: Path, initial_branch: Optional[str] = None
) -> None:
if not initial_branch:
initial_branch = self._default_version()

# We need an initial commit to be able to create a worktree. Create initial
# commit from empty tree.
initsha = self._repo.git.execute( # type: ignore[call-overload]
command=[
"git",
"commit-tree",
"-m",
"Initial commit",
self._null_tree.hexsha,
],
env=self._author_env,
)

# Create worktree using the provided branch name
self._repo.git.execute(
["git", "worktree", "add", str(worktree), initsha, "-b", initial_branch]
)

@property
def worktrees(self) -> list[GitRepo]:
"""List all worktrees for the repo"""
# First prune worktrees, to ensure repo worktree state is clean
self._repo.git.execute(["git", "worktree", "prune"])
worktrees: list[GitRepo] = []
wt_list = self._repo.git.execute(
["git", "worktree", "list", "--porcelain"],
as_process=False,
with_extended_output=False,
stdout_as_string=True,
).splitlines()
for line in wt_list:
if " " not in line:
continue
k, v = line.split(" ")
if k == "worktree":
worktrees.append(GitRepo(None, Path(v)))

return worktrees

def checkout(self, version: Optional[str] = None):
remote_heads = self.fetch()
if not remote_heads:
Expand Down Expand Up @@ -492,13 +546,28 @@ def stage_files(self, files: Sequence[str]):
"""Add provided list of files to index."""
self._repo.index.add(files)

def commit(self, commit_message: str):
def commit(self, commit_message: str, amend=False):
author = self._author

if self._trace:
click.echo(f' > Using "{author.name} <{author.email}>" as commit author')

self._repo.index.commit(commit_message, author=author, committer=author)
if amend:
# We need to call out to `git commit` for amending
self._repo.git.execute( # type: ignore[call-overload]
[
"git",
"commit",
"--amend",
"--no-edit",
"--reset-author",
"-m",
commit_message,
],
env=self._author_env,
)
else:
self._repo.index.commit(commit_message, author=author, committer=author)

def push(
self, remote: Optional[str] = None, version: Optional[str] = None
Expand Down
11 changes: 11 additions & 0 deletions commodore/multi_dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def url(self) -> str:
def url(self, repo_url: str):
self._repo.remote = repo_url

@property
def repo_directory(self) -> Path:
return Path(self._repo.repo.common_dir).resolve().absolute()

def get_component(self, name: str) -> Optional[Path]:
return self._components.get(name)

Expand Down Expand Up @@ -71,6 +75,13 @@ def checkout_package(self, name: str, version: str):
raise ValueError(f"can't checkout unknown package {name}")
self._repo.checkout_worktree(target_dir, version=version)

def initialize_worktree(self, target_dir: Path) -> None:
"""Initialize a worktree in `target_dir`."""
self._repo.initialize_worktree(target_dir)

def has_checkouts(self) -> bool:
return len(self._repo.worktrees) > 1


def dependency_dir(base_dir: Path, repo_url: str) -> Path:
return base_dir / ".repos" / dependency_key(repo_url)
Expand Down
27 changes: 8 additions & 19 deletions commodore/package/template.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from __future__ import annotations

import json
import shutil
import tempfile

from pathlib import Path
from typing import Any, Optional, Sequence
Expand All @@ -27,6 +25,7 @@ class PackageTemplater(Templater):
template_commit: str
test_cases: list[str] = ["defaults"]
copyright_year: Optional[str] = None
_target_dir: Optional[Path] = None

@classmethod
def from_existing(cls, config: Config, package_path: Path):
Expand All @@ -39,6 +38,7 @@ def from_existing(cls, config: Config, package_path: Path):
t = PackageTemplater(
config, cookiecutter_args["slug"], name=cookiecutter_args["name"]
)
t._target_dir = package_path
t.output_dir = package_path.absolute().parent
t.template_url = cruft_json["template"]
if cruft_json["checkout"]:
Expand All @@ -61,28 +61,13 @@ def _cruft_renderer(
no_input: bool,
output_dir: Path,
):
"""Render package cookiecutter template in tempdir and move the results to
`output_dir/pkg.<slug>`, because as far as I can see we can't configure
cruft/cookiecutter to create a directory named `pkg.<slug>` except if we
change the slug itself, which would need a bunch of template changes.
"""

# Because we render the template in a temp directory and move it to the desired
# target directory, we don't need argument `output_dir` which is set to
# `self.target_dir.parent` when the renderer function is called by the base
# class, and instead move the final rendered package to `self.target_dir`
# ourselves.
_ = output_dir
tmpdir = Path(tempfile.mkdtemp())
cruft_create(
template_location,
checkout=self.template_version,
extra_context=extra_context,
no_input=no_input,
output_dir=tmpdir,
output_dir=output_dir,
)
shutil.move(str(tmpdir / self.slug), self.target_dir)
shutil.rmtree(tmpdir)

def _validate_slug(self, value: str):
# First perform default slug checks
Expand Down Expand Up @@ -124,6 +109,9 @@ def template_renderer(self) -> Renderer:

@property
def target_dir(self) -> Path:
if self._target_dir:
return self._target_dir

if self.output_dir:
return self.output_dir / self.slug

Expand Down Expand Up @@ -153,7 +141,8 @@ def update(self):

self.commit(
"Update from template\n\n"
+ f"Template version: {self.template_version} ({self.template_commit[:7]})"
+ f"Template version: {self.template_version} ({self.template_commit[:7]})",
init=False,
)

click.secho(
Expand Down
4 changes: 3 additions & 1 deletion docs/modules/ROOT/pages/reference/commands.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ check with the user whether they really want to remove the items listed above.
commodore component new SLUG

This command creates a new component repository under `dependencies/` in Commodore's working directory.
Commodore creates a Git worktree checkout for the new component.
The component repository is created using a Cookiecutter template which provides a skeleton for writing a new component.
The command requires the argument `SLUG` to match the regular expression `^[a-z][a-z0-9-]+[a-z0-9]$`.
Optionally, the template can be used to add a component library and postprocessing filter configuration.
Expand Down Expand Up @@ -152,7 +153,8 @@ If necessary, the command will call `commodore login` internally to fetch a vali
commodore package new SLUG

This command creates a new config package repository.
If not specified explicitly, the command will create the new package under `inventory/classes/` in Commodore's working directory.
If not specified explicitly, the command will create the new package under `dependencies/` in Commodore's working directory.
If the new package is created in `dependencies`, Commodore will create a Git worktree checkout.
The package repository is created using a Cookiecutter template which provides a skeleton for writing a new package.
The command requires the argument `SLUG` to match the regular expression `^[a-z][a-z0-9-]+[a-z0-9]$`.
Additionally, the command prevents users from creating packages using reserved names or prefixes.
Expand Down
28 changes: 18 additions & 10 deletions tests/test_component_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from subprocess import call
from git import Repo

from conftest import RunnerFunc
from test_component import setup_directory


Expand Down Expand Up @@ -92,7 +93,16 @@ def test_run_component_new_command(
)
for file in expected_files:
assert (tmp_path / "dependencies" / component_name / file).exists()
# Check that there are no uncommited files in the component repo
# Check that we created a worktree
assert (tmp_path / "dependencies" / component_name / ".git").is_file()
# Verify that worktree and bare copy configs are correct
repo = Repo(tmp_path / "dependencies" / component_name)
assert not repo.bare
assert P(repo.working_tree_dir) == tmp_path / "dependencies" / component_name
md_repo = Repo(P(repo.common_dir).resolve())
assert md_repo.bare
assert md_repo.working_tree_dir is None
# Check that there are no uncommitted files in the component repo
repo = Repo(tmp_path / "dependencies" / component_name)
assert not repo.is_dirty()
assert not repo.untracked_files
Expand Down Expand Up @@ -225,24 +235,22 @@ def test_run_component_new_command_with_illegal_slug(tmp_path: P, test_input):
assert exit_status != 0


def test_run_component_new_then_delete(tmp_path: P):
def test_run_component_new_then_delete(tmp_path: P, cli_runner: RunnerFunc):
"""
Create a new component, then immediately delete it.
"""
setup_directory(tmp_path)

component_name = "test-component"
exit_status = call(
f"commodore -d {tmp_path} -vvv component new {component_name} --lib --pp",
shell=True,
result = cli_runner(
["-d", tmp_path, "-vvv", "component", "new", component_name, "--lib", "--pp"]
)
assert exit_status == 0
assert result.exit_code == 0

exit_status = call(
f"commodore -d {tmp_path} -vvv component delete --force {component_name}",
shell=True,
result = cli_runner(
["-d", tmp_path, "-vvv", "component", "delete", "--force", component_name]
)
assert exit_status == 0
assert result.exit_code == 0

# Ensure the dependencies folder is gone.
assert not (tmp_path / "dependencies" / component_name).exists()
Expand Down
Loading

0 comments on commit bb4d058

Please sign in to comment.