237 lines
7.6 KiB
Python
237 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Preflight and execute git push with optional Gitea token fallback."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
|
|
from common import (
|
|
build_authenticated_push_url,
|
|
current_branch,
|
|
ensure_git_repo,
|
|
get_current_user_login,
|
|
get_upstream,
|
|
is_auth_error,
|
|
load_token,
|
|
mask_url,
|
|
remote_branch_sha,
|
|
resolve_repo,
|
|
worktree_changes,
|
|
ahead_behind,
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Preflight and optionally push the current branch to Gitea."
|
|
)
|
|
parser.add_argument("--repo-url", help="Explicit target repo URL.")
|
|
parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.")
|
|
parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin")
|
|
parser.add_argument("--branch", help="Branch to push. Default: current branch")
|
|
parser.add_argument(
|
|
"--execute",
|
|
action="store_true",
|
|
help="Run git push after preflight instead of only printing the plan.",
|
|
)
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Allow force push. Uses --force-with-lease under the hood.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def build_push_command(target: str, branch: str, force: bool) -> list[str]:
|
|
command = ["git", "push"]
|
|
if force:
|
|
command.append("--force-with-lease")
|
|
command.extend([target, f"{branch}:{branch}"])
|
|
return command
|
|
|
|
|
|
def command_text(command: list[str]) -> str:
|
|
return " ".join(shlex.quote(part) for part in command)
|
|
|
|
|
|
def compute_push_plan(
|
|
repo_url: str | None = None,
|
|
repo: str | None = None,
|
|
remote: str = "origin",
|
|
branch: str | None = None,
|
|
force: bool = False,
|
|
) -> dict:
|
|
ensure_git_repo()
|
|
context = resolve_repo(repo_url=repo_url, repo=repo, remote=remote)
|
|
branch_name = branch or current_branch()
|
|
dirty_lines = worktree_changes()
|
|
upstream = get_upstream(branch_name)
|
|
compare_ref = upstream
|
|
remote_exists = True
|
|
ahead = 0
|
|
behind = 0
|
|
|
|
if upstream:
|
|
behind, ahead = ahead_behind(upstream)
|
|
else:
|
|
remote_sha = remote_branch_sha(remote, branch_name)
|
|
if remote_sha:
|
|
compare_ref = f"{remote}/{branch_name}"
|
|
behind, ahead = ahead_behind(remote_sha)
|
|
else:
|
|
remote_exists = False
|
|
compare_ref = None
|
|
ahead = None
|
|
behind = 0
|
|
|
|
diverged = bool(remote_exists and behind > 0 and (ahead or 0) > 0)
|
|
behind_only = bool(remote_exists and behind > 0 and (ahead or 0) == 0)
|
|
needs_push = (not remote_exists) or ((ahead or 0) > 0)
|
|
|
|
status = "ready"
|
|
if diverged and not force:
|
|
status = "blocked"
|
|
elif not needs_push:
|
|
status = "noop"
|
|
|
|
messages: list[str] = []
|
|
if dirty_lines:
|
|
messages.append("Working tree is dirty; push will include only committed changes.")
|
|
if not remote_exists:
|
|
messages.append("Remote branch does not exist yet; push will create it.")
|
|
if behind_only:
|
|
messages.append("Local branch is behind the remote branch; there are no local commits to push.")
|
|
if diverged and not force:
|
|
messages.append("Branch has diverged from remote; rerun with explicit force only if that is intended.")
|
|
|
|
push_command = build_push_command(remote, branch_name, force)
|
|
fallback_command = None
|
|
token_available = bool(load_token(required=False))
|
|
if token_available:
|
|
try:
|
|
token = load_token(required=False)
|
|
login = get_current_user_login(context.origin, token)
|
|
auth_url = build_authenticated_push_url(context.repo_url, login, token)
|
|
fallback_command = build_push_command(auth_url, branch_name, force)
|
|
except SystemExit as exc:
|
|
messages.append(f"Failed to prepare HTTPS token fallback: {exc}")
|
|
|
|
return {
|
|
"repo_url": context.repo_url,
|
|
"origin": context.origin,
|
|
"owner": context.owner,
|
|
"repo": context.repo,
|
|
"remote": remote,
|
|
"branch": branch_name,
|
|
"upstream": upstream,
|
|
"compare_ref": compare_ref,
|
|
"remote_branch_exists": remote_exists,
|
|
"dirty": bool(dirty_lines),
|
|
"dirty_paths": dirty_lines,
|
|
"ahead": ahead,
|
|
"behind": behind,
|
|
"diverged": diverged,
|
|
"behind_only": behind_only,
|
|
"needs_push": needs_push,
|
|
"status": status,
|
|
"messages": messages,
|
|
"push_command": command_text(push_command),
|
|
"fallback_push_command": command_text(
|
|
[
|
|
*(fallback_command[:-2] if fallback_command else []),
|
|
mask_url(fallback_command[-2]) if fallback_command else "",
|
|
*(fallback_command[-1:] if fallback_command else []),
|
|
]
|
|
)
|
|
if fallback_command
|
|
else None,
|
|
}
|
|
|
|
|
|
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(command, capture_output=True, text=True)
|
|
|
|
|
|
def execute_push(plan: dict, force: bool = False) -> dict:
|
|
if plan["status"] == "noop":
|
|
return {
|
|
"status": "noop",
|
|
"executed": False,
|
|
"reason": "nothing_to_push",
|
|
"messages": plan["messages"],
|
|
}
|
|
if plan["diverged"] and not force:
|
|
raise SystemExit(
|
|
"Branch has diverged from remote. Refusing to push without explicit --force."
|
|
)
|
|
|
|
primary_command = build_push_command(plan["remote"], plan["branch"], force)
|
|
primary_result = run_command(primary_command)
|
|
if primary_result.returncode == 0:
|
|
return {
|
|
"status": "pushed",
|
|
"executed": True,
|
|
"method": "remote",
|
|
"command": command_text(primary_command),
|
|
"stdout": primary_result.stdout.strip(),
|
|
"stderr": primary_result.stderr.strip(),
|
|
}
|
|
|
|
primary_stderr = primary_result.stderr.strip() or primary_result.stdout.strip()
|
|
if not is_auth_error(primary_stderr):
|
|
raise SystemExit(f"git push failed: {primary_stderr}")
|
|
|
|
token = load_token(required=True)
|
|
login = get_current_user_login(plan["origin"], token)
|
|
auth_url = build_authenticated_push_url(plan["repo_url"], login, token)
|
|
fallback_command = build_push_command(auth_url, plan["branch"], force)
|
|
fallback_result = run_command(fallback_command)
|
|
if fallback_result.returncode == 0:
|
|
return {
|
|
"status": "pushed",
|
|
"executed": True,
|
|
"method": "https-token",
|
|
"command": command_text(
|
|
[
|
|
*(fallback_command[:-2]),
|
|
mask_url(fallback_command[-2]),
|
|
fallback_command[-1],
|
|
]
|
|
),
|
|
"stdout": fallback_result.stdout.strip(),
|
|
"stderr": fallback_result.stderr.strip(),
|
|
}
|
|
|
|
fallback_stderr = fallback_result.stderr.strip() or fallback_result.stdout.strip()
|
|
raise SystemExit(
|
|
"git push failed with remote auth and HTTPS token fallback: "
|
|
f"{fallback_stderr}"
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
plan = compute_push_plan(
|
|
repo_url=args.repo_url,
|
|
repo=args.repo,
|
|
remote=args.remote,
|
|
branch=args.branch,
|
|
force=args.force,
|
|
)
|
|
if not args.execute:
|
|
print(json.dumps(plan, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
result = execute_push(plan, force=args.force)
|
|
payload = {"plan": plan, "result": result}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|