Skip to content

Index

repo_scaffold.github_init

GitHub bootstrap helpers for repo-scaffold gh-init.

Three layers, split across this package:

  • :mod:repo_scaffold.github_init.configGhInitConfig/GhInitResult and build_config collect the repo metadata, secrets, and variables to apply.
  • :mod:repo_scaffold.github_init.clientGhInitClient, a thin wrapper around PyGithub so tests can swap the whole client out without monkey-patching the SDK.
  • this module — init_repository orchestrates the calls and returns the URLs the CLI prints back to the user, plus the git_push/deploy_docs subprocess helpers.

git_push performs the optional initial commit + push via subprocess, with no extra dependency on GitPython.

GhInitClient

GhInitClient(token: str)

Tiny PyGithub wrapper that orchestrator and tests both consume.

Construct a client backed by the given personal access token.

Source code in repo_scaffold/github_init/client.py
def __init__(self, token: str):
    """Construct a client backed by the given personal access token."""
    self._gh = Github(auth=Auth.Token(token))
    self._user_login: str | None = None
    self._token = token

token property

token: str

The personal access token this client authenticates with.

Exposed so the orchestrator can feed it to the non-interactive git push / mkdocs gh-deploy subprocesses (see git_push and deploy_docs), which need it to authenticate an HTTPS remote that has no credential helper and no TTY.

authenticated_login

authenticated_login() -> str

Return the login of the token's user. Raises on a bad token.

Source code in repo_scaffold/github_init/client.py
def authenticated_login(self) -> str:
    """Return the login of the token's user. Raises on a bad token."""
    if self._user_login is None:
        self._user_login = self._gh.get_user().login
    return self._user_login

enable_pages

enable_pages(
    repo: Repository, branch: str, path: str = "/"
) -> None

Configure GitHub Pages to deploy from branch/path.

PyGithub 2.x has no Pages helper, so this calls the REST API directly: POST .../pages to create the site, falling back to PUT to update the source when a site already exists.

Source code in repo_scaffold/github_init/client.py
def enable_pages(self, repo: Repository, branch: str, path: str = "/") -> None:
    """Configure GitHub Pages to deploy from ``branch``/``path``.

    PyGithub 2.x has no Pages helper, so this calls the REST API directly:
    ``POST .../pages`` to create the site, falling back to ``PUT`` to update
    the source when a site already exists.
    """
    source = {"branch": branch, "path": path}
    try:
        repo.requester.requestJsonAndCheck("POST", f"{repo.url}/pages", input={"source": source})
    except GithubException as exc:
        if exc.status in (409, 422):
            repo.requester.requestJsonAndCheck("PUT", f"{repo.url}/pages", input={"source": source})
            return
        raise

get_or_create_repo

get_or_create_repo(
    owner: str | None,
    name: str,
    *,
    description: str,
    private: bool,
    allow_existing: bool,
) -> Repository

Create the repo on the right account, or look it up if already there.

Source code in repo_scaffold/github_init/client.py
def get_or_create_repo(
    self,
    owner: str | None,
    name: str,
    *,
    description: str,
    private: bool,
    allow_existing: bool,
) -> Repository:
    """Create the repo on the right account, or look it up if already there."""
    login = self.authenticated_login()
    target_owner = owner or login
    try:
        if not owner or owner == login:
            user = self._gh.get_user()
            return user.create_repo(
                name=name,
                description=description or "",
                private=private,
                auto_init=False,
            )
        org = self._gh.get_organization(owner)
        return org.create_repo(
            name=name,
            description=description or "",
            private=private,
            auto_init=False,
        )
    except GithubException as exc:
        if exc.status == 422 and allow_existing:
            return self._gh.get_repo(f"{target_owner}/{name}")
        raise

protect_branch

protect_branch(repo: Repository, branch: str) -> None

Enable branch protection on branch (requires admin on the repo).

Rules are intentionally compatible with the generated version-bump workflow: enforce_admins is left off so the release token (owned by a repo admin) can still push chore(version): commits and tags directly. Branch protection is unavailable on private repos without a paid GitHub plan; such a failure surfaces to the caller.

Source code in repo_scaffold/github_init/client.py
def protect_branch(self, repo: Repository, branch: str) -> None:
    """Enable branch protection on ``branch`` (requires admin on the repo).

    Rules are intentionally compatible with the generated ``version-bump``
    workflow: ``enforce_admins`` is left off so the release token (owned by
    a repo admin) can still push ``chore(version):`` commits and tags
    directly. Branch protection is unavailable on private repos without a
    paid GitHub plan; such a failure surfaces to the caller.
    """
    repo.get_branch(branch).edit_protection(
        required_approving_review_count=1,
        enforce_admins=False,
        allow_force_pushes=False,
        allow_deletions=False,
    )

set_homepage

set_homepage(repo: Repository, url: str) -> None

Point the repository's Website field at the docs URL.

Source code in repo_scaffold/github_init/client.py
def set_homepage(self, repo: Repository, url: str) -> None:
    """Point the repository's ``Website`` field at the docs URL."""
    repo.edit(homepage=url)

set_secret

set_secret(repo: Repository, name: str, value: str) -> None

Create or replace an Actions secret. create_secret is PUT-based.

Source code in repo_scaffold/github_init/client.py
def set_secret(self, repo: Repository, name: str, value: str) -> None:
    """Create or replace an Actions secret. ``create_secret`` is PUT-based."""
    repo.create_secret(name, value)

set_variable

set_variable(
    repo: Repository, name: str, value: str
) -> None

Create an Actions variable, falling back to edit if it exists.

Source code in repo_scaffold/github_init/client.py
def set_variable(self, repo: Repository, name: str, value: str) -> None:
    """Create an Actions variable, falling back to ``edit`` if it exists."""
    try:
        repo.create_variable(name, value)
    except GithubException as exc:
        if exc.status in (409, 422):
            repo.get_variable(name).edit(value=value)
            return
        raise

GhInitConfig dataclass

GhInitConfig(
    project_path: Path,
    name: str,
    description: str,
    private: bool,
    default_branch: str,
    secrets: dict[str, str] = dict(),
    variables: dict[str, str] = dict(),
    owner: str | None = None,
    push: bool = True,
    force_push: bool = False,
    allow_existing: bool = False,
    setup_pages: bool = True,
    protect_branch: bool = False,
)

Resolved configuration for one gh-init invocation.

GhInitResult dataclass

GhInitResult(
    html_url: str,
    actions_url: str,
    pages_url: str,
    skipped_secrets: list[str],
    pushed: bool,
    pages_configured: bool = False,
    pages_branch: str = PAGES_BRANCH,
    pages_error: str | None = None,
    homepage_set: bool = False,
    branch_protected: bool = False,
    protection_error: str | None = None,
)

Outputs surfaced to the CLI after a successful run.

_github_error_message

_github_error_message(exc: GithubException) -> str

Best-effort human-readable message from a GithubException.

Source code in repo_scaffold/github_init/__init__.py
def _github_error_message(exc: GithubException) -> str:
    """Best-effort human-readable message from a GithubException."""
    data = getattr(exc, "data", None)
    return str(data["message"]) if isinstance(data, dict) and "message" in data else str(exc)

build_config

build_config(
    project_path: Path,
    *,
    owner: str | None = None,
    name: str | None = None,
    description: str | None = None,
    private: bool = False,
    default_branch: str | None = None,
    push: bool = True,
    force_push: bool = False,
    allow_existing: bool = False,
    setup_pages: bool = True,
    protect_branch: bool = False,
    extra_env: dict[str, str] | None = None,
    prompter: Callable[[str, str | None], str]
    | None = None,
) -> GhInitConfig

Resolve a GhInitConfig from CLI args, environment, .env, and pyproject.

Precedence per key (highest first): explicit kwarg, extra_env (used for secrets/variables and for pulling values from os.environ at the call site), then the project's .env file, then pyproject.toml defaults, then prompter if provided.

prompter receives (key, default) and returns the user's answer, or an empty string to skip. When prompter is None (i.e. --no-input) missing optional values are simply dropped.

Source code in repo_scaffold/github_init/config.py
def build_config(
    project_path: Path,
    *,
    owner: str | None = None,
    name: str | None = None,
    description: str | None = None,
    private: bool = False,
    default_branch: str | None = None,
    push: bool = True,
    force_push: bool = False,
    allow_existing: bool = False,
    setup_pages: bool = True,
    protect_branch: bool = False,
    extra_env: dict[str, str] | None = None,
    prompter: Callable[[str, str | None], str] | None = None,
) -> GhInitConfig:
    """Resolve a ``GhInitConfig`` from CLI args, environment, ``.env``, and pyproject.

    Precedence per key (highest first): explicit kwarg, ``extra_env`` (used for
    secrets/variables and for pulling values from ``os.environ`` at the call
    site), then the project's ``.env`` file, then ``pyproject.toml`` defaults,
    then ``prompter`` if provided.

    ``prompter`` receives ``(key, default)`` and returns the user's answer, or
    an empty string to skip. When ``prompter`` is ``None`` (i.e. ``--no-input``)
    missing optional values are simply dropped.
    """
    project_path = project_path.resolve()
    pyproject = load_pyproject(project_path)
    dotenv_path = project_path / ".env"
    dotenv = parse_dotenv(dotenv_path.read_text(encoding="utf-8")) if dotenv_path.is_file() else {}
    env = extra_env if extra_env is not None else dict(os.environ)

    def from_env(key: str) -> str:
        return env.get(key) or dotenv.get(key) or ""

    resolved_name = name or pyproject.get("name") or project_path.name
    resolved_description = description if description is not None else pyproject.get("description") or ""
    resolved_branch = default_branch or detect_default_branch(project_path) or "master"

    # Owner precedence: explicit flag, then a GitHub owner detected from the
    # project (pyproject URLs, then cog.toml), else None -> authenticated user.
    if owner:
        resolved_owner: str | None = owner
        owner_source: str | None = "flag"
    else:
        resolved_owner, owner_source = detect_owner(project_path)

    secrets: dict[str, str] = {}
    skipped: list[str] = []
    for key in DEFAULT_SECRET_KEYS:
        value = from_env(key)
        if not value and prompter is not None:
            value = prompter(key, None)
        if value:
            secrets[key] = value
        else:
            skipped.append(key)

    variables: dict[str, str] = {}
    for key in DEFAULT_VARIABLE_KEYS:
        value = from_env(key)
        if not value and prompter is not None:
            value = prompter(key, "false")
        if value:
            variables[key] = value

    config = GhInitConfig(
        project_path=project_path,
        name=resolved_name,
        description=resolved_description,
        private=private,
        default_branch=resolved_branch,
        secrets=secrets,
        variables=variables,
        owner=resolved_owner,
        push=push,
        force_push=force_push,
        allow_existing=allow_existing,
        setup_pages=setup_pages,
        protect_branch=protect_branch,
    )
    config._skipped_secrets = skipped  # type: ignore[attr-defined]
    config._owner_source = owner_source  # type: ignore[attr-defined]
    return config

deploy_docs

deploy_docs(
    project_path: Path, *, token: str | None = None
) -> None

Build the MkDocs site and push it to the gh-pages branch.

Runs the project's deploy-gh-pages just recipe (mkdocs gh-deploy --force), which builds with the right docs dependency group/extra, writes .nojekyll, and pushes to origin. Raises RuntimeError on failure so the orchestrator can surface it without aborting the whole bootstrap.

mkdocs gh-deploy shells out to git push itself, so when token is given the PAT is injected via GIT_CONFIG_PARAMETERS (an http.extraheader Authorization: Basic header) and the terminal prompt is disabled. This authenticates the push non-interactively without ever writing the token to .git/config.

Source code in repo_scaffold/github_init/__init__.py
def deploy_docs(project_path: Path, *, token: str | None = None) -> None:
    """Build the MkDocs site and push it to the ``gh-pages`` branch.

    Runs the project's ``deploy-gh-pages`` just recipe (``mkdocs gh-deploy
    --force``), which builds with the right docs dependency group/extra, writes
    ``.nojekyll``, and pushes to ``origin``. Raises ``RuntimeError`` on failure
    so the orchestrator can surface it without aborting the whole bootstrap.

    ``mkdocs gh-deploy`` shells out to ``git push`` itself, so when ``token`` is
    given the PAT is injected via ``GIT_CONFIG_PARAMETERS`` (an
    ``http.extraheader`` ``Authorization: Basic`` header) and the terminal
    prompt is disabled. This authenticates the push non-interactively without
    ever writing the token to ``.git/config``.
    """
    cmd = ["uvx", "--from", "rust-just", "just", "deploy-gh-pages"]
    env = None
    if token:
        creds = base64.b64encode(f"x-access-token:{token}".encode()).decode()
        env = {
            **os.environ,
            "GIT_CONFIG_PARAMETERS": f"'http.extraheader=Authorization: Basic {creds}'",
            "GIT_TERMINAL_PROMPT": "0",
        }
    try:
        subprocess.run(cmd, cwd=str(project_path), check=True, capture_output=True, text=True, env=env)
    except FileNotFoundError as exc:
        raise RuntimeError("`uvx` was not found on PATH; install uv before running gh-init.") from exc
    except subprocess.CalledProcessError as exc:
        tail = "\n".join((exc.stderr or exc.stdout or "").strip().splitlines()[-5:])
        raise RuntimeError(f"`mkdocs gh-deploy` failed:\n{tail}") from exc

detect_default_branch

detect_default_branch(project_path: Path) -> str | None

Return the current local git branch name, or None if not a git repo.

Source code in repo_scaffold/github_init/config.py
def detect_default_branch(project_path: Path) -> str | None:
    """Return the current local git branch name, or ``None`` if not a git repo."""
    try:
        result = subprocess.run(
            ["git", "-C", str(project_path), "branch", "--show-current"],
            check=False,
            capture_output=True,
            text=True,
        )
    except FileNotFoundError:
        return None
    branch = result.stdout.strip()
    return branch or None

detect_owner

detect_owner(
    project_path: Path,
) -> tuple[str | None, str | None]

Best-effort GitHub owner for the project and the source it came from.

Resolution order: a github.com/<owner>/... URL in pyproject.toml's [project.urls], then the Cocogitto [changelog].owner field in cog.toml. Returns (None, None) when no owner can be determined, in which case the caller falls back to the authenticated user.

Source code in repo_scaffold/github_init/config.py
def detect_owner(project_path: Path) -> tuple[str | None, str | None]:
    """Best-effort GitHub owner for the project and the source it came from.

    Resolution order: a ``github.com/<owner>/...`` URL in ``pyproject.toml``'s
    ``[project.urls]``, then the Cocogitto ``[changelog].owner`` field in
    ``cog.toml``. Returns ``(None, None)`` when no owner can be determined, in
    which case the caller falls back to the authenticated user.
    """
    pyproject = project_path / "pyproject.toml"
    if pyproject.is_file():
        with pyproject.open("rb") as f:
            data = tomllib.load(f)
        urls = (data.get("project", {}) or {}).get("urls", {}) or {}
        for value in urls.values():
            owner = _github_owner_from_url(str(value))
            if owner:
                return owner, "pyproject"

    cog = project_path / "cog.toml"
    if cog.is_file():
        with cog.open("rb") as f:
            data = tomllib.load(f)
        owner = (data.get("changelog", {}) or {}).get("owner")
        if owner:
            return str(owner), "cog.toml"

    return None, None

git_push

git_push(
    project_path: Path,
    remote_url: str,
    branch: str,
    force: bool,
    *,
    token: str | None = None,
) -> None

Initialize git in project_path if needed, commit, and push to origin.

Steps
  1. git init if no .git directory exists.
  2. Stage everything and create an initial commit (only when there is no existing HEAD — re-runs against an already-initialized repo skip this).
  3. Rename the current branch to branch.
  4. (Re-)add origin pointing at remote_url.
  5. Push branch to origin with -u (and optionally --force).

remote_url is the repo's plain HTTPS clone URL (no embedded creds), so a non-interactive push has no way to authenticate: there is no TTY under capture_output and no credential helper is assumed. When token is given, the PAT is sent as an Authorization: Basic header via a one-shot -c http.extraheader=... so the push authenticates without the token ever being written to .git/config.

Source code in repo_scaffold/github_init/__init__.py
def git_push(
    project_path: Path,
    remote_url: str,
    branch: str,
    force: bool,
    *,
    token: str | None = None,
) -> None:
    """Initialize git in ``project_path`` if needed, commit, and push to origin.

    Steps:
      1. ``git init`` if no ``.git`` directory exists.
      2. Stage everything and create an initial commit (only when there is no
         existing HEAD — re-runs against an already-initialized repo skip this).
      3. Rename the current branch to ``branch``.
      4. (Re-)add ``origin`` pointing at ``remote_url``.
      5. Push ``branch`` to ``origin`` with ``-u`` (and optionally ``--force``).

    ``remote_url`` is the repo's plain HTTPS clone URL (no embedded creds), so a
    non-interactive push has no way to authenticate: there is no TTY under
    ``capture_output`` and no credential helper is assumed. When ``token`` is
    given, the PAT is sent as an ``Authorization: Basic`` header via a one-shot
    ``-c http.extraheader=...`` so the push authenticates without the token ever
    being written to ``.git/config``.
    """
    try:
        if not (project_path / ".git").exists():
            subprocess.run(["git", "init", str(project_path)], check=True, capture_output=True, text=True)

        head_exists = (
            subprocess.run(
                ["git", "-C", str(project_path), "rev-parse", "--verify", "HEAD"],
                check=False,
                capture_output=True,
                text=True,
            ).returncode
            == 0
        )

        if not head_exists:
            _git(project_path, "add", "-A")
            _git(
                project_path,
                "-c",
                f"user.name={INITIAL_COMMIT_USER}",
                "-c",
                f"user.email={INITIAL_COMMIT_EMAIL}",
                "commit",
                "-m",
                INITIAL_COMMIT_MESSAGE,
            )

        _git(project_path, "branch", "-M", branch)

        # Best-effort: drop any pre-existing origin so `remote add` always works.
        subprocess.run(
            ["git", "-C", str(project_path), "remote", "remove", "origin"],
            check=False,
            capture_output=True,
            text=True,
        )
        _git(project_path, "remote", "add", "origin", remote_url)

        push_args: list[str] = []
        if token:
            creds = base64.b64encode(f"x-access-token:{token}".encode()).decode()
            push_args += ["-c", f"http.extraheader=Authorization: Basic {creds}"]
        push_args += ["push", "-u"]
        if force:
            push_args.append("--force")
        push_args += ["origin", branch]
        _git(project_path, *push_args)
    except FileNotFoundError as exc:
        raise RuntimeError("`git` was not found on PATH; install git before running gh-init.") from exc

init_repository

init_repository(
    config: GhInitConfig, client: GhInitClient
) -> GhInitResult

Apply the config to GitHub and (optionally) push the initial commit.

Source code in repo_scaffold/github_init/__init__.py
def init_repository(config: GhInitConfig, client: GhInitClient) -> GhInitResult:
    """Apply the config to GitHub and (optionally) push the initial commit."""
    client.authenticated_login()
    repo = client.get_or_create_repo(
        config.owner,
        config.name,
        description=config.description,
        private=config.private,
        allow_existing=config.allow_existing,
    )
    for secret_name, value in config.secrets.items():
        client.set_secret(repo, secret_name, value)
    for variable_name, value in config.variables.items():
        client.set_variable(repo, variable_name, value)

    pushed = False
    if config.push:
        git_push(
            project_path=config.project_path,
            remote_url=repo.clone_url,
            branch=config.default_branch,
            force=config.force_push,
            token=client.token,
        )
        pushed = True

    owner_login = repo.owner.login
    html_url = repo.html_url
    actions_url = f"{html_url}/actions"
    pages_url = f"https://{owner_login}.github.io/{repo.name}"

    # Build and publish the docs with `mkdocs gh-deploy` (which creates the
    # gh-pages branch with the real styled site + .nojekyll), then enable Pages
    # on it and point the repo's Website at the docs URL. Needs the push to have
    # happened and the project to actually have docs (mkdocs.yml). Any failure
    # is surfaced but never aborts a bootstrap that already created and pushed.
    pages_configured = False
    homepage_set = False
    pages_error: str | None = None
    has_docs = (config.project_path / "mkdocs.yml").is_file()
    if config.setup_pages and pushed and has_docs:
        try:
            deploy_docs(config.project_path, token=client.token)
            client.enable_pages(repo, PAGES_BRANCH)
            pages_configured = True
            client.set_homepage(repo, pages_url)
            homepage_set = True
        except (GithubException, RuntimeError) as exc:
            pages_error = _github_error_message(exc) if isinstance(exc, GithubException) else str(exc)

    # Branch protection also needs the branch to exist on the remote. Like
    # Pages, a failure (e.g. private repo on a free plan, or a token without
    # admin rights) is reported but never aborts the bootstrap.
    branch_protected = False
    protection_error: str | None = None
    if config.protect_branch and pushed:
        try:
            client.protect_branch(repo, config.default_branch)
            branch_protected = True
        except GithubException as exc:
            protection_error = _github_error_message(exc)

    skipped = list(getattr(config, "_skipped_secrets", []))
    return GhInitResult(
        html_url=html_url,
        actions_url=actions_url,
        pages_url=pages_url,
        skipped_secrets=skipped,
        pushed=pushed,
        pages_configured=pages_configured,
        pages_branch=PAGES_BRANCH,
        pages_error=pages_error,
        homepage_set=homepage_set,
        branch_protected=branch_protected,
        protection_error=protection_error,
    )

load_pyproject

load_pyproject(project_path: Path) -> dict[str, Any]

Return the [project] table from the project's pyproject.toml.

Source code in repo_scaffold/github_init/config.py
def load_pyproject(project_path: Path) -> dict[str, Any]:
    """Return the ``[project]`` table from the project's ``pyproject.toml``."""
    pyproject = project_path / "pyproject.toml"
    if not pyproject.is_file():
        return {}
    with pyproject.open("rb") as f:
        data = tomllib.load(f)
    return data.get("project", {}) or {}

parse_dotenv

parse_dotenv(text: str) -> dict[str, str]

Parse a tiny subset of dotenv syntax: KEY=VALUE lines, # comments.

Quoted values are unquoted. Anything more exotic (export, multiline, command substitution) is intentionally not supported — projects that need that should set real env vars before running gh-init.

Source code in repo_scaffold/github_init/config.py
def parse_dotenv(text: str) -> dict[str, str]:
    """Parse a tiny subset of dotenv syntax: ``KEY=VALUE`` lines, ``#`` comments.

    Quoted values are unquoted. Anything more exotic (export, multiline, command
    substitution) is intentionally not supported — projects that need that should
    set real env vars before running gh-init.
    """
    result: dict[str, str] = {}
    for raw in text.splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        key, sep, value = line.partition("=")
        if not sep:
            continue
        key = key.strip()
        value = value.strip()
        if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
            value = value[1:-1]
        if key:
            result[key] = value
    return result