Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Minimalist Git & Github support via CLI #407

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@ manual_test/

# other local dev info
.vscode/
.history/

# Mac OS-specific storage files
.DS_Store
Icon?
Icon
Icon[\r]

# ruff
.ruff_cache/

# vim
*.swp
Expand Down
37 changes: 37 additions & 0 deletions ccds-help.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,5 +277,42 @@
}
}
]
},
{
"field": "version_control",
"help": {
"description": "What kind of version control system (vcs) and repository host to use.",
"more_information": ""
},
"choices": [
{
"choice": "none",
"help": {
"description": "No version control.",
"more_information": ""
}
},
{
"choice": "git (local)",
"help": {
"description": "Initialize project as a local git repository.",
"more_information": "[Git CLI](https://git-scm.com/downloads) Required"
}
},
{
"choice": "git (github private)",
"help": {
"description": "Initialize project and upload to GitHub as a **private** repo.",
"more_information": "[Git CLI](https://git-scm.com/downloads) + [GitHub CLI](https://cli.github.com/) & [Auth](https://cli.github.com/manual/gh_auth_login) Required"
}
},
{
"choice": "git (github public)",
"help": {
"description": "Initialize project and upload to GitHub as a **public** repo.",
"more_information": "[Git CLI](https://git-scm.com/downloads) + [GitHub CLI](https://cli.github.com/) & [Auth](https://cli.github.com/manual/gh_auth_login) Required"
}
}
]
}
]
8 changes: 7 additions & 1 deletion ccds.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,11 @@
],
"open_source_license": ["No license file", "MIT", "BSD-3-Clause"],
"docs": ["mkdocs", "none"],
"include_code_scaffold": ["Yes", "No"]
"include_code_scaffold": ["Yes", "No"],
"version_control": [
"none",
"git (local)",
"git (github private)",
"git (github public)"
]
}
156 changes: 156 additions & 0 deletions ccds/hook_utils/configure_vcs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import os
import subprocess
from pathlib import Path
from typing import Literal, Union

# ---------------------------------------------------------------------------- #
# Git #
# ---------------------------------------------------------------------------- #


def init_local_git_repo(
directory: Union[str, Path], _make_initial_commit: bool = True
) -> bool:
"""
Initialize a local git repository without any GitHub integration.

Args:
directory: Directory where the repository will be created
_make_initial_commit: Whether to make initial commit (for testing)

Returns:
bool: True if initialization was successful, False otherwise
"""
try:
if not _check_git_cli_installed():
raise RuntimeError("git CLI is required but not installed")

directory = Path(directory)
if not directory.is_dir():
raise ValueError(f"Directory '{directory}' does not exist.")

os.chdir(directory)

if not (directory / ".git").is_dir():
_git("init")
if _make_initial_commit:
_git("add .")
_git("commit -m 'Initial commit'")

return True
except Exception as e:
print(f"Error during repository initialization: {e}")
return False


def _git(command: str, **kwargs) -> subprocess.CompletedProcess:
"""Run a git command and return the result."""
return subprocess.run(f"git {command}", shell=True, check=True, **kwargs)


def _check_git_cli_installed() -> bool:
"""Check whether git cli is installed"""
try:
subprocess.run("git --version", shell=True, check=True, capture_output=True)
return True
except subprocess.CalledProcessError:
return False


# ---------------------------------------------------------------------------- #
# Git + Github #
# ---------------------------------------------------------------------------- #


def configure_github_repo(
directory: Union[str, Path],
repo_name: str,
visibility: Literal["private", "public"] = "private",
) -> bool:
"""
Configure a Git repository locally and optionally on GitHub with specified branch protections.

Args:
directory: Directory where the repository will be created or updated
repo_name: Name of the repository
visibility: Whether to upload to github as a public or private repo

Returns:
bool: True if configuration was successful, False otherwise
"""
try:
subprocess.run("gh --version", shell=True, check=True, capture_output=True)
except subprocess.CalledProcessError:
raise RuntimeError("GitHub CLI is not installed. Please install and try again.")
try:
subprocess.run("gh auth status", shell=True, check=True, capture_output=True)
except subprocess.CalledProcessError:
raise RuntimeError(
"GitHub CLI not authenticated. Please run `gh auth login` and try again."
)

try:
# GitHub operations
github_username = _gh(
"api user -q .login", capture_output=True, text=True
).stdout.strip()

# Create or update GitHub repository
if not _github_repo_exists(github_username, repo_name):
# Initialize local repository
if not init_local_git_repo(directory):
return False
_gh(
f"repo create {repo_name} --{visibility} --source=. --remote=origin --push"
)
else:
remote_url = _get_gh_remote_url(github_username, repo_name)
raise RuntimeError(f"GitHub repo already exists at {remote_url}")
# TODO: Prompt user if they would like to set existing repo as origin.
# remote_url = _get_gh_remote_url(github_username, repo_name)
# try:
# _git(f"remote set-url origin {remote_url}")
# except subprocess.CalledProcessError:
# _git(f"remote add origin {remote_url}")

# Push to newly created origin
_git("push -u origin main")

print("Repository configuration complete on GitHub!")

return True

except Exception as e:
print(f"Error during repository configuration: {e}")
return False


def _gh(command: str, **kwargs) -> subprocess.CompletedProcess:
"""Run a GitHub CLI command and return the result."""
return subprocess.run(f"gh {command}", shell=True, check=True, **kwargs)


def _get_gh_remote_url(github_username: str, repo_name: str) -> Literal["https", "ssh"]:
"""Returns whether the github protocol is https or ssh from user's config"""
try:
protocol = _gh(
"config get git_protocol", capture_output=True, text=True
).stdout.strip()
if protocol == "ssh":
return f"[email protected]:{github_username}/{repo_name}.git"
elif protocol == "https":
return f"https://github.com/{github_username}/{repo_name}"
else:
raise ValueError(f"Unexepected GitHub protocol {protocol}")
except subprocess.CalledProcessError:
# Default to https if not set
return "https"


def _github_repo_exists(username: str, repo_name: str) -> bool:
"""Check if a GitHub repository exists."""
try:
_gh(f"repo view {username}/{repo_name}", capture_output=True)
return True
except subprocess.CalledProcessError:
return False
18 changes: 18 additions & 0 deletions hooks/post_gen_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from copy import copy
from pathlib import Path

from ccds.hook_utils.configure_vcs import configure_github_repo, init_local_git_repo

# https://github.com/cookiecutter/cookiecutter/issues/824
# our workaround is to include these utility functions in the CCDS package
from ccds.hook_utils.custom_config import write_custom_config
Expand Down Expand Up @@ -80,3 +82,19 @@
# remove any content in __init__.py since it won't be available
generated_path.write_text("")
# {% endif %}

#
# VERSION CONTROL
#

# {% if cookiecutter.version_control == "git (local)" %}
init_local_git_repo(directory=Path.cwd())
# {% elif cookiecutter.version_control == "git (github private)" %}
configure_github_repo(
directory=Path.cwd(), repo_name="{{ cookiecutter.repo_name }}", visibility="private"
)
# {% elif cookiecutter.version_control == "git (github public)" %}
configure_github_repo(
directory=Path.cwd(), repo_name="{{ cookiecutter.repo_name }}", visibility="public"
)
# {% endif %}
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ ccds = "ccds.__main__:main"
"Source Code" = "https://github.com/drivendataorg/cookiecutter-data-science/"
"Bug Tracker" = "https://github.com/drivendataorg/cookiecutter-data-science/issues"
"DrivenData" = "https://drivendata.co"

[tool.pytest.ini_options]
testpaths = "./tests"
addopts = "-vv --color=yes"
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"module_name": "project_module",
"author_name": "DrivenData",
"description": "A test project",
"version_control": "git (local)",
}


Expand All @@ -38,6 +39,8 @@ def config_generator(fast=False):
],
[("dependency_file", opt) for opt in cookiecutter_json["dependency_file"]],
[("pydata_packages", opt) for opt in cookiecutter_json["pydata_packages"]],
[("version_control", opt) for opt in ("none", "git (local)")],
# TODO: Tests for "version_control": "git (github)"
)

def _is_valid(config):
Expand Down
Loading