|
9 | 9 | import subprocess |
10 | 10 | import sys |
11 | 11 | import tempfile |
| 12 | +import time |
12 | 13 | from pathlib import Path |
13 | 14 | from typing import Iterable, cast |
14 | 15 |
|
|
27 | 28 | FULLREPO_PROTOCOL_VERSION = "1.0.0" |
28 | 29 | FULLREPO_GENERATOR_DIGEST = "bb2036ac52e20f898a17ab9333099758da690f933a14e720133eb3ba967256a8" |
29 | 30 | PUBLISH_RETRIES = 3 |
| 31 | +BOOTSTRAP_CI_ATTEMPTS = 6 |
| 32 | +BOOTSTRAP_CI_SLEEP_SECONDS = 10.0 |
| 33 | +BOOTSTRAP_CI_RECEIPT = "dist/gates/fullrepo-bootstrap-ci.json" |
30 | 34 | EXCLUDE_BEGIN = "# >>> rldyour fullrepo agent-only files >>>" |
31 | 35 | EXCLUDE_END = "# <<< rldyour fullrepo agent-only files <<<" |
32 | 36 |
|
@@ -705,6 +709,33 @@ def restore_local(remote: str, branch: str, dry_run: bool = False, *, ignore_pro |
705 | 709 | print(f"restored {len(remote_paths)} agent-only files from local {remote}/{branch}@{resolved_ref[:12]} ({resolved['mode']})") |
706 | 710 |
|
707 | 711 |
|
| 712 | +def restore_resolved_overlay( |
| 713 | + resolved: dict[str, object], |
| 714 | + *, |
| 715 | + label: str, |
| 716 | + dry_run: bool = False, |
| 717 | +) -> list[str]: |
| 718 | + resolved_ref = str(resolved["commit"]) |
| 719 | + remote_paths = tracked_agent_paths(resolved_ref) |
| 720 | + if not remote_paths: |
| 721 | + print(f"resolved fullrepo overlay {resolved_ref[:12]} has no agent-only files") |
| 722 | + return [] |
| 723 | + |
| 724 | + if dry_run: |
| 725 | + print( |
| 726 | + f"dry-run: would restore {len(remote_paths)} agent-only files from " |
| 727 | + f"{label}@{resolved_ref[:12]} ({resolved['mode']})" |
| 728 | + ) |
| 729 | + return remote_paths |
| 730 | + |
| 731 | + for index in range(0, len(remote_paths), 64): |
| 732 | + chunk = remote_paths[index : index + 64] |
| 733 | + _git("restore", "--source", resolved_ref, "--worktree", "--", *chunk) |
| 734 | + |
| 735 | + print(f"restored {len(remote_paths)} agent-only files from {label}@{resolved_ref[:12]} ({resolved['mode']})") |
| 736 | + return remote_paths |
| 737 | + |
| 738 | + |
708 | 739 | def migrate_main(dry_run: bool = False, *, ignore_project_policy: bool = False) -> None: |
709 | 740 | policy = _project_policy() |
710 | 741 | enforce_fullrepo_policy(policy, "migrate-main", ignore_project_policy=ignore_project_policy) |
@@ -772,6 +803,144 @@ def bootstrap_init( |
772 | 803 | print_status(payload, as_json=False) |
773 | 804 |
|
774 | 805 |
|
| 806 | +def _env_int(name: str, default: int) -> int: |
| 807 | + raw = os.environ.get(name) |
| 808 | + if not raw: |
| 809 | + return default |
| 810 | + try: |
| 811 | + value = int(raw) |
| 812 | + except ValueError: |
| 813 | + raise FullrepoError(f"{name} must be an integer") |
| 814 | + if value < 1: |
| 815 | + raise FullrepoError(f"{name} must be >= 1") |
| 816 | + return value |
| 817 | + |
| 818 | + |
| 819 | +def _positive_int(value: int, name: str) -> int: |
| 820 | + if value < 1: |
| 821 | + raise FullrepoError(f"{name} must be >= 1") |
| 822 | + return value |
| 823 | + |
| 824 | + |
| 825 | +def _env_float(name: str, default: float) -> float: |
| 826 | + raw = os.environ.get(name) |
| 827 | + if not raw: |
| 828 | + return default |
| 829 | + try: |
| 830 | + value = float(raw) |
| 831 | + except ValueError: |
| 832 | + raise FullrepoError(f"{name} must be a number") |
| 833 | + if value < 0: |
| 834 | + raise FullrepoError(f"{name} must be >= 0") |
| 835 | + return value |
| 836 | + |
| 837 | + |
| 838 | +def _non_negative_float(value: float, name: str) -> float: |
| 839 | + if value < 0: |
| 840 | + raise FullrepoError(f"{name} must be >= 0") |
| 841 | + return value |
| 842 | + |
| 843 | + |
| 844 | +def write_bootstrap_ci_receipt(path: Path, payload: dict[str, object]) -> None: |
| 845 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 846 | + tmp = path.with_name(f".{path.name}.tmp") |
| 847 | + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| 848 | + os.replace(tmp, path) |
| 849 | + |
| 850 | + |
| 851 | +def bootstrap_ci( |
| 852 | + remote: str, |
| 853 | + branch: str, |
| 854 | + dry_run: bool = False, |
| 855 | + *, |
| 856 | + attempts: int | None = None, |
| 857 | + sleep_seconds: float | None = None, |
| 858 | + receipt_path: Path | None = None, |
| 859 | + ignore_project_policy: bool = False, |
| 860 | +) -> None: |
| 861 | + policy = _project_policy() |
| 862 | + enforce_fullrepo_policy(policy, "restore", ignore_project_policy=ignore_project_policy) |
| 863 | + if tracked_agent_paths_in_index(): |
| 864 | + raise FullrepoError("CI bootstrap refuses to migrate source-tracked agent-only files") |
| 865 | + if _policy_value(policy, "install_exclude", True) or ignore_project_policy: |
| 866 | + install_exclude(dry_run=dry_run) |
| 867 | + |
| 868 | + max_attempts = ( |
| 869 | + _positive_int(attempts, "--bootstrap-attempts") |
| 870 | + if attempts is not None |
| 871 | + else _env_int("RLDYOUR_FULLREPO_BOOTSTRAP_CI_ATTEMPTS", BOOTSTRAP_CI_ATTEMPTS) |
| 872 | + ) |
| 873 | + delay = ( |
| 874 | + _non_negative_float(sleep_seconds, "--bootstrap-sleep-seconds") |
| 875 | + if sleep_seconds is not None |
| 876 | + else _env_float("RLDYOUR_FULLREPO_BOOTSTRAP_CI_SLEEP_SECONDS", BOOTSTRAP_CI_SLEEP_SECONDS) |
| 877 | + ) |
| 878 | + receipt = receipt_path or (repo_root() / BOOTSTRAP_CI_RECEIPT) |
| 879 | + remote_ref = f"refs/remotes/{remote}/{branch}" |
| 880 | + source_head = commit_sha("HEAD") |
| 881 | + source_tree = ref_tree_sha(source_head) if source_head else "" |
| 882 | + errors: list[str] = [] |
| 883 | + |
| 884 | + for attempt in range(1, max_attempts + 1): |
| 885 | + fetched = fetch_fullrepo(remote, branch) |
| 886 | + if not fetched: |
| 887 | + errors.append(f"attempt {attempt}: fullrepo branch {remote}/{branch} does not exist or could not be fetched") |
| 888 | + else: |
| 889 | + try: |
| 890 | + resolved = resolve_fullrepo_ref(remote_ref, "HEAD") |
| 891 | + except FullrepoError as exc: |
| 892 | + errors.append(f"attempt {attempt}: {exc}") |
| 893 | + else: |
| 894 | + restored_paths = restore_resolved_overlay( |
| 895 | + resolved, |
| 896 | + label=f"{remote}/{branch}", |
| 897 | + dry_run=dry_run, |
| 898 | + ) |
| 899 | + payload: dict[str, object] = { |
| 900 | + "schema_version": 1, |
| 901 | + "state": "PASS", |
| 902 | + "mode": "bootstrap-ci", |
| 903 | + "remote": remote, |
| 904 | + "fullrepo_branch": branch, |
| 905 | + "attempt": attempt, |
| 906 | + "attempts": max_attempts, |
| 907 | + "source_commit": source_head, |
| 908 | + "source_tree": source_tree, |
| 909 | + "resolved_fullrepo_commit": str(resolved.get("commit", "")), |
| 910 | + "resolution_mode": str(resolved.get("mode", "")), |
| 911 | + "agent_tree": str(resolved.get("agent_tree", "")), |
| 912 | + "restored_agent_paths": restored_paths, |
| 913 | + "restore_count": len(restored_paths), |
| 914 | + "dry_run": dry_run, |
| 915 | + "errors": errors, |
| 916 | + } |
| 917 | + write_bootstrap_ci_receipt(receipt, payload) |
| 918 | + print(json.dumps(payload, indent=2, sort_keys=True)) |
| 919 | + return |
| 920 | + if attempt < max_attempts and delay > 0: |
| 921 | + print( |
| 922 | + f"fullrepo overlay for {source_head[:12]} not ready; retrying " |
| 923 | + f"({attempt}/{max_attempts}) after {delay:g}s", |
| 924 | + file=sys.stderr, |
| 925 | + ) |
| 926 | + time.sleep(delay) |
| 927 | + |
| 928 | + payload = { |
| 929 | + "schema_version": 1, |
| 930 | + "state": "FAIL", |
| 931 | + "mode": "bootstrap-ci", |
| 932 | + "remote": remote, |
| 933 | + "fullrepo_branch": branch, |
| 934 | + "attempts": max_attempts, |
| 935 | + "source_commit": source_head, |
| 936 | + "source_tree": source_tree, |
| 937 | + "dry_run": dry_run, |
| 938 | + "errors": errors, |
| 939 | + } |
| 940 | + write_bootstrap_ci_receipt(receipt, payload) |
| 941 | + raise FullrepoError("; ".join(errors) if errors else "CI bootstrap failed without diagnostic") |
| 942 | + |
| 943 | + |
775 | 944 | def status(remote: str, branch: str, *, local_only: bool = False) -> dict[str, object]: |
776 | 945 | root = repo_root() |
777 | 946 | policy = _project_policy() |
@@ -924,6 +1093,24 @@ def parse_args() -> argparse.Namespace: |
924 | 1093 | actions.add_argument("--resolve-json", action="store_true") |
925 | 1094 | actions.add_argument("--migrate-main", action="store_true") |
926 | 1095 | actions.add_argument("--bootstrap-init", action="store_true") |
| 1096 | + actions.add_argument("--bootstrap-ci", action="store_true") |
| 1097 | + parser.add_argument( |
| 1098 | + "--bootstrap-attempts", |
| 1099 | + type=int, |
| 1100 | + default=None, |
| 1101 | + help="Maximum fetch/resolve attempts for --bootstrap-ci. Defaults to RLDYOUR_FULLREPO_BOOTSTRAP_CI_ATTEMPTS or 6.", |
| 1102 | + ) |
| 1103 | + parser.add_argument( |
| 1104 | + "--bootstrap-sleep-seconds", |
| 1105 | + type=float, |
| 1106 | + default=None, |
| 1107 | + help="Delay between --bootstrap-ci attempts. Defaults to RLDYOUR_FULLREPO_BOOTSTRAP_CI_SLEEP_SECONDS or 10.", |
| 1108 | + ) |
| 1109 | + parser.add_argument( |
| 1110 | + "--bootstrap-receipt", |
| 1111 | + default=BOOTSTRAP_CI_RECEIPT, |
| 1112 | + help=f"Receipt path for --bootstrap-ci, relative to the repository root by default ({BOOTSTRAP_CI_RECEIPT}).", |
| 1113 | + ) |
927 | 1114 | return parser.parse_args() |
928 | 1115 |
|
929 | 1116 |
|
@@ -974,6 +1161,19 @@ def main() -> int: |
974 | 1161 | create_missing=args.create_missing, |
975 | 1162 | ignore_project_policy=args.ignore_project_policy, |
976 | 1163 | ) |
| 1164 | + elif args.bootstrap_ci: |
| 1165 | + receipt_path = Path(args.bootstrap_receipt) |
| 1166 | + if not receipt_path.is_absolute(): |
| 1167 | + receipt_path = repo_root() / receipt_path |
| 1168 | + bootstrap_ci( |
| 1169 | + args.remote, |
| 1170 | + args.branch, |
| 1171 | + dry_run=args.dry_run, |
| 1172 | + attempts=args.bootstrap_attempts, |
| 1173 | + sleep_seconds=args.bootstrap_sleep_seconds, |
| 1174 | + receipt_path=receipt_path, |
| 1175 | + ignore_project_policy=args.ignore_project_policy, |
| 1176 | + ) |
977 | 1177 | except FullrepoError as exc: |
978 | 1178 | print(str(exc), file=sys.stderr) |
979 | 1179 | return 1 |
|
0 commit comments