holocron-sync 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
holocron/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .__main__ import main
2
+
3
+ __all__ = ["main"]
holocron/__main__.py ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+
7
+ # Import from local modules
8
+ from .config import parse_args
9
+ from .logger import log
10
+ from .github_provider import get_github_repos
11
+ from .mirror import needs_sync, sync_one_repo
12
+
13
+ def main():
14
+ args = parse_args()
15
+
16
+ # Load secrets
17
+ gh_token = os.environ.get("GITHUB_TOKEN")
18
+ gl_token = os.environ.get("GITLAB_TOKEN")
19
+
20
+ # Validation logic
21
+ if not gh_token:
22
+ print("CRITICAL: Missing GITHUB_TOKEN.")
23
+ sys.exit(1)
24
+
25
+ if not args.backup_only and not gl_token:
26
+ print("CRITICAL: Missing GITLAB_TOKEN.")
27
+ print("Please set GITLAB_TOKEN or use --backup-only.")
28
+ sys.exit(1)
29
+
30
+ log("Initializing Holocron...")
31
+ if args.dry_run:
32
+ log("!!! DRY RUN MODE ACTIVE !!!")
33
+
34
+ # Track the last synced push timestamp for each repo
35
+ synced_pushes = {}
36
+
37
+ while True:
38
+ repos = get_github_repos(gh_token, args.verbose)
39
+ log(f"Found {len(repos)} repositories on GitHub.", is_verbose_mode=args.verbose)
40
+
41
+ sync_count = 0
42
+ with ThreadPoolExecutor(max_workers=args.concurrency) as executor:
43
+ future_to_repo = {}
44
+ for repo in repos:
45
+ repo_name = repo['name']
46
+ pushed_at = repo.get('pushed_at')
47
+ repo_dir = os.path.join(args.storage, f"{repo_name}.git")
48
+
49
+ # If watching, use the smart filter. If running once, sync all.
50
+ if args.watch:
51
+ # 1. Skip if we already synced this exact push
52
+ if repo_name in synced_pushes and synced_pushes[repo_name] == pushed_at:
53
+ continue
54
+
55
+ # 2. Check time window (SKIP if old AND local repo exists)
56
+ # If local repo is missing, we MUST sync (bootstrap), regardless of age.
57
+ if os.path.exists(repo_dir) and not needs_sync(repo, args.window):
58
+ continue
59
+
60
+ future = executor.submit(sync_one_repo, repo, args, gh_token, gl_token)
61
+ future_to_repo[future] = repo
62
+
63
+ for future in as_completed(future_to_repo):
64
+ repo = future_to_repo[future]
65
+ try:
66
+ future.result()
67
+ sync_count += 1
68
+ # Update tracking on success
69
+ if repo.get('pushed_at'):
70
+ synced_pushes[repo['name']] = repo['pushed_at']
71
+ except Exception as exc:
72
+ log(f"[{repo['name']}] generated an exception: {exc}")
73
+
74
+ if sync_count > 0:
75
+ log(f"Sync cycle complete. Updated {sync_count} repositories.")
76
+ elif args.verbose:
77
+ log("No changes detected in this cycle.")
78
+
79
+ if not args.watch:
80
+ break
81
+
82
+ time.sleep(args.interval)
83
+
84
+ if __name__ == "__main__":
85
+ main()
holocron/config.py ADDED
@@ -0,0 +1,36 @@
1
+ import os
2
+ import argparse
3
+ from dotenv import load_dotenv
4
+
5
+ # Load env vars from .env file
6
+ load_dotenv()
7
+
8
+ # --- CONFIGURATION DEFAULTS ---
9
+ # We use Environment Variables for security.
10
+ # Never hardcode passwords in open source code!
11
+ GITHUB_API_URL = "https://api.github.com"
12
+ GITLAB_API_URL = os.environ.get("GITLAB_API_URL", "http://gitlab.local/api/v4")
13
+
14
+ def parse_args():
15
+ """
16
+ Sets up the command line arguments.
17
+ This allows the user to run: 'python g2g.py --dry-run'
18
+ """
19
+ parser = argparse.ArgumentParser(
20
+ description="Holocron: GitHub to GitLab/Local Mirroring Tool"
21
+ )
22
+
23
+ # Flags (True/False options)
24
+ parser.add_argument("--dry-run", action="store_true", help="Simulate execution without making changes")
25
+ parser.add_argument("--watch", action="store_true", help="Run continuously in a loop (Daemon mode)")
26
+ parser.add_argument("--verbose", action="store_true", help="Print detailed logs")
27
+
28
+ # value options
29
+ parser.add_argument("--interval", type=int, default=60, help="Seconds to wait between checks (default: 60)")
30
+ parser.add_argument("--window", type=int, default=10, help="Only sync repos updated in the last X minutes")
31
+ parser.add_argument("--storage", type=str, default="./mirror-data", help="Local path to store git repositories")
32
+ parser.add_argument("--concurrency", type=int, default=5, help="Number of concurrent sync threads (default: 5)")
33
+ parser.add_argument("--backup-only", action="store_true", help="Mirror locally only, skip pushing to GitLab")
34
+ parser.add_argument("--checkout", action="store_true", help="Create a checkout of the repository alongside the mirror")
35
+
36
+ return parser.parse_args()
@@ -0,0 +1,99 @@
1
+ import requests
2
+ from .logger import log
3
+ from .config import GITHUB_API_URL
4
+
5
+ def get_all_pages(base_url, headers, verbose, context_name, query_params=None):
6
+ """Helper to fetch all pages from a GitHub endpoint."""
7
+ if query_params is None:
8
+ query_params = {}
9
+
10
+ items = []
11
+ page = 1
12
+ query_params['per_page'] = 100
13
+
14
+ log(f"Fetching {context_name}...", is_verbose_mode=verbose)
15
+
16
+ while True:
17
+ try:
18
+ # Update page number in params
19
+ query_params['page'] = page
20
+
21
+ if verbose:
22
+ # Log the actual request for debugging
23
+ # (Masking token if we were printing headers, but we aren't)
24
+ log(f"DEBUG: Requesting page {page} from {base_url} with params {query_params}", verbose_only=True, is_verbose_mode=True)
25
+
26
+ r = requests.get(base_url, headers=headers, params=query_params, timeout=20)
27
+ r.raise_for_status()
28
+
29
+ data = r.json()
30
+ if not data:
31
+ if verbose:
32
+ log(f"DEBUG: Page {page} empty. stopping.", verbose_only=True, is_verbose_mode=True)
33
+ break
34
+
35
+ count = len(data)
36
+ if verbose:
37
+ log(f"DEBUG: Page {page} returned {count} items.", verbose_only=True, is_verbose_mode=True)
38
+
39
+ items.extend(data)
40
+
41
+ # Optimization: If we got fewer than per_page, we are done
42
+ if count < query_params['per_page']:
43
+ break
44
+
45
+ page += 1
46
+ except Exception as e:
47
+ log(f"ERROR fetching {context_name}: {e}")
48
+ break
49
+ return items
50
+
51
+ def get_github_repos(token, verbose):
52
+ """Fetches all repositories from the user AND their organizations."""
53
+ headers = {'Authorization': f'token {token}'}
54
+ all_repos = []
55
+ seen_ids = set()
56
+
57
+ # 1. Fetch User Repos (includes explicit access to outside org repos)
58
+ # Using specific parameters as per GitHub docs to ensure maximal coverage
59
+ user_repos = get_all_pages(
60
+ f"{GITHUB_API_URL}/user/repos",
61
+ headers,
62
+ verbose,
63
+ "user repositories (visibility=all, all affiliations)",
64
+ query_params={
65
+ "visibility": "all",
66
+ "affiliation": "owner,collaborator,organization_member"
67
+ }
68
+ )
69
+
70
+ for repo in user_repos:
71
+ if repo['id'] not in seen_ids:
72
+ all_repos.append(repo)
73
+ seen_ids.add(repo['id'])
74
+
75
+ # 2. Fetch User Organizations
76
+ orgs = get_all_pages(
77
+ f"{GITHUB_API_URL}/user/orgs",
78
+ headers,
79
+ verbose,
80
+ "organizations"
81
+ )
82
+
83
+ # 3. Fetch Repos for each Org
84
+ for org in orgs:
85
+ org_name = org['login']
86
+ # type=all includes forks, private, public
87
+ org_repos = get_all_pages(
88
+ f"{GITHUB_API_URL}/orgs/{org_name}/repos",
89
+ headers,
90
+ verbose,
91
+ f"repositories for organization '{org_name}'",
92
+ query_params={"type": "all"}
93
+ )
94
+ for repo in org_repos:
95
+ if repo['id'] not in seen_ids:
96
+ all_repos.append(repo)
97
+ seen_ids.add(repo['id'])
98
+
99
+ return all_repos
holocron/logger.py ADDED
@@ -0,0 +1,8 @@
1
+ from datetime import datetime
2
+
3
+ def log(message, verbose_only=False, is_verbose_mode=False):
4
+ """Simple logger that checks if we are in verbose mode."""
5
+ if verbose_only and not is_verbose_mode:
6
+ return
7
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
8
+ print(f"[{timestamp}] {message}")
holocron/mirror.py ADDED
@@ -0,0 +1,111 @@
1
+ import os
2
+ import subprocess
3
+ from datetime import datetime, timedelta, timezone
4
+ from .logger import log
5
+ from .config import GITLAB_API_URL
6
+
7
+ def needs_sync(repo, window_minutes):
8
+ """
9
+ Returns True if the repo was pushed to within the 'window_minutes'.
10
+ """
11
+ pushed_at_str = repo.get('pushed_at')
12
+ if not pushed_at_str:
13
+ return False
14
+
15
+ # Parse ISO timestamp (Handles the 'Z' for UTC)
16
+ pushed_at = datetime.fromisoformat(pushed_at_str.replace('Z', '+00:00'))
17
+ now = datetime.now(timezone.utc)
18
+
19
+ # Check if the difference is inside our window
20
+ return (now - pushed_at) < timedelta(minutes=window_minutes)
21
+
22
+ def sync_one_repo(repo, args, github_token, gitlab_token):
23
+ name = repo['name']
24
+ repo_dir = os.path.join(args.storage, f"{name}.git")
25
+
26
+ # 1. Construct Secure URLs (Injecting tokens)
27
+ # Note: We use the OAuth2 syntax for GitLab
28
+ gh_clone_url = repo['clone_url'].replace("https://", f"https://oauth2:{github_token}@")
29
+
30
+ # We assume the GitLab group matches the GitHub username or is defined in the URL
31
+ # For this script, we construct a generic target URL.
32
+ # In a real scenario, you might want to customize the group logic.
33
+ if not args.backup_only:
34
+ gl_target_url = f"{GITLAB_API_URL.replace('/api/v4', '')}/{name}.git".replace("http://", f"http://oauth2:{gitlab_token}@")
35
+ else:
36
+ gl_target_url = None
37
+
38
+ # 2. Dry Run Check
39
+ if args.dry_run:
40
+ target_msg = gl_target_url if not args.backup_only else "(Local Backup Only)"
41
+ log(f"[DRY-RUN] Would sync '{name}' -> '{target_msg}'")
42
+ return
43
+
44
+ # 3. Create Storage Directory if needed
45
+ os.makedirs(args.storage, exist_ok=True)
46
+
47
+ # 4. Git Operations
48
+ try:
49
+ # Use --quiet to suppress progress bars which mess up parallel logs
50
+ # We also redirect stderr to DEVNULL to be completely silent unless it fails (which check=True handles by raising)
51
+ # Note: If git fails, we won't see the error message if we silence stderr perfectly.
52
+ # A better approach is capture_output=True and log stderr on error.
53
+
54
+ if not os.path.exists(repo_dir):
55
+ log(f"[{name}] Cloning new mirror...")
56
+ try:
57
+ subprocess.run(["git", "clone", "--mirror", "--quiet", gh_clone_url, repo_dir], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
58
+ except subprocess.CalledProcessError as e:
59
+ # Decode stderr for the log
60
+ err_msg = e.stderr.decode().strip() if e.stderr else str(e)
61
+ raise subprocess.CalledProcessError(e.returncode, e.cmd, output=e.output, stderr=err_msg)
62
+
63
+ else:
64
+ log(f"[{name}] Fetching updates...", verbose_only=True, is_verbose_mode=args.verbose)
65
+ try:
66
+ subprocess.run(["git", "-C", repo_dir, "fetch", "--quiet", "-p", "origin"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
67
+ except subprocess.CalledProcessError as e:
68
+ err_msg = e.stderr.decode().strip() if e.stderr else str(e)
69
+ raise subprocess.CalledProcessError(e.returncode, e.cmd, output=e.output, stderr=err_msg)
70
+
71
+ # If not backup only, ensure push remote is set (optional but good practice if it changes)
72
+ if not args.backup_only:
73
+ subprocess.run(["git", "-C", repo_dir, "remote", "set-url", "--push", "origin", gl_target_url], check=True, stderr=subprocess.DEVNULL)
74
+
75
+ # 5. Push to GitLab
76
+ # We always push to ensure the mirror is exact, UNLESS backup-only mode is on
77
+ if not args.backup_only:
78
+ try:
79
+ subprocess.run(["git", "-C", repo_dir, "push", "--mirror", "--quiet"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
80
+ log(f"[{name}] Successfully synced to GitLab.")
81
+ except subprocess.CalledProcessError as e:
82
+ err_msg = e.stderr.decode().strip() if e.stderr else str(e)
83
+ raise subprocess.CalledProcessError(e.returncode, e.cmd, output=e.output, stderr=err_msg)
84
+ else:
85
+ log(f"[{name}] Successfully backed up locally.")
86
+
87
+ # 6. Optional Checkout (Sidecar)
88
+ if args.checkout:
89
+ # Derived directly from repo_dir (which ends in .git)
90
+ checkout_dir = repo_dir.replace(".git", "")
91
+
92
+ if not os.path.exists(checkout_dir):
93
+ log(f"[{name}] Creating checkout...", verbose_only=True, is_verbose_mode=args.verbose)
94
+ try:
95
+ # Clone from the local mirror
96
+ subprocess.run(["git", "clone", "--quiet", repo_dir, checkout_dir], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
97
+ except subprocess.CalledProcessError as e:
98
+ err_msg = e.stderr.decode().strip() if e.stderr else str(e)
99
+ log(f"[{name}] Failed to create checkout: {err_msg}")
100
+ else:
101
+ log(f"[{name}] Updating checkout...", verbose_only=True, is_verbose_mode=args.verbose)
102
+ try:
103
+ # We use pull to update the current branch.
104
+ # If this fails (e.g. merge conflict, though unlikely for a backup), we catch it.
105
+ subprocess.run(["git", "-C", checkout_dir, "pull", "--quiet"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
106
+ except subprocess.CalledProcessError as e:
107
+ err_msg = e.stderr.decode().strip() if e.stderr else str(e)
108
+ log(f"[{name}] Failed to update checkout: {err_msg}")
109
+
110
+ except subprocess.CalledProcessError as e:
111
+ log(f"ERROR syncing {name}: {e}")
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: holocron-sync
3
+ Version: 0.1.0
4
+ Summary: Holocron: The Ultimate Git Mirroring Tool
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: python-dotenv
8
+ Requires-Dist: requests>=2.28.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Holocron
12
+ > **The "Ultimate" Git Mirroring Tool**
13
+
14
+ ```
15
+ /\
16
+ / \
17
+ / /\ \
18
+ / / \ \
19
+ / / \ \
20
+ /_/______\_\
21
+ \ \ / /
22
+ \ \ / /
23
+ \ \ / /
24
+ \ \/ /
25
+ \ /
26
+ \/
27
+ ```
28
+
29
+ **Holocron** is a powerful Python application designed to mirror your GitHub repositories to a local directory or a self-hosted GitLab instance. It supports parallel syncing, continuous watch mode, and local-only backups (no GitLab required).
30
+
31
+ ## Features
32
+ - **Parallel Syncing**: Sync multiple repositories concurrently for maximum speed.
33
+ - **Continuous Watch Mode**: Polls for changes and syncs only when necessary (smart redundancy checks).
34
+ - **Two-Way Mirroring**: Creates a bare mirror (`.git` folder) for safety AND an optional checkout for visibility.
35
+ - **Dockerized**: Runs as a lightweight container.
36
+ - **Backup Level**:
37
+ - Full: GitHub -> Local -> GitLab
38
+ - Backup-Only: GitHub -> Local (No GitLab token needed)
39
+
40
+ ## Quick Start
41
+
42
+ ### 1. Docker (Recommended)
43
+ ```bash
44
+ docker-compose up -d --build
45
+ ```
46
+ This starts Holocron in watch mode.
47
+
48
+ ### 2. Manual Run
49
+ ```bash
50
+ # Install dependencies
51
+ uv sync
52
+
53
+ # Run a one-time backup of all your repos locally (visible files)
54
+ export GITHUB_TOKEN=your_token
55
+ uv run python src/holocron.py --backup-only --checkout --concurrency 10
56
+ ```
57
+
58
+ ## Configuration
59
+ Holocron uses environment variables for secrets:
60
+
61
+ | Variable | Description | Required |
62
+ | :--- | :--- | :--- |
63
+ | `GITHUB_TOKEN` | Your GitHub Personal Access Token (repo scope) | **Yes** |
64
+ | `GITLAB_TOKEN` | Your GitLab Personal Access Token (api scope) | No (if `--backup-only`) |
65
+ | `GITLAB_API_URL` | URL to your GitLab API (default: `http://gitlab.local/api/v4`) | No |
66
+
67
+ ### Command Line Arguments
68
+ | Flag | Default | Description |
69
+ | :--- | :--- | :--- |
70
+ | `--watch` | False | Run continuously in a loop |
71
+ | `--interval` | 60 | Seconds to sleep between checks in watch mode |
72
+ | `--window` | 60 | Sync only repos pushed within the last N minutes |
73
+ | `--backup-only` | False | Mirror locally only, do not push to GitLab |
74
+ | `--checkout` | False | Create a visible working directory alongside the mirror |
75
+ | `--concurrency` | 5 | Number of parallel sync threads |
76
+ | `--storage` | `./mirror-data` | Directory to store repositories |
77
+ | `--dry-run` | False | Print what would happen without doing it |
78
+ | `--verbose` | False | Enable detailed debug logging |
79
+
80
+ ## Development
81
+ Run tests with coverage:
82
+ ```bash
83
+ uv run pytest --cov=src
84
+ ```
@@ -0,0 +1,11 @@
1
+ holocron/__init__.py,sha256=9jRVm2gCiDxGHfum8z9JBVNCkzjdCHJ0OApdD8C7SiM,47
2
+ holocron/__main__.py,sha256=Lbk1n_Lwz9BoqX5UUUsI0O4jziTQR1Sh9uHLNAiwwWo,2988
3
+ holocron/config.py,sha256=6ixFtSPGoK0bOilKLcnJmMDyhjmomWBYdfPCwAtUuew,1692
4
+ holocron/github_provider.py,sha256=FS7gDAa_JcMBVRWEkabqh2JsNwqXTeKzBdlp7hRAkCg,3271
5
+ holocron/logger.py,sha256=bLspdJUWhPelKN72DhJAT8jgZuRkWfqF0IiDaacONfA,314
6
+ holocron/mirror.py,sha256=KnolJYwZ02uRIdHJXFmDeB4xgXeidpLdlR3pPMBay7A,5733
7
+ holocron_sync-0.1.0.dist-info/METADATA,sha256=fF35ERZPFooA_B8gT1OWh7hqVUYL2ynMrMyzP-mYk88,2734
8
+ holocron_sync-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ holocron_sync-0.1.0.dist-info/entry_points.txt,sha256=9nwV7AF3lX03yqL64yAO-RF0Uy3tBHaZm57JlKecOSY,52
10
+ holocron_sync-0.1.0.dist-info/licenses/LICENSE,sha256=VEobrYN3vjG9NON0lbc7JBIFo6rFe9MY1MuGv6U4F0M,1072
11
+ holocron_sync-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ holocron = holocron.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Wouter Bloeyaert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.