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 +3 -0
- holocron/__main__.py +85 -0
- holocron/config.py +36 -0
- holocron/github_provider.py +99 -0
- holocron/logger.py +8 -0
- holocron/mirror.py +111 -0
- holocron_sync-0.1.0.dist-info/METADATA +84 -0
- holocron_sync-0.1.0.dist-info/RECORD +11 -0
- holocron_sync-0.1.0.dist-info/WHEEL +4 -0
- holocron_sync-0.1.0.dist-info/entry_points.txt +2 -0
- holocron_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
holocron/__init__.py
ADDED
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,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.
|