From 29d1adaf78b86728ed3b46e56df810657ad6d372 Mon Sep 17 00:00:00 2001 From: Huerte Date: Fri, 12 Jun 2026 15:04:56 +0800 Subject: [PATCH] feat: add quickstart command and modernize python scaffold --- CHANGELOG.md | 7 +- CONTRIBUTING.md | 4 +- src/pygitgo/commands/init.py | 221 ++++++++++++++++++++++++++++------- src/pygitgo/commands/link.py | 19 +-- src/pygitgo/commands/new.py | 134 ++------------------- src/pygitgo/commands/repo.py | 133 +++++++++++++++++++++ src/pygitgo/exceptions.py | 10 -- src/pygitgo/main.py | 93 +++++++++++++-- tests/test_init.py | 99 ++++++++++++++++ tests/test_new.py | 77 +++--------- tests/test_repo.py | 125 ++++++++++++++++++++ 11 files changed, 666 insertions(+), 256 deletions(-) create mode 100644 src/pygitgo/commands/repo.py create mode 100644 tests/test_init.py create mode 100644 tests/test_repo.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a95a022..584f1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,14 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added -- **Project Scaffolding:** Added `gitgo init` to handle local project setup, gitignore fetching, and GitHub template downloads. -- **Remote Repo Creation:** Added `gitgo new` to create remote GitHub repositories without leaving the terminal. +- **Remote Repo Creation:** Added `gitgo repo` to create a remote GitHub repository without leaving the terminal. +- **Quickstart Command:** Added `gitgo new [lang]` as a one-shot command that scaffolds a local project, creates the GitHub repo, and pushes it all in sequence. ### Changed - **Documentation:** Added `docs/login-guide.md`, a step-by-step login guide with screenshots covering the full `gitgo user login` flow for starters. - **Internal Utils:** Centralized browser opening logic and updated the auth manager to support repo creation tokens. - `gitgo new` token prompt now opens the classic PAT page by default, making it easier to create non-expiring tokens. +- **Python Scaffolding:** `gitgo init python` now generates a modern `pyproject.toml` and `.python-version` file instead of `requirements.txt`. ### Fixed - Fixed `gitgo new` opening the browser on every call. It now saves the token to git config (`gitgo.github-token`) after the first paste. @@ -180,4 +181,4 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **HTTPS to SSH Conversion:** If your remote is HTTPS and SSH is configured, GitGo silently converts the remote URL before pushing. - **Termux Support:** Detects Termux via environment variables, adjusts binary paths to `$PREFIX/bin`, uses `termux-open` for browser actions, and handles the `detected dubious ownership` Git error natively. - **URL Validation:** `gitgo link` validates the repository URL format (HTTPS and SSH) before proceeding. -- **Exception Hierarchy:** `GitGoError`, `GitCommandError`, `AuthError`, `ConfigError` for structured error handling across the codebase. +- **Exception Hierarchy:** `GitGoError`, `GitCommandError` for structured error handling across the codebase. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4807146..7a8971b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,7 @@ That's it. No Docker, no virtual environment required ``` src/pygitgo/ ├── main.py # Entry point, argument parsing, command routing -├── exceptions.py # GitGoError hierarchy (GitCommandError, AuthError, ConfigError) +├── exceptions.py # GitGoError hierarchy (GitCommandError) ├── commands/ │ ├── config.py # gitgo config handler (set/get defaults) │ ├── git_branch.py # Branch queries: get current branch, check existence, create @@ -161,7 +161,7 @@ git commit -m "update" ### Error Handling -- Raise `GitGoError` subclasses (`GitCommandError`, `AuthError`, `ConfigError`) instead of calling `sys.exit()` inside command modules. +- Raise `GitGoError` subclasses (`GitCommandError`) instead of calling `sys.exit()` inside command modules. - Only `main.py` should call `sys.exit()`. Command functions should raise or return. - Never swallow exceptions silently with empty `except` blocks. diff --git a/src/pygitgo/commands/init.py b/src/pygitgo/commands/init.py index 3a501a5..fb2aa92 100644 --- a/src/pygitgo/commands/init.py +++ b/src/pygitgo/commands/init.py @@ -1,11 +1,15 @@ -import urllib - from pygitgo.commands.git_core import git_init +from pygitgo.utils.colors import info, success from pygitgo.exceptions import GitGoError -from pygitgo.utils.colors import * +import urllib.request +import urllib.error +import zipfile +import json +import io import os -LANG_ALIASES: dict[str, str] = { + +LANG_ALIASES = { "py": "python", "rs": "rust", "rb": "ruby", @@ -13,21 +17,29 @@ "cpp": "c++", "cc": "c++", "cplusplus": "c++", - "cs": "csharp", + + "cs": "visualstudio", + "csharp": "visualstudio", + + ".net": "dotnet", "js": "node", "ts": "node", "javascript": "node", "typescript": "node", "golang": "go", - "dotnet": "csharp", - ".net": "csharp", } -PYTHON_REQUIREMENTS = """# Add project dependencies here +PYTHON_PYPROJECT = """[project] +name = "{name}" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [] """ -NODE_PACKAGE_JSON = """{{ +NODE_PACKAGE_JSON = """{{\ "name": "{name}", "version": "1.0.0", "description": "", @@ -68,47 +80,49 @@ go 1.20 """ +CSHARP_CSPROJ = """ + + Exe + net8.0 + enable + enable + + +""" + README_TEMPLATE = """# {name} -Scaffolded u """ +GITIGNORE_API_URL = "https://api.github.com/repos/github/gitignore/contents/" -def _fetch_available_templates(): - pass -def _fetch_gitignore(language): - url = ( - f"https://raw.githubusercontent.com/github/gitignore/main/{language}.gitignore" - ) +def _fetch_available_templates(): req = urllib.request.Request( - url, - headers={"User-Agent": "GitGo-CLI"} + GITIGNORE_API_URL, + headers={ + "User-Agent": "GitGo-CLI", + "Accept": "application/vnd.github+json", + }, ) - try: - with urllib.request.urlopen(req, timeout=15) as response: - return response.read().decode("utf-8") + with urllib.request.urlopen(req, timeout=15) as resp: + entries = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: - if e.code == 404: - raise GitGoError( - f"Language '{language}' not found in GitHub gitignore templates." - ) - raise GitGoError(f"Failed to fetch gitignore: HTTP {e.code}") + raise GitGoError(f"Failed to fetch template list: HTTP {e.code}") except Exception as e: - raise GitGoError(f"Network error fetching gitignore: {e}") - + raise GitGoError(f"Network error fetching template list: {e}") -def _download_and_extract_template(): - pass + suffix = ".gitignore" + return { + item["name"][: -len(suffix)].lower(): item["name"][: -len(suffix)] + for item in entries + if item.get("type") == "file" and item["name"].endswith(suffix) + } -def _scaffold_language(): - pass - - -def _resolved_language(language, available): - normalized = LANG_ALIASES.get(language.lower(), language.lower()) +def _resolve_lang(lang, available): + normalized = LANG_ALIASES.get(lang.lower(), lang.lower()) if normalized in available: return available[normalized] @@ -120,22 +134,143 @@ def _resolved_language(language, available): key=lambda t: abs(len(t) - len(normalized)), )[:5] hint = f"\n Similar templates: {', '.join(suggestions)}" if suggestions else "" - raise GitGoError(f"No .gitignore template found for '{language}'.{hint}") + raise GitGoError(f"No .gitignore template found for '{lang}'.{hint}") -def _download_and_extract_template(template) + +def _fetch_gitignore(resolved_lang): + url = ( + f"https://raw.githubusercontent.com/github/gitignore/main" + f"/{resolved_lang}.gitignore" + ) + req = urllib.request.Request(url, headers={"User-Agent": "GitGo-CLI"}) + try: + with urllib.request.urlopen(req, timeout=15) as response: + return response.read().decode("utf-8") + except urllib.error.HTTPError as e: + if e.code == 404: + raise GitGoError( + f"Language '{resolved_lang}' not found in GitHub gitignore templates." + ) + raise GitGoError(f"Failed to fetch gitignore: HTTP {e.code}") + except Exception as e: + raise GitGoError(f"Network error fetching gitignore: {e}") + + +def _download_and_extract_template(template_slug, target_dir): + url = f"https://api.github.com/repos/{template_slug}/zipball" + req = urllib.request.Request(url, headers={"User-Agent": "GitGo-CLI"}) + info(f"Downloading template from GitHub: {template_slug}...") + try: + with urllib.request.urlopen(req, timeout=30) as response: + zip_data = response.read() + except urllib.error.HTTPError as e: + if e.code == 404: + raise GitGoError( + f"Template repository '{template_slug}' not found on GitHub." + ) + raise GitGoError(f"Failed to download template: HTTP {e.code}") + except Exception as e: + raise GitGoError(f"Network error downloading template: {e}") + + try: + with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + namelist = zf.namelist() + if not namelist: + raise GitGoError("Downloaded ZIP archive is empty.") + + root_dir = namelist[0].split("/")[0] + "/" + for member in namelist: + if not member.startswith(root_dir): + continue + rel_path = member[len(root_dir):] + if not rel_path: + continue + dest = os.path.join(target_dir, rel_path) + if member.endswith("/"): + os.makedirs(dest, exist_ok=True) + else: + os.makedirs(os.path.dirname(dest), exist_ok=True) + with zf.open(member) as src, open(dest, "wb") as out: + out.write(src.read()) + success("Template extracted successfully.") + except Exception as e: + raise GitGoError(f"Failed to extract template: {e}") + + +def _scaffold_language(lang, target_dir, name): + available = _fetch_available_templates() + resolved_lang = _resolve_lang(lang, available) + gitignore_content = _fetch_gitignore(resolved_lang) + + with open(os.path.join(target_dir, "README.md"), "w", encoding="utf-8") as f: + f.write(README_TEMPLATE.format(name=name)) + with open(os.path.join(target_dir, ".gitignore"), "w", encoding="utf-8") as f: + f.write(gitignore_content) + + info("Created README.md and .gitignore") + + canonical = LANG_ALIASES.get(lang.lower(), lang.lower()) + + if canonical == "python": + with open(os.path.join(target_dir, "pyproject.toml"), "w", encoding="utf-8") as f: + f.write(PYTHON_PYPROJECT.format(name=name)) + with open(os.path.join(target_dir, ".python-version"), "w", encoding="utf-8") as f: + f.write("3.8\n") + info("Created pyproject.toml and .python-version") + + elif canonical == "node": + with open(os.path.join(target_dir, "package.json"), "w", encoding="utf-8") as f: + f.write(NODE_PACKAGE_JSON.format(name=name)) + info("Created package.json") + + elif canonical == "rust": + cargo_path = os.path.join(target_dir, "Cargo.toml") + with open(cargo_path, "w", encoding="utf-8") as f: + f.write(RUST_CARGO_TOML.format(name=name)) + src_dir = os.path.join(target_dir, "src") + os.makedirs(src_dir, exist_ok=True) + with open(os.path.join(src_dir, "main.rs"), "w", encoding="utf-8") as f: + f.write('fn main() {\n println!("Hello, world!");\n}\n') + info("Created Cargo.toml and src/main.rs") + + elif canonical == "dart": + with open(os.path.join(target_dir, "pubspec.yaml"), "w", encoding="utf-8") as f: + f.write(DART_PUBSPEC_YAML.format(name=name)) + info("Created pubspec.yaml") + + elif canonical == "flutter": + with open(os.path.join(target_dir, "pubspec.yaml"), "w", encoding="utf-8") as f: + f.write(FLUTTER_PUBSPEC_YAML.format(name=name)) + info("Created pubspec.yaml (Flutter)") + + elif canonical == "go": + with open(os.path.join(target_dir, "go.mod"), "w", encoding="utf-8") as f: + f.write(GO_MOD.format(name=name)) + with open(os.path.join(target_dir, "main.go"), "w", encoding="utf-8") as f: + f.write( + 'package main\n\nimport "fmt"\n\n' + 'func main() {\n\tfmt.Println("Hello, World!")\n}\n' + ) + info("Created go.mod and main.go") + + elif canonical in ("visualstudio", "dotnet"): + csproj_path = os.path.join(target_dir, f"{name}.csproj") + with open(csproj_path, "w", encoding="utf-8") as f: + f.write(CSHARP_CSPROJ) + with open(os.path.join(target_dir, "Program.cs"), "w", encoding="utf-8") as f: + f.write('Console.WriteLine("Hello, World!");\n') + info(f"Created {name}.csproj and Program.cs") def init_operation(args): - target_dir = args.name if os.path.exists(target_dir) and os.listdir(target_dir): raise GitGoError(f"Folder '{target_dir}' already exists and is not empty.") - + os.makedirs(target_dir, exist_ok=True) orig_cwd = os.getcwd() - try: if args.template: _download_and_extract_template(args.template, target_dir) @@ -156,6 +291,4 @@ def init_operation(args): pass raise e finally: - os.chdir(orig_cwd) - - + os.chdir(orig_cwd) \ No newline at end of file diff --git a/src/pygitgo/commands/link.py b/src/pygitgo/commands/link.py index 8f86280..63b2390 100644 --- a/src/pygitgo/commands/link.py +++ b/src/pygitgo/commands/link.py @@ -30,9 +30,7 @@ def _link_interrupt_cleanup(repo_url, initialized, committed, remote_added): success("No git state was changed.") -def link_operation(args): - repo_url = args.url - +def link_core(repo_url, commit_message="Initial commit", silent=False): if not validate_repo_url(repo_url): raise GitGoError( "\nInvalid repository URL!\n" @@ -40,8 +38,6 @@ def link_operation(args): " or: git@github.com:username/repo.git\n" ) - commit_message = args.message - initialized = False committed = False remote_added = False @@ -98,13 +94,18 @@ def link_operation(args): git_push(current_branch) - print_banner("REPOSITORY INITIALIZED AND DEPLOYED.") - - print() - info("Run 'gitgo undo link' to remove the remote and undo the initial commit.") + if not silent: + print_banner("REPOSITORY INITIALIZED AND DEPLOYED.") + print() + info("Run 'gitgo undo link' to remove the remote and undo the initial commit.") except KeyboardInterrupt: print() warning("Link interrupted (Ctrl+C).") _link_interrupt_cleanup(repo_url, initialized, committed, remote_added) sys.exit(130) + + +def link_operation(args): + link_core(args.url, args.message) + diff --git a/src/pygitgo/commands/new.py b/src/pygitgo/commands/new.py index f19ee5c..9e64992 100644 --- a/src/pygitgo/commands/new.py +++ b/src/pygitgo/commands/new.py @@ -1,131 +1,21 @@ -from pygitgo.utils.colors import info, success, warning -from pygitgo.utils.platform import open_url -from pygitgo.utils.config import get_config, set_config -from pygitgo.exceptions import GitGoError -import subprocess -import urllib -import json +from pygitgo.utils.colors import info, print_banner +from pygitgo.commands.init import init_operation +from pygitgo.commands.repo import repo_operation +from pygitgo.commands.link import link_core import os -GITHUB_API = "https://api.github.com" - -_TOKEN_URL = "https://github.com/settings/tokens/new?scopes=repo&description=GitGo" - - -def _clear_saved_token(): - try: - subprocess.run( - ["git", "config", "--global", "--unset", "gitgo.github-token"], - capture_output=True - ) - except Exception: - pass - - -def _prompt_for_token(): - info("GitGo needs a GitHub token to create repositories.") - info("Required scope: repo") - info("Tip: set 'Expiration' to 'No expiration' for a permanent token (Classic PAT only).") - open_url(_TOKEN_URL) - - token = input( - "After creating the token on GitHub,\n" - "come back here and paste it: " - ).strip() - - if not token: - raise GitGoError("Cancelled. No repository was created.") - - set_config("github-token", token, silent=True) - return token - - -def _get_github_token(): - token = os.environ.get("GITHUB_TOKEN", "").strip() - if token: - return token - - try: - result = subprocess.run( - ["gh", "auth", "token"], - capture_output=True, text=True, timeout=5 - ) - if result.returncode == 0: - token = result.stdout.strip() - if token: - return token - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - - token = get_config("github-token", "").strip() - if token: - return token - - return _prompt_for_token() +def new_operation(args): + init_operation(args) -def create_github_repo(name, private=False, description="", token=None): - - if not token: - token = _get_github_token() - - payload = json.dumps({ - "name": name, - "private": private, - "description": description, - }).encode("utf-8") + os.chdir(args.name) - req = urllib.request.Request( - f"{GITHUB_API}/user/repos", - data=payload, - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "GitGo-CLI" - }, method="POST", - ) + repo_url = repo_operation(args, silent=True) - try: - with urllib.request.urlopen(req, timeout=15) as resp: - return json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as e: - if e.code == 422: - raise GitGoError(f"Repository '{name}' already exists on GitHub.") - elif e.code == 401: - warning("Token is invalid or expired. Clearing saved token...") - _clear_saved_token() - warning("Please create a new token. Opening GitHub now...") - new_token = _prompt_for_token() - return create_github_repo(name, private=private, description=description, token=new_token) + link_core(repo_url, "Initial commit", silent=True) - body = e.read().decode("utf-8", errors="replace") - try: - msg = json.loads(body).get("message", body) - except Exception: - msg = body - raise GitGoError(f"GitHub API error {e.code}: {msg}") - except urllib.error.URLError as e: - raise GitGoError(f"Network error creating repo: {e.reason}") + print_banner("PROJECT LAUNCHED. SCAFFOLDED, CREATED, AND DEPLOYED.") + print() + info("Run 'gitgo undo link' to remove the remote and undo the initial commit.") -def new_operation(args): - - if args.name: - repo_name = args.name - else: - repo_name = os.path.basename(os.path.abspath(".")) - info(f"No name given: using current directory name: '{repo_name}'") - - token = _get_github_token() - info(f"Creating GitHub repository '{repo_name}'...") - repo_ = create_github_repo( - name=repo_name, - private=args.private, - description=args.description or "", - token=token - ) - repo_url = repo_.get("clone_url") - success(f"Successfully created remote repository: {repo_url}") - info(f"\nTo connect and push your local code, run:\n gitgo link {repo_url}") diff --git a/src/pygitgo/commands/repo.py b/src/pygitgo/commands/repo.py new file mode 100644 index 0000000..7bfcc53 --- /dev/null +++ b/src/pygitgo/commands/repo.py @@ -0,0 +1,133 @@ +from pygitgo.utils.colors import info, success, warning +from pygitgo.utils.config import get_config, set_config +from pygitgo.utils.platform import open_url +from pygitgo.exceptions import GitGoError +import subprocess +import urllib +import json +import os + +GITHUB_API = "https://api.github.com" + +_TOKEN_URL = "https://github.com/settings/tokens/new?scopes=repo&description=GitGo" + + +def _clear_saved_token(): + try: + subprocess.run( + ["git", "config", "--global", "--unset", "gitgo.github-token"], + capture_output=True + ) + except Exception: + pass + + +def _prompt_for_token(): + info("GitGo needs a GitHub token to create repositories.") + info("Required scope: repo") + info("Tip: set 'Expiration' to 'No expiration' for a permanent token (Classic PAT only).") + open_url(_TOKEN_URL) + + token = input( + "After creating the token on GitHub,\n" + "come back here and paste it: " + ).strip() + + if not token: + raise GitGoError("Cancelled. No repository was created.") + + set_config("github-token", token, silent=True) + return token + + +def _get_github_token(): + token = os.environ.get("GITHUB_TOKEN", "").strip() + if token: + return token + + try: + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + token = result.stdout.strip() + if token: + return token + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + token = get_config("github-token", "").strip() + if token: + return token + + return _prompt_for_token() + + +def create_github_repo(name, private=False, description="", token=None): + + if not token: + token = _get_github_token() + + payload = json.dumps({ + "name": name, + "private": private, + "description": description, + }).encode("utf-8") + + req = urllib.request.Request( + f"{GITHUB_API}/user/repos", + data=payload, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "GitGo-CLI" + }, method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + if e.code == 422: + raise GitGoError(f"Repository '{name}' already exists on GitHub.") + elif e.code == 401: + warning("Token is invalid or expired. Clearing saved token...") + _clear_saved_token() + warning("Please create a new token. Opening GitHub now...") + new_token = _prompt_for_token() + return create_github_repo(name, private=private, description=description, token=new_token) + + body = e.read().decode("utf-8", errors="replace") + try: + msg = json.loads(body).get("message", body) + except Exception: + msg = body + raise GitGoError(f"GitHub API error {e.code}: {msg}") + except urllib.error.URLError as e: + raise GitGoError(f"Network error creating repo: {e.reason}") + + +def repo_operation(args, silent=False): + if args.name: + repo_name = args.name + else: + repo_name = os.path.basename(os.path.abspath(".")) + info(f"No name given: using current directory name: '{repo_name}'") + + token = _get_github_token() + info(f"Creating GitHub repository '{repo_name}'...") + repo_ = create_github_repo( + name=repo_name, + private=args.private, + description=args.description or "", + token=token + ) + repo_url = repo_.get("clone_url") + success(f"Successfully created remote repository: {repo_url}") + if not silent: + info(f"\nTo connect and push your local code, run:\n gitgo link {repo_url}") + return repo_url + diff --git a/src/pygitgo/exceptions.py b/src/pygitgo/exceptions.py index ccefa72..c209c8e 100644 --- a/src/pygitgo/exceptions.py +++ b/src/pygitgo/exceptions.py @@ -1,5 +1,4 @@ class GitGoError(Exception): - """Base exception for all GitGo errors.""" pass @@ -12,12 +11,3 @@ def __init__(self, command, stderr="", returncode=1): cmd_str = ' '.join(command) if isinstance(command, list) else command super().__init__(f"Command failed: {cmd_str}\n{stderr}") - -class AuthError(GitGoError): - """Raised when SSH or authentication operations fail.""" - pass - - -class ConfigError(GitGoError): - """Raised when configuration is missing or invalid.""" - pass diff --git a/src/pygitgo/main.py b/src/pygitgo/main.py index c0fb450..78060bc 100644 --- a/src/pygitgo/main.py +++ b/src/pygitgo/main.py @@ -9,6 +9,8 @@ from pygitgo.commands.link import link_operation from pygitgo.commands.push import push_operation from pygitgo.commands.user import user_operation +from pygitgo.commands.repo import repo_operation +from pygitgo.commands.init import init_operation from pygitgo.commands.new import new_operation from pygitgo.exceptions import GitGoError import argparse @@ -147,24 +149,95 @@ def main(): ) pull_parser.add_argument("branch", nargs="?", default=None, help="The branch to pull from (default is your current branch)") - new_parser = subparsers.add_parser( - "new", - help="Create a GitHub repo and link the current project to it", + init_parser = subparsers.add_parser( + "init", + help="Scaffold a new project structure", epilog=( "Examples:\n" - " gitgo new Use current directory name as repo name\n" - " gitgo new my-app Create repo 'my-app' and push\n" - " gitgo new my-app --private Create a private repo\n" + " gitgo init my-app python Scaffold a Python project locally\n" + " gitgo init my-app --template owner/repo Download a template locally\n" ), formatter_class=argparse.RawDescriptionHelpFormatter ) - new_parser.add_argument( + init_parser.add_argument( + "name", + metavar="NAME", + help="Project name or path to scaffold" + ) + init_parser.add_argument( + "lang", + nargs="?", + default=None, + metavar="LANG", + help="Language to scaffold (e.g. python, node, rust, go)." + ) + init_parser.add_argument( + "--template", + default=None, + metavar="OWNER/REPO", + help="GitHub template repo to clone instead of a language scaffold." + ) + + repo_parser = subparsers.add_parser( + "repo", + help="Create a remote GitHub repository", + epilog=( + "Examples:\n" + " gitgo repo Use current directory name as repo name\n" + " gitgo repo my-app Create repo 'my-app' on GitHub\n" + " gitgo repo my-app --private Create a private repo\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter + ) + repo_parser.add_argument( "name", default=None, nargs="?", metavar="NAME", help="Repository name. Defaults to current directory name." ) + repo_parser.add_argument( + "-p", "--private", + default=False, + action="store_true", + help="Create a private repository." + ) + repo_parser.add_argument( + "-d", "--description", + default="", + metavar="TEXT", + help="Short repository description shown on GitHub." + ) + + new_parser = subparsers.add_parser( + "new", + help="Scaffold, create remote repo, and push in one command", + epilog=( + "Examples:\n" + " gitgo new my-app python Scaffold a Python project and push it\n" + " gitgo new my-app --private Private repo, no language scaffold\n" + " gitgo new my-app rust --private Private Rust project\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter + ) + new_parser.add_argument( + "name", + metavar="NAME", + help="Project name. Used for both the local folder and the GitHub repo." + ) + new_parser.add_argument( + "lang", + nargs="?", + default=None, + metavar="LANG", + help="Language to scaffold (e.g. python, node, rust, go)." + ) + new_parser.add_argument( + "--template", + default=None, + metavar="OWNER/REPO", + help="GitHub template repo to clone instead of a language scaffold." + ) new_parser.add_argument( "-p", "--private", default=False, @@ -177,7 +250,7 @@ def main(): metavar="TEXT", help="Short repository description shown on GitHub." ) - + args = parser.parse_args() if getattr(args, 'version', False): @@ -216,8 +289,12 @@ def main(): undo_operation(args) elif args.command == "pull": pull_operation(args) + elif args.command == "repo": + repo_operation(args) elif args.command == "new": new_operation(args) + elif args.command == "init": + init_operation(args) except GitGoError as e: error(f"{e}") sys.exit(1) diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..1a8d30b --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,99 @@ +from unittest.mock import patch, MagicMock +from pygitgo.exceptions import GitGoError +from pygitgo.commands.init import ( + _resolve_lang, + _scaffold_language, + init_operation, +) +import pytest +import os + + +SAMPLE_AVAILABLE = { + "python": "Python", + "go": "Go", + "rust": "Rust", + "ruby": "Ruby", + "c++": "C++", + "node": "Node", + "dotnet": "Dotnet", + "visualstudio": "VisualStudio", +} + + +def test_resolve_lang(): + assert _resolve_lang("py", SAMPLE_AVAILABLE) == "Python" + assert _resolve_lang("python", SAMPLE_AVAILABLE) == "Python" + assert _resolve_lang("golang", SAMPLE_AVAILABLE) == "Go" + assert _resolve_lang("rust", SAMPLE_AVAILABLE) == "Rust" + assert _resolve_lang("ruby", SAMPLE_AVAILABLE) == "Ruby" + + +def test_resolve_lang_csharp_aliases(): + assert _resolve_lang("cs", SAMPLE_AVAILABLE) == "VisualStudio" + assert _resolve_lang("csharp", SAMPLE_AVAILABLE) == "VisualStudio" + assert _resolve_lang("dotnet", SAMPLE_AVAILABLE) == "Dotnet" + assert _resolve_lang(".net", SAMPLE_AVAILABLE) == "Dotnet" + + +def test_resolve_lang_unknown_raises(): + with pytest.raises(GitGoError): + _resolve_lang("notalanguage", SAMPLE_AVAILABLE) + + +@patch("pygitgo.commands.init._fetch_gitignore") +@patch("pygitgo.commands.init._fetch_available_templates") +def test_scaffold_language_python(mock_available, mock_gitignore, tmp_path): + mock_available.return_value = {"python": "Python"} + mock_gitignore.return_value = "mock gitignore content" + + _scaffold_language("python", str(tmp_path), "test-project") + + assert (tmp_path / "README.md").exists() + assert (tmp_path / ".gitignore").exists() + assert (tmp_path / "pyproject.toml").exists() + assert (tmp_path / ".python-version").exists() + mock_gitignore.assert_called_once_with("Python") + + +@patch("pygitgo.commands.init._fetch_gitignore") +@patch("pygitgo.commands.init._fetch_available_templates") +def test_scaffold_language_csharp(mock_available, mock_gitignore, tmp_path): + mock_available.return_value = {"visualstudio": "VisualStudio"} + mock_gitignore.return_value = "mock gitignore content" + + _scaffold_language("cs", str(tmp_path), "test-project") + + assert (tmp_path / "README.md").exists() + assert (tmp_path / ".gitignore").exists() + assert (tmp_path / "test-project.csproj").exists() + assert (tmp_path / "Program.cs").exists() + mock_gitignore.assert_called_once_with("VisualStudio") + + +@patch("pygitgo.commands.init._download_and_extract_template") +@patch("pygitgo.commands.init.git_init") +def test_init_operation_template(mock_git_init, mock_download, tmp_path): + args = MagicMock() + args.name = str(tmp_path / "new-project") + args.template = "owner/repo" + args.lang = None + + init_operation(args) + + mock_download.assert_called_once_with("owner/repo", args.name) + mock_git_init.assert_called_once() + + +@patch("pygitgo.commands.init._scaffold_language") +@patch("pygitgo.commands.init.git_init") +def test_init_operation_lang(mock_git_init, mock_scaffold, tmp_path): + args = MagicMock() + args.name = str(tmp_path / "new-project") + args.template = None + args.lang = "python" + + init_operation(args) + + mock_scaffold.assert_called_once_with("python", args.name, args.name) + mock_git_init.assert_called_once() \ No newline at end of file diff --git a/tests/test_new.py b/tests/test_new.py index 57e46ab..d12f702 100644 --- a/tests/test_new.py +++ b/tests/test_new.py @@ -1,66 +1,27 @@ from unittest.mock import patch, MagicMock -from pygitgo.exceptions import GitGoError -from pygitgo.commands.new import ( - _get_github_token, - create_github_repo, - new_operation, -) -import pytest +from pygitgo.commands.new import new_operation -@patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) -def test_get_github_token_env(): - assert _get_github_token() == "test-token" - - -@patch.dict("os.environ", {}, clear=True) -@patch("subprocess.run") -def test_get_github_token_gh_cli(mock_run): - mock_run.return_value = MagicMock(returncode=0, stdout=" cli-token ") - assert _get_github_token() == "cli-token" - - -@patch.dict("os.environ", {}, clear=True) -@patch("subprocess.run") -@patch("pygitgo.commands.new.open_url") -@patch("builtins.input", return_value="user-pasted-token") -def test_get_github_token_guided(mock_input, mock_open_url, mock_run): - mock_run.side_effect = FileNotFoundError() - assert _get_github_token() == "user-pasted-token" - mock_open_url.assert_called_once_with("https://github.com/settings/tokens/new?scopes=repo&description=GitGo") - - -@patch.dict("os.environ", {}, clear=True) -@patch("subprocess.run") -@patch("pygitgo.commands.new.open_url") -@patch("builtins.input", return_value="") -def test_get_github_token_cancelled(mock_input, mock_open_url, mock_run): - mock_run.side_effect = FileNotFoundError() - with pytest.raises(GitGoError, match="Cancelled"): - _get_github_token() - - -@patch("urllib.request.urlopen") -def test_create_github_repo_success(mock_urlopen): - mock_response = MagicMock() - mock_response.read.return_value = b'{"clone_url": "https://github.com/user/repo.git"}' - mock_urlopen.return_value.__enter__.return_value = mock_response - - res = create_github_repo("repo", token="token") - assert res["clone_url"] == "https://github.com/user/repo.git" - - -@patch("pygitgo.commands.new._get_github_token", return_value="token") -@patch("pygitgo.commands.new.create_github_repo") -def test_new_operation(mock_create, mock_token, capsys): - mock_create.return_value = {"clone_url": "https://github.com/user/repo.git"} +@patch("pygitgo.commands.new.init_operation") +@patch("pygitgo.commands.new.repo_operation") +@patch("pygitgo.commands.new.link_core") +@patch("os.chdir") +def test_new_operation_quickstart(mock_chdir, mock_link_core, mock_repo_operation, mock_init_operation, capsys): args = MagicMock() - args.name = "repo" - args.private = False - args.description = "desc" + args.name = "my-project" + args.lang = "python" + args.template = None + args.private = True + args.description = "my desc" + + mock_repo_operation.return_value = "https://github.com/user/my-project.git" new_operation(args) + mock_init_operation.assert_called_once_with(args) + mock_chdir.assert_called_once_with("my-project") + mock_repo_operation.assert_called_once_with(args, silent=True) + mock_link_core.assert_called_once_with("https://github.com/user/my-project.git", "Initial commit", silent=True) + captured = capsys.readouterr() - assert "Successfully created remote repository" in captured.out - assert "gitgo link" in captured.out \ No newline at end of file + assert "undo link" in captured.out \ No newline at end of file diff --git a/tests/test_repo.py b/tests/test_repo.py new file mode 100644 index 0000000..1a80e72 --- /dev/null +++ b/tests/test_repo.py @@ -0,0 +1,125 @@ +from unittest.mock import patch, MagicMock +from pygitgo.exceptions import GitGoError +from pygitgo.commands.repo import ( + _get_github_token, + create_github_repo, + repo_operation, + _clear_saved_token, +) +import urllib.error +import pytest + + +@patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) +def test_get_github_token_env(): + assert _get_github_token() == "test-token" + + +@patch.dict("os.environ", {}, clear=True) +@patch("subprocess.run") +def test_get_github_token_gh_cli(mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout=" cli-token ") + assert _get_github_token() == "cli-token" + + +@patch.dict("os.environ", {}, clear=True) +@patch("subprocess.run") +@patch("pygitgo.commands.repo.get_config", return_value="cached-token") +def test_get_github_token_cached(mock_get_config, mock_run): + mock_run.side_effect = FileNotFoundError() + assert _get_github_token() == "cached-token" + mock_get_config.assert_called_once_with("github-token", "") + + +@patch.dict("os.environ", {}, clear=True) +@patch("subprocess.run") +@patch("pygitgo.commands.repo.get_config", return_value="") +@patch("pygitgo.commands.repo.open_url") +@patch("pygitgo.commands.repo.set_config") +@patch("builtins.input", return_value="user-pasted-token") +def test_get_github_token_prompt(mock_input, mock_set_config, mock_open_url, mock_get_config, mock_run): + mock_run.side_effect = FileNotFoundError() + assert _get_github_token() == "user-pasted-token" + mock_open_url.assert_called_once() + mock_set_config.assert_called_once_with("github-token", "user-pasted-token", silent=True) + + +@patch.dict("os.environ", {}, clear=True) +@patch("subprocess.run") +@patch("pygitgo.commands.repo.get_config", return_value="") +@patch("pygitgo.commands.repo.open_url") +@patch("builtins.input", return_value="") +def test_get_github_token_cancelled(mock_input, mock_open_url, mock_get_config, mock_run): + mock_run.side_effect = FileNotFoundError() + with pytest.raises(GitGoError, match="Cancelled"): + _get_github_token() + + +@patch("urllib.request.urlopen") +def test_create_github_repo_success(mock_urlopen): + mock_response = MagicMock() + mock_response.read.return_value = b'{"clone_url": "https://github.com/user/repo.git"}' + mock_urlopen.return_value.__enter__.return_value = mock_response + + res = create_github_repo("repo", token="token") + assert res["clone_url"] == "https://github.com/user/repo.git" + + +@patch("urllib.request.urlopen") +@patch("pygitgo.commands.repo._get_github_token", return_value="old-token") +@patch("pygitgo.commands.repo._prompt_for_token", return_value="new-token") +@patch("pygitgo.commands.repo._clear_saved_token") +def test_create_github_repo_401_retry(mock_clear, mock_prompt, mock_token, mock_urlopen): + # First call fails with 401, second call (during retry) succeeds + mock_error_resp = MagicMock() + mock_error_resp.code = 401 + mock_error_resp.read.return_value = b'{"message": "Unauthorized"}' + + mock_success_resp = MagicMock() + mock_success_resp.read.return_value = b'{"clone_url": "https://github.com/user/repo-retry.git"}' + mock_success_resp.__enter__.return_value = mock_success_resp + + # We mock urlopen to raise HTTPError first, then return success response + mock_urlopen.side_effect = [ + urllib.error.HTTPError("url", 401, "msg", {}, mock_error_resp), + mock_success_resp + ] + + res = create_github_repo("repo") + assert res["clone_url"] == "https://github.com/user/repo-retry.git" + mock_clear.assert_called_once() + mock_prompt.assert_called_once() + + +@patch("pygitgo.commands.repo._get_github_token", return_value="token") +@patch("pygitgo.commands.repo.create_github_repo") +def test_repo_operation_verbose(mock_create, mock_token, capsys): + mock_create.return_value = {"clone_url": "https://github.com/user/repo.git"} + args = MagicMock() + args.name = "repo" + args.private = False + args.description = "desc" + + url = repo_operation(args, silent=False) + assert url == "https://github.com/user/repo.git" + + captured = capsys.readouterr() + assert "Successfully created remote repository" in captured.out + assert "gitgo link" in captured.out + + +@patch("pygitgo.commands.repo._get_github_token", return_value="token") +@patch("pygitgo.commands.repo.create_github_repo") +def test_repo_operation_silent(mock_create, mock_token, capsys): + mock_create.return_value = {"clone_url": "https://github.com/user/repo.git"} + args = MagicMock() + args.name = "repo" + args.private = False + args.description = "desc" + + url = repo_operation(args, silent=True) + assert url == "https://github.com/user/repo.git" + + captured = capsys.readouterr() + assert "Successfully created remote repository" in captured.out + assert "gitgo link" not in captured.out