Skip to content
Merged
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: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [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 <name> 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.
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
221 changes: 177 additions & 44 deletions src/pygitgo/commands/init.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,45 @@
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",
"kt": "kotlin",
"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": "",
Expand Down Expand Up @@ -68,47 +80,49 @@
go 1.20
"""

CSHARP_CSPROJ = """<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
"""

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]

Expand All @@ -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)
Expand All @@ -156,6 +291,4 @@ def init_operation(args):
pass
raise e
finally:
os.chdir(orig_cwd)


os.chdir(orig_cwd)
19 changes: 10 additions & 9 deletions src/pygitgo/commands/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,14 @@ 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"
"Expected format: https://github.com/username/repo.git"
" or: git@github.com:username/repo.git\n"
)

commit_message = args.message

initialized = False
committed = False
remote_added = False
Expand Down Expand Up @@ -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)

Loading
Loading