mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-27 02:32:02 -04:00
874df9b5bb
Retire the setup-host release model (CI wiped generated/ and shipped
emitters + sources + a wizard so every player regenerated and rebuilt the
title). Titles now commit generated/ and CI ships the compiled game.
- docs/ci/templates/game-release.yml replaces setup-release.yml: gate on
committed game C (tools/ci/check_generated.sh), emit the OpenBIOS backend
in CI (tools/ci/generate_openbios.sh), configure with
PSXRECOMP_REQUIRE_GAME_C=ON and assert the full path was taken, package
with tools/package_game_release.sh, verify the zip is a game not a kit.
- tools/package_game_release.sh: exe, assets, bundled OpenBIOS, configs, mod
catalog + overlay toolchain via release_stage.py, notices; refuses sources,
emitters, generated C, BIOS dumps. clear_generated.sh removed;
package_setup_host.sh marked retired.
- Scaffold: gitignore templates track generated/, package_release.sh.in
wrapper, setup_project.{sh,ps1} commit generated/ after Generate.
- Project Studio: audit flags ignored/untracked generated/, retail BIOS C
and setup-host workflows; merge_gitignore un-ignores generated/; ops emit
the bundled packager + workflow; generate_ci uses the new template.
- Docs: docs/ci/BUNDLED_RELEASES.md replaces HOST_ONLY_RELEASES.md.
- Tests: tools/tests/test_bundled_release_layout.py; fix pre-existing
false positives in the packager-surface and overlay-static tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1715 lines
55 KiB
Python
1715 lines
55 KiB
Python
"""Git / GitHub operations for a game repository.
|
|
|
|
Uses ``git`` and (optionally) ``gh``. No force-push, amend, or hook skips.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
DEFAULT_PSXRECOMP_URL = "https://github.com/mstan/psxrecomp.git"
|
|
DEFAULT_RECOMP_UI_URL = "https://github.com/RetroPortingToolKit/recomp-ui.git"
|
|
DEFAULT_RECOMP_NET_URL = "https://github.com/RetroPortingToolKit/recomp-net.git"
|
|
DEFAULT_RBENGINE_URL = "https://github.com/RetroPortingToolKit/rbengine.git"
|
|
DEFAULT_BRANCH = "master"
|
|
DEFAULT_NESTED_BRANCH = "main"
|
|
KNOWN_SUBMODULES = ("psxrecomp", "recomp-ui")
|
|
# Nested under a psxrecomp checkout (game/psxrecomp or the engine repo itself).
|
|
KNOWN_NESTED_SUBMODULES: tuple[tuple[str, str, str], ...] = (
|
|
("lib/recomp-net", DEFAULT_RECOMP_NET_URL, DEFAULT_NESTED_BRANCH),
|
|
("lib/retcomm-rbengine", DEFAULT_RBENGINE_URL, DEFAULT_NESTED_BRANCH),
|
|
)
|
|
NESTED_PATHS = tuple(p for p, _, _ in KNOWN_NESTED_SUBMODULES)
|
|
|
|
|
|
@dataclass
|
|
class CmdResult:
|
|
ok: bool
|
|
message: str
|
|
detail: str = ""
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass
|
|
class SubmoduleInfo:
|
|
name: str
|
|
path: str
|
|
url: str = ""
|
|
branch: str = ""
|
|
sha: str = ""
|
|
present: bool = False
|
|
initialized: bool = False
|
|
checkout_branch: str = "" # actual HEAD branch; empty if detached / missing
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass
|
|
class RepoStatus:
|
|
root: str
|
|
is_git: bool
|
|
branch: str = ""
|
|
upstream: str = ""
|
|
ahead: int = 0
|
|
behind: int = 0
|
|
dirty: bool = False
|
|
staged: int = 0
|
|
unstaged: int = 0
|
|
untracked: int = 0
|
|
remote_url: str = ""
|
|
gh_available: bool = False
|
|
gh_repo: str = ""
|
|
short_status: str = ""
|
|
submodules: list[SubmoduleInfo] = field(default_factory=list)
|
|
nested_submodules: list[SubmoduleInfo] = field(default_factory=list)
|
|
psxrecomp_root: str = ""
|
|
notes: list[str] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
d = asdict(self)
|
|
d["submodules"] = [s.to_dict() for s in self.submodules]
|
|
d["nested_submodules"] = [s.to_dict() for s in self.nested_submodules]
|
|
return d
|
|
|
|
|
|
def _run(
|
|
cmd: list[str],
|
|
cwd: Path,
|
|
*,
|
|
dry_run: bool = False,
|
|
check: bool = False,
|
|
) -> tuple[int, str, str]:
|
|
if dry_run:
|
|
return 0, "dry-run: " + " ".join(cmd), ""
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
cwd=str(cwd),
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except OSError as exc:
|
|
if check:
|
|
raise
|
|
return 127, "", str(exc)
|
|
return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip()
|
|
|
|
|
|
# Git verbs / patterns that talk to a remote (DNS / TLS / HTTP).
|
|
_GIT_NETWORK_VERBS = frozenset(
|
|
{"fetch", "pull", "push", "clone", "ls-remote"}
|
|
)
|
|
|
|
# Transient reachability failures (DNS blips, TLS resets, GitHub 5xx/429, etc.).
|
|
_TRANSIENT_GIT_NETWORK_MARKERS = (
|
|
"could not resolve host",
|
|
"temporary failure in name resolution",
|
|
"name or service not known",
|
|
"nodename nor servname provided",
|
|
"failed to connect",
|
|
"failed to connect to",
|
|
"connection timed out",
|
|
"connection refused",
|
|
"connection reset by peer",
|
|
"network is unreachable",
|
|
"no route to host",
|
|
# Note: do not match bare "unable to access" — that also covers 401/403 auth.
|
|
"the remote end hung up unexpectedly",
|
|
"ssl_error_syscall",
|
|
"ssl connection timeout",
|
|
"tls handshake timeout",
|
|
"gnutls_handshake",
|
|
"openssl ssl_read",
|
|
"openssl ssl_connect",
|
|
"empty reply from server",
|
|
"operation timed out",
|
|
"transfer closed with outstanding read data remaining",
|
|
"rpc failed",
|
|
"recv failure",
|
|
"early eof",
|
|
"http/2 stream",
|
|
"http 429",
|
|
"http 502",
|
|
"http 503",
|
|
"http 504",
|
|
"error: 429",
|
|
"error: 502",
|
|
"error: 503",
|
|
"error: 504",
|
|
"server aborted the request",
|
|
)
|
|
|
|
# Default: 4 attempts with 1s / 2s / 4s backoff (covers bulk-pull DNS flakes).
|
|
_GIT_NETWORK_ATTEMPTS = 4
|
|
_GIT_NETWORK_BASE_DELAY_S = 1.0
|
|
|
|
|
|
def _git_args_need_network(args: tuple[str, ...]) -> bool:
|
|
for i, a in enumerate(args):
|
|
if a in _GIT_NETWORK_VERBS:
|
|
return True
|
|
if a == "submodule" and i + 1 < len(args) and args[i + 1] in (
|
|
"update",
|
|
"add",
|
|
):
|
|
return True
|
|
if a == "remote" and i + 1 < len(args) and args[i + 1] == "update":
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_transient_git_network_error(stdout: str, stderr: str) -> bool:
|
|
blob = f"{stdout}\n{stderr}".lower()
|
|
return any(m in blob for m in _TRANSIENT_GIT_NETWORK_MARKERS)
|
|
|
|
|
|
def _git(
|
|
cwd: Path,
|
|
*args: str,
|
|
dry_run: bool = False,
|
|
network_attempts: int | None = None,
|
|
) -> tuple[int, str, str]:
|
|
"""Run git. Network-touching verbs retry on transient DNS/connect errors."""
|
|
if dry_run or not _git_args_need_network(args):
|
|
return _run(["git", *args], cwd, dry_run=dry_run)
|
|
|
|
attempts = (
|
|
_GIT_NETWORK_ATTEMPTS if network_attempts is None else max(1, int(network_attempts))
|
|
)
|
|
code, out, err = 1, "", ""
|
|
for attempt in range(1, attempts + 1):
|
|
code, out, err = _run(["git", *args], cwd, dry_run=False)
|
|
if code == 0:
|
|
if attempt > 1:
|
|
note = f"(git network: succeeded on attempt {attempt}/{attempts})"
|
|
out = f"{out}\n{note}".strip() if out else note
|
|
return code, out, err
|
|
if attempt < attempts and _is_transient_git_network_error(out, err):
|
|
time.sleep(_GIT_NETWORK_BASE_DELAY_S * (2 ** (attempt - 1)))
|
|
continue
|
|
if attempt > 1 and _is_transient_git_network_error(out, err):
|
|
note = f"(git network: failed after {attempts} attempts)"
|
|
err = f"{err}\n{note}".strip() if err else note
|
|
return code, out, err
|
|
return code, out, err
|
|
|
|
|
|
def current_branch(root: Path) -> str | None:
|
|
"""Return the checked-out branch name, or None if detached / unknown."""
|
|
root = root.expanduser().resolve()
|
|
if not root.is_dir():
|
|
return None
|
|
code, out, _ = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
if code != 0:
|
|
return None
|
|
name = out.strip()
|
|
if not name or name == "HEAD":
|
|
return None
|
|
return name
|
|
|
|
|
|
def _which_gh() -> str | None:
|
|
return shutil.which("gh")
|
|
|
|
|
|
def _is_git_repo(root: Path) -> bool:
|
|
code, out, _ = _git(root, "rev-parse", "--is-inside-work-tree")
|
|
return code == 0 and out.strip() == "true"
|
|
|
|
|
|
def _read_gitmodules(root: Path) -> configparser.ConfigParser:
|
|
cp = configparser.ConfigParser(interpolation=None)
|
|
gm = root / ".gitmodules"
|
|
if gm.is_file():
|
|
cp.read(gm, encoding="utf-8")
|
|
return cp
|
|
|
|
|
|
def _write_gitmodules(root: Path, cp: configparser.ConfigParser, *, dry_run: bool) -> None:
|
|
if dry_run:
|
|
return
|
|
buf: list[str] = []
|
|
for section in cp.sections():
|
|
buf.append(f"[{section}]")
|
|
for key, value in cp.items(section):
|
|
buf.append(f"\t{key} = {value}")
|
|
buf.append("")
|
|
text = "\n".join(buf).rstrip() + "\n"
|
|
(root / ".gitmodules").write_text(text, encoding="utf-8")
|
|
|
|
|
|
def _section_for_path(cp: configparser.ConfigParser, path: str) -> str | None:
|
|
for section in cp.sections():
|
|
if cp.get(section, "path", fallback="") == path:
|
|
return section
|
|
# Common form: [submodule "psxrecomp"]
|
|
want = f'submodule "{path}"'
|
|
for section in cp.sections():
|
|
if section == want or section.endswith(f'"{path}"'):
|
|
return section
|
|
return None
|
|
|
|
|
|
def _submodule_sha(root: Path, path: str) -> str:
|
|
code, out, _ = _git(root, "rev-parse", f"HEAD:{path}")
|
|
if code == 0 and re.fullmatch(r"[0-9a-f]{40}", out.strip()):
|
|
return out.strip()[:12]
|
|
# Fallback: ls-tree
|
|
code, out, _ = _git(root, "ls-tree", "HEAD", path)
|
|
if code == 0:
|
|
parts = out.split()
|
|
if len(parts) >= 3 and parts[0] == "160000":
|
|
return parts[2][:12]
|
|
# Working tree HEAD inside submodule
|
|
sub = root / path
|
|
if sub.is_dir():
|
|
code, out, _ = _git(sub, "rev-parse", "HEAD")
|
|
if code == 0:
|
|
return out.strip()[:12]
|
|
return ""
|
|
|
|
|
|
def _submodule_remote_url(root: Path, path: str) -> str:
|
|
"""Best-effort origin URL from a submodule working tree."""
|
|
sub = root / path
|
|
if not sub.is_dir():
|
|
return ""
|
|
code, out, _ = _git(sub, "remote", "get-url", "origin")
|
|
if code == 0 and out.strip():
|
|
return out.strip()
|
|
return ""
|
|
|
|
|
|
def _default_url_for_path(path: str) -> str:
|
|
for p, url, _ in KNOWN_NESTED_SUBMODULES:
|
|
if p == path:
|
|
return url
|
|
if path == "psxrecomp":
|
|
return DEFAULT_PSXRECOMP_URL
|
|
if path == "recomp-ui":
|
|
return DEFAULT_RECOMP_UI_URL
|
|
return ""
|
|
|
|
|
|
def _list_submodules(root: Path, *, known: tuple[str, ...] = KNOWN_SUBMODULES) -> list[SubmoduleInfo]:
|
|
cp = _read_gitmodules(root)
|
|
found: dict[str, SubmoduleInfo] = {}
|
|
for section in cp.sections():
|
|
path = cp.get(section, "path", fallback="")
|
|
if not path:
|
|
continue
|
|
name = path
|
|
m = re.search(r'"([^"]+)"', section)
|
|
if m:
|
|
name = m.group(1)
|
|
url = cp.get(section, "url", fallback="").strip()
|
|
if not url:
|
|
url = _submodule_remote_url(root, path) or _default_url_for_path(path)
|
|
found[path] = SubmoduleInfo(
|
|
name=name,
|
|
path=path,
|
|
url=url,
|
|
branch=cp.get(section, "branch", fallback=""),
|
|
sha=_submodule_sha(root, path),
|
|
present=(root / path).exists(),
|
|
initialized=(root / path / ".git").exists()
|
|
or ((root / path).is_dir() and (root / ".git" / "modules" / path).exists()),
|
|
checkout_branch=current_branch(root / path) or ""
|
|
if (root / path).is_dir()
|
|
else "",
|
|
)
|
|
# Ensure known slots show up even if missing from .gitmodules
|
|
for path in known:
|
|
if path not in found:
|
|
url = _submodule_remote_url(root, path) or _default_url_for_path(path)
|
|
present = (root / path).exists()
|
|
found[path] = SubmoduleInfo(
|
|
name=path,
|
|
path=path,
|
|
url=url,
|
|
present=present,
|
|
initialized=(root / path / ".git").exists(),
|
|
sha=_submodule_sha(root, path) if present else "",
|
|
checkout_branch=(current_branch(root / path) or "") if present else "",
|
|
)
|
|
# Stable order: known first, then others
|
|
ordered: list[SubmoduleInfo] = []
|
|
for path in known:
|
|
if path in found:
|
|
ordered.append(found.pop(path))
|
|
ordered.extend(sorted(found.values(), key=lambda s: s.path))
|
|
return ordered
|
|
|
|
|
|
def resolve_psxrecomp_dir(root: Path) -> Path | None:
|
|
"""Return the psxrecomp checkout: either ``root`` itself or ``root/psxrecomp``."""
|
|
root = root.expanduser().resolve()
|
|
if (root / "runtime" / "runtime.cmake").is_file():
|
|
return root
|
|
nested = root / "psxrecomp"
|
|
if (nested / "runtime" / "runtime.cmake").is_file():
|
|
return nested
|
|
if nested.is_dir() and (nested / ".git").exists():
|
|
return nested
|
|
return None
|
|
|
|
|
|
def list_nested_modules(root: Path) -> list[SubmoduleInfo]:
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return []
|
|
return _list_submodules(psx, known=NESTED_PATHS)
|
|
|
|
|
|
def repo_status(root: Path) -> RepoStatus:
|
|
root = root.expanduser().resolve()
|
|
st = RepoStatus(root=str(root), is_git=_is_git_repo(root), gh_available=bool(_which_gh()))
|
|
if not st.is_git:
|
|
st.notes.append("Not a git repository.")
|
|
return st
|
|
|
|
code, branch, _ = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
st.branch = branch if code == 0 else ""
|
|
|
|
code, upstream, _ = _git(root, "rev-parse", "--abbrev-ref", "@{upstream}")
|
|
if code == 0:
|
|
st.upstream = upstream
|
|
code2, ab, _ = _git(root, "rev-list", "--left-right", "--count", f"{upstream}...HEAD")
|
|
if code2 == 0:
|
|
parts = ab.split()
|
|
if len(parts) == 2:
|
|
st.behind = int(parts[0])
|
|
st.ahead = int(parts[1])
|
|
|
|
code, porcelain, _ = _git(root, "status", "--porcelain")
|
|
if code == 0:
|
|
lines = [ln for ln in porcelain.splitlines() if ln.strip()]
|
|
st.short_status = "\n".join(lines[:40])
|
|
for ln in lines:
|
|
xy = ln[:2]
|
|
if ln.startswith("??"):
|
|
st.untracked += 1
|
|
else:
|
|
if xy[0] not in (" ", "?"):
|
|
st.staged += 1
|
|
if xy[1] not in (" ", "?"):
|
|
st.unstaged += 1
|
|
st.dirty = bool(lines)
|
|
|
|
code, url, _ = _git(root, "remote", "get-url", "origin")
|
|
if code == 0:
|
|
st.remote_url = url
|
|
|
|
if st.gh_available:
|
|
code, out, err = _run(
|
|
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
|
root,
|
|
)
|
|
if code == 0 and out:
|
|
st.gh_repo = out.strip()
|
|
elif err:
|
|
st.notes.append("gh present but not authenticated / no GitHub remote.")
|
|
|
|
st.submodules = _list_submodules(root)
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is not None:
|
|
st.psxrecomp_root = str(psx)
|
|
if psx == root:
|
|
# Engine checkout: top-level known slots are the nested libs.
|
|
st.submodules = _list_submodules(root, known=NESTED_PATHS)
|
|
st.nested_submodules = list(st.submodules)
|
|
st.notes.append("Root is a psxrecomp checkout (nested modules are direct).")
|
|
else:
|
|
st.nested_submodules = list_nested_modules(root)
|
|
return st
|
|
|
|
|
|
def set_submodule_url(
|
|
root: Path,
|
|
path: str,
|
|
url: str,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
"""Write ``url`` into ``.gitmodules`` for ``path`` (create section if needed)."""
|
|
root = root.expanduser().resolve()
|
|
url = url.strip()
|
|
if not url:
|
|
return CmdResult(False, "URL required")
|
|
path = path.strip().replace("\\", "/")
|
|
cp = _read_gitmodules(root)
|
|
section = _section_for_path(cp, path)
|
|
if section is None:
|
|
if not (root / path).exists():
|
|
return CmdResult(False, f"No .gitmodules entry for {path}")
|
|
section = f'submodule "{path}"'
|
|
cp.add_section(section)
|
|
cp.set(section, "path", path)
|
|
prev = cp.get(section, "url", fallback="").strip()
|
|
if prev == url:
|
|
return CmdResult(True, f"{path} url already set")
|
|
cp.set(section, "url", url)
|
|
_write_gitmodules(root, cp, dry_run=dry_run)
|
|
if not dry_run:
|
|
_git(root, "config", "-f", ".gitmodules", f"submodule.{path}.url", url)
|
|
_git(root, "submodule", "sync", "--", path)
|
|
return CmdResult(True, f"Set {path} url → {url}")
|
|
|
|
|
|
def ensure_submodule(
|
|
root: Path,
|
|
path: str,
|
|
*,
|
|
url: str,
|
|
branch: str = DEFAULT_BRANCH,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
root = root.expanduser().resolve()
|
|
if not _is_git_repo(root):
|
|
return CmdResult(False, "Not a git repository")
|
|
dest = root / path
|
|
cp = _read_gitmodules(root)
|
|
already_registered = _section_for_path(cp, path) is not None
|
|
looks_present = dest.exists() and (
|
|
(dest / ".git").exists()
|
|
or (dest / "CMakeLists.txt").is_file()
|
|
or (dest / "runtime" / "runtime.cmake").is_file()
|
|
or any(dest.iterdir())
|
|
)
|
|
if already_registered or looks_present:
|
|
notes: list[str] = []
|
|
# Heal missing .gitmodules url (common when a submodule was added by hand).
|
|
section = _section_for_path(cp, path)
|
|
have_url = ""
|
|
if section:
|
|
have_url = cp.get(section, "url", fallback="").strip()
|
|
if not have_url and url:
|
|
url_r = set_submodule_url(root, path, url, dry_run=dry_run)
|
|
notes.append(url_r.message)
|
|
set_r = set_submodule_branch(root, path, branch, dry_run=dry_run)
|
|
notes.append(set_r.message)
|
|
return CmdResult(
|
|
True,
|
|
f"{path} already present",
|
|
"; ".join(n for n in notes if n),
|
|
)
|
|
|
|
code, out, err = _git(
|
|
root,
|
|
"submodule",
|
|
"add",
|
|
"-b",
|
|
branch,
|
|
url,
|
|
path,
|
|
dry_run=dry_run,
|
|
)
|
|
if code != 0:
|
|
return CmdResult(False, f"Failed to add {path}", err or out)
|
|
_git(root, "submodule", "update", "--init", "--recursive", path, dry_run=dry_run)
|
|
return CmdResult(True, f"Added submodule {path} (branch {branch})", out)
|
|
|
|
|
|
def ensure_known_submodules(
|
|
root: Path,
|
|
*,
|
|
psxrecomp_branch: str = DEFAULT_BRANCH,
|
|
recomp_ui_branch: str = DEFAULT_BRANCH,
|
|
dry_run: bool = False,
|
|
) -> list[CmdResult]:
|
|
return [
|
|
ensure_submodule(
|
|
root,
|
|
"psxrecomp",
|
|
url=DEFAULT_PSXRECOMP_URL,
|
|
branch=psxrecomp_branch or DEFAULT_BRANCH,
|
|
dry_run=dry_run,
|
|
),
|
|
ensure_submodule(
|
|
root,
|
|
"recomp-ui",
|
|
url=DEFAULT_RECOMP_UI_URL,
|
|
branch=recomp_ui_branch or DEFAULT_BRANCH,
|
|
dry_run=dry_run,
|
|
),
|
|
]
|
|
|
|
|
|
def ensure_nested_modules(
|
|
root: Path,
|
|
*,
|
|
recomp_net_branch: str = DEFAULT_NESTED_BRANCH,
|
|
rbengine_branch: str = DEFAULT_NESTED_BRANCH,
|
|
dry_run: bool = False,
|
|
) -> list[CmdResult]:
|
|
"""Ensure ``lib/recomp-net`` + ``lib/retcomm-rbengine`` inside psxrecomp."""
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return [CmdResult(False, "No psxrecomp checkout found (need root or root/psxrecomp)")]
|
|
if not _is_git_repo(psx):
|
|
return [CmdResult(False, f"psxrecomp is not a git repo: {psx}")]
|
|
|
|
branch_by_path = {
|
|
"lib/recomp-net": recomp_net_branch or DEFAULT_NESTED_BRANCH,
|
|
"lib/retcomm-rbengine": rbengine_branch or DEFAULT_NESTED_BRANCH,
|
|
}
|
|
results: list[CmdResult] = []
|
|
for path, url, default_branch in KNOWN_NESTED_SUBMODULES:
|
|
results.append(
|
|
ensure_submodule(
|
|
psx,
|
|
path,
|
|
url=url,
|
|
branch=branch_by_path.get(path, default_branch),
|
|
dry_run=dry_run,
|
|
)
|
|
)
|
|
return results
|
|
|
|
|
|
def update_nested_modules(
|
|
root: Path,
|
|
*,
|
|
paths: list[str] | None = None,
|
|
remote: bool = False,
|
|
stage: bool = True,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
"""Update nested modules inside psxrecomp; optionally stage gitlinks there."""
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return CmdResult(False, "No psxrecomp checkout found")
|
|
want = paths or list(NESTED_PATHS)
|
|
# Allow callers to pass game-relative paths
|
|
normalized: list[str] = []
|
|
for p in want:
|
|
p = p.strip().replace("\\", "/")
|
|
if p.startswith("psxrecomp/"):
|
|
p = p[len("psxrecomp/") :]
|
|
normalized.append(p)
|
|
|
|
r = update_submodules(psx, paths=normalized, remote=remote, dry_run=dry_run)
|
|
if not r.ok:
|
|
return r
|
|
if stage and not dry_run:
|
|
code, out, err = _git(psx, "add", "--", *normalized)
|
|
if code != 0:
|
|
return CmdResult(
|
|
False,
|
|
"Nested update ok but failed to stage gitlinks in psxrecomp",
|
|
err or out,
|
|
)
|
|
detail = (r.detail + "\n" if r.detail else "") + "staged in psxrecomp: " + ", ".join(
|
|
normalized
|
|
)
|
|
return CmdResult(
|
|
True,
|
|
r.message + " (staged in psxrecomp)",
|
|
detail.strip(),
|
|
)
|
|
if stage and dry_run:
|
|
return CmdResult(
|
|
True,
|
|
r.message + " (would stage in psxrecomp)",
|
|
r.detail,
|
|
)
|
|
return r
|
|
|
|
|
|
def commit_nested(
|
|
root: Path,
|
|
message: str,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
"""Commit inside the psxrecomp checkout (nested gitlink bumps)."""
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return CmdResult(False, "No psxrecomp checkout found")
|
|
r = commit_all(psx, message, dry_run=dry_run)
|
|
if r.ok:
|
|
r = CmdResult(r.ok, f"psxrecomp: {r.message}", r.detail)
|
|
return r
|
|
|
|
|
|
def set_nested_branch(
|
|
root: Path,
|
|
path: str,
|
|
branch: str,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return CmdResult(False, "No psxrecomp checkout found")
|
|
path = path.strip().replace("\\", "/")
|
|
if path.startswith("psxrecomp/"):
|
|
path = path[len("psxrecomp/") :]
|
|
return set_submodule_branch(psx, path, branch, dry_run=dry_run)
|
|
|
|
|
|
def set_submodule_branch(
|
|
root: Path,
|
|
path: str,
|
|
branch: str,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
root = root.expanduser().resolve()
|
|
branch = branch.strip()
|
|
if not branch:
|
|
return CmdResult(False, "Branch name required")
|
|
cp = _read_gitmodules(root)
|
|
section = _section_for_path(cp, path)
|
|
if section is None:
|
|
# Create section if submodule dir exists
|
|
if not (root / path).exists():
|
|
return CmdResult(False, f"No .gitmodules entry for {path}")
|
|
section = f'submodule "{path}"'
|
|
cp.add_section(section)
|
|
cp.set(section, "path", path)
|
|
# Try to keep existing url from git config
|
|
code, url, _ = _git(root, "config", "-f", ".gitmodules", f"submodule.{path}.url")
|
|
if code == 0 and url:
|
|
cp.set(section, "url", url)
|
|
cp.set(section, "branch", branch)
|
|
_write_gitmodules(root, cp, dry_run=dry_run)
|
|
if not dry_run:
|
|
_git(root, "config", "-f", ".gitmodules", f"submodule.{path}.branch", branch)
|
|
# Sync into local git config
|
|
_git(root, "submodule", "sync", "--", path)
|
|
return CmdResult(True, f"Set {path} tracking branch → {branch}")
|
|
|
|
|
|
def resolve_default_branch(root: Path, *, remote: str = "origin") -> str | None:
|
|
"""Best-effort default branch for a checkout (``origin/HEAD``, else main/master)."""
|
|
root = root.expanduser().resolve()
|
|
if not _is_git_repo(root):
|
|
return None
|
|
code, out, _err = _git(
|
|
root,
|
|
"symbolic-ref",
|
|
"--quiet",
|
|
"--short",
|
|
f"refs/remotes/{remote}/HEAD",
|
|
)
|
|
if code == 0:
|
|
ref = (out or "").strip()
|
|
if ref:
|
|
# origin/main → main
|
|
if "/" in ref:
|
|
return ref.split("/", 1)[-1]
|
|
return ref
|
|
for name in ("main", "master"):
|
|
code, _o, _e = _git(
|
|
root, "rev-parse", "--verify", "--quiet", f"refs/remotes/{remote}/{name}"
|
|
)
|
|
if code == 0:
|
|
return name
|
|
code, _o, _e = _git(
|
|
root, "rev-parse", "--verify", "--quiet", f"refs/heads/{name}"
|
|
)
|
|
if code == 0:
|
|
return name
|
|
code, out, _err = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
if code == 0:
|
|
head = (out or "").strip()
|
|
if head and head != "HEAD":
|
|
return head
|
|
return None
|
|
|
|
|
|
def is_default_branch_token(branch: str | None) -> bool:
|
|
"""True for empty / ``(default)`` / similar UI sentinels."""
|
|
s = (branch or "").strip().lower()
|
|
if not s:
|
|
return True
|
|
if s.startswith("(") and "default" in s:
|
|
return True
|
|
return s in ("default", "auto")
|
|
|
|
|
|
def _git_switch(
|
|
cwd: Path,
|
|
*args: str,
|
|
dry_run: bool = False,
|
|
) -> tuple[int, str, str]:
|
|
"""Run ``git switch``; fall back to ``git checkout`` on older Git."""
|
|
code, out, err = _git(cwd, "switch", *args, dry_run=dry_run)
|
|
if dry_run or code == 0:
|
|
return code, out, err
|
|
err_l = f"{err}\n{out}".lower()
|
|
if "not a git command" not in err_l and "unknown command" not in err_l:
|
|
return code, out, err
|
|
checkout_args: list[str] = []
|
|
i = 0
|
|
a = list(args)
|
|
while i < len(a):
|
|
if a[i] in ("--guess", "--no-guess"):
|
|
i += 1
|
|
continue
|
|
if a[i] == "-c":
|
|
checkout_args.append("-b")
|
|
i += 1
|
|
continue
|
|
if a[i] == "-C":
|
|
checkout_args.append("-B")
|
|
i += 1
|
|
continue
|
|
if a[i] == "--track" and i + 1 < len(a):
|
|
checkout_args.extend(["--track", a[i + 1]])
|
|
i += 2
|
|
continue
|
|
checkout_args.append(a[i])
|
|
i += 1
|
|
return _git(cwd, "checkout", *checkout_args, dry_run=False)
|
|
|
|
|
|
def switch_branch(
|
|
root: Path,
|
|
branch: str,
|
|
*,
|
|
create: bool = False,
|
|
dry_run: bool = False,
|
|
prefer_default: bool = True,
|
|
) -> CmdResult:
|
|
"""``git switch`` onto ``branch`` (guess remote-tracking; optional ``-c``).
|
|
|
|
Empty / ``(default)`` resolves each repo's default branch (``origin/HEAD``,
|
|
else main/master). When ``prefer_default`` is set and a named branch is
|
|
missing, falls back to that default instead of failing.
|
|
"""
|
|
root = root.expanduser().resolve()
|
|
if not _is_git_repo(root):
|
|
return CmdResult(False, "Not a git repository")
|
|
|
|
requested = (branch or "").strip()
|
|
used_default_token = is_default_branch_token(requested)
|
|
if used_default_token:
|
|
resolved = resolve_default_branch(root)
|
|
if not resolved:
|
|
return CmdResult(
|
|
False,
|
|
"Could not resolve default branch (fetch remotes / set origin/HEAD)",
|
|
)
|
|
target = resolved
|
|
note = "default"
|
|
else:
|
|
target = requested
|
|
note = ""
|
|
|
|
current = current_branch(root)
|
|
if current == target:
|
|
suffix = f" ({note})" if note else ""
|
|
return CmdResult(True, f"Already on {target}{suffix}")
|
|
|
|
verb = "Would switch" if dry_run else "Switched"
|
|
if dry_run:
|
|
# Validate the ref exists so dry-run matches real switch + default fallback.
|
|
code_v, _, _ = _git(
|
|
root, "rev-parse", "--verify", "--quiet", f"refs/heads/{target}"
|
|
)
|
|
if code_v != 0:
|
|
code_v, _, _ = _git(
|
|
root,
|
|
"rev-parse",
|
|
"--verify",
|
|
"--quiet",
|
|
f"refs/remotes/origin/{target}",
|
|
)
|
|
if code_v == 0:
|
|
suffix = f" ({note})" if note else ""
|
|
return CmdResult(True, f"{verb} to {target}{suffix}")
|
|
# fall through to create / prefer_default handling below
|
|
code, out, err = 1, "", f"fatal: invalid reference: {target}"
|
|
else:
|
|
code, out, err = _git_switch(root, "--guess", target, dry_run=False)
|
|
if code == 0:
|
|
suffix = f" ({note})" if note else ""
|
|
return CmdResult(True, f"{verb} to {target}{suffix}", out)
|
|
|
|
if create and not used_default_token:
|
|
code2, out2, err2 = _git_switch(root, "-c", target, dry_run=dry_run)
|
|
if code2 == 0:
|
|
return CmdResult(True, f"{verb} to new branch {target}", out2)
|
|
return CmdResult(
|
|
False,
|
|
f"Could not create/switch to {target}",
|
|
err2 or out2 or err or out,
|
|
)
|
|
|
|
# Named branch missing → prefer this checkout's default branch.
|
|
if prefer_default and not used_default_token and not create:
|
|
fallback = resolve_default_branch(root)
|
|
if fallback and fallback != target:
|
|
if current == fallback:
|
|
return CmdResult(
|
|
True,
|
|
f"Already on {fallback} (default; {target} missing)",
|
|
)
|
|
if dry_run:
|
|
return CmdResult(
|
|
True,
|
|
f"{verb} to {fallback} (default; {target} missing)",
|
|
)
|
|
code_f, out_f, err_f = _git_switch(
|
|
root, "--guess", fallback, dry_run=False
|
|
)
|
|
if code_f == 0:
|
|
return CmdResult(
|
|
True,
|
|
f"{verb} to {fallback} (default; {target} missing)",
|
|
out_f or err or out,
|
|
)
|
|
err = err_f or out_f or err
|
|
|
|
hint = " (enable Create / --create to start a new branch)"
|
|
return CmdResult(False, f"Could not switch to {target}{hint}", err or out)
|
|
|
|
|
|
def set_repo_branch(
|
|
root: Path,
|
|
branch: str,
|
|
*,
|
|
create: bool = False,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
"""Switch the repo working tree (``git switch``). Kept for CLI compatibility."""
|
|
return switch_branch(root, branch, create=create, dry_run=dry_run)
|
|
|
|
|
|
def list_branches(
|
|
repo: Path,
|
|
*,
|
|
remotes: bool = True,
|
|
fetch: bool = False,
|
|
) -> list[str]:
|
|
"""Return sorted local (+ remote-tracking) branch short names for a repo."""
|
|
repo = repo.expanduser().resolve()
|
|
if not repo.is_dir() or not _is_git_repo(repo):
|
|
return []
|
|
if fetch:
|
|
_git(repo, "fetch", "--prune", "--quiet")
|
|
names: set[str] = set()
|
|
code, out, _ = _git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads")
|
|
if code == 0:
|
|
for line in out.splitlines():
|
|
n = line.strip()
|
|
if n and n != "HEAD":
|
|
names.add(n)
|
|
if remotes:
|
|
code, out, _ = _git(
|
|
repo, "for-each-ref", "--format=%(refname:short)", "refs/remotes"
|
|
)
|
|
if code == 0:
|
|
for line in out.splitlines():
|
|
n = line.strip()
|
|
if not n or n.endswith("/HEAD") or "->" in n:
|
|
continue
|
|
# origin/main → main
|
|
if "/" in n:
|
|
n = n.split("/", 1)[1]
|
|
if n and n != "HEAD":
|
|
names.add(n)
|
|
return _sort_branch_names(names)
|
|
|
|
|
|
def list_remote_head_branches(url: str) -> list[str]:
|
|
"""``git ls-remote --heads`` when a local checkout is unavailable."""
|
|
url = (url or "").strip()
|
|
if not url:
|
|
return []
|
|
code, out, _ = _git(Path.cwd(), "ls-remote", "--heads", url)
|
|
if code != 0:
|
|
return []
|
|
names: set[str] = set()
|
|
for line in out.splitlines():
|
|
parts = line.split()
|
|
if len(parts) < 2:
|
|
continue
|
|
ref = parts[1]
|
|
if ref.startswith("refs/heads/"):
|
|
names.add(ref[len("refs/heads/") :])
|
|
return _sort_branch_names(names)
|
|
|
|
|
|
def list_module_branches(
|
|
root: Path,
|
|
path: str,
|
|
*,
|
|
nested: bool = False,
|
|
remotes: bool = True,
|
|
fetch: bool = False,
|
|
url_fallback: str = "",
|
|
) -> list[str]:
|
|
"""Branches for a game submodule or a nested module inside psxrecomp."""
|
|
root = root.expanduser().resolve()
|
|
path = path.strip().replace("\\", "/")
|
|
if nested:
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return list_remote_head_branches(url_fallback) if url_fallback else []
|
|
if path.startswith("psxrecomp/"):
|
|
path = path[len("psxrecomp/") :]
|
|
owner = psx
|
|
else:
|
|
owner = root
|
|
sub = owner / path
|
|
if sub.is_dir() and _is_git_repo(sub):
|
|
return list_branches(sub, remotes=remotes, fetch=fetch)
|
|
# Fall back to URL from .gitmodules or caller
|
|
url = url_fallback
|
|
if not url:
|
|
cp = _read_gitmodules(owner)
|
|
section = _section_for_path(cp, path)
|
|
if section:
|
|
url = cp.get(section, "url", fallback="")
|
|
return list_remote_head_branches(url)
|
|
|
|
|
|
def _sort_branch_names(names: set[str] | list[str]) -> list[str]:
|
|
preferred = ("main", "master", "develop", "development")
|
|
|
|
def key(s: str) -> tuple:
|
|
s_l = s.lower()
|
|
try:
|
|
rank = preferred.index(s_l)
|
|
except ValueError:
|
|
rank = len(preferred)
|
|
return (rank, s_l)
|
|
|
|
return sorted(set(names), key=key)
|
|
|
|
|
|
def update_submodules(
|
|
root: Path,
|
|
*,
|
|
paths: list[str] | None = None,
|
|
remote: bool = False,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
root = root.expanduser().resolve()
|
|
if not _is_git_repo(root):
|
|
return CmdResult(False, "Not a git repository")
|
|
cmd = ["submodule", "update", "--init", "--recursive"]
|
|
if remote:
|
|
cmd.append("--remote")
|
|
if paths:
|
|
cmd.append("--")
|
|
cmd.extend(paths)
|
|
code, out, err = _git(root, *cmd, dry_run=dry_run)
|
|
if code != 0:
|
|
return CmdResult(False, "Submodule update failed", err or out)
|
|
mode = "remote tracking tip" if remote else "pinned gitlink"
|
|
return CmdResult(True, f"Updated submodules ({mode})", out)
|
|
|
|
|
|
# pull() strategies — used by CLI/GUI for game root, submodules, and nested libs.
|
|
PULL_MODES = ("ff-only", "rebase", "merge", "reset")
|
|
PULL_DIRTY = ("fail", "stash", "discard")
|
|
|
|
|
|
def _upstream_ref(root: Path) -> str | None:
|
|
"""Return @{upstream} rev-parse, or origin/<branch> if branch tracks nothing yet."""
|
|
code, out, _ = _git(root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
if code == 0:
|
|
ref = out.strip()
|
|
if ref:
|
|
return ref
|
|
branch = current_branch(root)
|
|
if branch:
|
|
return f"origin/{branch}"
|
|
return None
|
|
|
|
|
|
def _working_tree_dirty(root: Path) -> bool:
|
|
code, out, _ = _git(root, "status", "--porcelain")
|
|
return code == 0 and bool(out.strip())
|
|
|
|
|
|
def pull(
|
|
root: Path,
|
|
*,
|
|
mode: str = "ff-only",
|
|
dirty: str = "fail",
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
"""Pull (or reset-to-upstream) for a git checkout.
|
|
|
|
``mode``:
|
|
- ``ff-only`` (default): ``git pull --ff-only``
|
|
- ``rebase``: ``git pull --rebase``
|
|
- ``merge``: ``git pull --no-rebase``
|
|
- ``reset``: ``git fetch`` + ``git reset --hard <upstream>`` (match GH tip)
|
|
|
|
``dirty`` (ignored for ``reset``, which always hard-resets):
|
|
- ``fail`` (default): leave tree alone; git aborts if it would overwrite
|
|
- ``stash``: ``stash push -u`` before pull, ``stash pop`` after
|
|
- ``discard``: ``git reset --hard HEAD`` before pull (drops local edits)
|
|
"""
|
|
root = root.expanduser().resolve()
|
|
mode = (mode or "ff-only").strip().lower().replace("_", "-")
|
|
dirty = (dirty or "fail").strip().lower()
|
|
if mode == "no-rebase":
|
|
mode = "merge"
|
|
if mode not in PULL_MODES:
|
|
return CmdResult(
|
|
False,
|
|
f"Unknown pull mode {mode!r} (want: {', '.join(PULL_MODES)})",
|
|
)
|
|
if dirty not in PULL_DIRTY:
|
|
return CmdResult(
|
|
False,
|
|
f"Unknown dirty policy {dirty!r} (want: {', '.join(PULL_DIRTY)})",
|
|
)
|
|
if not _is_git_repo(root):
|
|
return CmdResult(False, "Not a git repository")
|
|
|
|
# --- reset: make local HEAD match upstream tip (discards local commits+edits) ---
|
|
if mode == "reset":
|
|
code, out, err = _git(root, "fetch", "origin", dry_run=dry_run)
|
|
if code != 0 and not dry_run:
|
|
return CmdResult(False, "Fetch failed", err or out)
|
|
upstream = _upstream_ref(root)
|
|
if not upstream:
|
|
return CmdResult(
|
|
False,
|
|
"No upstream branch (set upstream or checkout a branch tracking origin)",
|
|
)
|
|
code, out, err = _git(root, "reset", "--hard", upstream, dry_run=dry_run)
|
|
if code != 0:
|
|
return CmdResult(False, f"reset --hard {upstream} failed", err or out)
|
|
return CmdResult(True, f"Reset to {upstream}", out)
|
|
|
|
# --- dirty working tree handling before pull ---
|
|
stashed = False
|
|
if dirty == "discard" and (_working_tree_dirty(root) or dry_run):
|
|
code, out, err = _git(root, "reset", "--hard", "HEAD", dry_run=dry_run)
|
|
if code != 0:
|
|
return CmdResult(False, "discard (reset --hard HEAD) failed", err or out)
|
|
elif dirty == "stash" and (_working_tree_dirty(root) or dry_run):
|
|
code, out, err = _git(
|
|
root, "stash", "push", "-u", "-m", "project-studio pull", dry_run=dry_run
|
|
)
|
|
if code != 0:
|
|
return CmdResult(False, "stash before pull failed", err or out)
|
|
stashed = True
|
|
elif dirty == "fail" and _working_tree_dirty(root) and not dry_run:
|
|
return CmdResult(
|
|
False,
|
|
"Pull blocked: working tree dirty "
|
|
"(commit, or pass dirty=stash|discard, or mode=reset)",
|
|
)
|
|
|
|
if mode == "ff-only":
|
|
pull_args = ("pull", "--ff-only")
|
|
label = "ff-only"
|
|
elif mode == "rebase":
|
|
pull_args = ("pull", "--rebase")
|
|
label = "rebase"
|
|
else: # merge
|
|
pull_args = ("pull", "--no-rebase")
|
|
label = "merge"
|
|
|
|
code, out, err = _git(root, *pull_args, dry_run=dry_run)
|
|
if code != 0:
|
|
if stashed and not dry_run:
|
|
_git(root, "stash", "pop")
|
|
return CmdResult(False, f"Pull failed ({label})", err or out)
|
|
|
|
detail = out
|
|
if stashed and not dry_run:
|
|
sc, so, se = _git(root, "stash", "pop")
|
|
if sc != 0:
|
|
return CmdResult(
|
|
False,
|
|
f"Pulled ({label}) but stash pop failed",
|
|
(detail + "\n" + (se or so)).strip(),
|
|
)
|
|
detail = (detail + "\n" + so).strip()
|
|
return CmdResult(True, f"Pulled ({label})", detail)
|
|
|
|
|
|
def commit_all(
|
|
root: Path,
|
|
message: str,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
root = root.expanduser().resolve()
|
|
message = message.strip()
|
|
if not message:
|
|
return CmdResult(False, "Commit message required")
|
|
code, porcelain, _ = _git(root, "status", "--porcelain")
|
|
if code != 0:
|
|
return CmdResult(False, "git status failed", porcelain)
|
|
if not porcelain.strip() and not dry_run:
|
|
return CmdResult(False, "Nothing to commit")
|
|
code, out, err = _git(root, "add", "-A", dry_run=dry_run)
|
|
if code != 0:
|
|
return CmdResult(False, "git add failed", err or out)
|
|
code, out, err = _git(root, "commit", "-m", message, dry_run=dry_run)
|
|
if code != 0:
|
|
return CmdResult(False, "git commit failed", err or out)
|
|
return CmdResult(True, "Committed", out)
|
|
|
|
|
|
def push(
|
|
root: Path,
|
|
*,
|
|
branch: str = "",
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
"""Push current HEAD to origin.
|
|
|
|
If HEAD is detached, ``branch`` is required and we push
|
|
``HEAD:refs/heads/<branch>`` so GitHub gets a real branch ref.
|
|
"""
|
|
root = root.expanduser().resolve()
|
|
code, _, err = _git(root, "remote", "get-url", "origin")
|
|
if code != 0 and not dry_run:
|
|
return CmdResult(False, "No origin remote", err)
|
|
|
|
local = current_branch(root)
|
|
target = (branch or "").strip()
|
|
if target.startswith("("):
|
|
target = ""
|
|
|
|
if local:
|
|
# On a branch: normal upstream push
|
|
code, out, err = _git(root, "push", "-u", "origin", "HEAD", dry_run=dry_run)
|
|
if code != 0:
|
|
return CmdResult(False, "Push failed", err or out)
|
|
return CmdResult(True, f"Pushed {local} → origin", out)
|
|
|
|
# Detached HEAD
|
|
if not target:
|
|
return CmdResult(
|
|
False,
|
|
"Detached HEAD — select/checkout a branch before push "
|
|
"(or Studio will push HEAD:refs/heads/<branch> if one is selected)",
|
|
)
|
|
refspec = f"HEAD:refs/heads/{target}"
|
|
code, out, err = _git(root, "push", "-u", "origin", refspec, dry_run=dry_run)
|
|
if code != 0:
|
|
return CmdResult(False, f"Push failed (detached → {target})", err or out)
|
|
# Best-effort: attach local checkout to that branch so later pushes are normal
|
|
if not dry_run:
|
|
_git_switch(root, "-C", target)
|
|
return CmdResult(
|
|
True,
|
|
f"Pushed detached HEAD → origin/{target} (switched to {target})",
|
|
out,
|
|
)
|
|
return CmdResult(
|
|
True,
|
|
f"Would push detached HEAD → origin/{target} and switch to {target}",
|
|
out,
|
|
)
|
|
|
|
|
|
def _normalize_module_path(path: str, *, nested: bool) -> str:
|
|
p = path.strip().replace("\\", "/")
|
|
if nested and p.startswith("psxrecomp/"):
|
|
p = p[len("psxrecomp/") :]
|
|
return p
|
|
|
|
|
|
def resolve_module_dir(
|
|
root: Path,
|
|
path: str,
|
|
*,
|
|
nested: bool = False,
|
|
) -> Path | None:
|
|
"""Resolve a game submodule or a nested module checkout under psxrecomp."""
|
|
root = root.expanduser().resolve()
|
|
path = _normalize_module_path(path, nested=nested)
|
|
if not path:
|
|
return None
|
|
if nested:
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return None
|
|
# Engine repo itself: nested paths are direct.
|
|
owner = psx
|
|
else:
|
|
owner = root
|
|
sub = owner / path
|
|
if sub.is_dir() and _is_git_repo(sub):
|
|
return sub
|
|
return None
|
|
|
|
|
|
def default_module_paths(*, nested: bool = False) -> tuple[str, ...]:
|
|
return NESTED_PATHS if nested else KNOWN_SUBMODULES
|
|
|
|
|
|
def _tracking_branch_for(owner: Path, path: str) -> str:
|
|
cp = _read_gitmodules(owner)
|
|
section = _section_for_path(cp, path)
|
|
if not section:
|
|
return ""
|
|
return cp.get(section, "branch", fallback="").strip()
|
|
|
|
|
|
def switch_modules(
|
|
root: Path,
|
|
*,
|
|
paths: list[str] | None = None,
|
|
nested: bool = False,
|
|
branch_by_path: dict[str, str] | None = None,
|
|
create: bool = False,
|
|
set_tracking: bool = True,
|
|
dry_run: bool = False,
|
|
) -> list[CmdResult]:
|
|
"""``git switch`` inside each module checkout.
|
|
|
|
``branch_by_path`` overrides .gitmodules tracking. When a path has no
|
|
explicit branch, tracking is used, then the checkout's default branch.
|
|
``(default)`` always means each module's own default. ``set_tracking``
|
|
also writes ``branch =`` in .gitmodules so ``update --remote`` stays aligned.
|
|
"""
|
|
want = paths or list(default_module_paths(nested=nested))
|
|
branches = {
|
|
_normalize_module_path(k, nested=nested): (v or "").strip()
|
|
for k, v in (branch_by_path or {}).items()
|
|
}
|
|
results: list[CmdResult] = []
|
|
owner: Path | None = None
|
|
if nested:
|
|
owner = resolve_psxrecomp_dir(root)
|
|
else:
|
|
owner = root.expanduser().resolve()
|
|
for path in want:
|
|
path = _normalize_module_path(path, nested=nested)
|
|
branch = (branches.get(path) or "").strip()
|
|
if is_default_branch_token(branch):
|
|
# Blank override → prefer .gitmodules tracking, else default.
|
|
if not branch and owner is not None:
|
|
tracked = _tracking_branch_for(owner, path)
|
|
if tracked and not is_default_branch_token(tracked):
|
|
branch = tracked
|
|
else:
|
|
branch = "(default)"
|
|
else:
|
|
branch = "(default)"
|
|
if not branch:
|
|
results.append(CmdResult(False, f"{path}: no branch to switch to"))
|
|
continue
|
|
sub = resolve_module_dir(root, path, nested=nested)
|
|
if sub is None:
|
|
results.append(CmdResult(False, f"{path}: checkout missing"))
|
|
continue
|
|
r = switch_branch(sub, branch, create=create, dry_run=dry_run)
|
|
msg = f"{path}: {r.message}"
|
|
if r.ok and set_tracking:
|
|
# Track the branch we actually landed on (may be default fallback).
|
|
actual = ""
|
|
if not dry_run:
|
|
actual = current_branch(sub) or ""
|
|
if not actual:
|
|
# dry-run: derive from switch message when possible
|
|
for token in ("Switched to ", "Would switch to ", "Already on "):
|
|
if token in (r.message or ""):
|
|
rest = (r.message or "").split(token, 1)[1]
|
|
actual = rest.split()[0] if rest else ""
|
|
break
|
|
if not actual:
|
|
actual = (
|
|
resolve_default_branch(sub)
|
|
if is_default_branch_token(branch)
|
|
else branch
|
|
)
|
|
if nested:
|
|
tr = set_nested_branch(root, path, actual, dry_run=dry_run)
|
|
else:
|
|
tr = set_submodule_branch(root, path, actual, dry_run=dry_run)
|
|
detail = "\n".join(x for x in (r.detail, tr.message) if x)
|
|
results.append(CmdResult(tr.ok, f"{msg}; {tr.message}", detail))
|
|
else:
|
|
results.append(CmdResult(r.ok, msg, r.detail))
|
|
return results
|
|
|
|
|
|
def switch_psxrecomp(
|
|
root: Path,
|
|
branch: str,
|
|
*,
|
|
create: bool = False,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return CmdResult(False, "No psxrecomp checkout found")
|
|
r = switch_branch(psx, branch, create=create, dry_run=dry_run)
|
|
return CmdResult(r.ok, f"psxrecomp: {r.message}", r.detail)
|
|
|
|
|
|
def pull_modules(
|
|
root: Path,
|
|
*,
|
|
paths: list[str] | None = None,
|
|
nested: bool = False,
|
|
mode: str = "ff-only",
|
|
dirty: str = "fail",
|
|
dry_run: bool = False,
|
|
) -> list[CmdResult]:
|
|
"""Pull inside each module checkout (see ``pull`` for mode/dirty)."""
|
|
want = paths or list(default_module_paths(nested=nested))
|
|
results: list[CmdResult] = []
|
|
for path in want:
|
|
path = _normalize_module_path(path, nested=nested)
|
|
sub = resolve_module_dir(root, path, nested=nested)
|
|
if sub is None:
|
|
results.append(CmdResult(False, f"{path}: checkout missing"))
|
|
continue
|
|
r = pull(sub, mode=mode, dirty=dirty, dry_run=dry_run)
|
|
results.append(CmdResult(r.ok, f"{path}: {r.message}", r.detail))
|
|
return results
|
|
|
|
|
|
def push_modules(
|
|
root: Path,
|
|
*,
|
|
paths: list[str] | None = None,
|
|
nested: bool = False,
|
|
branch_by_path: dict[str, str] | None = None,
|
|
dry_run: bool = False,
|
|
) -> list[CmdResult]:
|
|
"""Push each module checkout to origin (handles detached HEAD via branch map)."""
|
|
want = paths or list(default_module_paths(nested=nested))
|
|
branches = branch_by_path or {}
|
|
results: list[CmdResult] = []
|
|
for path in want:
|
|
path = _normalize_module_path(path, nested=nested)
|
|
sub = resolve_module_dir(root, path, nested=nested)
|
|
if sub is None:
|
|
results.append(CmdResult(False, f"{path}: checkout missing"))
|
|
continue
|
|
r = push(sub, branch=branches.get(path, ""), dry_run=dry_run)
|
|
results.append(CmdResult(r.ok, f"{path}: {r.message}", r.detail))
|
|
return results
|
|
|
|
|
|
def commit_modules(
|
|
root: Path,
|
|
message: str,
|
|
*,
|
|
paths: list[str] | None = None,
|
|
nested: bool = False,
|
|
dry_run: bool = False,
|
|
) -> list[CmdResult]:
|
|
"""``git add -A && git commit`` inside each module checkout."""
|
|
message = message.strip()
|
|
if not message:
|
|
return [CmdResult(False, "Commit message required")]
|
|
want = paths or list(default_module_paths(nested=nested))
|
|
results: list[CmdResult] = []
|
|
for path in want:
|
|
path = _normalize_module_path(path, nested=nested)
|
|
sub = resolve_module_dir(root, path, nested=nested)
|
|
if sub is None:
|
|
results.append(CmdResult(False, f"{path}: checkout missing"))
|
|
continue
|
|
r = commit_all(sub, message, dry_run=dry_run)
|
|
results.append(CmdResult(r.ok, f"{path}: {r.message}", r.detail))
|
|
return results
|
|
|
|
|
|
def pull_psxrecomp(
|
|
root: Path,
|
|
*,
|
|
mode: str = "ff-only",
|
|
dirty: str = "fail",
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return CmdResult(False, "No psxrecomp checkout found")
|
|
r = pull(psx, mode=mode, dirty=dirty, dry_run=dry_run)
|
|
return CmdResult(r.ok, f"psxrecomp: {r.message}", r.detail)
|
|
|
|
|
|
def push_psxrecomp(
|
|
root: Path,
|
|
*,
|
|
branch: str = "",
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
psx = resolve_psxrecomp_dir(root)
|
|
if psx is None:
|
|
return CmdResult(False, "No psxrecomp checkout found")
|
|
r = push(psx, branch=branch, dry_run=dry_run)
|
|
return CmdResult(r.ok, f"psxrecomp: {r.message}", r.detail)
|
|
|
|
|
|
def install_and_push_release_ci(
|
|
root: Path,
|
|
*,
|
|
zip_prefix: str = "",
|
|
force: bool = False,
|
|
push_remote: bool = True,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
"""Write psxrecomp game-release.yml (+ packager), commit, and push.
|
|
|
|
Uses the same template as ``setup_project --enable-ci`` /
|
|
``op_emit_ci_workflow``. After push, best-effort checks that Actions has
|
|
registered ``release.yml`` (nudge commit if needed).
|
|
"""
|
|
from .models import MigrateOptions
|
|
from .ops import op_emit_ci_workflow, op_emit_packager
|
|
|
|
root = root.expanduser().resolve()
|
|
if not _is_git_repo(root):
|
|
return CmdResult(False, "Not a git repository")
|
|
|
|
opts = MigrateOptions(
|
|
zip_prefix=zip_prefix.strip() or None,
|
|
enable_ci=True,
|
|
force=force,
|
|
dry_run=dry_run,
|
|
)
|
|
pack = op_emit_packager(root, opts)
|
|
if not pack.ok:
|
|
return CmdResult(False, f"Packager: {pack.message}")
|
|
ci = op_emit_ci_workflow(root, opts)
|
|
if not ci.ok:
|
|
return CmdResult(False, f"CI workflow: {ci.message}")
|
|
|
|
paths = sorted({*pack.changed_paths, *ci.changed_paths})
|
|
if not paths and not dry_run:
|
|
# Already present — still allow push of any uncommitted workflow edits.
|
|
wf = root / ".github" / "workflows" / "release.yml"
|
|
pkg = root / "scripts" / "package_release.sh"
|
|
for p in (wf, pkg):
|
|
if p.is_file():
|
|
rel = str(p.relative_to(root)).replace("\\", "/")
|
|
if rel not in paths:
|
|
paths.append(rel)
|
|
|
|
if dry_run:
|
|
return CmdResult(
|
|
True,
|
|
"dry-run: would install CI + commit + push\n"
|
|
+ "\n".join(f" {p}" for p in paths),
|
|
)
|
|
|
|
if not paths:
|
|
return CmdResult(False, "No CI files to commit")
|
|
|
|
code, out, err = _git(root, "add", "--", *paths)
|
|
if code != 0:
|
|
return CmdResult(False, "git add failed", err or out)
|
|
|
|
code, porcelain, _ = _git(root, "diff", "--cached", "--name-only")
|
|
staged = [ln.strip() for ln in porcelain.splitlines() if ln.strip()]
|
|
if not staged:
|
|
# Nothing new staged — still try push so remote gets existing commits.
|
|
detail = f"{pack.op_id}: {pack.message}; {ci.op_id}: {ci.message}"
|
|
if not push_remote:
|
|
return CmdResult(True, "CI already up to date (nothing to commit)", detail)
|
|
push_r = push(root, dry_run=False)
|
|
if not push_r.ok:
|
|
return CmdResult(
|
|
True,
|
|
"CI already up to date locally; push skipped/failed",
|
|
(detail + "\n" + push_r.message).strip(),
|
|
)
|
|
nudge = ensure_actions_registers_release_yml(root)
|
|
msg = "CI already up to date; pushed existing commits"
|
|
if nudge.message:
|
|
msg += f"\n{nudge.message}"
|
|
return CmdResult(True, msg, detail)
|
|
|
|
code, out, err = _git(
|
|
root,
|
|
"commit",
|
|
"-m",
|
|
"ci: add bundled release.yml (psxrecomp template)",
|
|
)
|
|
if code != 0:
|
|
return CmdResult(False, "git commit failed", err or out)
|
|
|
|
if not push_remote:
|
|
return CmdResult(True, "Installed + committed release CI (not pushed)", out)
|
|
|
|
push_r = push(root, dry_run=False)
|
|
if not push_r.ok:
|
|
return CmdResult(False, f"Committed CI but push failed: {push_r.message}", push_r.detail)
|
|
|
|
nudge = ensure_actions_registers_release_yml(root)
|
|
msg = f"Installed + pushed release CI\n{push_r.message}"
|
|
if nudge.message:
|
|
msg += f"\n{nudge.message}"
|
|
return CmdResult(True, msg, out)
|
|
|
|
|
|
def ensure_actions_registers_release_yml(root: Path) -> CmdResult:
|
|
"""Poll Actions for release.yml; nudge with an empty commit if missing.
|
|
|
|
Mirrors setup_project.sh ensure_actions_registers_release_yml.
|
|
"""
|
|
root = root.expanduser().resolve()
|
|
if not _which_gh():
|
|
return CmdResult(False, "gh CLI not found — skip Actions registration check")
|
|
|
|
wf_path = ".github/workflows/release.yml"
|
|
if not (root / wf_path).is_file():
|
|
return CmdResult(False, "release.yml missing locally")
|
|
|
|
import time
|
|
|
|
for _ in range(6):
|
|
name = release_workflow_name(root)
|
|
if name:
|
|
return CmdResult(
|
|
True,
|
|
f"Actions workflow registered: {name}\n"
|
|
"(runs on workflow_dispatch or push of v* tags)",
|
|
)
|
|
time.sleep(2)
|
|
|
|
# Nudge: empty commit touching the workflow mtime via amend-free empty commit
|
|
code, out, err = _git(
|
|
root,
|
|
"commit",
|
|
"--allow-empty",
|
|
"-m",
|
|
"ci: nudge Actions to register release.yml",
|
|
)
|
|
if code != 0:
|
|
return CmdResult(
|
|
False,
|
|
"Actions has not listed release.yml yet; nudge commit failed",
|
|
err or out,
|
|
)
|
|
push_r = push(root, dry_run=False)
|
|
if not push_r.ok:
|
|
return CmdResult(
|
|
False,
|
|
"Nudge committed but push failed",
|
|
push_r.detail or push_r.message,
|
|
)
|
|
|
|
for _ in range(8):
|
|
time.sleep(2)
|
|
name = release_workflow_name(root)
|
|
if name:
|
|
return CmdResult(
|
|
True,
|
|
f"Actions workflow registered after nudge: {name}\n"
|
|
"(runs on workflow_dispatch or push of v* tags)",
|
|
)
|
|
return CmdResult(
|
|
False,
|
|
"release.yml nudged but Actions still has not listed it; "
|
|
"open the repo Actions tab or re-run Install & push CI later",
|
|
)
|
|
|
|
|
|
def release_workflow_name(root: Path) -> str | None:
|
|
"""Return registered Actions workflow name for release.yml, if any."""
|
|
if not _which_gh():
|
|
return None
|
|
code, out, _ = _run(
|
|
[
|
|
"gh",
|
|
"api",
|
|
"repos/{owner}/{repo}/actions/workflows",
|
|
"--jq",
|
|
'.workflows[] | select(.path|endswith("release.yml")) | .name',
|
|
],
|
|
root,
|
|
)
|
|
if code != 0:
|
|
return None
|
|
for line in out.splitlines():
|
|
name = line.strip()
|
|
if name:
|
|
return name
|
|
return None
|
|
|
|
|
|
def run_release_workflow(
|
|
root: Path,
|
|
*,
|
|
version: str = "",
|
|
bump: str = "patch",
|
|
publish: bool = True,
|
|
reuse_cached_emitters: bool = True,
|
|
dry_run: bool = False,
|
|
) -> CmdResult:
|
|
root = root.expanduser().resolve()
|
|
if not _which_gh():
|
|
return CmdResult(False, "gh CLI not found — install GitHub CLI and auth login")
|
|
if bump not in ("patch", "minor", "major"):
|
|
return CmdResult(False, f"Invalid bump: {bump}")
|
|
|
|
wf = root / ".github" / "workflows" / "release.yml"
|
|
if not wf.is_file() and not dry_run:
|
|
return CmdResult(False, "Missing .github/workflows/release.yml")
|
|
|
|
# Prefer workflow file path; gh accepts it.
|
|
cmd = [
|
|
"gh",
|
|
"workflow",
|
|
"run",
|
|
"release.yml",
|
|
"-f",
|
|
f"version={version}",
|
|
"-f",
|
|
f"bump={bump}",
|
|
"-f",
|
|
f"publish={'true' if publish else 'false'}",
|
|
"-f",
|
|
f"reuse_cached_emitters={'true' if reuse_cached_emitters else 'false'}",
|
|
]
|
|
if dry_run:
|
|
return CmdResult(True, "dry-run: " + " ".join(cmd))
|
|
|
|
code, out, err = _run(cmd, root)
|
|
if code != 0:
|
|
return CmdResult(False, "Failed to dispatch release workflow", err or out)
|
|
|
|
# Best-effort: fetch latest run URL
|
|
run_url = ""
|
|
code2, runs_json, _ = _run(
|
|
[
|
|
"gh",
|
|
"run",
|
|
"list",
|
|
"--workflow",
|
|
"release.yml",
|
|
"--limit",
|
|
"1",
|
|
"--json",
|
|
"url,databaseId,status",
|
|
],
|
|
root,
|
|
)
|
|
if code2 == 0 and runs_json:
|
|
try:
|
|
runs = json.loads(runs_json)
|
|
if runs:
|
|
run_url = runs[0].get("url") or ""
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
msg = "Dispatched Release builds workflow"
|
|
if run_url:
|
|
msg += f"\n{run_url}"
|
|
return CmdResult(True, msg, out)
|