From a80c436e75078842a891b6757eb8fcc3cebb18e8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 9 Mar 2026 16:34:43 -0400 Subject: [PATCH] [IMP] script git_local_server: tool to deploy git server to share code Generated by Claude Code v2.1.71 model Opus 4.6 --- script/git/git_local_server.py | 631 +++++++++++++++++++++++++++++++++ script/todo/todo.json | 3 +- script/todo/todo.py | 151 +++++++- script/todo/todo_i18n.py | 69 +++- 4 files changed, 840 insertions(+), 14 deletions(-) create mode 100755 script/git/git_local_server.py diff --git a/script/git/git_local_server.py b/script/git/git_local_server.py new file mode 100755 index 0000000..c55a5ef --- /dev/null +++ b/script/git/git_local_server.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +# © 2025-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import argparse +import logging +import os +import signal +import subprocess +import sys +import xml.etree.ElementTree as ET + +_logger = logging.getLogger(__name__) + +DEFAULT_ERPLIBRE_PATH = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +DEFAULT_GIT_PATH = os.path.join(os.path.expanduser("~"), ".git-server") +PRODUCTION_GIT_PATH = "/srv/git" +DEFAULT_MANIFEST = ".repo/local_manifests/erplibre_manifest.xml" +DEFAULT_REMOTE_NAME = "local" +DEFAULT_PORT = 9418 + + +def get_config(): + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""\ +Deploy a local git server using git daemon and configure +all repos from the ERPLibre manifest. + +Actions (in order): + 1. init - Create bare repos in the git server path + 2. remote - Add 'local' remote to each repo + 3. push - Push all branches to the local remote + 4. serve - Start git daemon + +By default, all actions are executed sequentially. +The default path is ~/.git-server (user-level, no root needed). +Use --production-ready for /srv/git (requires root). +""", + ) + parser.add_argument( + "-p", + "--path", + default=None, + help=( + "Path for git server bare repos" f" (default: {DEFAULT_GIT_PATH})" + ), + ) + parser.add_argument( + "--production-ready", + action="store_true", + help=( + f"Use {PRODUCTION_GIT_PATH} as git server path" + " (requires root privileges)" + ), + ) + parser.add_argument( + "-m", + "--manifest", + default=DEFAULT_MANIFEST, + help="Manifest XML file" f" (default: {DEFAULT_MANIFEST})", + ) + parser.add_argument( + "--remote-name", + default=DEFAULT_REMOTE_NAME, + help="Remote name to add" f" (default: {DEFAULT_REMOTE_NAME})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Git daemon port (default: {DEFAULT_PORT})", + ) + parser.add_argument( + "--action", + choices=["init", "remote", "push", "serve", "all"], + default="all", + help="Action to perform (default: all)", + ) + parser.add_argument( + "--erplibre-root", + default=None, + help="ERPLibre root directory (default: auto-detect)", + ) + parser.add_argument( + "--push-all-branches", + action="store_true", + help="Push all local branches, not just the current one", + ) + parser.add_argument( + "--remote-url-type", + choices=["file", "daemon"], + default="file", + help=( + "Remote URL type: 'file' uses local path" + " (push works without daemon), 'daemon'" + " uses git:// protocol (default: file)" + ), + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + args = parser.parse_args() + + # Resolve git server path + if args.path and args.production_ready: + parser.error("--path and --production-ready are mutually exclusive") + if args.production_ready: + args.path = PRODUCTION_GIT_PATH + elif args.path is None: + args.path = DEFAULT_GIT_PATH + + return args + + +def check_path_permissions(path): + """Check if we have write permissions on the target + path. Try the path itself first, then its closest + existing parent directory.""" + check = path + while check and not os.path.exists(check): + check = os.path.dirname(check) + if not check: + check = "/" + if os.access(check, os.W_OK): + return True + return False + + +def require_permissions_or_exit(path): + """Exit with instructions if we cannot write to path.""" + print(f"Error: No write permission on {path}") + print( + "Please run this script with appropriate" + " privileges:" + f"\n sudo {sys.executable}" + f" {' '.join(sys.argv)}" + ) + sys.exit(1) + + +def parse_manifest(manifest_path): + """Parse the manifest XML and return list of projects.""" + tree = ET.parse(manifest_path) + root = tree.getroot() + + remotes = {} + for remote in root.findall("remote"): + remotes[remote.get("name")] = remote.get("fetch") + + projects = [] + for project in root.findall("project"): + name = project.get("name") + path = project.get("path") + remote = project.get("remote") + revision = project.get("revision") + projects.append( + { + "name": name, + "path": path, + "remote": remote, + "revision": revision, + "fetch_url": remotes.get(remote, ""), + } + ) + + return projects + + +def init_bare_repos(git_path, projects): + """Create bare repos for all projects in the manifest.""" + os.makedirs(git_path, exist_ok=True) + + created = 0 + skipped = 0 + for project in projects: + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + bare_path = os.path.join(git_path, repo_name) + + if os.path.exists(bare_path): + _logger.info(f" Exists: {bare_path}") + skipped += 1 + continue + + _logger.info(f" Creating: {bare_path}") + subprocess.run( + ["git", "init", "--bare", bare_path], + check=True, + capture_output=True, + ) + + # Enable git daemon export + export_file = os.path.join(bare_path, "git-daemon-export-ok") + open(export_file, "w").close() + + created += 1 + + print(f"Bare repos: {created} created, {skipped} skipped (already exist)") + + +def build_remote_url(git_path, repo_name, port, remote_url_type): + """Build the remote URL based on the type.""" + if remote_url_type == "daemon": + if port != DEFAULT_PORT: + return f"git://localhost:{port}/{repo_name}" + return f"git://localhost/{repo_name}" + # Default: file path + return os.path.join(git_path, repo_name) + + +def add_remotes( + erplibre_root, + git_path, + projects, + remote_name, + port, + remote_url_type, +): + """Add local remote to each repo. + + remote_url_type='file': uses local path, push works + without daemon running. + remote_url_type='daemon': uses git://localhost URL, + requires daemon for push. + """ + added = 0 + skipped = 0 + errors = 0 + for project in projects: + repo_path = os.path.join(erplibre_root, project["path"]) + if not os.path.isdir(repo_path): + _logger.warning(f" Not found: {repo_path}") + errors += 1 + continue + + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + + remote_url = build_remote_url( + git_path, repo_name, port, remote_url_type + ) + + # Check if remote already exists + result = subprocess.run( + ["git", "-C", repo_path, "remote"], + capture_output=True, + text=True, + ) + existing_remotes = result.stdout.strip().split("\n") + + if remote_name in existing_remotes: + # Update URL if remote exists + subprocess.run( + [ + "git", + "-C", + repo_path, + "remote", + "set-url", + remote_name, + remote_url, + ], + check=True, + capture_output=True, + ) + _logger.info(f" Updated: {project['path']}") + skipped += 1 + else: + subprocess.run( + [ + "git", + "-C", + repo_path, + "remote", + "add", + remote_name, + remote_url, + ], + check=True, + capture_output=True, + ) + _logger.info(f" Added: {project['path']}") + added += 1 + + print(f"Remotes: {added} added, {skipped} updated," f" {errors} errors") + + +def is_detached_head(repo_path): + """Check if a repo is in detached HEAD state.""" + result = subprocess.run( + ["git", "-C", repo_path, "symbolic-ref", "HEAD"], + capture_output=True, + text=True, + ) + return result.returncode != 0 + + +def checkout_manifest_branch(repo_path, revision): + """Checkout the branch from the manifest revision. + + When Google Repo syncs, repos end up in detached HEAD + on the exact commit. We create/checkout a local branch + matching the manifest revision so git push works. + """ + # Extract branch name — revision can be + # "18.0", "18.0_dev", "ERPLibre/18.0", "main", etc. + branch = revision.split("/")[-1] if "/" in revision else revision + + # Check if local branch already exists + result = subprocess.run( + [ + "git", + "-C", + repo_path, + "show-ref", + "--verify", + f"refs/heads/{branch}", + ], + capture_output=True, + ) + if result.returncode == 0: + # Branch exists, checkout it + subprocess.run( + ["git", "-C", repo_path, "checkout", branch], + capture_output=True, + ) + else: + # Create branch from current HEAD + subprocess.run( + [ + "git", + "-C", + repo_path, + "checkout", + "-b", + branch, + ], + capture_output=True, + ) + return branch + + +def update_bare_head(git_path, project): + """Update the HEAD of the bare repo to point to the + manifest branch, so git clone checks out the right + branch by default.""" + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + bare_path = os.path.join(git_path, repo_name) + if not os.path.isdir(bare_path): + return + + revision = project.get("revision", "") + if not revision: + return + branch = revision.split("/")[-1] if "/" in revision else revision + subprocess.run( + [ + "git", + "-C", + bare_path, + "symbolic-ref", + "HEAD", + f"refs/heads/{branch}", + ], + capture_output=True, + ) + + +def push_to_local( + erplibre_root, + git_path, + projects, + remote_name, + push_all_branches, +): + """Push to local remote for each repo.""" + pushed = 0 + errors = 0 + checkouts = 0 + for project in projects: + repo_path = os.path.join(erplibre_root, project["path"]) + if not os.path.isdir(repo_path): + _logger.warning(f" Not found: {repo_path}") + errors += 1 + continue + + # Unshallow if needed (clone-depth in manifest) + shallow_file = os.path.join(repo_path, ".git", "shallow") + if os.path.exists(shallow_file): + _logger.info(f" Unshallowing {project['path']}...") + # Try each remote until unshallow succeeds + result = subprocess.run( + ["git", "-C", repo_path, "remote"], + capture_output=True, + text=True, + ) + remotes = [ + r + for r in result.stdout.strip().split("\n") + if r and r != remote_name + ] + # Try the manifest remote first + manifest_remote = project.get("remote", "") + if manifest_remote in remotes: + remotes.remove(manifest_remote) + remotes.insert(0, manifest_remote) + + for try_remote in remotes: + result = subprocess.run( + [ + "git", + "-C", + repo_path, + "fetch", + "--unshallow", + try_remote, + ], + capture_output=True, + text=True, + timeout=300, + ) + if not os.path.exists(shallow_file): + _logger.info(f" Unshallowed via" f" {try_remote}") + break + else: + if os.path.exists(shallow_file): + _logger.warning( + f" Unshallow failed for" + f" {project['path']}," + " push may fail" + ) + + # Handle detached HEAD: checkout manifest branch + if is_detached_head(repo_path): + revision = project.get("revision", "") + if revision: + branch = checkout_manifest_branch(repo_path, revision) + _logger.info( + f" Checkout {branch} for" + f" {project['path']}" + " (was detached HEAD)" + ) + checkouts += 1 + else: + _logger.warning( + f" Detached HEAD with no revision" + f" for {project['path']}, skipping" + ) + errors += 1 + continue + + try: + if push_all_branches: + cmd = [ + "git", + "-C", + repo_path, + "push", + remote_name, + "--all", + ] + else: + cmd = [ + "git", + "-C", + repo_path, + "push", + remote_name, + ] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + _logger.warning( + f" Push failed for" + f" {project['path']}:" + f" {result.stderr.strip()}" + ) + errors += 1 + else: + update_bare_head(git_path, project) + _logger.info(f" Pushed: {project['path']}") + pushed += 1 + except subprocess.TimeoutExpired: + _logger.warning(f" Timeout pushing {project['path']}") + errors += 1 + + print( + f"Push: {pushed} pushed, {checkouts} branch" + f" checkouts, {errors} errors" + ) + + +def print_clone_commands(git_path, projects, port): + """Print git clone commands for all available repos.""" + print("=== Available repos to clone ===") + base_url = ( + f"git://localhost:{port}" + if port != DEFAULT_PORT + else "git://localhost" + ) + count = 0 + for project in projects: + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + bare_path = os.path.join(git_path, repo_name) + if os.path.isdir(bare_path): + print(f" git clone {base_url}/{repo_name}" f" {project['path']}") + count += 1 + print(f"\nTotal: {count} repos available") + print() + + +def serve_git_daemon(git_path, projects, port): + """Start git daemon to serve repos.""" + print_clone_commands(git_path, projects, port) + + cmd = [ + "git", + "daemon", + "--reuseaddr", + f"--base-path={git_path}", + f"--port={port}", + "--export-all", + "--enable=receive-pack", + git_path, + ] + print(f"Starting git daemon on port {port}...") + print(f" Base path: {git_path}") + print(f" URL: git://localhost:{port}/") + print(" Press Ctrl+C to stop") + + try: + process = subprocess.Popen(cmd) + process.wait() + except KeyboardInterrupt: + print("\nStopping git daemon...") + process.send_signal(signal.SIGTERM) + process.wait() + + +def main(): + config = get_config() + + log_level = logging.DEBUG if config.verbose else logging.INFO + logging.basicConfig( + level=log_level, + format="%(levelname)s: %(message)s", + ) + + # Check write permissions on target path + if not check_path_permissions(config.path): + require_permissions_or_exit(config.path) + + # Detect ERPLibre root + if config.erplibre_root: + erplibre_root = os.path.abspath(config.erplibre_root) + else: + erplibre_root = DEFAULT_ERPLIBRE_PATH + + manifest_path = os.path.join(erplibre_root, config.manifest) + if not os.path.exists(manifest_path): + print(f"Error: Manifest not found: {manifest_path}") + sys.exit(1) + + print(f"ERPLibre root: {erplibre_root}") + print(f"Manifest: {manifest_path}") + print(f"Git server path: {config.path}") + print(f"Remote name: {config.remote_name}") + if config.production_ready: + print("Mode: production (/srv/git)") + else: + print("Mode: development (~/.git-server)") + print(f"Remote URL type: {config.remote_url_type}") + print() + + projects = parse_manifest(manifest_path) + print(f"Found {len(projects)} projects in manifest") + print() + + action = config.action + + if action in ("init", "all"): + print("=== Creating bare repos ===") + init_bare_repos(config.path, projects) + print() + + if action in ("remote", "all"): + print("=== Adding remotes ===") + add_remotes( + erplibre_root, + config.path, + projects, + config.remote_name, + config.port, + config.remote_url_type, + ) + print() + + if action in ("push", "all"): + print("=== Pushing to local ===") + push_to_local( + erplibre_root, + config.path, + projects, + config.remote_name, + config.push_all_branches, + ) + print() + + if action in ("serve", "all"): + print("=== Starting git daemon ===") + serve_git_daemon(config.path, projects, config.port) + + +if __name__ == "__main__": + main() diff --git a/script/todo/todo.json b/script/todo/todo.json index ad9b89b..90a52a7 100644 --- a/script/todo/todo.json +++ b/script/todo/todo.json @@ -51,5 +51,6 @@ "prompt_description_key": "json_format_modified_code", "makefile_cmd": "format" } - ] + ], + "git_from_makefile": [] } diff --git a/script/todo/todo.py b/script/todo/todo.py index 8b4606d..5c3b3fb 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -236,12 +236,13 @@ class TODO: [4] {t("menu_code")} [5] {t("menu_doc")} [6] {t("menu_database")} -[7] {t("menu_process")} -[8] {t("menu_config")} -[9] {t("menu_network")} -[10] {t("menu_security")} -[11] {t("menu_test")} -[12] {t("menu_lang")} +[7] {t("menu_git")} +[8] {t("menu_process")} +[9] {t("menu_config")} +[10] {t("menu_network")} +[11] {t("menu_security")} +[12] {t("menu_test")} +[13] {t("menu_lang")} [0] {t("back")} """ while True: @@ -274,26 +275,30 @@ class TODO: if status is not False: return elif status == "7": - status = self.prompt_execute_process() + status = self.prompt_execute_git() if status is not False: return elif status == "8": - status = self.prompt_execute_config() + status = self.prompt_execute_process() if status is not False: return elif status == "9": - status = self.prompt_execute_network() + status = self.prompt_execute_config() if status is not False: return elif status == "10": - status = self.prompt_execute_security() + status = self.prompt_execute_network() if status is not False: return elif status == "11": - status = self.prompt_execute_test() + status = self.prompt_execute_security() if status is not False: return elif status == "12": + status = self.prompt_execute_test() + if status is not False: + return + elif status == "13": status = self._change_language() if status is not False: return @@ -681,6 +686,120 @@ class TODO: if cmd_no_found: print(t("cmd_not_found")) + def prompt_execute_git(self): + print(f"🤖 {t('git_manage')}") + lst_choice = [ + {"prompt_description": t("git_local_server")}, + ] + + # Append config-driven entries + lst_config = self.config_file.get_config("git_from_makefile") + if lst_config: + lst_choice.extend(lst_config) + + help_info = self.fill_help_info(lst_choice) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self.prompt_execute_git_local_server() + else: + cmd_no_found = True + try: + int_cmd = int(status) + if 0 < int_cmd <= len(lst_choice): + cmd_no_found = False + dct_instance = lst_choice[int_cmd - 1] + self.execute_from_configuration(dct_instance) + except ValueError: + pass + if cmd_no_found: + print(t("cmd_not_found")) + + def prompt_execute_git_local_server(self): + print(f"🤖 {t('git_repo_manage')}") + lst_choice = [ + {"prompt_description": t("git_repo_deploy_local")}, + {"prompt_description": t("git_repo_deploy_production")}, + ] + help_info = self.fill_help_info(lst_choice) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._prompt_git_server_actions(production_ready=False) + elif status == "2": + self._prompt_git_server_actions(production_ready=True) + else: + print(t("cmd_not_found")) + + def _prompt_git_server_actions(self, production_ready=False): + mode = ( + t("git_mode_production") + if production_ready + else t("git_mode_local") + ) + print(f"🤖 {mode}") + lst_choice = [ + {"prompt_description": t("git_action_all")}, + {"prompt_description": t("git_action_init")}, + {"prompt_description": t("git_action_remote")}, + {"prompt_description": t("git_action_push")}, + {"prompt_description": t("git_action_serve")}, + ] + help_info = self.fill_help_info(lst_choice) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._deploy_git_server( + production_ready=production_ready, + action="all", + ) + elif status == "2": + self._deploy_git_server( + production_ready=production_ready, + action="init", + ) + elif status == "3": + self._deploy_git_server( + production_ready=production_ready, + action="remote", + ) + elif status == "4": + self._deploy_git_server( + production_ready=production_ready, + action="push", + ) + elif status == "5": + self._deploy_git_server( + production_ready=production_ready, + action="serve", + ) + else: + print(t("cmd_not_found")) + + def _deploy_git_server(self, production_ready=False, action="all"): + print(t("git_repo_deploy_starting")) + cmd = ( + "python3 ./script/git/git_local_server.py -v" f" --action {action}" + ) + if production_ready: + cmd += " --production-ready" + self.execute.exec_command_live( + cmd, + source_erplibre=False, + ) + def prompt_execute_doc(self): print(f"🤖 {t('doc_search')}") lst_choice = [ @@ -757,6 +876,7 @@ class TODO: print(f"🤖 {t('process_manage')}") lst_choice = [ {"prompt_description": t("kill_process_port")}, + {"prompt_description": t("kill_git_daemon")}, ] help_info = self.fill_help_info(lst_choice) @@ -767,9 +887,18 @@ class TODO: return False elif status == "1": self.process_kill_from_port() + elif status == "2": + self.process_kill_git_daemon() else: print(t("cmd_not_found")) + def process_kill_git_daemon(self): + self.execute.exec_command_live( + "pkill -f 'git daemon'", + source_erplibre=False, + ) + print(t("kill_git_daemon_done")) + def prompt_execute_config(self): print(f"🤖 {t('config_manage')}") lst_choice = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 476fa69..7fe1b46 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -304,8 +304,16 @@ TRANSLATIONS = { "en": "Create backup (.zip)", }, "kill_process_port": { - "fr": "Terminer le processus du port actuel", - "en": "Kill process from actual port", + "fr": "Terminer le processus Odoo du port actuel", + "en": "Kill Odoo process from actual port", + }, + "kill_git_daemon": { + "fr": "Terminer le processus du serveur git daemon", + "en": "Kill git daemon server process", + }, + "kill_git_daemon_done": { + "fr": "Processus git daemon terminé.", + "en": "Git daemon process killed.", }, "generate_all_config": { "fr": "Générer toute la configuration", @@ -412,6 +420,63 @@ TRANSLATIONS = { "fr": "Le nom du module est requis!", "en": "Module name is required!", }, + # Git section + "menu_git": { + "fr": "Git - Outils Git", + "en": "Git - Git tools", + }, + "git_manage": { + "fr": "Outils de gestion Git!", + "en": "Git management tools!", + }, + "git_local_server": { + "fr": "Serveur git local", + "en": "Local git server", + }, + "git_repo_manage": { + "fr": "Gérer le serveur de dépôts git local!", + "en": "Manage local git repository server!", + }, + "git_repo_deploy_local": { + "fr": "Déployer un serveur git local (~/.git-server)", + "en": "Deploy a local git server (~/.git-server)", + }, + "git_repo_deploy_production": { + "fr": "Déployer un serveur git production (/srv/git, root requis)", + "en": "Deploy a production git server (/srv/git, root required)", + }, + "git_repo_deploy_starting": { + "fr": "Démarrage du déploiement du serveur git...", + "en": "Starting git server deployment...", + }, + "git_mode_local": { + "fr": "Mode local (~/.git-server)", + "en": "Local mode (~/.git-server)", + }, + "git_mode_production": { + "fr": "Mode production (/srv/git, root requis)", + "en": "Production mode (/srv/git, root required)", + }, + "git_action_all": { + "fr": "Tout exécuter (init + remote + push + serve)", + "en": "Run all (init + remote + push + serve)", + }, + "git_action_init": { + "fr": "Init - Créer les bare repos", + "en": "Init - Create bare repos", + }, + "git_action_remote": { + "fr": "Remote - Ajouter les remotes locaux", + "en": "Remote - Add local remotes", + }, + "git_action_push": { + "fr": "Push - Pousser vers le serveur local", + "en": "Push - Push to local server", + }, + "git_action_serve": { + "fr": "Serve - Démarrer le daemon git", + "en": "Serve - Start git daemon", + }, # Language selection "lang_prompt": { "fr": "Choisir la langue / Choose language",