"""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 ..."