Skip to content
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.env
config.yaml
tmp/
github-backup/
__pycache__/
*.pyc
*.pyo
Expand Down
54 changes: 36 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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
<details>
<summary>Running from source</summary>

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 .
```

</details>

## Token Setup

### GitHub token
Expand All @@ -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:
Expand All @@ -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_...
Expand All @@ -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
Expand All @@ -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
Expand Down
130 changes: 2 additions & 128 deletions backup.py
Original file line number Diff line number Diff line change
@@ -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()
40 changes: 40 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
5 changes: 0 additions & 5 deletions requirements.txt

This file was deleted.

Empty file removed src/__init__.py
Empty file.
1 change: 1 addition & 0 deletions src/gh2gl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "1.0.0"
8 changes: 4 additions & 4 deletions src/backup_runner.py → src/gh2gl/backup_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading