From ad63669d0930cde2ffac01385a6d249e50fd0ee0 Mon Sep 17 00:00:00 2001 From: Fernando Paladini Date: Fri, 12 Jun 2026 11:11:53 -0300 Subject: [PATCH 1/2] feat: package as gh2gl on PyPI with gh2gl init workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pyproject.toml (hatchling build, gh2gl entry point) - Move src/ → src/gh2gl/ as a proper importable package - Add cli.py with backup (default) and init subcommands - gh2gl init creates github-backup/ workspace folder with config.yaml and .env templates ready to edit - Update README with pip install / init / run flow Co-Authored-By: Claude Sonnet 4.6 --- README.md | 54 ++++++--- backup.py | 130 +------------------- pyproject.toml | 40 +++++++ requirements.txt | 5 - src/__init__.py | 0 src/gh2gl/__init__.py | 1 + src/{ => gh2gl}/backup_runner.py | 8 +- src/gh2gl/cli.py | 198 +++++++++++++++++++++++++++++++ src/{ => gh2gl}/config.py | 11 +- src/{ => gh2gl}/git_ops.py | 0 src/{ => gh2gl}/github_client.py | 2 +- src/{ => gh2gl}/gitlab_client.py | 2 +- src/{ => gh2gl}/models.py | 0 13 files changed, 288 insertions(+), 163 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 src/__init__.py create mode 100644 src/gh2gl/__init__.py rename src/{ => gh2gl}/backup_runner.py (93%) create mode 100644 src/gh2gl/cli.py rename src/{ => gh2gl}/config.py (84%) rename src/{ => gh2gl}/git_ops.py (100%) rename src/{ => gh2gl}/github_client.py (98%) rename src/{ => gh2gl}/gitlab_client.py (98%) rename src/{ => gh2gl}/models.py (100%) diff --git a/README.md b/README.md index 2f2a312..1adadec 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ -# backup-github-to-gitlab +# gh2gl > Mirror all your GitHub repositories to GitLab automatically — private stays private, public stays public. +[![PyPI](https://img.shields.io/pypi/v/gh2gl.svg)](https://pypi.org/project/gh2gl/) [![Python](https://img.shields.io/badge/python-3.11+-3776AB.svg?logo=python&logoColor=white)](https://python.org) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) @@ -32,17 +33,30 @@ Having all your code on a single platform is a risk. This tool clones every repo ## Quick Start ```bash -git clone git@github.com:paladini/backup-github-to-gitlab.git -cd backup-github-to-gitlab -pip install -r requirements.txt +pip install gh2gl + +gh2gl init +# Creates a github-backup/ folder in the current directory + +cd github-backup +# Edit config.yaml — set github.username and gitlab.username +# Edit .env — set GITHUB_TOKEN and GITLAB_TOKEN + +gh2gl --dry-run # preview — no changes made +gh2gl # run the backup +``` -cp config.example.yaml config.yaml # then edit: set github.username and gitlab.username -cp .env.example .env # then edit: set GITHUB_TOKEN and GITLAB_TOKEN +
+Running from source -python backup.py --dry-run # preview — no changes made -python backup.py # run the backup +```bash +git clone git@github.com:paladini/backup-github-to-gitlab.git +cd backup-github-to-gitlab +pip install -e . ``` +
+ ## Token Setup ### GitHub token @@ -60,7 +74,9 @@ python backup.py # run the backup ## Configuration -**`config.yaml`** — copy from `config.example.yaml`: +After running `gh2gl init`, two files are created in the `github-backup/` folder: + +**`config.yaml`**: ```yaml github: @@ -73,10 +89,10 @@ gitlab: backup: include_forks: false # include forked repositories? (default: false) include_archived: true # include archived repositories? (default: true) - temp_dir: ./tmp # temporary dir for clones — auto-cleaned after each repo + # temp_dir: ./tmp # temporary dir for clones — auto-cleaned after each repo ``` -**`.env`** — copy from `.env.example`: +**`.env`**: ``` GITHUB_TOKEN=ghp_... @@ -87,24 +103,26 @@ Tokens are loaded at runtime and never logged. `.env` is in `.gitignore`. ## Usage +Run all commands from inside the `github-backup/` folder (or any folder with `config.yaml` and `.env`): + ```bash # Back up all personal repositories -python backup.py +gh2gl # Preview what would happen — no writes -python backup.py --dry-run +gh2gl --dry-run # Back up only repos matching a pattern -python backup.py --filter "myproject-*" +gh2gl --filter "myproject-*" # Include forks (skipped by default) -python backup.py --include-forks +gh2gl --include-forks # Verbose: show raw git output (useful for debugging SSH issues) -python backup.py --verbose +gh2gl --verbose # Use a different config file -python backup.py --config /path/to/config.yaml +gh2gl --config /path/to/config.yaml ``` ### Example output @@ -121,7 +139,7 @@ archived-experiment ~ dry run would create as private 10 dry run -[DRY RUN] Nenhuma operação foi executada. +[DRY RUN] No operations were executed. ``` ## Security diff --git a/backup.py b/backup.py index b2f5269..24a7b03 100644 --- a/backup.py +++ b/backup.py @@ -1,131 +1,5 @@ -import argparse -import sys - -from rich.console import Console -from rich.table import Table - -from src.backup_runner import BackupRunner -from src.config import ConfigError, load_config, validate_config -from src.git_ops import GitOps -from src.github_client import GithubClient -from src.gitlab_client import GitlabClient - -_console = Console() - - -def main() -> None: - parser = argparse.ArgumentParser( - prog="backup.py", - description="Backup GitHub repositories to GitLab, preserving visibility.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -examples: - python backup.py backup all personal repos - python backup.py --dry-run preview without making changes - python backup.py --filter "myproject-*" backup only matching repos - python backup.py --include-forks include forked repositories - python backup.py --verbose show git command output - """, - ) - parser.add_argument( - "--dry-run", action="store_true", - help="show what would be done without making any changes", - ) - parser.add_argument( - "--filter", metavar="PATTERN", dest="filter_pattern", - help="only backup repos matching glob pattern (e.g. 'myproject-*')", - ) - parser.add_argument( - "--verbose", action="store_true", - help="show detailed git output for debugging", - ) - parser.add_argument( - "--include-forks", action="store_true", - help="include forked repositories (default: skip forks)", - ) - parser.add_argument( - "--include-archived", action="store_true", - help="force-include archived repositories even if config says false", - ) - parser.add_argument( - "--config", metavar="PATH", default="config.yaml", - help="path to config file (default: config.yaml)", - ) - args = parser.parse_args() - - overrides: dict = {} - if args.dry_run: - overrides["dry_run"] = True - if args.filter_pattern: - overrides["filter_pattern"] = args.filter_pattern - if args.verbose: - overrides["verbose"] = True - if args.include_forks: - overrides["include_forks"] = True - if args.include_archived: - overrides["include_archived"] = True - - try: - config = load_config(args.config, overrides) - validate_config(config) - except ConfigError as e: - _console.print(f"[bold red]Configuration error:[/bold red] {e}") - sys.exit(1) - - if config.dry_run: - _console.print("[yellow bold]DRY RUN MODE — no changes will be made[/yellow bold]\n") - - github = GithubClient(config.github_token, config.github_username) - gitlab = GitlabClient(config.gitlab_token, config.gitlab_username, config.gitlab_url) - git_ops = GitOps(config.temp_dir, config.verbose) - runner = BackupRunner(config, github, gitlab, git_ops) - - results = runner.run() - - _print_report(results, config.dry_run) - - if any(r.status == "error" for r in results): - sys.exit(1) - - -def _print_report(results: list, dry_run: bool) -> None: - status_display = { - "success": "[green]✓ success[/green]", - "skip": "[blue]→ skip[/blue]", - "error": "[red]✗ error[/red]", - "dry_run": "[yellow]~ dry run[/yellow]", - } - - table = Table(show_header=True, header_style="bold", box=None) - table.add_column("Repository", style="cyan", min_width=30) - table.add_column("Status", min_width=14) - table.add_column("Details", style="dim") - - for r in results: - table.add_row(r.repo_name, status_display.get(r.status, r.status), r.message) - - _console.print() - _console.print(table) - - counts: dict[str, int] = {} - for r in results: - counts[r.status] = counts.get(r.status, 0) + 1 - - parts = [] - if counts.get("success"): - parts.append(f"[green]{counts['success']} copied[/green]") - if counts.get("skip"): - parts.append(f"[blue]{counts['skip']} skipped[/blue]") - if counts.get("dry_run"): - parts.append(f"[yellow]{counts['dry_run']} dry run[/yellow]") - if counts.get("error"): - parts.append(f"[red]{counts['error']} errors[/red]") - - _console.print(" • ".join(parts) if parts else "[dim]nothing to report[/dim]") - - if dry_run: - _console.print("\n[yellow][DRY RUN] Nenhuma operação foi executada.[/yellow]") - +# Dev shim — use 'gh2gl' after 'pip install -e .' +from gh2gl.cli import main if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..60b4bc8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "gh2gl" +version = "1.0.0" +description = "Mirror GitHub repositories to GitLab automatically." +readme = "README.md" +license = { file = "LICENSE" } +authors = [{ name = "Fernando Paladini", email = "fnpaladini@gmail.com" }] +requires-python = ">=3.11" +keywords = ["github", "gitlab", "backup", "mirror", "git"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Version Control :: Git", + "Topic :: System :: Archiving :: Backup", +] +dependencies = [ + "PyGithub>=2.3", + "python-gitlab>=4.4", + "python-dotenv>=1.0", + "rich>=13.7", + "PyYAML>=6.0", +] + +[project.urls] +Homepage = "https://github.com/paladini/backup-github-to-gitlab" + +[project.scripts] +gh2gl = "gh2gl.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/gh2gl"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c4cb8be..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -PyGithub>=2.3 -python-gitlab>=4.4 -python-dotenv>=1.0 -rich>=13.7 -PyYAML>=6.0 diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/gh2gl/__init__.py b/src/gh2gl/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/src/gh2gl/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/src/backup_runner.py b/src/gh2gl/backup_runner.py similarity index 93% rename from src/backup_runner.py rename to src/gh2gl/backup_runner.py index 3a99229..8775a1e 100644 --- a/src/backup_runner.py +++ b/src/gh2gl/backup_runner.py @@ -3,10 +3,10 @@ from rich.console import Console from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn -from src.git_ops import GitOps -from src.github_client import GithubClient -from src.gitlab_client import GitlabClient -from src.models import BackupResult, Config, RepoInfo +from gh2gl.git_ops import GitOps +from gh2gl.github_client import GithubClient +from gh2gl.gitlab_client import GitlabClient +from gh2gl.models import BackupResult, Config, RepoInfo _console = Console() diff --git a/src/gh2gl/cli.py b/src/gh2gl/cli.py new file mode 100644 index 0000000..97c8925 --- /dev/null +++ b/src/gh2gl/cli.py @@ -0,0 +1,198 @@ +import argparse +import sys +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +from gh2gl.backup_runner import BackupRunner +from gh2gl.config import ConfigError, load_config, validate_config +from gh2gl.git_ops import GitOps +from gh2gl.github_client import GithubClient +from gh2gl.gitlab_client import GitlabClient + +_console = Console() + +INIT_FOLDER = "github-backup" + +_CONFIG_TEMPLATE = """\ +github: + username: seu-usuario-github + +gitlab: + username: seu-usuario-gitlab + url: https://gitlab.com + +backup: + include_forks: false + include_archived: true + # temp_dir: ./tmp +""" + +_ENV_TEMPLATE = """\ +GITHUB_TOKEN=seu-token-github +GITLAB_TOKEN=seu-token-gitlab +""" + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="gh2gl", + description="Mirror GitHub repositories to GitLab, preserving visibility.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +examples: + gh2gl init create workspace folder with config files + gh2gl --dry-run preview without making changes + gh2gl --filter "myproject-*" backup only matching repos + gh2gl --include-forks include forked repositories + gh2gl --verbose show git command output + """, + ) + subparsers = parser.add_subparsers(dest="command") + + backup_parser = subparsers.add_parser( + "backup", + help="run the backup (default when no subcommand is given)", + ) + _add_backup_args(backup_parser) + + subparsers.add_parser( + "init", + help=f"create a '{INIT_FOLDER}/' workspace folder with config.yaml and .env", + ) + + # Top-level backup args for backwards compat: `gh2gl --dry-run` works without subcommand + _add_backup_args(parser) + + args = parser.parse_args() + + if args.command == "init": + _cmd_init() + else: + _cmd_backup(args) + + +def _add_backup_args(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--dry-run", action="store_true", + help="show what would be done without making any changes", + ) + p.add_argument( + "--filter", metavar="PATTERN", dest="filter_pattern", + help="only backup repos matching glob pattern (e.g. 'myproject-*')", + ) + p.add_argument( + "--verbose", action="store_true", + help="show detailed git output for debugging", + ) + p.add_argument( + "--include-forks", action="store_true", + help="include forked repositories (default: skip forks)", + ) + p.add_argument( + "--include-archived", action="store_true", + help="force-include archived repositories even if config says false", + ) + p.add_argument( + "--config", metavar="PATH", default="config.yaml", + help="path to config file (default: config.yaml)", + ) + + +def _cmd_backup(args: argparse.Namespace) -> None: + overrides: dict = {} + if args.dry_run: + overrides["dry_run"] = True + if args.filter_pattern: + overrides["filter_pattern"] = args.filter_pattern + if args.verbose: + overrides["verbose"] = True + if args.include_forks: + overrides["include_forks"] = True + if args.include_archived: + overrides["include_archived"] = True + + try: + config = load_config(args.config, overrides) + validate_config(config) + except ConfigError as e: + _console.print(f"[bold red]Configuration error:[/bold red] {e}") + sys.exit(1) + + if config.dry_run: + _console.print("[yellow bold]DRY RUN MODE — no changes will be made[/yellow bold]\n") + + github = GithubClient(config.github_token, config.github_username) + gitlab = GitlabClient(config.gitlab_token, config.gitlab_username, config.gitlab_url) + git_ops = GitOps(config.temp_dir, config.verbose) + runner = BackupRunner(config, github, gitlab, git_ops) + + results = runner.run() + + _print_report(results, config.dry_run) + + if any(r.status == "error" for r in results): + sys.exit(1) + + +def _cmd_init() -> None: + folder = Path(INIT_FOLDER) + if folder.exists(): + _console.print(f"[yellow]Folder already exists:[/yellow] {folder}/") + _console.print(f" Edit [cyan]{folder}/config.yaml[/cyan] and [cyan]{folder}/.env[/cyan]") + _console.print(f" Then run [bold cyan]gh2gl[/bold cyan] from inside that folder.") + return + + folder.mkdir() + (folder / "config.yaml").write_text(_CONFIG_TEMPLATE, encoding="utf-8") + (folder / ".env").write_text(_ENV_TEMPLATE, encoding="utf-8") + + _console.print(f"[green]Created:[/green] {folder}/config.yaml") + _console.print(f"[green]Created:[/green] {folder}/.env") + _console.print() + _console.print("[bold]Next steps:[/bold]") + _console.print(f" [cyan]cd {folder}[/cyan]") + _console.print(f" # Edit config.yaml — set github.username and gitlab.username") + _console.print(f" # Edit .env — set GITHUB_TOKEN and GITLAB_TOKEN") + _console.print(f" [cyan]gh2gl --dry-run[/cyan] # preview") + _console.print(f" [cyan]gh2gl[/cyan] # run backup") + + +def _print_report(results: list, dry_run: bool) -> None: + status_display = { + "success": "[green]✓ success[/green]", + "skip": "[blue]→ skip[/blue]", + "error": "[red]✗ error[/red]", + "dry_run": "[yellow]~ dry run[/yellow]", + } + + table = Table(show_header=True, header_style="bold", box=None) + table.add_column("Repository", style="cyan", min_width=30) + table.add_column("Status", min_width=14) + table.add_column("Details", style="dim") + + for r in results: + table.add_row(r.repo_name, status_display.get(r.status, r.status), r.message) + + _console.print() + _console.print(table) + + counts: dict[str, int] = {} + for r in results: + counts[r.status] = counts.get(r.status, 0) + 1 + + parts = [] + if counts.get("success"): + parts.append(f"[green]{counts['success']} copied[/green]") + if counts.get("skip"): + parts.append(f"[blue]{counts['skip']} skipped[/blue]") + if counts.get("dry_run"): + parts.append(f"[yellow]{counts['dry_run']} dry run[/yellow]") + if counts.get("error"): + parts.append(f"[red]{counts['error']} errors[/red]") + + _console.print(" • ".join(parts) if parts else "[dim]nothing to report[/dim]") + + if dry_run: + _console.print("\n[yellow][DRY RUN] No operations were executed.[/yellow]") diff --git a/src/config.py b/src/gh2gl/config.py similarity index 84% rename from src/config.py rename to src/gh2gl/config.py index 22de1ff..75e4424 100644 --- a/src/config.py +++ b/src/gh2gl/config.py @@ -4,7 +4,7 @@ import yaml from dotenv import load_dotenv -from src.models import Config +from gh2gl.models import Config class ConfigError(Exception): @@ -19,9 +19,8 @@ def load_config(config_path: str = "config.yaml", overrides: dict = None) -> Con if not path.exists(): raise ConfigError( f"'{config_path}' not found.\n" - "Create it from the example:\n" - " copy config.example.yaml config.yaml (Windows)\n" - " cp config.example.yaml config.yaml (Linux/macOS)" + "Run 'gh2gl init' to create a workspace with config.yaml and .env,\n" + "then run 'gh2gl' from inside that folder." ) load_dotenv() @@ -52,13 +51,13 @@ def validate_config(config: Config) -> None: if not config.github_token: raise ConfigError( "GITHUB_TOKEN is not set.\n" - "Add it to .env (copy from .env.example). Required scope: repo\n" + "Add it to .env (edit the file created by 'gh2gl init'). Required scope: repo\n" "Create at: https://github.com/settings/tokens/new?scopes=repo" ) if not config.gitlab_token: raise ConfigError( "GITLAB_TOKEN is not set.\n" - "Add it to .env (copy from .env.example). Required scope: api\n" + "Add it to .env (edit the file created by 'gh2gl init'). Required scope: api\n" "Create at: https://gitlab.com/-/user_settings/personal_access_tokens" ) if not config.github_username: diff --git a/src/git_ops.py b/src/gh2gl/git_ops.py similarity index 100% rename from src/git_ops.py rename to src/gh2gl/git_ops.py diff --git a/src/github_client.py b/src/gh2gl/github_client.py similarity index 98% rename from src/github_client.py rename to src/gh2gl/github_client.py index 701646f..af4b8a9 100644 --- a/src/github_client.py +++ b/src/gh2gl/github_client.py @@ -4,7 +4,7 @@ from github import Github, RateLimitExceededException from rich.console import Console -from src.models import RepoInfo +from gh2gl.models import RepoInfo _console = Console() diff --git a/src/gitlab_client.py b/src/gh2gl/gitlab_client.py similarity index 98% rename from src/gitlab_client.py rename to src/gh2gl/gitlab_client.py index fe6dc7c..b3eb6c9 100644 --- a/src/gitlab_client.py +++ b/src/gh2gl/gitlab_client.py @@ -5,7 +5,7 @@ from gitlab.exceptions import GitlabGetError, GitlabHttpError from rich.console import Console -from src.models import RepoInfo +from gh2gl.models import RepoInfo _console = Console() _RATE_LIMIT_SLEEP = 60 diff --git a/src/models.py b/src/gh2gl/models.py similarity index 100% rename from src/models.py rename to src/gh2gl/models.py From 7460f66d29501d3dbc81ad4f25ba6577b0bbe01f Mon Sep 17 00:00:00 2001 From: Fernando Paladini Date: Sat, 13 Jun 2026 13:13:16 -0300 Subject: [PATCH 2/2] chore: add github-backup/ to .gitignore Prevents the workspace folder created by 'gh2gl init' from being accidentally staged when running inside the repo during development. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index df78c50..2918d88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env config.yaml tmp/ +github-backup/ __pycache__/ *.pyc *.pyo