intermediate commit
This commit is contained in:
149
tools/release/govoplan_release/repository_push.py
Normal file
149
tools/release/govoplan_release/repository_push.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Preview and run plain Git pushes for selected repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from .git_state import collect_repository_snapshot
|
||||
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||
|
||||
|
||||
def push_repositories(
|
||||
*,
|
||||
repos: tuple[str, ...],
|
||||
workspace_root: Path | str | None = None,
|
||||
remote: str = "origin",
|
||||
apply: bool = False, # noqa: A002 - mirrors API field.
|
||||
) -> dict[str, object]:
|
||||
workspace = resolve_workspace_root(workspace_root)
|
||||
remote = remote.strip() or "origin"
|
||||
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||
selected = tuple(dict.fromkeys(repos))
|
||||
results: list[dict[str, object]] = []
|
||||
|
||||
for repo in selected:
|
||||
spec = specs.get(repo)
|
||||
if spec is None:
|
||||
results.append({"repo": repo, "status": "blocked", "detail": "repository is not listed in repositories.json"})
|
||||
continue
|
||||
snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||
has_upstream = bool(snapshot.upstream)
|
||||
ahead = int(snapshot.ahead or 0)
|
||||
behind = int(snapshot.behind or 0)
|
||||
dirty_count = len(snapshot.dirty_entries)
|
||||
command = push_command(snapshot.branch, remote=remote, has_upstream=has_upstream)
|
||||
row: dict[str, object] = {
|
||||
"repo": repo,
|
||||
"cwd": snapshot.absolute_path,
|
||||
"branch": snapshot.branch,
|
||||
"upstream": snapshot.upstream,
|
||||
"head": snapshot.head,
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"dirty_count": dirty_count,
|
||||
"push_target": push_target(snapshot.branch, upstream=snapshot.upstream, remote=remote),
|
||||
"command": command_text(command),
|
||||
}
|
||||
if not snapshot.exists or not snapshot.is_git:
|
||||
results.append({**row, "status": "blocked", "detail": "repository is missing or is not a Git repository"})
|
||||
continue
|
||||
if snapshot.safe_directory_required:
|
||||
results.append({**row, "status": "blocked", "detail": "Git safe.directory approval is required"})
|
||||
continue
|
||||
if not snapshot.has_head:
|
||||
results.append({**row, "status": "blocked", "detail": "repository has no commit to push"})
|
||||
continue
|
||||
if not snapshot.branch:
|
||||
results.append({**row, "status": "blocked", "detail": "repository is not on a named branch"})
|
||||
continue
|
||||
if behind:
|
||||
results.append({**row, "status": "blocked", "detail": f"repository is behind {snapshot.upstream}"})
|
||||
continue
|
||||
if not apply:
|
||||
if has_upstream:
|
||||
status = "planned" if ahead else "noop"
|
||||
detail = (
|
||||
f"{ahead} commit(s) will be pushed to {row['push_target']}"
|
||||
if ahead
|
||||
else "branch has no unpushed commits; nothing would be pushed"
|
||||
)
|
||||
else:
|
||||
status = "planned"
|
||||
detail = f"branch has no upstream; HEAD will be pushed to {row['push_target']}"
|
||||
if dirty_count:
|
||||
detail = f"{detail}; {dirty_count} dirty/untracked path(s) stay local"
|
||||
results.append({**row, "status": status, "detail": detail})
|
||||
continue
|
||||
if has_upstream and ahead == 0:
|
||||
detail = "branch has no unpushed commits; nothing was pushed"
|
||||
if dirty_count:
|
||||
detail = f"{detail}; {dirty_count} dirty/untracked path(s) stay local"
|
||||
results.append({**row, "status": "noop", "detail": detail, "command_ran": False})
|
||||
continue
|
||||
|
||||
process = run(command, cwd=resolve_repo_path(spec, workspace))
|
||||
status = "pushed" if process.returncode == 0 else "failed"
|
||||
detail = (
|
||||
f"pushed {ahead} commit(s) to {row['push_target']}"
|
||||
if process.returncode == 0 and has_upstream
|
||||
else f"pushed branch to {row['push_target']}"
|
||||
if process.returncode == 0
|
||||
else "git push failed"
|
||||
)
|
||||
if process.returncode == 0 and dirty_count:
|
||||
detail = f"{detail}; {dirty_count} dirty/untracked path(s) stay local"
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": status,
|
||||
"returncode": process.returncode,
|
||||
"detail": detail,
|
||||
"command_ran": True,
|
||||
"stdout": compact_output(process.stdout),
|
||||
"stderr": compact_output(process.stderr),
|
||||
}
|
||||
)
|
||||
|
||||
if any(item["status"] in {"blocked", "failed"} for item in results):
|
||||
status = "blocked" if not apply else "partial"
|
||||
elif any(item["status"] == "pushed" for item in results):
|
||||
status = "pushed"
|
||||
elif any(item["status"] == "planned" for item in results):
|
||||
status = "planned"
|
||||
else:
|
||||
status = "noop"
|
||||
return {"status": status, "apply": apply, "remote": remote, "repositories": results}
|
||||
|
||||
|
||||
def push_command(branch: str | None, *, remote: str, has_upstream: bool) -> tuple[str, ...]:
|
||||
if has_upstream:
|
||||
return ("git", "push")
|
||||
if branch:
|
||||
return ("git", "push", "-u", remote, "HEAD")
|
||||
return ("git", "push", remote)
|
||||
|
||||
|
||||
def push_target(branch: str | None, *, upstream: str | None, remote: str) -> str:
|
||||
if upstream:
|
||||
return upstream
|
||||
if branch:
|
||||
return f"{remote}/{branch}"
|
||||
return remote
|
||||
|
||||
|
||||
def command_text(command: tuple[str, ...]) -> str:
|
||||
return " ".join(shlex.quote(part) for part in command)
|
||||
|
||||
|
||||
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git push timed out")
|
||||
|
||||
|
||||
def compact_output(value: str) -> str:
|
||||
text = value.strip()
|
||||
return text if len(text) <= 4000 else f"{text[:4000]}\n... truncated ..."
|
||||
Reference in New Issue
Block a user