diff --git a/.env b/.env index 10e89b9..9084726 100644 --- a/.env +++ b/.env @@ -2,10 +2,22 @@ # Users can override these by creating a .env.local file (not committed to git) # App author name used for cache directory paths -COOKIECUTTER_ROBUST_PYTHON_APP_AUTHOR=robust-python +COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER="" -# Main branch name (typically used for stable releases) -COOKIECUTTER_ROBUST_PYTHON_MAIN_BRANCH=main +COOKIECUTTER_ROBUST_PYTHON__APP_NAME="cookiecutter-robust-python" +COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR="robust-python" +COOKIECUTTER_ROBUST_PYTHON__REMOTE="origin" +COOKIECUTTER_ROBUST_PYTHON__MAIN_BRANCH="main" +COOKIECUTTER_ROBUST_PYTHON__DEVELOP_BRANCH="develop" -# Development branch name (where feature development occurs) -COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH=develop +ROBUST_PYTHON_DEMO__APP_NAME="robust-python-demo" +ROBUST_PYTHON_DEMO__APP_AUTHOR="robust-python" +ROBUST_PYTHON_DEMO__REMOTE="origin" +ROBUST_PYTHON_DEMO__MAIN_BRANCH="main" +ROBUST_PYTHON_DEMO__DEVELOP_BRANCH="develop" + +ROBUST_MATURIN_DEMO__APP_NAME="robust-maturin-demo" +ROBUST_MATURIN_DEMO__APP_AUTHOR="robust-python" +ROBUST_MATURIN_DEMO__REMOTE="origin" +ROBUST_MATURIN_DEMO__MAIN_BRANCH="main" +ROBUST_MATURIN_DEMO__DEVELOP_BRANCH="develop" diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml new file mode 100644 index 0000000..abeab83 --- /dev/null +++ b/.github/workflows/sync-demos.yml @@ -0,0 +1,48 @@ +name: sync-demo.yml +on: + pull_request: + branches: + - develop + +env: + COOKIECUTTER_ROBUST_PYTHON_PROJECT_DEMOS_FOLDER: ${{ github.workspace }} + COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER: ${{ github.workspace }} + COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR: ${{ github.repository_owner }} + ROBUST_PYTHON_DEMO__APP_AUTHOR: ${{ github.repository_owner }} + ROBUST_MATURIN_DEMO__APP_AUTHOR: ${{ github.repository_owner }} + +jobs: + update-demo: + name: Update Demo + runs-on: ubuntu-latest + + strategy: + matrix: + demo_name: + - "robust-python-demo" + - "robust-maturin-demo" + steps: + - name: Checkout Template + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + path: cookiecutter-robust-python + + - name: Checkout Demo + uses: actions/checkout@v4 + with: + repository: "${{ github.repository_owner }}/${{ matrix.demo_name }}" + path: ${{ matrix.demo_name }} + ref: develop + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Update Demo + working-directory: "${{ github.workspace }}/cookiecutter-robust-python" + run: "uvx nox -s 'update-demo(${{ matrix.demo_name }})' -- --branch-override ${{ github.head_ref }}" diff --git a/docs/_static/cookiecutter-robust-python-banner.png b/docs/_static/cookiecutter-robust-python-banner.png index 7a2a9a3..a016668 100644 Binary files a/docs/_static/cookiecutter-robust-python-banner.png and b/docs/_static/cookiecutter-robust-python-banner.png differ diff --git a/noxfile.py b/noxfile.py index 8a2936d..33d7d3d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,7 +6,10 @@ import os import shutil +from dataclasses import asdict +from dataclasses import dataclass from pathlib import Path +from typing import Any import nox import platformdirs @@ -66,6 +69,41 @@ ) +@dataclass +class RepoMetadata: + """Metadata for a given repo.""" + app_name: str + app_author: str + remote: str + main_branch: str + develop_branch: str + + +TEMPLATE: RepoMetadata = RepoMetadata( + app_name=os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_NAME"), + app_author=os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR"), + remote=os.getenv("COOKIECUTTER_ROBUST_PYTHON__REMOTE"), + main_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__MAIN_BRANCH"), + develop_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__DEVELOP_BRANCH") +) + +PYTHON_DEMO: RepoMetadata = RepoMetadata( + app_name=os.getenv("ROBUST_PYTHON_DEMO__APP_NAME"), + app_author=os.getenv("ROBUST_PYTHON_DEMO__APP_AUTHOR"), + remote=os.getenv("ROBUST_PYTHON_DEMO__REMOTE"), + main_branch=os.getenv("ROBUST_PYTHON_DEMO__MAIN_BRANCH"), + develop_branch=os.getenv("ROBUST_PYTHON_DEMO__DEVELOP_BRANCH") +) + +MATURIN_DEMO: RepoMetadata = RepoMetadata( + app_name=os.getenv("ROBUST_MATURIN_DEMO__APP_NAME"), + app_author=os.getenv("ROBUST_MATURIN_DEMO__APP_AUTHOR"), + remote=os.getenv("ROBUST_MATURIN_DEMO__REMOTE"), + main_branch=os.getenv("ROBUST_MATURIN_DEMO__MAIN_BRANCH"), + develop_branch=os.getenv("ROBUST_MATURIN_DEMO__DEVELOP_BRANCH") +) + + @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="generate-demo") def generate_demo(session: Session) -> None: """Generates a project demo using the cookiecutter-robust-python template.""" @@ -143,17 +181,26 @@ def test(session: Session) -> None: session.run("pytest", "tests") -@nox.parametrize(arg_names="add_rust_extension", arg_values_list=[False, True], ids=["no-rust", "rust"]) +@nox.parametrize( + arg_names="demo", + arg_values_list=[PYTHON_DEMO, MATURIN_DEMO], + ids=["robust-python-demo", "robust-maturin-demo"] +) @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="update-demo") -def update_demo(session: Session, add_rust_extension: bool) -> None: +def update_demo(session: Session, demo: RepoMetadata) -> None: session.log("Installing script dependencies for updating generated project demos...") session.install("cookiecutter", "cruft", "platformdirs", "loguru", "python-dotenv", "typer") session.log("Updating generated project demos...") args: list[str] = [*UPDATE_DEMO_OPTIONS] - if add_rust_extension: + if "maturin" in demo.app_name: args.append("--add-rust-extension") - session.run("python", UPDATE_DEMO_SCRIPT, *args) + + if session.posargs: + args.extend(session.posargs) + + demo_env: dict[str, Any] = {f"ROBUST_DEMO__{key.upper()}": value for key, value in asdict(demo).items()} + session.run("python", UPDATE_DEMO_SCRIPT, *args, env=demo_env) @nox.session(python=False, name="release-template") diff --git a/scripts/generate-demo.py b/scripts/generate-demo.py index 5d91dfa..9918e02 100644 --- a/scripts/generate-demo.py +++ b/scripts/generate-demo.py @@ -1,4 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "typer", +# ] +# /// """Python script for generating a demo project.""" + import sys from pathlib import Path from typing import Annotated diff --git a/scripts/lint-from-demo.py b/scripts/lint-from-demo.py index 57a7749..c9458a9 100644 --- a/scripts/lint-from-demo.py +++ b/scripts/lint-from-demo.py @@ -1,3 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "retrocookie", +# "typer", +# ] +# /// + import os from pathlib import Path from typing import Annotated @@ -6,10 +17,11 @@ import typer from retrocookie.core import retrocookie +from util import DEMO from util import git from util import FolderOption from util import in_new_demo -from util import require_clean_and_up_to_date_repo +from util import require_clean_and_up_to_date_demo_repo # These still may need linted, but retrocookie shouldn't be used on them @@ -29,23 +41,22 @@ def lint_from_demo( no_cache: Annotated[bool, typer.Option("--no-cache", "-n")] = False ) -> None: """Runs precommit in a generated project and matches the template to the results.""" - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") with in_new_demo( demos_cache_folder=demos_cache_folder, add_rust_extension=add_rust_extension, no_cache=no_cache ) as demo_path: - require_clean_and_up_to_date_repo() - git("checkout", develop_branch) + require_clean_and_up_to_date_demo_repo(demo_path=demo_path) + git("checkout", DEMO.develop_branch) git("branch", "-D", "temp/lint-from-demo", ignore_error=True) - git("checkout", "-b", "temp/lint-from-demo", develop_branch) + git("checkout", "-b", "temp/lint-from-demo", DEMO.develop_branch) pre_commit.main.main(["run", "--all-files", "--show-diff-on-failure"]) for path in IGNORED_FILES: git("checkout", "HEAD", "--", path) git("add", ".") git("commit", "-m", "meta: lint-from-demo", "--no-verify") - retrocookie(instance_path=demo_path, commits=[f"{develop_branch}..temp/lint-from-demo"]) + retrocookie(instance_path=demo_path, commits=[f"{DEMO.develop_branch}..temp/lint-from-demo"]) if __name__ == '__main__': diff --git a/scripts/release-demo.py b/scripts/release-demo.py new file mode 100644 index 0000000..ca07e33 --- /dev/null +++ b/scripts/release-demo.py @@ -0,0 +1,96 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "typer", +# ] +# /// +import itertools +import subprocess +import tempfile +from pathlib import Path +from typing import Annotated + +import typer +from cookiecutter.utils import work_in +from loguru import logger + +from util import get_current_branch +from util import get_demo_name +from util import gh +from util import git +from util import nox +from util import require_clean_and_up_to_date_demo_repo +from util import FolderOption +from util import DEMO + + +cli: typer.Typer = typer.Typer() + + +@cli.callback(invoke_without_command=True) +def release_demo( + demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")], + add_rust_extension: Annotated[bool, typer.Option("--add-rust-extension", "-r")] = False +) -> None: + """Creates a release of the demo's current develop branch if changes exist.""" + demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) + demo_path: Path = demos_cache_folder / demo_name + + with work_in(demo_path): + require_clean_and_up_to_date_demo_repo(demo_path) + git("checkout", DEMO.develop_branch) + try: + nox("setup-release", "--", "MINOR") + logger.success(f"Successfully created release {demo_name}") + gh("repo", "set-default", DEMO.app_author, DEMO.app_name) + + except subprocess.CalledProcessError as error: + logger.warning(f"Failed to setup release: {error}") + _rollback_failed_release() + raise error + + git("push", "-u") + _create_demo_pr() + + +def _rollback_failed_release() -> None: + """Returns the demo repo back to the state it was prior to the release attempt.""" + starting_demo_branch: str = get_current_branch() + + if starting_demo_branch != "develop": + git("checkout", DEMO.develop_branch) + + git("checkout", ".") + git("branch", "-D", starting_demo_branch) + + +def _create_demo_pr() -> None: + """Creates a pull request to merge the demo's feature branch into .""" + current_branch: str = get_current_branch() + if not current_branch.startswith("release/"): + raise ValueError("Not in a release branch, canceling PR creation.") + title: str = current_branch.capitalize() + release_notes: str = __get_demo_release_notes() + + pr_kwargs: dict[str, str] = { + "--title": title, + "--body": release_notes, + "--assignee": "@me", + "--base": "main", + } + command: list[str] = ["gh", "pr", "create", *itertools.chain(pr_kwargs.items())] + subprocess.run(command, check=True) + + +def __get_demo_release_notes() -> str: + """Returns the release notes for the demo.""" + temp_folder: Path = Path(tempfile.mkdtemp()).resolve() + notes_path: Path = temp_folder / "body.md" + command: list[str] = ["uv", "run", "./scripts/get-release-notes.py", notes_path] + subprocess.run(command, check=True) + + notes_contents: str = notes_path.read_text() + return notes_contents diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 4668a36..a300991 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -1,17 +1,36 @@ -import os -import sys +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "typer", +# ] +# /// +import itertools from pathlib import Path +from subprocess import CompletedProcess from typing import Annotated +from typing import Any +from typing import Optional import cruft import typer from cookiecutter.utils import work_in +from util import _read_cruft_file +from util import DEMO +from util import is_ancestor +from util import get_current_branch +from util import get_current_commit from util import get_demo_name +from util import get_last_cruft_update_commit +from util import gh from util import git from util import FolderOption from util import REPO_FOLDER -from util import require_clean_and_up_to_date_repo +from util import require_clean_and_up_to_date_demo_repo +from util import TEMPLATE from util import uv @@ -23,37 +42,120 @@ def update_demo( demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")], add_rust_extension: Annotated[bool, typer.Option("--add-rust-extension", "-r")] = False, min_python_version: Annotated[str, typer.Option("--min-python-version")] = "3.10", - max_python_version: Annotated[str, typer.Option("--max-python-version")] = "3.14" + max_python_version: Annotated[str, typer.Option("--max-python-version")] = "3.14", + branch_override: Annotated[Optional[str], typer.Option("--branch-override")] = None ) -> None: """Runs precommit in a generated project and matches the template to the results.""" - try: - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") - demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) - demo_path: Path = demos_cache_folder / demo_name - typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") - with work_in(demo_path): - require_clean_and_up_to_date_repo() - git("checkout", develop_branch) - uv("python", "pin", min_python_version) - uv("python", "install", min_python_version) - cruft.update( - project_dir=demo_path, - template_path=REPO_FOLDER, - extra_context={ - "project_name": demo_name, - "add_rust_extension": add_rust_extension, - "min_python_version": min_python_version, - "max_python_version": max_python_version - }, - ) - uv("lock") - git("add", ".") - git("commit", "-m", "chore: update demo to the latest cookiecutter-robust-python", "--no-verify") - git("push") - - except Exception as error: - typer.secho(f"error: {error}", fg="red") - sys.exit(1) + demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) + demo_path: Path = demos_cache_folder / demo_name + + if branch_override is not None: + typer.secho(f"Overriding current branch name for demo reference. Using '{branch_override}' instead.") + current_branch: str = branch_override + else: + current_branch: str = get_current_branch() + template_commit: str = get_current_commit() + + _validate_template_main_not_checked_out(branch=current_branch) + require_clean_and_up_to_date_demo_repo(demo_path=demo_path) + _checkout_demo_develop_or_existing_branch(demo_path=demo_path, branch=current_branch) + last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) + + if not is_ancestor(last_update_commit, template_commit): + raise ValueError( + f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " + f"'{template_commit}'." + ) + + typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") + with work_in(demo_path): + uv("python", "pin", min_python_version) + uv("python", "install", min_python_version) + cruft.update( + project_dir=demo_path, + template_path=REPO_FOLDER, + extra_context={ + "project_name": demo_name, + "add_rust_extension": add_rust_extension, + "min_python_version": min_python_version, + "max_python_version": max_python_version + }, + ) + uv("lock") + git("add", ".") + git("commit", "-m", f"chore: {last_update_commit} -> {template_commit}", "--no-verify") + git("push", "-u", "origin", current_branch) + if current_branch != "develop": + _create_demo_pr(demo_path=demo_path, branch=current_branch, commit_start=last_update_commit) + + +def _checkout_demo_develop_or_existing_branch(demo_path: Path, branch: str) -> None: + """Checkout either develop or an existing demo branch.""" + with work_in(demo_path): + if __has_existing_local_demo_branch(demo_path=demo_path, branch=branch): + typer.secho(f"Local demo found, updating demo from base {branch}") + git("checkout", branch) + return + + if __has_existing_remote_demo_branch(demo_path=demo_path, branch=branch): + remote_branch: str = f"{DEMO.remote}/{branch}" + typer.secho(f"Remote demo found, updating demo from base {remote_branch}") + git("checkout", "-b", branch, remote_branch) + return + + git("checkout", "develop") + + +def __has_existing_local_demo_branch(demo_path: Path, branch: str) -> bool: + """Returns whether a local branch has been made for the given branch.""" + with work_in(demo_path): + local_result: Optional[CompletedProcess] = git("branch", "--list", branch) + return local_result is not None and branch in local_result.stdout + + +def __has_existing_remote_demo_branch(demo_path: Path, branch: str) -> bool: + """Returns whether a remote branch has been made for the given branch.""" + with work_in(demo_path): + remote_result: Optional[CompletedProcess] = git("ls-remote", DEMO.remote, branch) + return remote_result is not None and branch in remote_result.stdout + + +def _validate_template_main_not_checked_out(branch: str) -> None: + """Validates that the cookiecutter isn't currently on main. + + We allow direct develop commits (although avoid it usually), but never direct main. This may change later if the + template moves to a trunk based structure, but for now options are being kept open due to the possibility of a + package release handling demo creation one day. + """ + main_like_names: list[str] = ["main", "master"] + if branch == TEMPLATE.main_branch or branch in main_like_names: + raise ValueError(f"Updating demos directly to main is not allowed currently.") + + +def _create_demo_pr(demo_path: Path, branch: str, commit_start: str) -> None: + """Creates a PR to merge the given branch into develop.""" + gh("repo", "set-default", f"{DEMO.app_author}/{DEMO.app_name}") + + body: str = _get_demo_feature_pr_body(demo_path=demo_path, commit_start=commit_start) + + pr_kwargs: dict[str, Any] = { + "--title": branch.capitalize(), + "--body": body, + "--base": DEMO.develop_branch, + "--assignee": "@me", + "--repo": f"{DEMO.app_author}/{DEMO.app_name}", + } + gh("pr", "create", *itertools.chain.from_iterable(pr_kwargs.items())) + + +def _get_demo_feature_pr_body(demo_path: Path, commit_start: str) -> str: + """Creates the body of the demo feature pull request.""" + cruft_config: dict[str, Any] = _read_cruft_file(demo_path) + commit_end: Optional[str] = cruft_config.get("commit", None) + if commit_end is None: + raise ValueError(f"Unable to find latest commit in .cruft.json for demo at {demo_path}.") + rev_range: str = f"{commit_start}..{commit_end}" + return rev_range if __name__ == '__main__': diff --git a/scripts/util.py b/scripts/util.py index 5ce7147..2844afb 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -1,10 +1,22 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "typer", +# ] +# /// """Module containing utility functions used throughout cookiecutter_robust_python scripts.""" + +import json import os import shutil import stat import subprocess import sys from contextlib import contextmanager +from dataclasses import dataclass from functools import partial from pathlib import Path from typing import Any @@ -16,7 +28,9 @@ import cruft import typer + from cookiecutter.utils import work_in +from cruft._commands.utils.cruft import get_cruft_file from dotenv import load_dotenv from typer.models import OptionInfo @@ -42,12 +56,38 @@ def _load_env() -> None: # Load environment variables at module import time _load_env() - FolderOption: partial[OptionInfo] = partial( typer.Option, dir_okay=True, file_okay=False, resolve_path=True, path_type=Path ) +@dataclass +class RepoMetadata: + """Metadata for a given repo.""" + app_name: str + app_author: str + remote: str + main_branch: str + develop_branch: str + + +TEMPLATE: RepoMetadata = RepoMetadata( + app_name=os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_NAME"), + app_author=os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR"), + remote=os.getenv("COOKIECUTTER_ROBUST_PYTHON__REMOTE"), + main_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__MAIN_BRANCH"), + develop_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__DEVELOP_BRANCH") +) + +DEMO: RepoMetadata = RepoMetadata( + app_name=os.getenv("ROBUST_DEMO__APP_NAME"), + app_author=os.getenv("ROBUST_DEMO__APP_AUTHOR"), + remote=os.getenv("ROBUST_DEMO__REMOTE"), + main_branch=os.getenv("ROBUST_DEMO__MAIN_BRANCH"), + develop_branch=os.getenv("ROBUST_DEMO__DEVELOP_BRANCH") +) + + def remove_readonly(func: Callable[[str], Any], path: str, _: Any) -> None: """Clears the readonly bit and attempts to call the provided function. @@ -77,26 +117,35 @@ def run_command(command: str, *args: str, ignore_error: bool = False) -> Optiona return None print(error.stdout, end="") print(error.stderr, end="", file=sys.stderr) - raise + raise error git: partial[subprocess.CompletedProcess] = partial(run_command, "git") uv: partial[subprocess.CompletedProcess] = partial(run_command, "uv") +nox: partial[subprocess.CompletedProcess] = partial(run_command, "nox") +gh: partial[subprocess.CompletedProcess] = partial(run_command, "gh") -def require_clean_and_up_to_date_repo() -> None: +def require_clean_and_up_to_date_demo_repo(demo_path: Path) -> None: """Checks if the repo is clean and up to date with any important branches.""" - main_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_MAIN_BRANCH", "main") - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") + try: + with work_in(demo_path): + git("fetch") + git("status", "--porcelain") + validate_is_synced_ancestor(ancestor=DEMO.main_branch, descendent=DEMO.develop_branch) + except Exception as e: + typer.secho(f"Failed initial repo state check.") + raise e - git("fetch") - git("status", "--porcelain") - if not is_branch_synced_with_remote(develop_branch): - raise ValueError(f"{develop_branch} is not synced with origin/{develop_branch}") - if not is_branch_synced_with_remote(main_branch): - raise ValueError(f"{main_branch} is not synced with origin/{main_branch}") - if not is_ancestor(main_branch, develop_branch): - raise ValueError(f"{main_branch} is not an ancestor of {develop_branch}") + +def validate_is_synced_ancestor(ancestor: str, descendent: str) -> None: + """Returns whether the given ancestor is actually an up-to-date ancestor of the given descendent branch.""" + if not is_branch_synced_with_remote(branch=descendent): + raise ValueError(f"{descendent} is not synced with origin/{descendent}") + if not is_branch_synced_with_remote(branch=ancestor): + raise ValueError(f"{ancestor} is not synced with origin/{ancestor}") + if not is_ancestor(ancestor=ancestor, descendent=descendent): + raise ValueError(f"{ancestor} is not an ancestor of {descendent}") def is_branch_synced_with_remote(branch: str) -> bool: @@ -106,7 +155,38 @@ def is_branch_synced_with_remote(branch: str) -> bool: def is_ancestor(ancestor: str, descendent: str) -> bool: """Checks if the branch is synced with its remote.""" - return git("merge-base", "--is-ancestor", ancestor, descendent).returncode == 0 + try: + git("merge-base", "--is-ancestor", ancestor, descendent) + return True + except subprocess.CalledProcessError: + return False + + +def get_current_branch() -> str: + """Returns the current branch name.""" + return git("branch", "--show-current").stdout.strip() + + +def get_current_commit() -> str: + """Returns the current commit reference.""" + return git("rev-parse", "HEAD").stdout.strip() + + +def get_last_cruft_update_commit(demo_path: Path) -> str: + """Returns the commit id for the last time cruft update was ran.""" + existing_cruft_config: dict[str, Any] = _read_cruft_file(demo_path) + last_cookiecutter_commit: Optional[str] = existing_cruft_config.get("commit", None) + if last_cookiecutter_commit is None: + raise ValueError("Could not find last commit id used to generate demo.") + return last_cookiecutter_commit + + +def _read_cruft_file(project_path: Path) -> dict[str, Any]: + """Reads the cruft file for the project path provided and returns the results.""" + cruft_path: Path = get_cruft_file(project_dir_path=project_path) + cruft_text: str = cruft_path.read_text() + cruft_config: dict[str, Any] = json.loads(cruft_text) + return cruft_config @contextmanager diff --git a/{{cookiecutter.project_name}}/scripts/setup-release.py b/{{cookiecutter.project_name}}/scripts/setup-release.py index 980af60..258728b 100644 --- a/{{cookiecutter.project_name}}/scripts/setup-release.py +++ b/{{cookiecutter.project_name}}/scripts/setup-release.py @@ -10,6 +10,7 @@ from util import create_release_branch from util import get_bumped_package_version from util import get_package_version +from util import require_clean_and_up_to_date_repo def main() -> None: @@ -20,9 +21,9 @@ def main() -> None: def get_parser() -> argparse.ArgumentParser: - """Creates the argument parser for prepare-release.""" + """Creates the argument parser for setup-release.""" parser: argparse.ArgumentParser = argparse.ArgumentParser( - prog="prepare-release", usage="python ./scripts/prepare-release.py patch" + prog="setup-release", usage="python ./scripts/setup-release.py patch" ) parser.add_argument( "increment", @@ -38,13 +39,26 @@ def get_parser() -> argparse.ArgumentParser: def setup_release(increment: Optional[str] = None) -> None: """Prepares a release of the {{cookiecutter.project_name}} package. - Sets up a release branch from the branch develop, bumps the version, and creates a release commit. Does not tag the - release or push any changes. + Will try to create the release and push, however will return to pre-existing state on error. """ check_dependencies(path=REPO_FOLDER, dependencies=["git"]) + require_clean_and_up_to_date_repo() current_version: str = get_package_version() new_version: str = get_bumped_package_version(increment=increment) + try: + _setup_release(increment=increment, current_version=current_version, new_version=new_version) + except Exception as error: + _rollback_release(version=new_version) + raise error + + +def _setup_release(increment: str, current_version: str, new_version: str) -> None: + """Prepares a release of the {{cookiecutter.project_name}} package. + + Sets up a release branch from the branch develop, bumps the version, and creates a release commit. Does not tag the + release or push any changes. + """ create_release_branch(new_version=new_version) bump_version(increment=increment) @@ -58,5 +72,17 @@ def setup_release(increment: Optional[str] = None) -> None: subprocess.run(command, cwd=REPO_FOLDER, capture_output=True, check=True) +def _rollback_release(version: str) -> None: + """Rolls back to the pre-existing state on error.""" + commands: list[list[str]] = [ + ["git", "checkout", "develop"], + ["git", "checkout", "."], + ["git", "branch", "-D", f"release/{version}"] + ] + + for command in commands: + subprocess.run(command, cwd=REPO_FOLDER, check=False) + + if __name__ == "__main__": main() diff --git a/{{cookiecutter.project_name}}/scripts/util.py b/{{cookiecutter.project_name}}/scripts/util.py index e09e873..b2b89dc 100644 --- a/{{cookiecutter.project_name}}/scripts/util.py +++ b/{{cookiecutter.project_name}}/scripts/util.py @@ -10,6 +10,8 @@ REPO_FOLDER: Path = Path(__file__).resolve().parent.parent +MAIN_BRANCH: str = "main" +DEVELOP_BRANCH: str = "develop" class MissingDependencyError(Exception): @@ -34,6 +36,22 @@ def check_dependencies(path: Path, dependencies: list[str]) -> None: raise MissingDependencyError(path, dependency) from e +def require_clean_and_up_to_date_repo() -> None: + """Checks if the repo is clean and up to date with any important branches.""" + commands: list[list[str]] = [ + ["git", "fetch"], + ["git", "merge-base", "--is-ancestor", MAIN_BRANCH, f"origin/{MAIN_BRANCH}"], + ["git", "merge-base", "--is-ancestor", f"origin/{MAIN_BRANCH}", MAIN_BRANCH], + ["git", "merge-base", "--is-ancestor", DEVELOP_BRANCH, f"origin/{DEVELOP_BRANCH}"], + ["git", "merge-base", "--is-ancestor", f"origin/{DEVELOP_BRANCH}", DEVELOP_BRANCH], + ["git", "merge-base", "--is-ancestor", MAIN_BRANCH, DEVELOP_BRANCH], + ["git", "status", "--porcelain"], + ] + + for command in commands: + subprocess.run(command, cwd=REPO_FOLDER, check=True) + + def existing_dir(value: str) -> Path: """Responsible for validating argparse inputs and returning them as pathlib Path's if they meet criteria.""" path = Path(value).expanduser().resolve()