Coverage for scripts / migrate_fork.py: 100.00%
237 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1#!/usr/bin/env python3
2"""Repoint this checkout's upstream references at your own fork.
4GCO hard-codes ``aws-solutions-library-samples/global-capacity-orchestrator-on-aws``
5in CI badges, clone instructions, issue links, package metadata, the GitHub
6Pages URL, and — critically — the OIDC trust-policy subject that lets GitHub
7Actions assume a deploy role. A fork that leaves those pointing upstream gets
8badges reporting someone else's CI, "report an issue" links filed against
9upstream, and an OIDC role that refuses its workflows.
11The obvious fix, a blanket ``sed`` over the org name, breaks the repository.
12The tree also carries references that must survive: links to sibling projects
13under the upstream org, other projects' GitHub Pages hosts, and — from the
14project's original ``awslabs`` home — the ``awslabs.*`` MCP server package
15names that ``mcp.json`` resolves at runtime. Rewriting those produces dead
16links and tooling that cannot start. This script therefore classifies every
17occurrence and rewrites only the ones that identify *this* repository.
19Usage::
21 # See what would change (default; nothing is written)
22 python scripts/migrate_fork.py --repo-url https://github.com/myorg/my-gco
24 # Apply it
25 python scripts/migrate_fork.py --repo-url https://github.com/myorg/my-gco --apply
27 # Equivalent, without a URL
28 python scripts/migrate_fork.py --owner myorg --repo my-gco --apply
30Only git-tracked text files are considered, so build artifacts and ignored
31directories are never touched. The script refuses to run against a dirty working
32tree unless ``--allow-dirty`` is passed, so ``git diff`` always shows exactly
33what it did and ``git checkout .`` always undoes it.
35Running it twice is a no-op: the second run finds nothing to rewrite.
37See ``docs/FORKING.md`` for the surrounding checklist — the parts of a migration
38that are decisions rather than string substitutions.
39"""
41from __future__ import annotations
43import argparse
44import json
45import re
46import subprocess
47import sys
48from collections.abc import Iterable, Iterator
49from dataclasses import dataclass, field
50from pathlib import Path
52REPO_ROOT = Path(__file__).resolve().parent.parent
54#: The upstream identity this checkout ships with. The project moved from
55#: ``awslabs`` to ``aws-solutions-library-samples`` in August 2026; checkouts
56#: predating the move ship the matching older constant, so the script always
57#: describes the tree it travels with.
58UPSTREAM_OWNER = "aws-solutions-library-samples"
59UPSTREAM_REPO = "global-capacity-orchestrator-on-aws"
61#: GitHub's own constraint on owner and repository names. Validated before any
62#: rewrite so a typo cannot scatter a malformed slug across the tree.
63_OWNER_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$")
64_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
66#: ``owner/repo`` parsed out of any of the URL forms GitHub hands out.
67_REPO_URL_RE = re.compile(
68 r"^(?:https?://(?:www\.)?github\.com/|git@github\.com:|github\.com/)"
69 r"(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
70)
72#: Files whose *content defines or documents the upstream identity*. Rewriting
73#: them would erase this script's own reference points and make the migration
74#: guide describe a migration away from the fork it already is.
75SELF_REFERENTIAL_PATHS = frozenset(
76 {
77 "scripts/migrate_fork.py",
78 "tests/test_migrate_fork.py",
79 "docs/FORKING.md",
80 }
81)
83#: Suffixes that are either not text or are recordings whose correct fix is
84#: re-recording, not editing captured terminal output.
85SKIPPED_SUFFIXES = frozenset(
86 {
87 ".cast",
88 ".gif",
89 ".png",
90 ".jpg",
91 ".jpeg",
92 ".svg",
93 ".ico",
94 ".gz",
95 ".zip",
96 ".whl",
97 ".pyc",
98 ".woff",
99 ".woff2",
100 }
101)
104@dataclass(frozen=True)
105class Rule:
106 """One classification rule applied to a single line of a tracked file."""
108 name: str
109 pattern: re.Pattern[str]
110 #: ``None`` marks a reference that must survive untouched.
111 replacement: str | None
112 why: str
115def _build_rules(owner: str, repo: str) -> tuple[Rule, ...]:
116 """Return the ordered rule table for a target ``owner``/``repo``.
118 Order matters: at any position the earliest-starting, and among ties the
119 first-listed, rule wins. Preservation rules come first so a reference to
120 another AWS Labs project can never be consumed by a broader rewrite, and the
121 rewrite rules run most-specific first so ``git@github.com:owner/repo`` is not
122 partially matched by the bare-slug rule.
123 """
124 up_owner = re.escape(UPSTREAM_OWNER)
125 up_repo = re.escape(UPSTREAM_REPO)
126 # The Pages host appears both literally and percent-encoded (the shields.io
127 # coverage badge embeds it as a query parameter). Both spellings must be
128 # excluded here or the package-name rule swallows them.
129 pages_guard = rf"(?!github\.io(?:/|%2[Ff]){up_repo})"
131 return (
132 Rule(
133 name="other-upstream-org-project",
134 pattern=re.compile(rf"github\.com/{up_owner}/(?!{up_repo})[\w.-]+"),
135 replacement=None,
136 why="link to a different project in the upstream org",
137 ),
138 Rule(
139 name="upstream-package-name",
140 # ``*`` is accepted so prose referring to a namespace as a glob
141 # ("the awslabs.* MCP servers") classifies as a package reference
142 # rather than falling through unrecognized. This rule also claims
143 # other projects' Pages hosts (``<owner>.github.io/other-repo``);
144 # the guard keeps it from swallowing this repository's own Pages
145 # URL, which the pages-url rules rewrite.
146 pattern=re.compile(rf"{up_owner}\.{pages_guard}[a-z0-9_*-]+[a-z0-9_.*-]*"),
147 replacement=None,
148 why="published package name or another project's GitHub Pages host",
149 ),
150 Rule(
151 name="clone-url-ssh",
152 pattern=re.compile(rf"git@github\.com:{up_owner}/{up_repo}"),
153 replacement=f"git@github.com:{owner}/{repo}",
154 why="SSH clone URL",
155 ),
156 Rule(
157 name="pages-url-encoded",
158 pattern=re.compile(rf"{up_owner}\.github\.io%2F{up_repo}"),
159 replacement=f"{owner}.github.io%2F{repo}",
160 why="percent-encoded GitHub Pages URL (shields.io badge endpoint)",
161 ),
162 Rule(
163 name="repo-url-encoded",
164 # The README's one-click MCP install buttons embed the git URL
165 # percent-encoded inside the Kiro and VS Code deep links. The
166 # Cursor deep link embeds the same URL base64-encoded, which no
167 # string rewrite can reach — _detect_follow_ups points migrators
168 # at the scripts/bump_version.py regeneration that fixes all
169 # three links at once.
170 pattern=re.compile(rf"github\.com%2[Ff]{up_owner}%2[Ff]{up_repo}"),
171 replacement=f"github.com%2F{owner}%2F{repo}",
172 why="percent-encoded repository URL (one-click MCP install deep links)",
173 ),
174 Rule(
175 name="pages-url",
176 pattern=re.compile(rf"{up_owner}\.github\.io/{up_repo}"),
177 replacement=f"{owner}.github.io/{repo}",
178 why="GitHub Pages URL",
179 ),
180 Rule(
181 name="oidc-immutable-subject-prefix",
182 pattern=re.compile(rf"repo:{up_owner}@[1-9]\d*/{up_repo}@[1-9]\d*"),
183 replacement="REPLACE_WITH_GITHUB_OIDC_SUBJECT_PREFIX",
184 why=(
185 "immutable GitHub OIDC repository subject; target owner/repository "
186 "IDs must be resolved from GitHub before deployment"
187 ),
188 ),
189 Rule(
190 name="github-api-repo-path",
191 pattern=re.compile(rf"repos/{up_owner}/{up_repo}(?=/|$)"),
192 replacement=f"repos/{owner}/{repo}",
193 why="GitHub REST API repository path",
194 ),
195 Rule(
196 name="shields-release-badge-path",
197 # The README's latest-release badge embeds the slug inside a
198 # shields.io URL (img.shields.io/github/v/release/<owner>/<repo>)
199 # rather than a github.com one. The bare-slug rule cannot claim it
200 # (the preceding "/" blocks its lookbehind), and without this rule
201 # only the repo name would be rewritten — leaving a fork's badge
202 # reporting the upstream repository's releases.
203 pattern=re.compile(rf"github/v/release/{up_owner}/{up_repo}"),
204 replacement=f"github/v/release/{owner}/{repo}",
205 why="shields.io latest-release badge path",
206 ),
207 Rule(
208 name="repo-url",
209 pattern=re.compile(rf"github\.com/{up_owner}/{up_repo}"),
210 replacement=f"github.com/{owner}/{repo}",
211 why="repository URL (badges, issue links, tree/blob links)",
212 ),
213 Rule(
214 name="repo-slug",
215 pattern=re.compile(rf"(?<![\w/.-]){up_owner}/{up_repo}(?![\w-])"),
216 replacement=f"{owner}/{repo}",
217 why="bare owner/repo slug (OIDC trust-policy subject, CI docs)",
218 ),
219 Rule(
220 name="repo-name",
221 # A preceding "/" is allowed so filesystem placeholders such as
222 # "/path/to/global-capacity-orchestrator-on-aws" (the MCP server
223 # setup instructions) are updated too. URL forms cannot be caught
224 # here by mistake: the rules above start earlier in the line and
225 # consume the whole reference first. A preceding word character,
226 # "-", or "." still blocks a match, so a differently-prefixed
227 # directory like "PROD-global-capacity-orchestrator-on-aws" is left
228 # alone.
229 #
230 # A percent-encoded slash ("%2F"/"%2f") is an allowed prefix for
231 # the same reason "/" is: a tree whose Pages host already carries
232 # a different owner (this repository after its own org move, or a
233 # fork migrating a second time) still embeds the repo name in the
234 # shields.io coverage badge's percent-encoded URL, and only this
235 # rule can claim it there — pages-url-encoded is anchored to the
236 # upstream owner's host.
237 pattern=re.compile(rf"(?:(?<=%2[Ff])|(?<![\w.-])){up_repo}(?![\w-])"),
238 replacement=repo,
239 why="bare repository name (clone directory, package metadata)",
240 ),
241 )
244@dataclass
245class Occurrence:
246 """A single classified match."""
248 path: str
249 lineno: int
250 rule: Rule
251 matched: str
253 @property
254 def rewritten(self) -> str | None:
255 return self.rule.replacement
258@dataclass
259class Report:
260 """Everything one run found."""
262 rewrites: list[Occurrence] = field(default_factory=list)
263 preserved: list[Occurrence] = field(default_factory=list)
264 changed_files: list[str] = field(default_factory=list)
265 skipped_binary: list[str] = field(default_factory=list)
266 skipped_self: list[str] = field(default_factory=list)
267 follow_ups: list[tuple[str, str]] = field(default_factory=list)
270def parse_target(args: argparse.Namespace) -> tuple[str, str]:
271 """Resolve and validate the destination ``owner``/``repo``."""
272 if args.repo_url:
273 match = _REPO_URL_RE.match(args.repo_url.strip())
274 if not match:
275 raise SystemExit(
276 f"Could not parse --repo-url {args.repo_url!r}. Expected something like "
277 "https://github.com/myorg/my-gco or git@github.com:myorg/my-gco.git"
278 )
279 owner, repo = match.group("owner"), match.group("repo")
280 else:
281 owner, repo = args.owner, args.repo
283 if not owner or not repo:
284 raise SystemExit("Provide --repo-url, or both --owner and --repo.")
285 if not _OWNER_RE.match(owner):
286 raise SystemExit(f"Invalid GitHub owner name: {owner!r}")
287 if not _REPO_NAME_RE.match(repo):
288 raise SystemExit(f"Invalid GitHub repository name: {repo!r}")
289 if (owner, repo) == (UPSTREAM_OWNER, UPSTREAM_REPO):
290 raise SystemExit(f"Target is the upstream repository ({owner}/{repo}); nothing to migrate.")
291 return owner, repo
294def _git(*argv: str) -> str:
295 """Run a read-only git command in the repository root."""
296 result = subprocess.run(
297 ["git", *argv],
298 cwd=REPO_ROOT,
299 capture_output=True,
300 text=True,
301 check=True,
302 )
303 return result.stdout
306def tracked_files() -> list[Path]:
307 """Every file git tracks, so ignored build output is never rewritten."""
308 return [Path(name) for name in _git("ls-files", "-z").split("\0") if name]
311def working_tree_is_dirty() -> bool:
312 """Whether the checkout has uncommitted changes."""
313 return bool(_git("status", "--porcelain").strip())
316def classify_line(line: str, rules: Iterable[Rule]) -> Iterator[tuple[Rule, re.Match[str]]]:
317 """Yield each classified match in ``line``, left to right, without overlap."""
318 rules = tuple(rules)
319 position = 0
320 while position < len(line):
321 best: tuple[Rule, re.Match[str]] | None = None
322 for rule in rules:
323 match = rule.pattern.search(line, position)
324 if match is None:
325 continue
326 if best is None or match.start() < best[1].start():
327 best = (rule, match)
328 if best is None:
329 return
330 yield best
331 position = max(best[1].end(), position + 1)
334def rewrite_text(text: str, path: str, rules: Iterable[Rule], report: Report) -> str:
335 """Return ``text`` with every rewrite rule applied, recording each match."""
336 rules = tuple(rules)
337 out_lines: list[str] = []
338 for lineno, line in enumerate(text.splitlines(keepends=True), 1):
339 stripped = line.rstrip("\r\n")
340 ending = line[len(stripped) :]
341 rebuilt: list[str] = []
342 cursor = 0
343 for rule, match in classify_line(stripped, rules):
344 occurrence = Occurrence(path, lineno, rule, match.group(0))
345 if rule.replacement is None:
346 report.preserved.append(occurrence)
347 continue
348 report.rewrites.append(occurrence)
349 rebuilt.append(stripped[cursor : match.start()])
350 rebuilt.append(rule.replacement)
351 cursor = match.end()
352 rebuilt.append(stripped[cursor:])
353 out_lines.append("".join(rebuilt) + ending)
354 return "".join(out_lines)
357def _detect_follow_ups() -> list[tuple[str, str]]:
358 """Decisions a string rewrite cannot make for you.
360 Each is detected rather than assumed, so the checklist reflects this
361 checkout instead of listing items that may not apply.
362 """
363 follow_ups: list[tuple[str, str]] = []
365 codeowners = REPO_ROOT / ".github" / "CODEOWNERS"
366 if codeowners.is_file():
367 owners = sorted(
368 set(
369 re.findall(
370 r"@[A-Za-z0-9][A-Za-z0-9-]*(?:/[A-Za-z0-9._-]+)?",
371 codeowners.read_text(encoding="utf-8"),
372 )
373 )
374 )
375 if owners:
376 named = ", ".join(owners)
377 follow_ups.append(
378 (
379 ".github/CODEOWNERS",
380 f"Assigns review to {named}, which will not resolve in your fork "
381 "(a personal handle without access, or a team that does not exist "
382 "in your organization). Every pull request then requests review "
383 "from a missing owner. Replace with your own owners or delete the "
384 "file.",
385 )
386 )
388 app_py = REPO_ROOT / "app.py"
389 if app_py.is_file():
390 text = app_py.read_text(encoding="utf-8")
391 match = re.search(r'SOLUTION_ID\s*=\s*"([^"]+)"', text)
392 if match:
393 follow_ups.append(
394 (
395 "app.py",
396 f"Sets SOLUTION_ID = {match.group(1)!r}, the AWS Solutions identifier "
397 "for the published guidance, on the global stack description. Decide "
398 "whether your fork should keep claiming it; a divergent fork usually "
399 "should not.",
400 )
401 )
403 security = REPO_ROOT / ".github" / "SECURITY.md"
404 if security.is_file():
405 follow_ups.append(
406 (
407 ".github/SECURITY.md",
408 "Describes AWS's vulnerability disclosure process. Keep it for the "
409 "inherited code, but add how reporters should contact you about "
410 "fork-specific issues.",
411 )
412 )
414 oidc = REPO_ROOT / ".github" / "oidc_provider" / "cdk.json"
415 if oidc.is_file():
416 follow_ups.append(
417 (
418 ".github/oidc_provider/",
419 "The github_repo context value and github_subject_prefix define the OIDC "
420 "trust-policy subject. This script updates github_repo and replaces an "
421 "upstream immutable prefix with an explicit placeholder. Query your fork's "
422 "prefix with `gh api repos/OWNER/REPO/actions/oidc/customization/sub "
423 "--jq .sub_claim_prefix`, set it in cdk.json, then redeploy the OIDC stack. "
424 "Until then your workflows cannot assume the role.",
425 )
426 )
428 for name in ("LICENSE", "NOTICE"):
429 if (REPO_ROOT / name).is_file():
430 follow_ups.append(
431 (
432 name,
433 "Upstream attribution. Left untouched deliberately; keep it, and add "
434 "your own copyright rather than replacing it.",
435 )
436 )
438 if (REPO_ROOT / ".github" / "workflows" / "pages.yml").is_file():
439 follow_ups.append(
440 (
441 ".github/workflows/pages.yml",
442 "Enable GitHub Pages on your fork (Settings > Pages, source: GitHub "
443 "Actions) or the coverage badge will 404 even with the URL updated.",
444 )
445 )
447 if "BEGIN MCP INSTALL TABLE" in _safe_read(Path("README.md")):
448 follow_ups.append(
449 (
450 "README.md",
451 "The one-click MCP install buttons embed this repository's git URL "
452 "in their deep links. The Kiro and VS Code links carry it "
453 "percent-encoded and are rewritten here, but the Cursor link "
454 "carries it base64-encoded, which a string rewrite cannot reach. "
455 "Regenerate the whole table from its owning script: "
456 "python3 -c \"import sys; sys.path.insert(0, 'scripts'); "
457 "import bump_version as b; "
458 'b.update_root_readme_install_table(b.get_version())".',
459 )
460 )
462 recordings = sorted(
463 str(path)
464 for path in tracked_files()
465 if path.suffix == ".cast" and UPSTREAM_REPO in _safe_read(path)
466 )
467 if recordings:
468 follow_ups.append(
469 (
470 ", ".join(recordings),
471 "Terminal recordings that captured the upstream clone URL. Editing "
472 "captured output would desynchronize the recording from reality; "
473 "re-record with demo/record_demo.sh instead.",
474 )
475 )
477 return follow_ups
480def _safe_read(path: Path) -> str:
481 """Read a tracked file as text, returning ``""`` when it is not text."""
482 try:
483 return (REPO_ROOT / path).read_text(encoding="utf-8")
484 except UnicodeDecodeError, OSError:
485 return ""
488def run(owner: str, repo: str, *, apply: bool) -> Report:
489 """Classify, and optionally rewrite, every tracked file."""
490 rules = _build_rules(owner, repo)
491 report = Report()
493 for path in tracked_files():
494 name = path.as_posix()
495 if name in SELF_REFERENTIAL_PATHS:
496 report.skipped_self.append(name)
497 continue
498 if path.suffix.lower() in SKIPPED_SUFFIXES:
499 if UPSTREAM_REPO in _safe_read(path) or UPSTREAM_OWNER in _safe_read(path):
500 report.skipped_binary.append(name)
501 continue
503 original = _safe_read(path)
504 if not original or (UPSTREAM_OWNER not in original and UPSTREAM_REPO not in original):
505 continue
507 before = len(report.rewrites)
508 updated = rewrite_text(original, name, rules, report)
509 if len(report.rewrites) == before:
510 continue
512 report.changed_files.append(name)
513 if apply and updated != original:
514 (REPO_ROOT / path).write_text(updated, encoding="utf-8")
516 report.follow_ups = _detect_follow_ups()
517 return report
520def print_report(report: Report, owner: str, repo: str, *, apply: bool) -> None:
521 """Render the report for a human reader."""
522 heading = "APPLIED" if apply else "DRY RUN — nothing was written"
523 print(f"{heading}")
524 print(f"Target: {UPSTREAM_OWNER}/{UPSTREAM_REPO} -> {owner}/{repo}\n")
526 if report.rewrites:
527 by_file: dict[str, list[Occurrence]] = {}
528 for occurrence in report.rewrites:
529 by_file.setdefault(occurrence.path, []).append(occurrence)
530 verb = "Rewrote" if apply else "Would rewrite"
531 print(f"{verb} {len(report.rewrites)} reference(s) in {len(by_file)} file(s):")
532 for path in sorted(by_file):
533 print(f"\n {path}")
534 for occurrence in by_file[path]:
535 print(
536 f" line {occurrence.lineno}: {occurrence.matched}"
537 f" -> {occurrence.rewritten}"
538 )
539 else:
540 print("No references needed rewriting.")
542 if report.preserved:
543 distinct: dict[str, list[Occurrence]] = {}
544 for occurrence in report.preserved:
545 distinct.setdefault(occurrence.matched, []).append(occurrence)
546 print(
547 f"\nPreserved {len(report.preserved)} reference(s) that are not this "
548 f"repository ({len(distinct)} distinct):"
549 )
550 for matched in sorted(distinct):
551 occurrences = distinct[matched]
552 print(f" {matched} ({occurrences[0].rule.why}, x{len(occurrences)})")
554 if report.skipped_self:
555 print("\nSkipped (define or document the upstream identity):")
556 for name in sorted(report.skipped_self):
557 print(f" {name}")
559 if report.skipped_binary:
560 print("\nSkipped (not text, or a recording to regenerate):")
561 for name in sorted(report.skipped_binary):
562 print(f" {name}")
564 if report.follow_ups:
565 print(f"\nManual follow-ups ({len(report.follow_ups)}) — see docs/FORKING.md:")
566 for target, note in report.follow_ups:
567 print(f"\n [ ] {target}")
568 for line in _wrap(note, 74):
569 print(f" {line}")
571 if not apply and report.rewrites:
572 print("\nRe-run with --apply to write these changes.")
575def _wrap(text: str, width: int) -> list[str]:
576 """Wrap ``text`` without importing textwrap for one call site."""
577 words = text.split()
578 lines: list[str] = []
579 current = ""
580 for word in words:
581 candidate = f"{current} {word}".strip()
582 if len(candidate) > width and current:
583 lines.append(current)
584 current = word
585 else:
586 current = candidate
587 if current:
588 lines.append(current)
589 return lines
592def _as_json(report: Report, owner: str, repo: str, *, apply: bool) -> str:
593 payload = {
594 "applied": apply,
595 "target": {"owner": owner, "repo": repo},
596 "upstream": {"owner": UPSTREAM_OWNER, "repo": UPSTREAM_REPO},
597 "rewrites": [
598 {
599 "path": occurrence.path,
600 "line": occurrence.lineno,
601 "rule": occurrence.rule.name,
602 "from": occurrence.matched,
603 "to": occurrence.rewritten,
604 }
605 for occurrence in report.rewrites
606 ],
607 "preserved": [
608 {
609 "path": occurrence.path,
610 "line": occurrence.lineno,
611 "rule": occurrence.rule.name,
612 "matched": occurrence.matched,
613 }
614 for occurrence in report.preserved
615 ],
616 "changed_files": sorted(report.changed_files),
617 "skipped": sorted(report.skipped_self + report.skipped_binary),
618 "follow_ups": [{"target": t, "note": n} for t, n in report.follow_ups],
619 }
620 return json.dumps(payload, indent=2, sort_keys=True)
623def main(argv: list[str] | None = None) -> int:
624 parser = argparse.ArgumentParser(
625 description="Repoint upstream GCO references at your own fork.",
626 epilog="Dry-run by default. See docs/FORKING.md for the full checklist.",
627 )
628 target = parser.add_argument_group("destination")
629 target.add_argument("--repo-url", help="Fork URL, e.g. https://github.com/myorg/my-gco")
630 target.add_argument("--owner", help="Fork owner (user or organization)")
631 target.add_argument("--repo", help="Fork repository name")
632 parser.add_argument(
633 "--apply",
634 action="store_true",
635 help="Write the changes (default: report only).",
636 )
637 parser.add_argument(
638 "--allow-dirty",
639 action="store_true",
640 help="Permit --apply with uncommitted changes present.",
641 )
642 parser.add_argument("--json", action="store_true", help="Emit the report as JSON.")
643 args = parser.parse_args(argv)
645 owner, repo = parse_target(args)
647 if args.apply and not args.allow_dirty and working_tree_is_dirty():
648 print(
649 "Refusing to rewrite a dirty working tree: commit or stash first so "
650 "`git diff` shows only this script's changes and `git checkout .` "
651 "reverts them. Override with --allow-dirty.",
652 file=sys.stderr,
653 )
654 return 2
656 report = run(owner, repo, apply=args.apply)
658 if args.json:
659 print(_as_json(report, owner, repo, apply=args.apply))
660 else:
661 print_report(report, owner, repo, apply=args.apply)
662 return 0
665if __name__ == "__main__":
666 raise SystemExit(main())