From da998d8eec9620bf3214b9b1b8bc8477164101a3 Mon Sep 17 00:00:00 2001 From: Christos Choutouridis Date: Fri, 27 Jun 2025 11:52:24 +0300 Subject: [PATCH] Init commit --- .gitignore | 8 +++ git-synchro.py | 132 +++++++++++++++++++++++++++++++++++++++++++++++++ repos.conf | 8 +++ 3 files changed, 148 insertions(+) create mode 100644 .gitignore create mode 100644 git-synchro.py create mode 100644 repos.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c93ca11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +#Pythons envs +.venv/ + +# IDEs +.idea/ +.vscode/ + + diff --git a/git-synchro.py b/git-synchro.py new file mode 100644 index 0000000..14d9f75 --- /dev/null +++ b/git-synchro.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import sys +from pathlib import Path + +CONFIG_FILE = 'repos.conf' + +def parse_config(): + """ + Parses the repos.conf file and returns a list of tuples with repository information. + The file should contain 5 fields separated by `;` and support `#` comments. + """ + repos = [] + try: + with open(CONFIG_FILE) as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + parts = list(map(str.strip, line.split(';'))) + if len(parts) != 5: + print(f"Invalid config line: {line}") + continue + nickname, server_nick, remote_name, url, directory = parts + repos.append((nickname, server_nick, remote_name, url, Path(directory))) + except FileNotFoundError: + print(f"Error: Configuration file '{CONFIG_FILE}' not found.") + sys.exit(1) + except Exception as e: + print(f"Error reading config file: {e}") + sys.exit(1) + return repos + +def match_repos(repos, from_server, to_server, only=None): + """ + Matches repository pairs based on from/to server nicknames. + If --only is specified, limits to the specific repo-nickname. + """ + matched = {} + for nick in {r[0] for r in repos}: + if only and nick != only: + continue + from_entry = next((r for r in repos if r[0] == nick and r[1] == from_server), None) + to_entry = next((r for r in repos if r[0] == nick and r[1] == to_server), None) + if from_entry and to_entry: + matched[nick] = (from_entry, to_entry) + return matched + +def run_git(cmd, cwd, dry_run=False): + """ + Prints and optionally executes the git command in the specified working directory. + This is where the script changes directory using cwd before running git. + """ + print(f"[CMD] ({cwd}) $ {' '.join(cmd)}") + if not dry_run: + try: + subprocess.run(cmd, cwd=cwd, check=True, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + print(f"Error: Git command '{' '.join(cmd)}' failed in {cwd}\nOutput:\n{e.output.decode() if e.output else str(e)}") + sys.exit(1) + +def sync_repo(nick, from_entry, to_entry, use_mirror, dry_run=False): + """ + Synchronizes a repository from from_entry to to_entry. + Executes git fetch from the source and git push to the destination. + If --mirror is used, performs full push of all refs. Otherwise, checks out all remote branches and pushes with --all and --tags. + """ + _, _, from_remote, from_url, local_dir = from_entry + _, _, to_remote, to_url, _ = to_entry + + if not local_dir.exists(): + print(f"Directory {local_dir} not found. Cloning...") + run_git(["git", "clone", from_url, str(local_dir)], cwd=".", dry_run=dry_run) + else: + run_git(["git", "fetch", "--all"], cwd=local_dir, dry_run=dry_run) + + if not dry_run: + try: + remotes = subprocess.check_output(["git", "remote"], cwd=local_dir).decode().splitlines() + except subprocess.CalledProcessError as e: + print(f"Error: Unable to list git remotes in {local_dir}\nMessage: {e}") + sys.exit(1) + + if to_remote not in remotes: + run_git(["git", "remote", "add", to_remote, to_url], cwd=local_dir, dry_run=dry_run) + + if use_mirror: + run_git(["git", "push", to_remote, "--mirror"], cwd=local_dir, dry_run=dry_run) + else: + if not dry_run: + try: + remotes = subprocess.check_output(["git", "branch", "-r"], cwd=local_dir).decode().splitlines() + for r in remotes: + if f'{from_remote}/' in r and '->' not in r: + branch = r.strip().split(f'{from_remote}/')[1] + subprocess.run(["git", "checkout", "-B", branch, f"{from_remote}/{branch}"], cwd=local_dir) + except subprocess.CalledProcessError as e: + print(f"Error: Failed to checkout remote branches in {local_dir}\nMessage: {e}") + sys.exit(1) + run_git(["git", "push", to_remote, "--all"], cwd=local_dir, dry_run=dry_run) + run_git(["git", "push", to_remote, "--tags"], cwd=local_dir, dry_run=dry_run) + +def main(): + """ + Main function. Parses command-line arguments, reads config, and syncs matching repositories between the specified servers. + """ + parser = argparse.ArgumentParser() + parser.add_argument('--from', dest='from_server', required=True) + parser.add_argument('--to', dest='to_server', required=True) + parser.add_argument('--mirror', action='store_true') + parser.add_argument('--dry-run', action='store_true') + parser.add_argument('--only', type=str, help='Sync only a specific repository nickname') + try: + args = parser.parse_args() + except SystemExit: + print("Error: Invalid or missing arguments. Usage: --from --to [--mirror] [--dry-run] [--only ]") + sys.exit(1) + + repos = parse_config() + matched = match_repos(repos, args.from_server, args.to_server, args.only) + + if not matched: + print("No matching repo pairs found.") + sys.exit(1) + + for nick, (from_entry, to_entry) in matched.items(): + print(f"\n=== Syncing [{nick}] from {args.from_server} to {args.to_server} ===") + sync_repo(nick, from_entry, to_entry, args.mirror, args.dry_run) + +if __name__ == "__main__": + main() diff --git a/repos.conf b/repos.conf new file mode 100644 index 0000000..09f669b --- /dev/null +++ b/repos.conf @@ -0,0 +1,8 @@ +# +# git-synchro configuration file +# +# format: +# repo-nickname; server-nickname; repo-remote; repo-url; directory +# + +