Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
787d5b7
Adopt new command pattern for parsing step
woojiahao Nov 18, 2025
4c71c92
Add pytest-cov to the requirements
woojiahao Nov 18, 2025
98758ed
Add test for dispatcher
woojiahao Nov 18, 2025
d518d88
Move dispatchers test to unit testing folder
woojiahao Nov 18, 2025
87ef675
Add step type test
woojiahao Nov 18, 2025
7c98152
Add unit test for AddStep
woojiahao Nov 18, 2025
23ded61
Improve test for add_step]
woojiahao Nov 18, 2025
9587552
Add FAQ to README
woojiahao Nov 18, 2025
f33a43e
Add unit test for bash_step
woojiahao Nov 18, 2025
38e6225
Add tests for all subclasses
woojiahao Nov 18, 2025
69bfb2c
Add unit test for branch delete step
woojiahao Nov 18, 2025
50d0e70
Fix branch delete
woojiahao Nov 18, 2025
d7a1428
Add unit test for branch rename
woojiahao Nov 18, 2025
736ebc9
Add unit test for branch step
woojiahao Nov 18, 2025
17cc6ea
Add unit test for checkout step
woojiahao Nov 18, 2025
ab112e7
Add unit test for commit step
woojiahao Nov 18, 2025
24e67a8
Add unit test for fetch step
woojiahao Nov 18, 2025
8fac89c
Move initialize_repo test under integration test
woojiahao Nov 18, 2025
3fb0ebb
Add unit test for merge step
woojiahao Nov 18, 2025
420065b
Add unit test for remote step
woojiahao Nov 18, 2025
1b878ee
Add unit test for tag step
woojiahao Nov 18, 2025
c5031f9
Move existing files to integration test folder
woojiahao Nov 18, 2025
7a3b3a5
Add step to run unit tests
woojiahao Nov 18, 2025
e0258cd
Update bash step integration tests
woojiahao Nov 19, 2025
d1ba746
Update integration tests for initialize_repo
woojiahao Nov 19, 2025
5a8fc02
Update add step integration tests
woojiahao Nov 19, 2025
885f19c
Fix bash step
woojiahao Nov 27, 2025
8dad72a
Add TODO
woojiahao Nov 27, 2025
ef2306c
Add integration tests for branch-delete
woojiahao Nov 27, 2025
13ca7d0
Enforce that init is main
woojiahao Nov 27, 2025
2e5e5da
Add TODO
woojiahao Nov 27, 2025
b40f38e
Add integration tests for branch-rename
woojiahao Nov 27, 2025
ebd2674
Move clone_from integration test
woojiahao Nov 27, 2025
06c4eab
Add fixture to autoload a "mock" remote repository
woojiahao Nov 27, 2025
61a3e91
Add integration test for fetch step
woojiahao Nov 27, 2025
477165c
Add integration test for merge step
woojiahao Nov 27, 2025
75de5b2
Add integration test for remote
woojiahao Nov 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ PyYAML
types-PyYAML
build
twine
pytest-cov
248 changes: 4 additions & 244 deletions src/repo_smith/initialize_repo.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import os
import re
import shutil
import tempfile
from contextlib import contextmanager
Expand All @@ -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]

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
29 changes: 26 additions & 3 deletions src/repo_smith/steps/add_step.py
Original file line number Diff line number Diff line change
@@ -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"],
)
31 changes: 27 additions & 4 deletions src/repo_smith/steps/bash_step.py
Original file line number Diff line number Diff line change
@@ -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"],
)
Loading