diff --git a/.github/upstream-wontport.txt b/.github/upstream-wontport.txt new file mode 100644 index 0000000..e83c9b6 --- /dev/null +++ b/.github/upstream-wontport.txt @@ -0,0 +1,12 @@ +# Upstream commits this fork has consciously decided never to port. +# tools/upstream_triage.py skips anything listed here so it stops re-surfacing +# in the weekly Upstream watch report. One SHA per line (short or full); text +# after # is a note. +# +# Only for commits you've reviewed and rejected on purpose. Commits you DO +# port drop off automatically once cherry-picked (patch-id match), so they +# never need an entry here. Likewise commits that only touch files your fork +# removed are auto-skipped - you don't need to list those either. +# +# This ships empty on the template. Populate it in your own fork, e.g.: +# cffacfd # Danish demo portals - my fork removed them on purpose diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml new file mode 100644 index 0000000..32d2100 --- /dev/null +++ b/.github/workflows/upstream-watch.yml @@ -0,0 +1,85 @@ +# Weekly upstream triage. Reports only - it NEVER merges, pushes, or edits code. +# +# It fetches the upstream template, runs tools/upstream_triage.py to sort the +# commits this fork lacks into "worth reviewing" vs "probably skip" (dropping +# cherry-picks already applied and changes that only touch files this fork +# removed), and writes the result into a single rolling issue. You read it and +# port anything worth porting by hand. +# +# The report/act boundary is deliberate and load-bearing: the report stops at +# ready-to-run cherry-pick lines and never opens a draft PR or merges. On a +# fork "applies cleanly" is not "correct" - a commit for portals the fork +# dropped can cherry-pick fine and still be wrong, and that silent-wrong case +# is worse than a conflict. Merges stay a human decision, the same posture +# /apply keeps (it drafts, never submits). Keep it that way. +# +# This is the commit-level companion to tools/check_upstream_updates.py, which +# tracks personalized-file version stamps. Two tools, two questions. +# +# Runs only on forks (guarded below), so the upstream template never triggers +# it against itself - GitHub also leaves inherited workflows disabled on a fork +# until the owner enables Actions, so the guard is a second fence, not the only +# one. Token is the built-in GITHUB_TOKEN, scoped to reading contents and +# writing issues in this repo only: the digest can never be written outside the +# fork. + +name: Upstream watch + +on: + schedule: + - cron: "0 8 * * 1" # 08:00 UTC every Monday + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + triage: + name: Triage upstream commits + # No-op on the upstream template itself. Pinned by + # tests/test_upstream_triage.py so a template clone never runs it by surprise. + if: github.repository != 'MadsLorentzen/ai-job-search' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Fetch upstream template + run: | + git remote add upstream https://github.com/MadsLorentzen/ai-job-search.git 2>/dev/null || true + git fetch --quiet upstream master + + - name: Build triage report + run: | + { + echo "_Last checked: $(date -u '+%Y-%m-%d %H:%M UTC') · [run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})_" + echo + python tools/upstream_triage.py --remote upstream --branch master + } > report.md + cat report.md + + - name: Open or update the rolling issue + env: + # Built-in token is scoped to this repo only, so the digest can never + # be written outside the fork. + GH_TOKEN: ${{ github.token }} + # Pin to this fork. Without it, the `upstream` git remote added above + # makes gh's remote resolution target the base repo, so the digest + # would land on upstream's tracker instead of the fork's. + GH_REPO: ${{ github.repository }} + run: | + title="Upstream sync watch" + existing=$(gh issue list --state open --search "in:title \"$title\"" \ + --json number,title --jq ".[] | select(.title==\"$title\") | .number" | head -n1) + if [ -n "$existing" ]; then + gh issue edit "$existing" --body-file report.md + echo "Updated issue #$existing" + else + gh issue create --title "$title" --body-file report.md + echo "Created a new rolling issue" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f6fe2..a09b219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ per-file diff commands. ### Added +- **Commit-level upstream triage for forks** (#305). A new `tools/upstream_triage.py` walks the + commits a fork is behind upstream and sorts them into "worth reviewing" vs "probably skip": + cherry-picks already applied drop off on their own (matched by `git patch-id`, so ported work + needs no bookkeeping), commits that only touch files the fork removed are set aside, and SHAs in + a flat `.github/upstream-wontport.txt` stop resurfacing. It's the commit-history companion to + `check_upstream_updates.py`'s version stamps - the two cross-reference each other in their output. + Report-only by design: it prints ready-to-run `git cherry-pick` lines but never merges, pushes, or + opens a PR, because on a fork "applies cleanly" isn't "correct". A `.github/workflows/upstream-watch.yml` + runs it weekly into a rolling issue, guarded to no-op on the upstream template (pinned by a test) and + scoped to the built-in `GITHUB_TOKEN` so it can never write outside its own fork. SETUP.md 8 + introduces both tools side by side. Offline tests cover patch-id matching, relevance filtering, the + won't-port list, and the workflow guard. Thanks @anjolok1997. + - **`security_guards.py` now holds `.claude/settings.json` hooks to an allowlist** - the guard read `permissions.allow` and nothing else, so a `hooks` block in the same file passed silently. A hook is strictly more dangerous than a pre-approved permission: a diff --git a/README.md b/README.md index cfff5fe..564bb5b 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ To wipe your profile data and start fresh: ### Staying up to date -Upstream moves fast. Rather than pulling raw `master` and hoping, update your fork to a tagged [release](../../releases) - a vetted checkpoint described in [CHANGELOG.md](CHANGELOG.md). `python3 tools/check_upstream_updates.py` previews exactly which of your personalized files an update touches before you merge. Full walkthrough in [SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork). +Upstream moves fast. Rather than pulling raw `master` and hoping, update your fork to a tagged [release](../../releases) - a vetted checkpoint described in [CHANGELOG.md](CHANGELOG.md). `python3 tools/check_upstream_updates.py` previews exactly which of your personalized files an update touches before you merge, and `python3 tools/upstream_triage.py` sorts the commits you're behind into "worth reviewing" vs "probably skip" (a weekly workflow can post this to a rolling issue). Full walkthrough in [SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork). ## Tips for better results diff --git a/SETUP.md b/SETUP.md index 71392cb..8a0e682 100644 --- a/SETUP.md +++ b/SETUP.md @@ -298,6 +298,16 @@ Upstream keeps improving the methodology files your fork has personalized, so pl python3 tools/check_upstream_updates.py ``` It compares the `framework_version` markers in your framework files against upstream and lists exactly which methodology files changed, with the diff command for each. + + Two tools answer two different questions, and it's worth running both: + - **`check_upstream_updates.py`** — *which of my personalized files changed?* It reads the `framework_version` stamp on each methodology file, so it flags exactly the customized files a release touched. + - **`upstream_triage.py`** — *which upstream commits deserve my attention?* It walks the commits you're behind and sorts them into "worth reviewing" vs "probably skip", dropping anything you've already cherry-picked (matched by `git patch-id`, so ported work falls off with no bookkeeping), commits that only touch files your fork removed, and SHAs you've listed in `.github/upstream-wontport.txt`. It's report-only — it prints ready-to-run `git cherry-pick` lines but never merges, pushes, or opens a PR, because on a fork "applies cleanly" isn't "correct". + + ```bash + python3 tools/upstream_triage.py --remote upstream + ``` + + Forks also inherit a `.github/workflows/upstream-watch.yml` that runs this weekly and writes the result into a single rolling issue (it no-ops on the upstream template itself, and stays disabled on a fork until you enable Actions). 3. **Merge normally.** `git merge upstream/master` (or `git pull`) three-way-merges upstream's edits around your personalization; because methodology edits rarely touch the lines `/setup` filled in, most updates land cleanly. A conflict in a personalized file is a *feature*, not a failure — it means upstream changed methodology in a section you customized, and the version marker plus its changelog commit tell you why. Resolve by keeping your data and adopting the methodology change around it. ## Troubleshooting diff --git a/tests/test_upstream_triage.py b/tests/test_upstream_triage.py new file mode 100644 index 0000000..73c7814 --- /dev/null +++ b/tests/test_upstream_triage.py @@ -0,0 +1,192 @@ +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "tools" / "upstream_triage.py" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "upstream-watch.yml" +UPSTREAM_SLUG = "MadsLorentzen/ai-job-search" + + +def git(root: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=root, check=True, capture_output=True, text=True + ).stdout + + +class TriageRepoFixture(unittest.TestCase): + """Builds a real git history: a shared base, then an `upstream/master` + ref that runs ahead, so the triage script can be exercised fully offline. + """ + + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + + (self.root / "tools").mkdir() + shutil.copy(SCRIPT, self.root / "tools" / "upstream_triage.py") + (self.root / ".github").mkdir() + + git(self.root, "init", "-b", "master") + git(self.root, "config", "user.name", "Test") + git(self.root, "config", "user.email", "test@example.com") + git(self.root, "remote", "add", "upstream", + f"https://github.com/{UPSTREAM_SLUG}.git") + + self.write("shared.txt", "base\n") + self.write("kept.py", "print('hi')\n") + self.commit("init") + + def write(self, rel: str, text: str) -> None: + path = self.root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def commit(self, msg: str) -> str: + git(self.root, "add", "-A") + git(self.root, "commit", "-m", msg) + return git(self.root, "rev-parse", "HEAD").strip() + + def set_upstream_to_head(self) -> None: + git(self.root, "update-ref", "refs/remotes/upstream/master", "HEAD") + + def run_triage(self, *args) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(self.root / "tools" / "upstream_triage.py"), *args], + cwd=self.root, capture_output=True, text=True, + ) + + +class UpToDateTests(TriageRepoFixture): + def test_reports_up_to_date_when_not_behind(self): + self.set_upstream_to_head() + result = self.run_triage() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Up to date", result.stdout) + + +class RelevanceFilterTests(TriageRepoFixture): + def test_commit_touching_only_removed_files_is_skipped(self): + # Upstream edits a file this fork never had -> not relevant. + self.write("portals/removed_portal.py", "x = 1\n") + self.commit("upstream: add removed_portal") + self.set_upstream_to_head() + # Fork drops back to before that commit and deletes nothing extra; + # the file simply is not in fork HEAD. + git(self.root, "reset", "--hard", "HEAD~1") + + result = self.run_triage() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("touches only files not in this fork", result.stdout) + self.assertIn("Probably skip", result.stdout) + + def test_commit_touching_kept_files_is_worth_reviewing(self): + self.write("kept.py", "print('changed')\n") + self.commit("upstream: change kept.py") + self.set_upstream_to_head() + git(self.root, "reset", "--hard", "HEAD~1") + + result = self.run_triage() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Worth reviewing", result.stdout) + self.assertIn("kept.py", result.stdout) + # Ready-to-run cherry-pick lines are offered, not executed. + self.assertIn("git cherry-pick", result.stdout) + + def test_changelog_only_footprint_is_skipped(self): + self.write("portals/gone.py", "y = 2\n") + self.write("CHANGELOG.md", "- did a thing\n") + self.commit("upstream: feature living in removed area + changelog") + self.set_upstream_to_head() + # Fork ships CHANGELOG.md but not the removed portal file. + git(self.root, "reset", "--hard", "HEAD~1") + self.write("CHANGELOG.md", "- fork changelog\n") + self.commit("fork changelog") + + result = self.run_triage() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("changelog-only footprint in this fork", result.stdout) + + +class AlreadyAppliedTests(TriageRepoFixture): + def test_cherry_picked_commit_drops_off_via_patch_id(self): + # Upstream adds a feature commit, then a second unrelated commit. + self.write("kept.py", "print('feature')\n") + upstream_sha = self.commit("upstream: add feature") + self.write("shared.txt", "upstream edit\n") + self.commit("upstream: unrelated change") + self.set_upstream_to_head() + + # Fork diverges (its own commit first), then cherry-picks the feature. + # The cherry-pick lands with a DIFFERENT sha but the same patch, so + # only patch-id matching - not raw sha - can tell it is already ported. + git(self.root, "reset", "--hard", "HEAD~2") + self.write("fork_only.txt", "mine\n") + self.commit("fork: divergent commit") + git(self.root, "cherry-pick", upstream_sha) + + result = self.run_triage() + self.assertEqual(result.returncode, 0, result.stderr) + # The feature dropped off via patch-id; only the unrelated commit + # remains worth reviewing. + self.assertIn("already applied (cherry-picked)", result.stdout) + self.assertIn("**1** worth reviewing", result.stdout) + + +class WontPortTests(TriageRepoFixture): + def test_listed_sha_is_excluded(self): + self.write("kept.py", "print('rejected feature')\n") + rejected = self.commit("upstream: feature the fork rejects") + self.set_upstream_to_head() + git(self.root, "reset", "--hard", "HEAD~1") + self.write(".github/upstream-wontport.txt", + f"{rejected[:9]} # rejected on purpose\n") + self.commit("fork: won't-port list") + + result = self.run_triage() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("on the fork's won't-port list", result.stdout) + + +class MissingUpstreamRefTests(TriageRepoFixture): + def test_missing_ref_degrades_gracefully(self): + # upstream/master ref never materialized. + result = self.run_triage() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("was not available", result.stdout) + + +class WorkflowGuardTests(unittest.TestCase): + """The workflow must no-op on the upstream template, so a template clone + never opens an issue by surprise. GitHub Actions can't run offline, so we + pin the guard by asserting the job's `if` condition excludes upstream.""" + + def test_workflow_is_guarded_against_upstream(self): + text = WORKFLOW.read_text(encoding="utf-8") + self.assertIn(f"github.repository != '{UPSTREAM_SLUG}'", text) + + def test_workflow_uses_builtin_token_only(self): + text = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("GH_TOKEN: ${{ github.token }}", text) + # A cross-repo PAT is what let an early run write outside its own repo; + # the built-in token can't. Make sure no PAT secret sneaks back in. + self.assertNotIn("secrets.", text) + + def test_actions_are_sha_pinned(self): + text = WORKFLOW.read_text(encoding="utf-8") + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("- uses:") or stripped.startswith("uses:"): + ref = stripped.split("uses:", 1)[1].strip() + self.assertIn("@", ref) + sha = ref.split("@", 1)[1].split()[0] + self.assertRegex(sha, r"^[0-9a-f]{40}$", + f"action not SHA-pinned: {ref}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_upstream_updates.py b/tools/check_upstream_updates.py index e58545b..f1595b9 100755 --- a/tools/check_upstream_updates.py +++ b/tools/check_upstream_updates.py @@ -175,7 +175,6 @@ def main() -> int: print(f" Diff command: git diff {ref} -- {up['path']}") print() print("Review these changes to see if they fit your personalized fork!") - return 0 else: if errors or missing_upstream: print( @@ -185,7 +184,13 @@ def main() -> int: ) else: print(f"[OK] All framework files are up to date with {ref}!") - return 0 + # Version stamps answer "which of my files changed"; commit-level triage + # answers "which upstream commits deserve review". Point at the companion. + print( + f"\nFor commit-level triage of upstream commits, run: " + f"python3 tools/upstream_triage.py --remote {remote}" + ) + return 0 if __name__ == "__main__": sys.exit(main()) diff --git a/tools/upstream_triage.py b/tools/upstream_triage.py new file mode 100755 index 0000000..74a2fb8 --- /dev/null +++ b/tools/upstream_triage.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Triage upstream commits this fork has not picked up yet. + +Emits a Markdown report that sorts the behind-list into "worth reviewing" vs +"probably skip", so a human decides what to merge/port. It never merges, +pushes, or edits anything - it only reads git history and prints. This is the +deliberate report/act boundary: on a fork "applies cleanly" is not "correct" - +a commit for portals the fork dropped can cherry-pick fine and still be wrong, +and that silent-wrong case is worse than a conflict. So the report stops at +ready-to-run cherry-pick lines; a human runs them. + +This is the commit-level companion to check_upstream_updates.py. That tool +answers "which of my personalized framework files changed" (version stamps); +this one answers "which upstream commits deserve my attention" (commit history). +Two tools, two questions - each cross-references the other in its output. + +Two signals drive the sort: + +1. Already applied? A cherry-pick lands with a NEW sha but the same patch, so a + raw sha comparison misreports it as missing. We compute git patch-ids for the + fork-only commits and treat any upstream commit whose patch-id (or exact + subject) matches as already applied. + +2. Relevant to this fork? A commit that only touches files this fork deleted + (e.g. removed demo portals) is almost certainly N/A. We check each commit's + touched paths against the working tree and flag accordingly. + +Usage: python tools/upstream_triage.py [--remote upstream] [--branch master] +Exits 0 always (a report, not a gate). Prints a note to stderr and exits 0 if +the upstream ref is unavailable, so a scheduled job degrades gracefully. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys + + +def git(*args: str) -> str: + return subprocess.run( + ["git", *args], capture_output=True, text=True, check=True + ).stdout + + +def rev_list(range_spec: str) -> list[str]: + out = git("rev-list", "--no-merges", range_spec).strip() + return out.splitlines() if out else [] + + +def patch_id(sha: str) -> str | None: + """Stable patch-id for a commit, or None if it has no diff.""" + show = subprocess.run( + ["git", "show", sha], capture_output=True, text=True, check=True + ).stdout + r = subprocess.run( + ["git", "patch-id", "--stable"], input=show, capture_output=True, text=True + ) + line = r.stdout.strip() + return line.split()[0] if line else None + + +def subject(sha: str) -> str: + return git("show", "-s", "--format=%s", sha).strip() + + +def files_touched(sha: str) -> list[str]: + out = git("show", "--name-only", "--format=", sha).strip() + return [f for f in out.splitlines() if f] + + +def path_exists(path: str) -> bool: + # ls-tree against HEAD is authoritative for "does this fork still ship it". + r = subprocess.run( + ["git", "cat-file", "-e", f"HEAD:{path}"], capture_output=True + ) + return r.returncode == 0 + + +def remote_slug(remote: str) -> str | None: + """owner/repo for a GitHub remote, or None if it can't be parsed.""" + try: + url = git("remote", "get-url", remote).strip() + except subprocess.CalledProcessError: + return None + for sep in ("github.com/", "github.com:"): + if sep in url: + path = url.split(sep, 1)[1] + return path[:-4] if path.endswith(".git") else path + return None + + +def load_wontport(path: str) -> list[str]: + """SHA prefixes the fork has decided never to port; missing file -> [].""" + try: + with open(path, encoding="utf-8") as f: + raw = f.read() + except FileNotFoundError: + return [] + entries = [] + for line in raw.splitlines(): + line = line.split("#", 1)[0].strip() + if line: + entries.append(line) + return entries + + +def commit_cell(short: str, sha: str, slug: str | None) -> str: + if slug: + return f"[`{short}`](https://github.com/{slug}/commit/{sha})" + return f"`{short}`" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--remote", default="upstream") + ap.add_argument("--branch", default="master") + ap.add_argument("--wontport", default=".github/upstream-wontport.txt") + args = ap.parse_args() + ref = f"{args.remote}/{args.branch}" + slug = remote_slug(args.remote) + wontport = load_wontport(args.wontport) + + try: + git("rev-parse", "--verify", ref) + except subprocess.CalledProcessError: + print( + f"note: {ref} not available (add the remote and fetch it first); " + "nothing to triage.", + file=sys.stderr, + ) + print(f"_Upstream ref `{ref}` was not available when this ran._") + return 0 + + behind = rev_list(f"HEAD..{ref}") + if not behind: + print(f"Up to date with `{ref}`. Nothing to review. :white_check_mark:") + _print_crossref(ref) + return 0 + + fork_only = rev_list(f"{ref}..HEAD") + fork_patch_ids = {p for p in (patch_id(s) for s in fork_only) if p} + fork_subjects = {subject(s) for s in fork_only} + + review: list[tuple[str, str, str, list[str]]] = [] + skip: list[tuple[str, str, str, str]] = [] + + for sha in behind: + subj = subject(sha) + short = sha[:9] + if patch_id(sha) in fork_patch_ids or subj in fork_subjects: + skip.append((short, sha, subj, "already applied (cherry-picked)")) + continue + if any(sha.startswith(e) for e in wontport): + skip.append((short, sha, subj, "on the fork's won't-port list")) + continue + touched = files_touched(sha) + present = [f for f in touched if path_exists(f)] + # A commit whose only surviving footprint is the changelog is one whose + # real change lives in files this fork removed - the code doesn't apply, + # only a doc line would. Low signal; demote it. + substantive = [f for f in present if f != "CHANGELOG.md"] + if touched and not present: + skip.append((short, sha, subj, "touches only files not in this fork")) + elif present and not substantive: + skip.append((short, sha, subj, "changelog-only footprint in this fork")) + else: + review.append((short, sha, subj, substantive)) + + lines: list[str] = [] + lines.append(f"Upstream `{ref}` has **{len(behind)}** commit(s) this fork lacks: " + f"**{len(review)}** worth reviewing, **{len(skip)}** probably skippable.") + lines.append("") + lines.append("_This is a triage report. Nothing was merged - review and port by hand._") + lines.append("") + + lines.append("### Worth reviewing") + if review: + lines.append("") + lines.append("| Commit | Subject | Fork files it touches |") + lines.append("|---|---|---|") + for short, sha, subj, present in review: + shown = ", ".join(f"`{p}`" for p in present[:4]) or "_(new/shared paths)_" + if len(present) > 4: + shown += f" +{len(present) - 4} more" + lines.append(f"| {commit_cell(short, sha, slug)} | {subj} | {shown} |") + # Ready-to-run cherry-pick lines - still information, not action. The + # report stops here on purpose; a human runs (and verifies) these. + lines.append("") + lines.append("
Ready-to-run cherry-picks (review each before running)") + lines.append("") + lines.append("```bash") + for short, sha, subj, _ in review: + lines.append(f"git cherry-pick {sha} # {subj}") + lines.append("```") + lines.append("") + lines.append("
") + else: + lines.append("") + lines.append("_None._") + lines.append("") + + lines.append("### Probably skip") + if skip: + lines.append("") + lines.append("| Commit | Subject | Why |") + lines.append("|---|---|---|") + for short, sha, subj, why in skip: + lines.append(f"| {commit_cell(short, sha, slug)} | {subj} | {why} |") + else: + lines.append("") + lines.append("_None._") + + print("\n".join(lines)) + _print_crossref(ref) + return 0 + + +def _print_crossref(ref: str) -> None: + print() + print( + "_For personalized-file version stamps (which methodology files changed), " + f"run `python tools/check_upstream_updates.py --remote {ref.split('/')[0]}`._" + ) + + +if __name__ == "__main__": + sys.exit(main())