diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 03b8c87..22354d6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,6 +30,10 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt + - name: Run unit tests + run: | + python -m pytest -s -vv + - name: Build binary run: | echo "__version__ = \"${GITHUB_REF_NAME}\"" > src/repo_smith/version.py diff --git a/README.md b/README.md index f96f7fe..bce1fea 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,9 @@ For more use cases of `repo-smith`, refer to: - [Official specification](/specification.md) - [Unit tests](./tests/) + +## FAQ + +### Why don't you assign every error to a constant and unit test against the constant? + +Suppose the constant was `X`, and we unit tested that the error value was `X`, we would not capture any nuances if the value of `X` had changed by accident. diff --git a/requirements.txt b/requirements.txt index 99d3d73..ec801cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ PyYAML types-PyYAML build twine +pytest-cov diff --git a/src/repo_smith/initialize_repo.py b/src/repo_smith/initialize_repo.py index 03220ba..4092bea 100644 --- a/src/repo_smith/initialize_repo.py +++ b/src/repo_smith/initialize_repo.py @@ -1,5 +1,4 @@ import os -import re import shutil import tempfile from contextlib import contextmanager @@ -8,22 +7,10 @@ import yaml from git import Repo -import repo_smith.steps.add_step -import repo_smith.steps.bash_step -import repo_smith.steps.branch_delete_step -import repo_smith.steps.branch_rename_step -import repo_smith.steps.branch_step -import repo_smith.steps.checkout_step -import repo_smith.steps.commit_step -import repo_smith.steps.fetch_step -import repo_smith.steps.file_step -import repo_smith.steps.merge_step -import repo_smith.steps.remote_step import repo_smith.steps.tag_step from repo_smith.clone_from import CloneFrom from repo_smith.spec import Spec -from repo_smith.steps.step import Step -from repo_smith.steps.step_type import StepType +from repo_smith.steps.dispatcher import Dispatcher Hook: TypeAlias = Callable[[Repo], None] @@ -46,7 +33,7 @@ def initialize(self, existing_path: Optional[str] = None) -> Iterator[Repo]: if self.__spec.clone_from is not None: repo = Repo.clone_from(self.__spec.clone_from.repo_url, tmp_dir) else: - repo = Repo.init(tmp_dir) + repo = Repo.init(tmp_dir, initial_branch="main") for step in self.__spec.steps: if step.id in self.__pre_hooks: @@ -118,8 +105,8 @@ def __get_all_ids(self, spec: Spec) -> Set[str]: def __parse_spec(self, spec: Any) -> Spec: steps = [] - for step in spec.get("initialization", {}).get("steps", []): - steps.append(self.__parse_step(step)) + for step in spec.get("initialization", {}).get("steps", []) or []: + steps.append(Dispatcher.dispatch(step)) clone_from = None if spec.get("initialization", {}).get("clone-from", None) is not None: @@ -134,233 +121,6 @@ def __parse_spec(self, spec: Any) -> Spec: clone_from=clone_from, ) - def __parse_step(self, step: Any) -> Step: - if "type" not in step: - raise ValueError('Missing "type" field in step.') - - name = step.get("name") - description = step.get("description") - step_type = StepType.from_value(step["type"]) - id = step.get("id") - - if step_type == StepType.COMMIT: - if "message" not in step: - raise ValueError('Missing "message" field in commit step.') - - return repo_smith.steps.commit_step.CommitStep( - name=name, - description=description, - step_type=StepType.COMMIT, - id=id, - empty=step.get("empty", False), - message=step["message"], - ) - elif step_type == StepType.ADD: - if "files" not in step: - raise ValueError('Missing "files" field in add step.') - - if step["files"] is None or step["files"] == []: - raise ValueError('Empty "files" list in add step.') - - return repo_smith.steps.add_step.AddStep( - name=name, - description=description, - step_type=StepType.ADD, - id=id, - files=step["files"], - ) - elif step_type == StepType.TAG: - if "tag-name" not in step: - raise ValueError('Missing "tag-name" field in tag step.') - - if step["tag-name"] is None or step["tag-name"].strip() == "": - raise ValueError('Empty "tag-name" field in tag step.') - - tag_name_regex = "^[0-9a-zA-Z-_.]*$" - if re.search(tag_name_regex, step["tag-name"]) is None: - raise ValueError( - 'Field "tag-name" can only contain alphanumeric characters, _, -, .' - ) - - return repo_smith.steps.tag_step.TagStep( - name=name, - description=description, - step_type=StepType.TAG, - id=id, - tag_name=step["tag-name"], - tag_message=step.get("tag-message"), - ) - elif step_type == StepType.BASH: - if "runs" not in step: - raise ValueError('Missing "runs" field in bash step.') - - if step["runs"] is None or step["runs"].strip() == "": - raise ValueError('Empty "runs" field in file step.') - - return repo_smith.steps.bash_step.BashStep( - name=name, - description=description, - step_type=step_type, - id=id, - body=step["runs"], - ) - elif step_type == StepType.BRANCH: - if "branch-name" not in step: - raise ValueError('Missing "branch-name" field in branch step.') - - if step["branch-name"] is None or step["branch-name"].strip() == "": - raise ValueError('Empty "branch-name" field in branch step.') - - return repo_smith.steps.branch_step.BranchStep( - name=name, - description=description, - step_type=step_type, - id=id, - branch_name=step["branch-name"], - ) - elif step_type == StepType.BRANCH_RENAME: - if "branch-name" not in step: - raise ValueError('Missing "branch-name" field in branch-rename step.') - - if step["branch-name"] is None or step["branch-name"].strip() == "": - raise ValueError('Empty "branch-name" field in branch-rename step.') - - if "new-name" not in step: - raise ValueError('Missing "new-name" field in branch-rename step.') - - if step["new-name"] is None or step["new-name"].strip() == "": - raise ValueError('Empty "new-name" field in branch-rename step.') - - return repo_smith.steps.branch_rename_step.BranchRenameStep( - name=name, - description=description, - step_type=step_type, - id=id, - original_branch_name=step["branch-name"], - target_branch_name=step["new-name"], - ) - elif step_type == StepType.BRANCH_DELETE: - if "branch-name" not in step: - raise ValueError('Missing "branch-name" field in branch step.') - - if step["branch-name"] is None or step["branch-name"].strip() == "": - raise ValueError('Empty "branch-name" field in branch step.') - - return repo_smith.steps.branch_delete_step.BranchDeleteStep( - name=name, - description=description, - step_type=step_type, - id=id, - branch_name=step["branch-name"], - ) - elif step_type == StepType.CHECKOUT: - if step.get("branch-name") is None and step.get("commit-hash") is None: - raise ValueError( - 'Provide either "branch-name" or "commit-hash" in checkout step.' - ) - - return repo_smith.steps.checkout_step.CheckoutStep( - name=name, - description=description, - step_type=step_type, - id=id, - branch_name=step.get("branch-name"), - commit_hash=step.get("commit-hash"), - ) - elif step_type == StepType.MERGE: - if step.get("branch-name") is None: - raise ValueError('Provide either "branch-name" in merge step.') - - return repo_smith.steps.merge_step.MergeStep( - name=name, - description=description, - step_type=step_type, - id=id, - branch_name=step.get("branch-name"), - no_fast_forward=step.get("no-ff", False), - squash=step.get("squash", False), - ) - elif step_type == StepType.REMOTE: - if "remote-url" not in step: - raise ValueError('Missing "remote-url" field in remote step.') - - if "remote-name" not in step: - raise ValueError('Missing "remote-name" field in remote step.') - - return repo_smith.steps.remote_step.RemoteStep( - name=name, - description=description, - id=id, - step_type=step_type, - remote_name=step["remote-name"], - remote_url=step["remote-url"], - ) - elif step_type == StepType.FETCH: - if "remote-name" not in step: - raise ValueError('Missing "remote-name" field in fetch step.') - - return repo_smith.steps.fetch_step.FetchStep( - name=name, - description=description, - id=id, - step_type=step_type, - remote_name=step["remote-name"], - ) - elif step_type in { - StepType.NEW_FILE, - StepType.APPEND_FILE, - StepType.EDIT_FILE, - StepType.DELETE_FILE, - }: - if "filename" not in step: - raise ValueError('Missing "filename" field in file step.') - - if step["filename"] is None or step["filename"].strip() == "": - raise ValueError('Empty "filename" field in file step.') - - filename = step["filename"] - contents = step.get("contents", "") or "" - - match step_type: - case StepType.NEW_FILE: - return repo_smith.steps.file_step.NewFileStep( - name=name, - description=description, - step_type=step_type, - id=id, - filename=filename, - contents=contents, - ) - case StepType.EDIT_FILE: - return repo_smith.steps.file_step.EditFileStep( - name=name, - description=description, - step_type=step_type, - id=id, - filename=filename, - contents=contents, - ) - case StepType.DELETE_FILE: - return repo_smith.steps.file_step.DeleteFileStep( - name=name, - description=description, - step_type=step_type, - id=id, - filename=filename, - contents=contents, - ) - case StepType.APPEND_FILE: - return repo_smith.steps.file_step.AppendFileStep( - name=name, - description=description, - step_type=step_type, - id=id, - filename=filename, - contents=contents, - ) - else: - raise ValueError('Improper "type" field in spec.') - def initialize_repo(spec_path: str) -> RepoInitializer: if not os.path.isfile(spec_path): diff --git a/src/repo_smith/steps/add_step.py b/src/repo_smith/steps/add_step.py index e88d616..51781fd 100644 --- a/src/repo_smith/steps/add_step.py +++ b/src/repo_smith/steps/add_step.py @@ -1,14 +1,37 @@ -from dataclasses import dataclass -from typing import List +from dataclasses import dataclass, field +from typing import Any, List, Optional, Self, Type from git import Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass class AddStep(Step): files: List[str] + step_type: StepType = field(init=False, default=StepType.ADD) + def execute(self, repo: Repo) -> None: repo.index.add(self.files) + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "files" not in step: + raise ValueError('Missing "files" field in add step.') + + if step["files"] is None or step["files"] == []: + raise ValueError('Empty "files" list in add step.') + + return cls( + name=name, + description=description, + id=id, + files=step["files"], + ) diff --git a/src/repo_smith/steps/bash_step.py b/src/repo_smith/steps/bash_step.py index e112326..9f65c44 100644 --- a/src/repo_smith/steps/bash_step.py +++ b/src/repo_smith/steps/bash_step.py @@ -1,15 +1,38 @@ import subprocess - -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass class BashStep(Step): body: str - def execute(self, repo: Repo) -> None: # type: ignore + step_type: StepType = field(init=False, default=StepType.BASH) + + def execute(self, repo: Repo) -> None: subprocess.check_call(self.body.strip(), shell=True, cwd=repo.working_dir) + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "runs" not in step: + raise ValueError('Missing "runs" field in bash step.') + + if step["runs"] is None or step["runs"].strip() == "": + raise ValueError('Empty "runs" field in bash step.') + + return cls( + name=name, + description=description, + id=id, + body=step["runs"], + ) diff --git a/src/repo_smith/steps/branch_delete_step.py b/src/repo_smith/steps/branch_delete_step.py index 3aae0ec..27eb70b 100644 --- a/src/repo_smith/steps/branch_delete_step.py +++ b/src/repo_smith/steps/branch_delete_step.py @@ -1,12 +1,42 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass class BranchDeleteStep(Step): branch_name: str + step_type: StepType = field(init=False, default=StepType.BRANCH_DELETE) + def execute(self, repo: Repo) -> None: + current_local_refs = [ref.name for ref in repo.refs] + if self.branch_name not in current_local_refs: + raise ValueError( + '"branch-name" field provided does not correspond to any existing branches in branch-delete step.' + ) repo.delete_head(self.branch_name, force=True) + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "branch-name" not in step: + raise ValueError('Missing "branch-name" field in branch-delete step.') + + if step["branch-name"] is None or step["branch-name"].strip() == "": + raise ValueError('Empty "branch-name" field in branch-delete step.') + + return cls( + name=name, + description=description, + id=id, + branch_name=step["branch-name"], + ) diff --git a/src/repo_smith/steps/branch_rename_step.py b/src/repo_smith/steps/branch_rename_step.py index dde7405..621ea41 100644 --- a/src/repo_smith/steps/branch_rename_step.py +++ b/src/repo_smith/steps/branch_rename_step.py @@ -1,7 +1,9 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass @@ -9,6 +11,44 @@ class BranchRenameStep(Step): original_branch_name: str target_branch_name: str + step_type: StepType = field(init=False, default=StepType.BRANCH) + def execute(self, repo: Repo) -> None: + if self.original_branch_name not in repo.heads: + raise ValueError( + '"branch-name" field provided does not correspond to any existing branches in branch-rename step.' + ) + if self.target_branch_name in repo.heads: + raise ValueError( + '"new-name" field provided corresponds to an existing branch already in branch-rename step.' + ) branch = repo.heads[self.original_branch_name] branch.rename(self.target_branch_name) + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "branch-name" not in step: + raise ValueError('Missing "branch-name" field in branch-rename step.') + + if step["branch-name"] is None or step["branch-name"].strip() == "": + raise ValueError('Empty "branch-name" field in branch-rename step.') + + if "new-name" not in step: + raise ValueError('Missing "new-name" field in branch-rename step.') + + if step["new-name"] is None or step["new-name"].strip() == "": + raise ValueError('Empty "new-name" field in branch-rename step.') + + return cls( + name=name, + description=description, + id=id, + original_branch_name=step["branch-name"], + target_branch_name=step["new-name"], + ) diff --git a/src/repo_smith/steps/branch_step.py b/src/repo_smith/steps/branch_step.py index e5c5600..930d606 100644 --- a/src/repo_smith/steps/branch_step.py +++ b/src/repo_smith/steps/branch_step.py @@ -1,14 +1,39 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass class BranchStep(Step): branch_name: str + step_type: StepType = field(init=False, default=StepType.BRANCH) + def execute(self, repo: Repo) -> None: + # TODO: Handle when attempting to create a branch when no commits exist branch = repo.create_head(self.branch_name) branch.checkout() + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "branch-name" not in step: + raise ValueError('Missing "branch-name" field in branch step.') + + if step["branch-name"] is None or step["branch-name"].strip() == "": + raise ValueError('Empty "branch-name" field in branch step.') + + return cls( + name=name, + description=description, + id=id, + branch_name=step["branch-name"], + ) diff --git a/src/repo_smith/steps/checkout_step.py b/src/repo_smith/steps/checkout_step.py index d69775b..a90ba4f 100644 --- a/src/repo_smith/steps/checkout_step.py +++ b/src/repo_smith/steps/checkout_step.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import BadName, Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass @@ -11,6 +11,8 @@ class CheckoutStep(Step): branch_name: Optional[str] commit_hash: Optional[str] + step_type: StepType = field(init=False, default=StepType.CHECKOUT) + def execute(self, repo: Repo) -> None: if self.branch_name is not None: if self.branch_name not in repo.heads: @@ -24,3 +26,35 @@ def execute(self, repo: Repo) -> None: repo.git.checkout(commit) except (ValueError, BadName): raise ValueError("Commit not found") + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if step.get("branch-name") is None and step.get("commit-hash") is None: + raise ValueError( + 'Provide either "branch-name" or "commit-hash" in checkout step.' + ) + + if step.get("branch-name") is not None and step.get("commit-hash") is not None: + raise ValueError( + 'Provide either "branch-name" or "commit-hash", not both, in checkout step.' + ) + + if step.get("branch-name") is not None and step["branch-name"].strip() == "": + raise ValueError('Empty "branch-name" field in checkout step.') + + if step.get("commit-hash") is not None and step["commit-hash"].strip() == "": + raise ValueError('Empty "commit-hash" field in checkout step.') + + return cls( + name=name, + description=description, + id=id, + branch_name=step.get("branch-name"), + commit_hash=step.get("commit-hash"), + ) diff --git a/src/repo_smith/steps/commit_step.py b/src/repo_smith/steps/commit_step.py index 3529fae..fb5e003 100644 --- a/src/repo_smith/steps/commit_step.py +++ b/src/repo_smith/steps/commit_step.py @@ -1,8 +1,9 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass @@ -10,8 +11,32 @@ class CommitStep(Step): empty: bool message: str + step_type: StepType = field(init=False, default=StepType.COMMIT) + def execute(self, repo: Repo) -> None: if self.empty: repo.git.commit("-m", self.message, "--allow-empty") else: repo.index.commit(message=self.message) + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "message" not in step: + raise ValueError('Missing "message" field in commit step.') + + if step["message"] is None or step["message"].strip() == "": + raise ValueError('Empty "message" field in commit step.') + + return cls( + name=name, + description=description, + id=id, + empty=step.get("empty", False), + message=step["message"], + ) diff --git a/src/repo_smith/steps/dispatcher.py b/src/repo_smith/steps/dispatcher.py new file mode 100644 index 0000000..7990a7d --- /dev/null +++ b/src/repo_smith/steps/dispatcher.py @@ -0,0 +1,69 @@ +from typing import Any, Type + +from repo_smith.steps.add_step import AddStep +from repo_smith.steps.bash_step import BashStep +from repo_smith.steps.branch_delete_step import BranchDeleteStep +from repo_smith.steps.branch_rename_step import BranchRenameStep +from repo_smith.steps.branch_step import BranchStep +from repo_smith.steps.checkout_step import CheckoutStep +from repo_smith.steps.commit_step import CommitStep +from repo_smith.steps.fetch_step import FetchStep +from repo_smith.steps.file_step import ( + AppendFileStep, + DeleteFileStep, + EditFileStep, + NewFileStep, +) +from repo_smith.steps.merge_step import MergeStep +from repo_smith.steps.remote_step import RemoteStep +from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType +from repo_smith.steps.tag_step import TagStep + + +class Dispatcher: + @staticmethod + def dispatch(step: Any) -> Step: + if "type" not in step: + raise ValueError('Missing "type" field in step.') + + name = step.get("name") + description = step.get("description") + step_type = StepType.from_value(step["type"]) + id = step.get("id") + retrieved_step_type = Dispatcher.__get_type(step_type) + return retrieved_step_type.parse(name, description, id, step) + + @staticmethod + def __get_type(step_type: StepType) -> Type[Step]: + match step_type: + case StepType.COMMIT: + return CommitStep + case StepType.ADD: + return AddStep + case StepType.TAG: + return TagStep + case StepType.BASH: + return BashStep + case StepType.BRANCH: + return BranchStep + case StepType.BRANCH_RENAME: + return BranchRenameStep + case StepType.BRANCH_DELETE: + return BranchDeleteStep + case StepType.CHECKOUT: + return CheckoutStep + case StepType.MERGE: + return MergeStep + case StepType.REMOTE: + return RemoteStep + case StepType.FETCH: + return FetchStep + case StepType.NEW_FILE: + return NewFileStep + case StepType.EDIT_FILE: + return EditFileStep + case StepType.DELETE_FILE: + return DeleteFileStep + case StepType.APPEND_FILE: + return AppendFileStep diff --git a/src/repo_smith/steps/fetch_step.py b/src/repo_smith/steps/fetch_step.py index 9ee8ef7..21d535c 100644 --- a/src/repo_smith/steps/fetch_step.py +++ b/src/repo_smith/steps/fetch_step.py @@ -1,20 +1,42 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo - from repo_smith.steps.step import Step - -# TODO: This needs a unit test file +from repo_smith.steps.step_type import StepType @dataclass class FetchStep(Step): remote_name: str + step_type: StepType = field(init=False, default=StepType.FETCH) + def execute(self, repo: Repo) -> None: try: remote = repo.remote(self.remote_name) except Exception: - raise ValueError(f"Missing remote '{self.remote_name}'") + raise ValueError(f"Missing remote '{self.remote_name}' in fetch step.") remote.fetch() + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "remote-name" not in step: + raise ValueError('Missing "remote-name" field in fetch step.') + + if step["remote-name"] is None or step["remote-name"].strip() == "": + raise ValueError('Empty "remote-name" field in fetch step.') + + return cls( + name=name, + description=description, + id=id, + remote_name=step["remote-name"], + ) diff --git a/src/repo_smith/steps/file_step.py b/src/repo_smith/steps/file_step.py index aaaf0fb..e1b6377 100644 --- a/src/repo_smith/steps/file_step.py +++ b/src/repo_smith/steps/file_step.py @@ -1,11 +1,12 @@ import os import os.path -from dataclasses import dataclass import pathlib +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Tuple, Type from git import Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass @@ -13,8 +14,23 @@ class FileStep(Step): filename: str contents: str + @staticmethod + def get_details(step: Any) -> Tuple[str, str]: + if "filename" not in step: + raise ValueError('Missing "filename" field in file step.') + + if step["filename"] is None or step["filename"].strip() == "": + raise ValueError('Empty "filename" field in file step.') + + filename = step["filename"] + contents = step.get("contents", "") or "" + return filename, contents + +@dataclass class NewFileStep(FileStep): + step_type: StepType = field(init=False, default=StepType.NEW_FILE) + def execute(self, repo: Repo) -> None: rw_dir = repo.working_dir filepath = os.path.join(rw_dir, self.filename) @@ -23,8 +39,28 @@ def execute(self, repo: Repo) -> None: with open(filepath, "w+") as fs: fs.write(self.contents) + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + filename, contents = FileStep.get_details(step) + return cls( + name=name, + description=description, + id=id, + filename=filename, + contents=contents, + ) + +@dataclass class EditFileStep(FileStep): + step_type: StepType = field(init=False, default=StepType.EDIT_FILE) + def execute(self, repo: Repo) -> None: rw_dir = repo.working_dir filepath = os.path.join(rw_dir, self.filename) @@ -33,8 +69,28 @@ def execute(self, repo: Repo) -> None: with open(filepath, "w") as fs: fs.write(self.contents) + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + filename, contents = FileStep.get_details(step) + return cls( + name=name, + description=description, + id=id, + filename=filename, + contents=contents, + ) + +@dataclass class DeleteFileStep(FileStep): + step_type: StepType = field(init=False, default=StepType.DELETE_FILE) + def execute(self, repo: Repo) -> None: rw_dir = repo.working_dir filepath = os.path.join(rw_dir, self.filename) @@ -42,8 +98,28 @@ def execute(self, repo: Repo) -> None: raise ValueError("Invalid filename for deleting") os.remove(filepath) + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + filename, contents = FileStep.get_details(step) + return cls( + name=name, + description=description, + id=id, + filename=filename, + contents=contents, + ) + +@dataclass class AppendFileStep(FileStep): + step_type: StepType = field(init=False, default=StepType.APPEND_FILE) + def execute(self, repo: Repo) -> None: rw_dir = repo.working_dir filepath = os.path.join(rw_dir, self.filename) @@ -51,3 +127,20 @@ def execute(self, repo: Repo) -> None: raise ValueError("Invalid filename for appending") with open(filepath, "a") as fs: fs.write(self.contents) + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + filename, contents = FileStep.get_details(step) + return cls( + name=name, + description=description, + id=id, + filename=filename, + contents=contents, + ) diff --git a/src/repo_smith/steps/merge_step.py b/src/repo_smith/steps/merge_step.py index d3f8efe..a1976bc 100644 --- a/src/repo_smith/steps/merge_step.py +++ b/src/repo_smith/steps/merge_step.py @@ -1,8 +1,9 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass @@ -11,7 +12,10 @@ class MergeStep(Step): no_fast_forward: bool squash: bool + step_type: StepType = field(init=False, default=StepType.MERGE) + def execute(self, repo: Repo) -> None: + # TODO: Maybe handle merge conflicts as they happen merge_args = [self.branch_name, "--no-edit"] if self.squash: @@ -23,3 +27,26 @@ def execute(self, repo: Repo) -> None: if self.squash: repo.git.commit("-m", f"Squash merge branch '{self.branch_name}'") + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "branch-name" not in step: + raise ValueError('Missing "branch-name" field in merge step.') + + if step["branch-name"] is None or step["branch-name"].strip() == "": + raise ValueError('Empty "branch-name" field in merge step.') + + return cls( + name=name, + description=description, + id=id, + branch_name=step.get("branch-name"), + no_fast_forward=step.get("no-ff", False), + squash=step.get("squash", False), + ) diff --git a/src/repo_smith/steps/remote_step.py b/src/repo_smith/steps/remote_step.py index cfaed3b..a6e8a38 100644 --- a/src/repo_smith/steps/remote_step.py +++ b/src/repo_smith/steps/remote_step.py @@ -1,8 +1,9 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass @@ -10,5 +11,35 @@ class RemoteStep(Step): remote_name: str remote_url: str + step_type: StepType = field(init=False, default=StepType.REMOTE) + def execute(self, repo: Repo) -> None: repo.create_remote(self.remote_name, self.remote_url) + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "remote-url" not in step: + raise ValueError('Missing "remote-url" field in remote step.') + + if step["remote-url"] is None or step["remote-url"].strip() == "": + raise ValueError('Empty "remote-url" field in remote step.') + + if "remote-name" not in step: + raise ValueError('Missing "remote-name" field in remote step.') + + if step["remote-name"] is None or step["remote-name"].strip() == "": + raise ValueError('Empty "remote-name" field in remote step.') + + return cls( + name=name, + description=description, + id=id, + remote_name=step["remote-name"], + remote_url=step["remote-url"], + ) diff --git a/src/repo_smith/steps/step.py b/src/repo_smith/steps/step.py index 444f90d..7f17b5c 100644 --- a/src/repo_smith/steps/step.py +++ b/src/repo_smith/steps/step.py @@ -1,9 +1,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional, Self, Type from git import Repo - from repo_smith.steps.step_type import StepType @@ -17,3 +16,14 @@ class Step(ABC): @abstractmethod def execute(self, repo: Repo) -> None: pass + + @classmethod + @abstractmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + pass diff --git a/src/repo_smith/steps/tag_step.py b/src/repo_smith/steps/tag_step.py index 74e721a..6014934 100644 --- a/src/repo_smith/steps/tag_step.py +++ b/src/repo_smith/steps/tag_step.py @@ -1,9 +1,10 @@ -from dataclasses import dataclass -from typing import Optional +import re +from dataclasses import dataclass, field +from typing import Any, Optional, Self, Type from git import Repo - from repo_smith.steps.step import Step +from repo_smith.steps.step_type import StepType @dataclass @@ -11,5 +12,35 @@ class TagStep(Step): tag_name: str tag_message: Optional[str] + step_type: StepType = field(init=False, default=StepType.TAG) + def execute(self, repo: Repo) -> None: repo.create_tag(self.tag_name, message=self.tag_message) + + @classmethod + def parse( + cls: Type[Self], + name: Optional[str], + description: Optional[str], + id: Optional[str], + step: Any, + ) -> Self: + if "tag-name" not in step: + raise ValueError('Missing "tag-name" field in tag step.') + + if step["tag-name"] is None or step["tag-name"].strip() == "": + raise ValueError('Empty "tag-name" field in tag step.') + + tag_name_regex = "^[0-9a-zA-Z-_.]*$" + if re.search(tag_name_regex, step["tag-name"]) is None: + raise ValueError( + 'Field "tag-name" can only contain alphanumeric characters, _, -, .' + ) + + return cls( + name=name, + description=description, + id=id, + tag_name=step["tag-name"], + tag_message=step.get("tag-message"), + ) diff --git a/tests/fixtures/git_fixtures.py b/tests/fixtures/git_fixtures.py new file mode 100644 index 0000000..573fcd6 --- /dev/null +++ b/tests/fixtures/git_fixtures.py @@ -0,0 +1,21 @@ +import os +import shutil +from pathlib import Path +from typing import Generator + +import pytest +from git import Repo + +DUMMY_PATH = Path("tests/dummy") +REMOTE_REPO_PATH = DUMMY_PATH / "remote_repo" + + +@pytest.fixture +def remote_repo() -> Generator[Repo, None, None]: + os.makedirs(REMOTE_REPO_PATH) + repo = Repo.init(REMOTE_REPO_PATH) + + yield repo + + shutil.rmtree(REMOTE_REPO_PATH, ignore_errors=True) + shutil.rmtree(DUMMY_PATH, ignore_errors=True) diff --git a/tests/test_add_step.py b/tests/integration/steps/test_add_step.py similarity index 82% rename from tests/test_add_step.py rename to tests/integration/steps/test_add_step.py index ea3fe3e..3358023 100644 --- a/tests/test_add_step.py +++ b/tests/integration/steps/test_add_step.py @@ -7,12 +7,12 @@ def test_add_step_missing_files(): - with pytest.raises(Exception): + with pytest.raises(ValueError, match='Missing "files" field in add step.'): initialize_repo("tests/specs/add_step/add_step_missing_files.yml") def test_add_step_empty_files(): - with pytest.raises(Exception): + with pytest.raises(ValueError, match='Empty "files" list in add step.'): initialize_repo("tests/specs/add_step/add_step_empty_files.yml") @@ -23,6 +23,7 @@ def pre_hook(r: Repo) -> None: repo_initializer = initialize_repo("tests/specs/add_step/add_step.yml") repo_initializer.add_pre_hook("add", pre_hook) + with repo_initializer.initialize() as r: assert len(r.index.entries) == 1 assert ("file.txt", 0) in r.index.entries diff --git a/tests/test_append_file_step.py b/tests/integration/steps/test_append_file_step.py similarity index 100% rename from tests/test_append_file_step.py rename to tests/integration/steps/test_append_file_step.py diff --git a/tests/test_bash_step.py b/tests/integration/steps/test_bash_step.py similarity index 72% rename from tests/test_bash_step.py rename to tests/integration/steps/test_bash_step.py index 66dc397..f462b53 100644 --- a/tests/test_bash_step.py +++ b/tests/integration/steps/test_bash_step.py @@ -7,7 +7,15 @@ def test_bash_step_missing_runs(): - with pytest.raises(Exception): + with pytest.raises(ValueError, match='Missing "runs" field in bash step.'): + initialize_repo("tests/specs/bash_step/bash_step_missing_runs.yml") + + +@pytest.mark.skip( + reason="an actual empty field is not parsed so we can safely ignore this" +) +def test_bash_step_empty_runs(): + with pytest.raises(ValueError, match='Empty "runs" field in bash step.'): initialize_repo("tests/specs/bash_step/bash_step_missing_runs.yml") diff --git a/tests/integration/steps/test_branch_delete_step.py b/tests/integration/steps/test_branch_delete_step.py new file mode 100644 index 0000000..ef01387 --- /dev/null +++ b/tests/integration/steps/test_branch_delete_step.py @@ -0,0 +1,23 @@ +import pytest +from repo_smith.initialize_repo import initialize_repo + + +def test_branch_delete_step_branch_exists(): + repo_initializer = initialize_repo( + "tests/specs/branch_delete_step/branch_delete_step_branch_exists.yml" + ) + with repo_initializer.initialize() as r: + assert [r.name for r in r.refs] == ["main"] + + +def test_branch_delete_step_branch_does_not_exist(): + repo_initializer = initialize_repo( + "tests/specs/branch_delete_step/branch_delete_step_branch_does_not_exist.yml" + ) + + with pytest.raises( + ValueError, + match='"branch-name" field provided does not correspond to any existing branches in branch-delete step.', + ): + with repo_initializer.initialize() as _: + pass diff --git a/tests/integration/steps/test_branch_rename_step.py b/tests/integration/steps/test_branch_rename_step.py new file mode 100644 index 0000000..90a9e40 --- /dev/null +++ b/tests/integration/steps/test_branch_rename_step.py @@ -0,0 +1,34 @@ +import pytest +from repo_smith.initialize_repo import initialize_repo + + +def test_branch_rename_step_branch_exists(): + repo_initializer = initialize_repo( + "tests/specs/branch_rename_step/branch_rename_step_branch_exists.yml" + ) + with repo_initializer.initialize() as r: + assert {r.name for r in r.refs} == {"main", "primary"} + + +def test_branch_rename_step_branch_does_not_exist(): + repo_initializer = initialize_repo( + "tests/specs/branch_rename_step/branch_rename_step_branch_does_not_exist.yml" + ) + with pytest.raises( + ValueError, + match='"branch-name" field provided does not correspond to any existing branches in branch-rename step.', + ): + with repo_initializer.initialize() as _: + pass + + +def test_branch_rename_step_branch_already_existed(): + repo_initializer = initialize_repo( + "tests/specs/branch_rename_step/branch_rename_step_branch_already_existed.yml" + ) + with pytest.raises( + ValueError, + match='"new-name" field provided corresponds to an existing branch already in branch-rename step.', + ): + with repo_initializer.initialize() as _: + pass diff --git a/tests/test_branch_step.py b/tests/integration/steps/test_branch_step.py similarity index 100% rename from tests/test_branch_step.py rename to tests/integration/steps/test_branch_step.py diff --git a/tests/test_checkout_step.py b/tests/integration/steps/test_checkout_step.py similarity index 100% rename from tests/test_checkout_step.py rename to tests/integration/steps/test_checkout_step.py diff --git a/tests/test_commit_step.py b/tests/integration/steps/test_commit_step.py similarity index 100% rename from tests/test_commit_step.py rename to tests/integration/steps/test_commit_step.py diff --git a/tests/test_delete_file_step.py b/tests/integration/steps/test_delete_file_step.py similarity index 100% rename from tests/test_delete_file_step.py rename to tests/integration/steps/test_delete_file_step.py diff --git a/tests/test_edit_file_step.py b/tests/integration/steps/test_edit_file_step.py similarity index 100% rename from tests/test_edit_file_step.py rename to tests/integration/steps/test_edit_file_step.py diff --git a/tests/integration/steps/test_fetch_step.py b/tests/integration/steps/test_fetch_step.py new file mode 100644 index 0000000..acf7ac8 --- /dev/null +++ b/tests/integration/steps/test_fetch_step.py @@ -0,0 +1,29 @@ +import os + +import pytest +from git import Repo +from repo_smith.initialize_repo import initialize_repo +from tests.fixtures.git_fixtures import REMOTE_REPO_PATH, remote_repo + + +def test_fetch_step_remote_valid(remote_repo: Repo): + (REMOTE_REPO_PATH / "dummy.txt").write_text("initial") + remote_repo.index.add(["dummy.txt"]) + remote_repo.index.commit("initial commit") + + remote_repo_commit_hexsha = remote_repo.commit("main").hexsha + ir = initialize_repo("tests/specs/fetch_step/fetch_step_remote_valid.yml") + with ir.initialize() as r: + latest_commit_hexsha = r.commit("origin/main").hexsha + assert latest_commit_hexsha == remote_repo_commit_hexsha + + +def test_fetch_step_missing_remote(remote_repo: Repo): + (REMOTE_REPO_PATH / "dummy.txt").write_text("initial") + remote_repo.index.add(["dummy.txt"]) + remote_repo.index.commit("initial commit") + + ir = initialize_repo("tests/specs/fetch_step/fetch_step_missing_remote.yml") + with pytest.raises(ValueError, match="Missing remote 'upstream' in fetch step."): + with ir.initialize() as _: + pass diff --git a/tests/integration/steps/test_merge_step.py b/tests/integration/steps/test_merge_step.py new file mode 100644 index 0000000..2cf4f5e --- /dev/null +++ b/tests/integration/steps/test_merge_step.py @@ -0,0 +1,29 @@ +from repo_smith.initialize_repo import initialize_repo + +# TODO: more corner case testing + + +def test_merge_step_squash(): + ir = initialize_repo("tests/specs/merge_step/merge_step_squash.yml") + with ir.initialize() as r: + commits = list(r.iter_commits()) + commit_messages = [c.message.strip() for c in commits][::-1] + assert commit_messages == ["Before", "Squash merge branch 'incoming'", "After"] + + +def test_merge_step_no_fast_forward(): + ir = initialize_repo("tests/specs/merge_step/merge_step_no_fast_forward.yml") + with ir.initialize() as r: + commits = list(r.iter_commits()) + commit_messages = [c.message.strip() for c in commits][::-1] + assert "Merge branch 'incoming'" in commit_messages + assert len(commits[1].parents) == 2 + + +def test_merge_step_with_fast_forward(): + ir = initialize_repo("tests/specs/merge_step/merge_step_with_fast_forward.yml") + with ir.initialize() as r: + commits = list(r.iter_commits()) + commit_messages = [c.message.strip() for c in commits][::-1] + assert "Merge branch 'incoming'" not in commit_messages + assert not any([len(c.parents) > 1 for c in commits]) diff --git a/tests/test_new_file_step.py b/tests/integration/steps/test_new_file_step.py similarity index 100% rename from tests/test_new_file_step.py rename to tests/integration/steps/test_new_file_step.py diff --git a/tests/integration/steps/test_remote_step.py b/tests/integration/steps/test_remote_step.py new file mode 100644 index 0000000..efb828f --- /dev/null +++ b/tests/integration/steps/test_remote_step.py @@ -0,0 +1,9 @@ +from repo_smith.initialize_repo import initialize_repo + + +def test_remote_step_valid(): + ir = initialize_repo("tests/specs/remote_step/remote_step_valid.yml") + with ir.initialize() as r: + assert len(r.remotes) == 1 + assert r.remotes[0].name == "upstream" + assert r.remotes[0].url == "https://github.com/git-mastery/repo-smith.git" diff --git a/tests/test_step.py b/tests/integration/steps/test_step.py similarity index 100% rename from tests/test_step.py rename to tests/integration/steps/test_step.py diff --git a/tests/test_tag_step.py b/tests/integration/steps/test_tag_step.py similarity index 100% rename from tests/test_tag_step.py rename to tests/integration/steps/test_tag_step.py diff --git a/tests/test_clone_from.py b/tests/integration/test_clone_from.py similarity index 100% rename from tests/test_clone_from.py rename to tests/integration/test_clone_from.py diff --git a/tests/integration/test_initialize_repo.py b/tests/integration/test_initialize_repo.py new file mode 100644 index 0000000..6bc4fa5 --- /dev/null +++ b/tests/integration/test_initialize_repo.py @@ -0,0 +1,72 @@ +import pytest +from git import Repo +from src.repo_smith.initialize_repo import initialize_repo + +# TODO: Test to make sure that the YAML parsing is accurate so we avoid individual +# integration test for every corner case covered in unit tests + + +def test_initialize_repo_missing_spec_path(): + with pytest.raises(ValueError, match="Invalid spec_path provided, not found."): + initialize_repo("tests/specs/invalid_spec_path_does_not_exist.yml") + + +def test_initialize_repo_incomplete_spec_file(): + with pytest.raises(ValueError, match="Incomplete spec file."): + initialize_repo("tests/specs/incomplete_spec_file.yml") + + +def test_initialize_repo_duplicate_ids(): + with pytest.raises( + ValueError, + match="ID commit is duplicated from a previous step. All IDs should be unique.", + ): + initialize_repo("tests/specs/duplicate_ids.yml") + + +def test_initialize_repo_duplicate_tags(): + with pytest.raises( + ValueError, + match="Tag tag is already in use by a previous step. All tag names should be unique.", + ): + initialize_repo("tests/specs/duplicate_tags.yml") + + +def test_initialize_repo_invalid_pre_hook(): + with pytest.raises(Exception): + repo_initializer = initialize_repo("tests/specs/basic_spec.yml") + repo_initializer.add_pre_hook("hello-world", lambda _: None) + + +def test_initialize_repo_invalid_post_hook(): + with pytest.raises(Exception): + repo_initializer = initialize_repo("tests/specs/basic_spec.yml") + repo_initializer.add_post_hook("hello-world", lambda _: None) + + +def test_initialize_repo_pre_hook(): + def initial_commit_pre_hook(_: Repo): + assert True + + repo_initializer = initialize_repo("tests/specs/basic_spec.yml") + repo_initializer.add_pre_hook("initial-commit", initial_commit_pre_hook) + with repo_initializer.initialize() as r: + assert r.commit("start-tag") is not None + + +def test_initialize_repo_post_hook(): + def initial_commit_post_hook(_: Repo): + assert True + + repo_initializer = initialize_repo("tests/specs/basic_spec.yml") + repo_initializer.add_post_hook("initial-commit", initial_commit_post_hook) + with repo_initializer.initialize(): + pass + + +def test_initialize_repo_basic_spec(): + initialize_repo("tests/specs/basic_spec.yml") + + +def test_initialize_repo_hooks(): + initialize_repo("tests/specs/hooks.yml") diff --git a/tests/specs/bash_step/bash_step_empty_runs.yml b/tests/specs/bash_step/bash_step_empty_runs.yml new file mode 100644 index 0000000..91def40 --- /dev/null +++ b/tests/specs/bash_step/bash_step_empty_runs.yml @@ -0,0 +1,6 @@ +name: Bash step missing runs field +description: Bash step with missing runs field raises error +initialization: + steps: + - type: bash + runs: "" diff --git a/tests/specs/branch_delete_step/branch_delete_step_branch_does_not_exist.yml b/tests/specs/branch_delete_step/branch_delete_step_branch_does_not_exist.yml new file mode 100644 index 0000000..580fbb0 --- /dev/null +++ b/tests/specs/branch_delete_step/branch_delete_step_branch_does_not_exist.yml @@ -0,0 +1,7 @@ +initialization: + steps: + - type: commit + empty: true + message: Empty + - type: branch-delete + branch-name: test diff --git a/tests/specs/branch_delete_step/branch_delete_step_branch_exists.yml b/tests/specs/branch_delete_step/branch_delete_step_branch_exists.yml new file mode 100644 index 0000000..595d0d5 --- /dev/null +++ b/tests/specs/branch_delete_step/branch_delete_step_branch_exists.yml @@ -0,0 +1,11 @@ +initialization: + steps: + - type: commit + empty: true + message: Empty + - type: branch + branch-name: test + - type: checkout + branch-name: main + - type: branch-delete + branch-name: test diff --git a/tests/specs/branch_delete_step/branch_delete_step_invalid_branch_name.yml b/tests/specs/branch_delete_step/branch_delete_step_invalid_branch_name.yml new file mode 100644 index 0000000..3989a61 --- /dev/null +++ b/tests/specs/branch_delete_step/branch_delete_step_invalid_branch_name.yml @@ -0,0 +1,5 @@ +initialization: + steps: + - name: Delete branch + type: branch-delete + branch-name: non-existent diff --git a/tests/specs/branch_delete_step/branch_delete_step_missing_branch_name.yml b/tests/specs/branch_delete_step/branch_delete_step_missing_branch_name.yml new file mode 100644 index 0000000..48c4c30 --- /dev/null +++ b/tests/specs/branch_delete_step/branch_delete_step_missing_branch_name.yml @@ -0,0 +1,4 @@ +initialization: + steps: + - name: Delete branch + type: branch-delete diff --git a/tests/specs/branch_rename_step/branch_rename_step_branch_already_existed.yml b/tests/specs/branch_rename_step/branch_rename_step_branch_already_existed.yml new file mode 100644 index 0000000..76ba3bd --- /dev/null +++ b/tests/specs/branch_rename_step/branch_rename_step_branch_already_existed.yml @@ -0,0 +1,12 @@ +initialization: + steps: + - type: commit + empty: true + message: Empty + - type: branch + branch-name: test + - type: checkout + branch-name: main + - type: branch-rename + branch-name: test + new-name: main diff --git a/tests/specs/branch_rename_step/branch_rename_step_branch_does_not_exist.yml b/tests/specs/branch_rename_step/branch_rename_step_branch_does_not_exist.yml new file mode 100644 index 0000000..5d958af --- /dev/null +++ b/tests/specs/branch_rename_step/branch_rename_step_branch_does_not_exist.yml @@ -0,0 +1,8 @@ +initialization: + steps: + - type: commit + empty: true + message: Empty + - type: branch-rename + branch-name: test + new-name: primary diff --git a/tests/specs/branch_rename_step/branch_rename_step_branch_exists.yml b/tests/specs/branch_rename_step/branch_rename_step_branch_exists.yml new file mode 100644 index 0000000..cb96525 --- /dev/null +++ b/tests/specs/branch_rename_step/branch_rename_step_branch_exists.yml @@ -0,0 +1,12 @@ +initialization: + steps: + - type: commit + empty: true + message: Empty + - type: branch + branch-name: test + - type: checkout + branch-name: main + - type: branch-rename + branch-name: test + new-name: primary diff --git a/tests/specs/fetch_step/fetch_step_missing_remote.yml b/tests/specs/fetch_step/fetch_step_missing_remote.yml new file mode 100644 index 0000000..1ea2f32 --- /dev/null +++ b/tests/specs/fetch_step/fetch_step_missing_remote.yml @@ -0,0 +1,5 @@ +initialization: + clone-from: tests/dummy/remote_repo + steps: + - type: fetch + remote-name: upstream diff --git a/tests/specs/fetch_step/fetch_step_remote_valid.yml b/tests/specs/fetch_step/fetch_step_remote_valid.yml new file mode 100644 index 0000000..9fa3d8d --- /dev/null +++ b/tests/specs/fetch_step/fetch_step_remote_valid.yml @@ -0,0 +1,5 @@ +initialization: + clone-from: tests/dummy/remote_repo + steps: + - type: fetch + remote-name: origin diff --git a/tests/specs/incomplete_spec_file.yml b/tests/specs/incomplete_spec_file.yml new file mode 100644 index 0000000..e69de29 diff --git a/tests/specs/merge_step/merge_step_no_fast_forward.yml b/tests/specs/merge_step/merge_step_no_fast_forward.yml new file mode 100644 index 0000000..9ff77d8 --- /dev/null +++ b/tests/specs/merge_step/merge_step_no_fast_forward.yml @@ -0,0 +1,32 @@ +initialization: + steps: + - type: commit + empty: true + message: Before + + - type: branch + branch-name: incoming + - type: new-file + filename: hello.txt + contents: | + Hello + - type: add + files: + - hello.txt + - type: commit + message: Add hello.txt + - type: commit + empty: true + message: Empty + - type: commit + empty: true + message: Empty + + - type: checkout + branch-name: main + - type: merge + no-ff: true + branch-name: incoming + - type: commit + empty: true + message: After diff --git a/tests/specs/merge_step/merge_step_squash.yml b/tests/specs/merge_step/merge_step_squash.yml new file mode 100644 index 0000000..fe0bde4 --- /dev/null +++ b/tests/specs/merge_step/merge_step_squash.yml @@ -0,0 +1,32 @@ +initialization: + steps: + - type: commit + empty: true + message: Before + + - type: branch + branch-name: incoming + - type: new-file + filename: hello.txt + contents: | + Hello + - type: add + files: + - hello.txt + - type: commit + message: Add hello.txt + - type: commit + empty: true + message: Empty + - type: commit + empty: true + message: Empty + + - type: checkout + branch-name: main + - type: merge + squash: true + branch-name: incoming + - type: commit + empty: true + message: After diff --git a/tests/specs/merge_step/merge_step_with_fast_forward.yml b/tests/specs/merge_step/merge_step_with_fast_forward.yml new file mode 100644 index 0000000..498ae8d --- /dev/null +++ b/tests/specs/merge_step/merge_step_with_fast_forward.yml @@ -0,0 +1,31 @@ +initialization: + steps: + - type: commit + empty: true + message: Before + + - type: branch + branch-name: incoming + - type: new-file + filename: hello.txt + contents: | + Hello + - type: add + files: + - hello.txt + - type: commit + message: Add hello.txt + - type: commit + empty: true + message: Empty + - type: commit + empty: true + message: Empty + + - type: checkout + branch-name: main + - type: merge + branch-name: incoming + - type: commit + empty: true + message: After diff --git a/tests/specs/remote_step/remote_step_valid.yml b/tests/specs/remote_step/remote_step_valid.yml new file mode 100644 index 0000000..a2b1c39 --- /dev/null +++ b/tests/specs/remote_step/remote_step_valid.yml @@ -0,0 +1,8 @@ +initialization: + steps: + - type: commit + empty: true + message: Empty + - type: remote + remote-name: upstream + remote-url: https://github.com/git-mastery/repo-smith.git diff --git a/tests/test_initialize_repo.py b/tests/test_initialize_repo.py deleted file mode 100644 index dacf367..0000000 --- a/tests/test_initialize_repo.py +++ /dev/null @@ -1,59 +0,0 @@ -import pytest -from git import Repo - -from src.repo_smith.initialize_repo import initialize_repo - - -def test_initialize_repo_basic_spec() -> None: - initialize_repo("tests/specs/basic_spec.yml") - - -def test_initialize_repo_hooks() -> None: - initialize_repo("tests/specs/hooks.yml") - - -def test_initialize_repo_duplicate_ids() -> None: - with pytest.raises(Exception): - initialize_repo("tests/specs/duplicate_ids.yml") - - -def test_initialize_repo_duplicate_tags() -> None: - with pytest.raises(Exception): - initialize_repo("tests/specs/duplicate_tags.yml") - - -def test_initialize_repo_invalid_tag() -> None: - with pytest.raises(Exception): - initialize_repo("tests/specs/invalid_tag.yml") - - -def test_initialize_repo_invalid_pre_hook() -> None: - with pytest.raises(Exception): - repo_initializer = initialize_repo("tests/specs/basic_spec.yml") - repo_initializer.add_pre_hook("hello-world", lambda _: None) - - -def test_initialize_repo_invalid_post_hook() -> None: - with pytest.raises(Exception): - repo_initializer = initialize_repo("tests/specs/basic_spec.yml") - repo_initializer.add_post_hook("hello-world", lambda _: None) - - -def test_initialize_repo_pre_hook() -> None: - def initial_commit_pre_hook(_: Repo) -> None: - assert True - - repo_initializer = initialize_repo("tests/specs/basic_spec.yml") - repo_initializer.add_pre_hook("initial-commit", initial_commit_pre_hook) - with repo_initializer.initialize() as r: - assert r.commit("start-tag") is not None - - -def test_initialize_repo_post_hook() -> None: - def initial_commit_post_hook(_: Repo) -> None: - assert True - - repo_initializer = initialize_repo("tests/specs/basic_spec.yml") - repo_initializer.add_post_hook("initial-commit", initial_commit_post_hook) - with repo_initializer.initialize(): - pass diff --git a/tests/unit/steps/test_add_step.py b/tests/unit/steps/test_add_step.py new file mode 100644 index 0000000..8f5ebff --- /dev/null +++ b/tests/unit/steps/test_add_step.py @@ -0,0 +1,22 @@ +import pytest + +from repo_smith.steps.add_step import AddStep + + +def test_add_step_parse_missing_files(): + with pytest.raises(ValueError, match='Missing "files" field in add step.'): + AddStep.parse("a", "d", "id", {}) + + +def test_add_step_parse_empty_files(): + with pytest.raises(ValueError, match='Empty "files" list in add step.'): + AddStep.parse("a", "d", "id", {"files": []}) + + +def test_add_step_parse(): + step = AddStep.parse("a", "d", "id", {"files": ["hello.txt"]}) + assert isinstance(step, AddStep) + assert step.name == "a" + assert step.description == "d" + assert step.id == "id" + assert step.files == ["hello.txt"] diff --git a/tests/unit/steps/test_bash_step.py b/tests/unit/steps/test_bash_step.py new file mode 100644 index 0000000..d731376 --- /dev/null +++ b/tests/unit/steps/test_bash_step.py @@ -0,0 +1,22 @@ +import pytest + +from repo_smith.steps.bash_step import BashStep + + +def test_bash_step_parse_missing_runs(): + with pytest.raises(ValueError, match='Missing "runs" field in bash step.'): + BashStep.parse("a", "d", "id", {}) + + +def test_bash_step_parse_empty_runs(): + with pytest.raises(ValueError, match='Empty "runs" field in bash step.'): + BashStep.parse("a", "d", "id", {"runs": ""}) + + +def test_bash_step_parse(): + step = BashStep.parse("a", "d", "id", {"runs": "ls"}) + assert isinstance(step, BashStep) + assert step.name == "a" + assert step.description == "d" + assert step.id == "id" + assert step.body == "ls" diff --git a/tests/unit/steps/test_branch_delete_step.py b/tests/unit/steps/test_branch_delete_step.py new file mode 100644 index 0000000..28823fc --- /dev/null +++ b/tests/unit/steps/test_branch_delete_step.py @@ -0,0 +1,27 @@ +import pytest + +from repo_smith.steps.branch_delete_step import BranchDeleteStep + + +def test_branch_delete_step_parse_missing_branch_name(): + with pytest.raises( + ValueError, match='Missing "branch-name" field in branch-delete step.' + ): + BranchDeleteStep.parse("n", "d", "id", {}) + + +def test_branch_delete_step_parse_empty_branch_name(): + with pytest.raises( + ValueError, match='Empty "branch-name" field in branch-delete step.' + ): + BranchDeleteStep.parse("n", "d", "id", {"branch-name": ""}) + + +def test_branch_delete_step_parse(): + step = BranchDeleteStep.parse("n", "d", "id", {"branch-name": "test"}) + assert isinstance(step, BranchDeleteStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.branch_name == "test" + diff --git a/tests/unit/steps/test_branch_rename_step.py b/tests/unit/steps/test_branch_rename_step.py new file mode 100644 index 0000000..0ef245e --- /dev/null +++ b/tests/unit/steps/test_branch_rename_step.py @@ -0,0 +1,44 @@ +import pytest + +from repo_smith.steps.branch_delete_step import BranchDeleteStep +from repo_smith.steps.branch_rename_step import BranchRenameStep + + +def test_branch_rename_step_parse_missing_branch_name(): + with pytest.raises( + ValueError, match='Missing "branch-name" field in branch-rename step.' + ): + BranchRenameStep.parse("n", "d", "id", {}) + + +def test_branch_rename_step_parse_empty_branch_name(): + with pytest.raises( + ValueError, match='Empty "branch-name" field in branch-rename step.' + ): + BranchRenameStep.parse("n", "d", "id", {"branch-name": ""}) + + +def test_branch_rename_step_parse_missing_new_name(): + with pytest.raises( + ValueError, match='Missing "new-name" field in branch-rename step.' + ): + BranchRenameStep.parse("n", "d", "id", {"branch-name": "test"}) + + +def test_branch_rename_step_parse_empty_new_name(): + with pytest.raises( + ValueError, match='Empty "new-name" field in branch-rename step.' + ): + BranchRenameStep.parse("n", "d", "id", {"branch-name": "test", "new-name": ""}) + + +def test_branch_rename_step_parse(): + step = BranchRenameStep.parse( + "n", "d", "id", {"branch-name": "test", "new-name": "other"} + ) + assert isinstance(step, BranchRenameStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.original_branch_name == "test" + assert step.target_branch_name == "other" diff --git a/tests/unit/steps/test_branch_step.py b/tests/unit/steps/test_branch_step.py new file mode 100644 index 0000000..3c98f5c --- /dev/null +++ b/tests/unit/steps/test_branch_step.py @@ -0,0 +1,22 @@ +import pytest + +from repo_smith.steps.branch_step import BranchStep + + +def test_branch_step_parse_missing_branch_name(): + with pytest.raises(ValueError, match='Missing "branch-name" field in branch step.'): + BranchStep.parse("n", "d", "id", {}) + + +def test_branch_step_parse_empty_branch_name(): + with pytest.raises(ValueError, match='Empty "branch-name" field in branch step.'): + BranchStep.parse("n", "d", "id", {"branch-name": ""}) + + +def test_branch_step_parse(): + step = BranchStep.parse("n", "d", "id", {"branch-name": "test"}) + assert isinstance(step, BranchStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.branch_name == "test" diff --git a/tests/unit/steps/test_checkout_step.py b/tests/unit/steps/test_checkout_step.py new file mode 100644 index 0000000..5a21d2c --- /dev/null +++ b/tests/unit/steps/test_checkout_step.py @@ -0,0 +1,51 @@ +import pytest + +from repo_smith.steps.checkout_step import CheckoutStep + + +def test_checkout_step_parse_missing_branch_name_and_commit_hash(): + with pytest.raises( + ValueError, + match='Provide either "branch-name" or "commit-hash" in checkout step.', + ): + CheckoutStep.parse("n", "d", "id", {}) + + +def test_checkout_step_parse_both_branch_name_and_commit_hash(): + with pytest.raises( + ValueError, + match='Provide either "branch-name" or "commit-hash", not both, in checkout step.', + ): + CheckoutStep.parse( + "n", "d", "id", {"branch-name": "test", "commit-hash": "abc123"} + ) + + +def test_checkout_step_parse_empty_branch_name(): + with pytest.raises(ValueError, match='Empty "branch-name" field in checkout step.'): + CheckoutStep.parse("n", "d", "id", {"branch-name": ""}) + + +def test_checkout_step_parse_empty_commit_hash(): + with pytest.raises(ValueError, match='Empty "commit-hash" field in checkout step.'): + CheckoutStep.parse("n", "d", "id", {"commit-hash": ""}) + + +def test_checkout_step_parse_with_branch_name(): + step = CheckoutStep.parse("n", "d", "id", {"branch-name": "test"}) + assert isinstance(step, CheckoutStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.branch_name == "test" + assert step.commit_hash is None + + +def test_checkout_step_parse_with_commit_hash(): + step = CheckoutStep.parse("n", "d", "id", {"commit-hash": "abc123"}) + assert isinstance(step, CheckoutStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.branch_name is None + assert step.commit_hash == "abc123" diff --git a/tests/unit/steps/test_commit_step.py b/tests/unit/steps/test_commit_step.py new file mode 100644 index 0000000..cc2b3f4 --- /dev/null +++ b/tests/unit/steps/test_commit_step.py @@ -0,0 +1,33 @@ +import pytest + +from repo_smith.steps.commit_step import CommitStep + + +def test_commit_step_parse_missing_message(): + with pytest.raises(ValueError, match='Missing "message" field in commit step.'): + CommitStep.parse("n", "d", "id", {}) + + +def test_commit_step_parse_empty_message(): + with pytest.raises(ValueError, match='Empty "message" field in commit step.'): + CommitStep.parse("n", "d", "id", {"message": ""}) + + +def test_commit_step_parse_missing_empty(): + step = CommitStep.parse("n", "d", "id", {"message": "Test"}) + assert isinstance(step, CommitStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.message == "Test" + assert not step.empty + + +def test_commit_step_parse_with_empty(): + step = CommitStep.parse("n", "d", "id", {"message": "Test", "empty": True}) + assert isinstance(step, CommitStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.message == "Test" + assert step.empty diff --git a/tests/unit/steps/test_dispatcher.py b/tests/unit/steps/test_dispatcher.py new file mode 100644 index 0000000..c3000b7 --- /dev/null +++ b/tests/unit/steps/test_dispatcher.py @@ -0,0 +1,74 @@ +from unittest.mock import patch + +import pytest +from repo_smith.steps.add_step import AddStep +from repo_smith.steps.bash_step import BashStep +from repo_smith.steps.branch_delete_step import BranchDeleteStep +from repo_smith.steps.branch_rename_step import BranchRenameStep +from repo_smith.steps.branch_step import BranchStep +from repo_smith.steps.checkout_step import CheckoutStep +from repo_smith.steps.commit_step import CommitStep +from repo_smith.steps.dispatcher import Dispatcher +from repo_smith.steps.fetch_step import FetchStep +from repo_smith.steps.file_step import ( + AppendFileStep, + DeleteFileStep, + EditFileStep, + NewFileStep, +) +from repo_smith.steps.merge_step import MergeStep +from repo_smith.steps.remote_step import RemoteStep +from repo_smith.steps.step_type import StepType +from repo_smith.steps.tag_step import TagStep + + +def test_dispatch_calls_correct_step_parse(): + step_dict = { + "type": "commit", + "name": "my commit", + "description": "desc", + "id": "123", + } + + # Patch CommitStep.parse so we don't run real logic + with patch( + "repo_smith.steps.commit_step.CommitStep.parse", return_value="parsed" + ) as mock_parse: + result = Dispatcher.dispatch(step_dict) + + # parse should have been called once with correct arguments + mock_parse.assert_called_once_with("my commit", "desc", "123", step_dict) + + # The dispatcher should return whatever parse returned + assert result == "parsed" + + +def test_dispatch_missing_type_raises(): + step_dict = {"name": "no type"} + with pytest.raises(ValueError, match='Missing "type" field in step.'): + Dispatcher.dispatch(step_dict) + + +STEP_TYPES_TO_CLASSES = { + StepType.COMMIT: CommitStep, + StepType.ADD: AddStep, + StepType.TAG: TagStep, + StepType.NEW_FILE: NewFileStep, + StepType.EDIT_FILE: EditFileStep, + StepType.DELETE_FILE: DeleteFileStep, + StepType.APPEND_FILE: AppendFileStep, + StepType.BASH: BashStep, + StepType.BRANCH: BranchStep, + StepType.BRANCH_RENAME: BranchRenameStep, + StepType.BRANCH_DELETE: BranchDeleteStep, + StepType.CHECKOUT: CheckoutStep, + StepType.REMOTE: RemoteStep, + StepType.MERGE: MergeStep, + StepType.FETCH: FetchStep, +} + + +@pytest.mark.parametrize("step_type, step_path", STEP_TYPES_TO_CLASSES.items()) +def test_get_type_returns_correct_class(step_type, step_path): + # Uses mangled name: https://stackoverflow.com/questions/2064202/private-members-in-python + assert Dispatcher._Dispatcher__get_type(step_type) is step_path # type: ignore diff --git a/tests/unit/steps/test_fetch_step.py b/tests/unit/steps/test_fetch_step.py new file mode 100644 index 0000000..fc5510d --- /dev/null +++ b/tests/unit/steps/test_fetch_step.py @@ -0,0 +1,22 @@ +import pytest + +from repo_smith.steps.fetch_step import FetchStep + + +def test_fetch_step_parse_missing_remote_name(): + with pytest.raises(ValueError, match='Missing "remote-name" field in fetch step.'): + FetchStep.parse("n", "d", "id", {}) + + +def test_fetch_step_parse_empty_remote_name(): + with pytest.raises(ValueError, match='Empty "remote-name" field in fetch step.'): + FetchStep.parse("n", "d", "id", {"remote-name": ""}) + + +def test_commit_step_parse_with_empty(): + step = FetchStep.parse("n", "d", "id", {"remote-name": "test"}) + assert isinstance(step, FetchStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.remote_name == "test" diff --git a/tests/unit/steps/test_file_step.py b/tests/unit/steps/test_file_step.py new file mode 100644 index 0000000..50b1304 --- /dev/null +++ b/tests/unit/steps/test_file_step.py @@ -0,0 +1,48 @@ +import pytest +from repo_smith.steps.file_step import ( + AppendFileStep, + DeleteFileStep, + EditFileStep, + FileStep, + NewFileStep, +) + + +def test_file_step_get_details_missing_filename(): + with pytest.raises(ValueError, match='Missing "filename" field in file step.'): + FileStep.get_details({}) + + +def test_file_step_get_details_empty_filename(): + with pytest.raises(ValueError, match='Empty "filename" field in file step.'): + FileStep.get_details({"filename": ""}) + + +def test_file_step_get_details_missing_contents(): + filename, contents = FileStep.get_details({"filename": "hello.txt"}) + assert filename == "hello.txt" + assert contents == "" + + +def test_file_step_get_details(): + filename, contents = FileStep.get_details( + {"filename": "hello.txt", "contents": "Hello world"} + ) + assert filename == "hello.txt" + assert contents == "Hello world" + + +FILE_STEP_CLASSES = [NewFileStep, EditFileStep, AppendFileStep, DeleteFileStep] + + +@pytest.mark.parametrize("step_class", FILE_STEP_CLASSES) +def test_new_file_step_parse(step_class): + step = step_class.parse( + "n", "d", "id", {"filename": "hello.txt", "contents": "Hello world"} + ) + assert isinstance(step, step_class) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.filename == "hello.txt" + assert step.contents == "Hello world" diff --git a/tests/unit/steps/test_merge_step.py b/tests/unit/steps/test_merge_step.py new file mode 100644 index 0000000..05816f1 --- /dev/null +++ b/tests/unit/steps/test_merge_step.py @@ -0,0 +1,33 @@ +import pytest + +from repo_smith.steps.merge_step import MergeStep + + +def test_merge_step_parse_missing_branch_name(): + with pytest.raises(ValueError, match='Missing "branch-name" field in merge step.'): + MergeStep.parse("n", "d", "id", {}) + + +def test_merge_step_parse_empty_branch_name(): + with pytest.raises(ValueError, match='Empty "branch-name" field in merge step.'): + MergeStep.parse("n", "d", "id", {"branch-name": ""}) + + +MERGE_STEP_CONFIGURATION = { + "no-ff not set, squash not set": {"branch-name": "test"}, + "no-ff not set, squash set": {"branch-name": "test", "squash": True}, + "no-ff set, squash not set": {"branch-name": "test", "no-ff": True}, + "no-ff set, squash set": {"branch-name": "test", "squash": True, "no-ff": True}, +} + + +@pytest.mark.parametrize("config_name, config", MERGE_STEP_CONFIGURATION.items()) +def test_merge_step_parse(config_name, config): + step = MergeStep.parse("n", "d", "id", config) + assert isinstance(step, MergeStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.branch_name == "test" + assert step.no_fast_forward == config.get("no-ff", False) + assert step.squash == config.get("squash", False) diff --git a/tests/unit/steps/test_remote_step.py b/tests/unit/steps/test_remote_step.py new file mode 100644 index 0000000..b3eb94f --- /dev/null +++ b/tests/unit/steps/test_remote_step.py @@ -0,0 +1,37 @@ +import pytest + +from repo_smith.steps.remote_step import RemoteStep + + +def test_remote_step_parse_missing_remote_url(): + with pytest.raises(ValueError, match='Missing "remote-url" field in remote step.'): + RemoteStep.parse("n", "d", "id", {}) + + +def test_remote_step_parse_empty_remote_url(): + with pytest.raises(ValueError, match='Empty "remote-url" field in remote step.'): + RemoteStep.parse("n", "d", "id", {"remote-url": ""}) + + +def test_remote_step_parse_missing_remote_name(): + with pytest.raises(ValueError, match='Missing "remote-name" field in remote step.'): + RemoteStep.parse("n", "d", "id", {"remote-url": "https://test.com"}) + + +def test_remote_step_parse_empty_remote_name(): + with pytest.raises(ValueError, match='Empty "remote-name" field in remote step.'): + RemoteStep.parse( + "n", "d", "id", {"remote-url": "https://test.com", "remote-name": ""} + ) + + +def test_remote_step_parse(): + step = RemoteStep.parse( + "n", "d", "id", {"remote-url": "https://test.com", "remote-name": "upstream"} + ) + assert isinstance(step, RemoteStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.remote_url == "https://test.com" + assert step.remote_name == "upstream" diff --git a/tests/unit/steps/test_step_type.py b/tests/unit/steps/test_step_type.py new file mode 100644 index 0000000..e9a8ab0 --- /dev/null +++ b/tests/unit/steps/test_step_type.py @@ -0,0 +1,8 @@ +import pytest + +from repo_smith.steps.step_type import StepType + + +def test_step_type_uncovered_type(): + with pytest.raises(ValueError): + StepType.from_value("this should not be implemented") diff --git a/tests/unit/steps/test_tag_step.py b/tests/unit/steps/test_tag_step.py new file mode 100644 index 0000000..71d98e8 --- /dev/null +++ b/tests/unit/steps/test_tag_step.py @@ -0,0 +1,42 @@ +import pytest +from repo_smith.steps.tag_step import TagStep + + +def test_tag_step_parse_missing_tag_name(): + with pytest.raises(ValueError, match='Missing "tag-name" field in tag step.'): + TagStep.parse("n", "d", "id", {}) + + +def test_tag_step_parse_empty_tag_name(): + with pytest.raises(ValueError, match='Empty "tag-name" field in tag step.'): + TagStep.parse("n", "d", "id", {"tag-name": ""}) + + +def test_tag_step_parse_invalid_tag_name(): + with pytest.raises( + ValueError, + match='Field "tag-name" can only contain alphanumeric characters, _, -, .', + ): + TagStep.parse("n", "d", "id", {"tag-name": "(open)"}) + + +def test_tag_step_parse_missing_tag_message(): + step = TagStep.parse("n", "d", "id", {"tag-name": "start"}) + assert isinstance(step, TagStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.tag_name == "start" + assert step.tag_message is None + + +def test_tag_step_parse_with_tag_message(): + step = TagStep.parse( + "n", "d", "id", {"tag-name": "start", "tag-message": "this is a message"} + ) + assert isinstance(step, TagStep) + assert step.name == "n" + assert step.description == "d" + assert step.id == "id" + assert step.tag_name == "start" + assert step.tag_message == "this is a message"