#!/usr/bin/env python3 """ gitea_safe_push.py — Safely push files to Gitea via API with branch existence checks. Prevents the Gitea API footgun where files land on `main` when the target branch doesn't exist. Always verifies branch existence before file operations. Usage: python3 bin/gitea_safe_push.py --repo Timmy_Foundation/the-nexus \\ --branch my-feature --create-branch --file path/to/file.py --message "add file" # Or use as a library: from bin.gitea_safe_push import GiteaSafePush push = GiteaSafePush("https://forge.example.com", "token123") push.ensure_branch("Timmy_Foundation/the-nexus", "my-branch", base="main") push.push_file("Timmy_Foundation/the-nexus", "my-branch", "file.py", "content", "commit msg") """ import argparse import base64 import json import os import sys import urllib.error import urllib.request from pathlib import Path from typing import Optional class GiteaAPIError(Exception): """Gitea API error with status code and response body.""" def __init__(self, status: int, message: str, body: str = ""): self.status = status self.body = body super().__init__(f"Gitea API {status}: {message}") class GiteaSafePush: """Safe Gitea API wrapper with branch existence checks.""" def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip("/") self.token = token self._headers = { "Authorization": f"token {token}", "Content-Type": "application/json", } def _api(self, method: str, path: str, data: dict = None, timeout: int = 30) -> dict: """Make a Gitea API call.""" url = f"{self.base_url}/api/v1{path}" body = json.dumps(data).encode() if data else None req = urllib.request.Request(url, data=body, headers=self._headers, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read()) if resp.status != 204 else {} except urllib.error.HTTPError as e: resp_body = e.read().decode()[:500] if hasattr(e, 'read') else "" raise GiteaAPIError(e.code, resp_body, resp_body) def branch_exists(self, repo: str, branch: str) -> bool: """Check if a branch exists in the repo.""" try: self._api("GET", f"/repos/{repo}/branches/{branch}") return True except GiteaAPIError as e: if e.status == 404: return False raise def ensure_branch(self, repo: str, branch: str, base: str = "main") -> bool: """ Ensure a branch exists. Creates it from base if it doesn't. Returns: True if branch exists or was created, False if creation failed. """ if self.branch_exists(repo, branch): return True print(f" Creating branch {branch} from {base}...") try: self._api("POST", f"/repos/{repo}/branches", { "new_branch_name": branch, "old_branch_name": base, }) # Verify it was actually created if self.branch_exists(repo, branch): print(f" Branch {branch} created.") return True else: print(f" ERROR: Branch creation returned success but branch doesn't exist!") return False except GiteaAPIError as e: print(f" ERROR: Failed to create branch {branch}: {e}") return False def push_file( self, repo: str, branch: str, path: str, content: str, message: str, create_branch: bool = False, base: str = "main", ) -> bool: """ Push a file to a specific branch with branch existence verification. This is the SAFE version — it never silently falls back to main. Args: repo: e.g. "Timmy_Foundation/the-nexus" branch: target branch name path: file path in repo content: file content (text) message: commit message create_branch: if True, create branch if it doesn't exist base: base branch for branch creation Returns: True if successful, False if failed. """ # Step 1: Ensure branch exists if not self.branch_exists(repo, branch): if create_branch: if not self.ensure_branch(repo, branch, base): print(f" FAIL: Cannot create branch {branch}. Aborting file push.") return False else: print(f" FAIL: Branch {branch} does not exist. Use --create-branch or ensure_branch() first.") return False # Step 2: Get existing file SHA if it exists on the target branch sha = None try: existing = self._api("GET", f"/repos/{repo}/contents/{path}?ref={branch}") sha = existing.get("sha") except GiteaAPIError as e: if e.status != 404: raise # Step 3: Create or update the file b64 = base64.b64encode(content.encode()).decode() payload = { "content": b64, "message": message, "branch_name": branch, } if sha: payload["sha"] = sha method = "PUT" action = "Updated" else: method = "POST" action = "Created" try: self._api(method, f"/repos/{repo}/contents/{path}", payload) print(f" {action} {path} on {branch}") return True except GiteaAPIError as e: print(f" FAIL: Could not {action.lower()} {path} on {branch}: {e}") return False def push_files( self, repo: str, branch: str, files: dict[str, str], message: str, create_branch: bool = True, base: str = "main", ) -> dict: """ Push multiple files to a branch. Args: repo: e.g. "Timmy_Foundation/the-nexus" branch: target branch files: dict of {path: content} message: commit message create_branch: create branch if needed base: base branch Returns: dict of {path: success_bool} """ results = {} # Ensure branch exists ONCE before any file operations if not self.ensure_branch(repo, branch, base): print(f" FAIL: Cannot ensure branch {branch}. No files pushed.") return {path: False for path in files} for path, content in files.items(): results[path] = self.push_file( repo, branch, path, content, message, create_branch=False, # already ensured above ) return results def main(): parser = argparse.ArgumentParser(description="Safely push files to Gitea with branch checks") parser.add_argument("--repo", required=True, help="Repo (e.g. Timmy_Foundation/the-nexus)") parser.add_argument("--branch", required=True, help="Target branch name") parser.add_argument("--base", default="main", help="Base branch for creation (default: main)") parser.add_argument("--create-branch", action="store_true", help="Create branch if it doesn't exist") parser.add_argument("--file", action="append", help="File to push (path:content or @filepath)") parser.add_argument("--message", default="Automated commit", help="Commit message") parser.add_argument("--token", default=None, help="Gitea token (or reads from ~/.config/gitea/token)") parser.add_argument("--url", default="https://forge.alexanderwhitestone.com", help="Gitea base URL") parser.add_argument("--check-branch", action="store_true", help="Only check if branch exists") args = parser.parse_args() # Get token token = args.token if not token: token_path = Path.home() / ".config" / "gitea" / "token" if token_path.exists(): token = token_path.read_text().strip() else: print("ERROR: No token provided and ~/.config/gitea/token not found", file=sys.stderr) sys.exit(1) push = GiteaSafePush(args.url, token) # Branch check mode if args.check_branch: exists = push.branch_exists(args.repo, args.branch) print(f"Branch {args.branch}: {'EXISTS' if exists else 'NOT FOUND'}") sys.exit(0 if exists else 1) # File push mode if not args.file: print("ERROR: No files specified. Use --file path (reads from stdin) or --file @path", file=sys.stderr) sys.exit(1) files = {} for f in args.file: if f.startswith("@"): # Read from file filepath = f[1:] with open(filepath) as fh: files[filepath] = fh.read() elif ":" in f: # path:content format path, content = f.split(":", 1) files[path] = content else: # Read file from disk with open(f) as fh: files[f] = fh.read() results = push.push_files( args.repo, args.branch, files, args.message, create_branch=args.create_branch, base=args.base, ) success = all(results.values()) print(f"\n{'All' if success else 'Some'} files pushed. Results: {results}") sys.exit(0 if success else 1) if __name__ == "__main__": main()