From 6ecfaf02b66cad8cc21cd12453befa481ad1eb2d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 22:58:10 -0700 Subject: [PATCH] Add spork strategy: tooling and documentation Spork-create mise task sets up a floating-branch soft-fork of a mirrored upstream project with daily mirror-sync via Forgejo Actions. Includes explanation card, how-to guides for setup and branch management, and the spork-create uv script. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+spork-strategy.feature.md | 1 + docs/explanation/spork-strategy.md | 41 ++ docs/how-to/configuration/create-a-spork.md | 84 ++++ .../configuration/manage-forgejo-mirrors.md | 2 + .../configuration/manage-spork-branches.md | 116 +++++ mise-tasks/spork-create | 431 ++++++++++++++++++ 6 files changed, 675 insertions(+) create mode 100644 docs/changelog.d/+spork-strategy.feature.md create mode 100644 docs/explanation/spork-strategy.md create mode 100644 docs/how-to/configuration/create-a-spork.md create mode 100644 docs/how-to/configuration/manage-spork-branches.md create mode 100755 mise-tasks/spork-create diff --git a/docs/changelog.d/+spork-strategy.feature.md b/docs/changelog.d/+spork-strategy.feature.md new file mode 100644 index 0000000..1f47bc1 --- /dev/null +++ b/docs/changelog.d/+spork-strategy.feature.md @@ -0,0 +1 @@ +Add spork strategy: floating-branch soft-fork tooling (`mise run spork-create`) and documentation for maintaining local patches against upstream projects. diff --git a/docs/explanation/spork-strategy.md b/docs/explanation/spork-strategy.md new file mode 100644 index 0000000..0b885f8 --- /dev/null +++ b/docs/explanation/spork-strategy.md @@ -0,0 +1,41 @@ +--- +title: Spork Strategy +modified: 2026-03-28 +last-reviewed: 2026-03-28 +tags: + - explanation + - git + - forgejo +--- + +# Spork Strategy + +> **Note:** This article was drafted by AI and reviewed by Erich. I plan to rewrite all explanatory content in my own words - these serve as placeholders to establish the documentation structure. + +A "spork" is a floating-branch soft-fork strategy for maintaining local changes against upstream projects without creating a true fork. The name: a fork that's trying its hardest not to be one. + +## The problem + +We mirror upstream projects on forge for supply-chain control. Sometimes we need to carry local patches — workflow support, build tooling, bug fixes. A real fork diverges silently until merge day becomes a nightmare. A spork stays perpetually close to upstream with patches "floating" on top, rebased daily. + +## The trade-off + +A spork chooses "small frequent pain" (constant rebasing, shifting branch targets) over "rare catastrophic pain" (fork divergence). For a solo operator carrying a handful of patches, this is the right trade-off. The key property: `git log main..blumeops` always shows your complete delta from upstream. No mystery divergence. + +Long-lived work against a sporked repo must accept that there is no "safe" branch — everything is an ever-shifting target. Anyone with a local checkout needs to be comfortable with `git pull --rebase`. + +## Architecture + +Three remotes, five branch types, one daily sync workflow. The `blumeops` branch is the default — it looks just like upstream with local workflows overlaid. Feature branches come in two flavors: upstreamable (branched off `main`, clean for contribution) and non-upstreamable (branched off `blumeops`, local-only). A `deploy` branch merges everything together as a build artifact. + +Forgejo Actions only checks `.forgejo/workflows/` when that directory exists, so upstream's `.github/workflows/` won't run on forge — no deletion needed. If upstream has its own `.forgejo/` directory (rare), it's removed during spork setup. + +## How-to guides + +- [[create-a-spork]] — initial setup with `mise run spork-create` +- [[manage-spork-branches]] — feature branches, the deploy branch, handling rebase conflicts + +## See also + +- [[manage-forgejo-mirrors]] — how upstream mirrors work +- [[kingfisher]] — first project using the spork strategy diff --git a/docs/how-to/configuration/create-a-spork.md b/docs/how-to/configuration/create-a-spork.md new file mode 100644 index 0000000..cd1a6f3 --- /dev/null +++ b/docs/how-to/configuration/create-a-spork.md @@ -0,0 +1,84 @@ +--- +title: Create a Spork +modified: 2026-03-28 +last-reviewed: 2026-03-28 +tags: + - how-to + - git + - forgejo +--- + +# Create a Spork + +How to set up a floating-branch soft-fork ("spork") of a mirrored upstream project using `mise run spork-create`. + +## Prerequisites + +- Mirror already exists at `mirrors/` on forge (see [[manage-forgejo-mirrors]]) +- 1Password CLI authenticated (`op` CLI) +- SSH access to `forge.ops.eblu.me:2222` + +## Create the spork + +```fish +mise run spork-create kingfisher +``` + +This will: + +1. Fork `mirrors/kingfisher` → `eblume/kingfisher` on forge +2. Create a `blumeops` branch from upstream's main branch +3. Remove any upstream `.forgejo/` directory (if present) +4. Add `.forgejo/workflows/mirror-sync.yaml` and commit it +5. Set `blumeops` as the default branch +6. Clone to `~/code/3rd/kingfisher` with three remotes: `origin`, `mirror`, `upstream` + +Options: + +```fish +mise run spork-create kingfisher --dry-run # preview only +mise run spork-create kingfisher --no-clone # skip local clone +mise run spork-create kingfisher --main-branch dev # override branch name +``` + +## Verify the setup + +```fish +cd ~/code/3rd/kingfisher +git remote -v +# origin ssh://forgejo@forge.ops.eblu.me:2222/eblume/kingfisher.git (fetch) +# mirror ssh://forgejo@forge.ops.eblu.me:2222/mirrors/kingfisher.git (fetch) +# upstream https://github.com/mongodb/kingfisher.git (fetch) + +git branch -a +# * blumeops +# remotes/origin/blumeops +# remotes/origin/main +``` + +## What happens next + +The mirror-sync workflow runs daily at 05:00 UTC and: + +- Fast-forwards `main` from the mirror +- Rebases `blumeops` on top of `main` +- Rebases any `feature/local/*` and `feature/upstream/*` branches +- Rebuilds the `deploy` branch (all features merged) + +See [[manage-spork-branches]] for working with feature branches. + +## Terminology + +| Term | Meaning | +|------|---------| +| `origin` | Your mutable fork at `eblume/` on forge | +| `mirror` | Read-only upstream mirror at `mirrors/` on forge | +| `upstream` | Canonical upstream repository (e.g., GitHub) | +| `main` | Clean upstream tracking branch (may be named `master`, `dev`, etc.) | +| `blumeops` | Default branch — upstream + local workflows/tooling | +| `deploy` | Build artifact branch — everything merged, used for deployments | + +## See also + +- [[manage-spork-branches]] — creating feature branches, upstreamable vs local +- [[manage-forgejo-mirrors]] — mirror setup and PAT rotation diff --git a/docs/how-to/configuration/manage-forgejo-mirrors.md b/docs/how-to/configuration/manage-forgejo-mirrors.md index 7a1d36a..7f98549 100644 --- a/docs/how-to/configuration/manage-forgejo-mirrors.md +++ b/docs/how-to/configuration/manage-forgejo-mirrors.md @@ -145,3 +145,5 @@ Trigger a manual sync on one mirror to confirm the new PAT works: - [[forgejo]] — Forgejo service reference - [[gandi-operations]] — Similar PAT rotation workflow for Gandi DNS +- [[spork-strategy]] — floating-branch soft-fork strategy explanation +- [[create-a-spork]] — create a spork on top of a mirror diff --git a/docs/how-to/configuration/manage-spork-branches.md b/docs/how-to/configuration/manage-spork-branches.md new file mode 100644 index 0000000..82778ef --- /dev/null +++ b/docs/how-to/configuration/manage-spork-branches.md @@ -0,0 +1,116 @@ +--- +title: Manage Spork Branches +modified: 2026-03-28 +last-reviewed: 2026-03-28 +tags: + - how-to + - git + - forgejo +--- + +# Manage Spork Branches + +How to create, maintain, and reason about feature branches on a sporked repository. See [[create-a-spork]] for initial setup. + +## Branch types + +### Upstreamable features (`feature/upstream/*`) + +Changes intended to be contributed upstream. Branch off `main` so the diff is clean — no local tooling or workflows mixed in. + +```fish +cd ~/code/3rd/kingfisher +git fetch origin +git checkout -b feature/upstream/forgejo-support origin/main + +# Make changes, commit as normal +git push -u origin feature/upstream/forgejo-support +``` + +The mirror-sync workflow will automatically rebase this branch onto `main` each day. + +To see what the upstream contribution looks like: + +```fish +git log main..feature/upstream/forgejo-support --oneline +git diff main...feature/upstream/forgejo-support +``` + +To create a preview PR on forge (targets the mirror, not upstream): + +```fish +# From the eblume/ repo, PR targeting mirrors/:main +# This gives a public URL showing the diff without filing upstream +tea pr create --repo mirrors/kingfisher --head eblume/kingfisher:feature/upstream/forgejo-support --base main +``` + +When ready to contribute upstream, manually translate the branch to a GitHub PR. + +### Non-upstreamable features (`feature/local/*`) + +Local-only changes that will never go upstream. Branch off `blumeops` so you have access to all local tooling. + +```fish +cd ~/code/3rd/kingfisher +git fetch origin +git checkout -b feature/local/custom-rules origin/blumeops + +# Make changes, commit as normal +git push -u origin feature/local/custom-rules +``` + +The mirror-sync workflow will automatically rebase this branch onto `blumeops` each day. + +## The `deploy` branch + +The `deploy` branch is a build artifact — rebuilt fresh by mirror-sync daily. It contains everything merged together: `blumeops` + all `feature/local/*` + all `feature/upstream/*`. Use this branch for deployments (e.g., ArgoCD `targetRevision`). + +**Never commit to or work from `deploy`.** + +## Working with rebasing branches + +Because mirror-sync force-pushes rebased branches daily, local checkouts will diverge. Always pull with rebase: + +```fish +git pull --rebase origin feature/upstream/my-change +``` + +Or set it as default for the repo: + +```fish +git config pull.rebase true +``` + +This is the fundamental trade-off of the spork strategy: small frequent rebases instead of rare catastrophic merges. + +## When rebases fail + +If upstream changes conflict with a feature branch, mirror-sync will skip that branch and log an error. Recovery: + +```fish +cd ~/code/3rd/kingfisher +git fetch origin +git checkout feature/upstream/my-change +git rebase origin/main +# Resolve conflicts... +git push --force-with-lease origin feature/upstream/my-change +``` + +The next mirror-sync run will pick up the resolved branch and rebuild `deploy`. + +**TODO:** Rebase failures are currently only visible in the Forgejo Actions UI. Alerting via Grafana is planned but not yet implemented. + +## Future: `.spork.toml` + +For repos with multiple feature branches, a `.spork.toml` file on the `blumeops` branch could declare: + +- **Branch dependencies** (stacked branches — `bar` depends on `foo`) +- **Feature descriptions** (what the branch is for, in prose) +- **Upstream/local classification** (as an alternative to the naming convention) + +This is not yet implemented. For now, the `feature/upstream/*` vs `feature/local/*` naming convention is the source of truth. + +## See also + +- [[create-a-spork]] — initial setup +- [[manage-forgejo-mirrors]] — mirror setup and PAT rotation diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create new file mode 100755 index 0000000..0ed5d86 --- /dev/null +++ b/mise-tasks/spork-create @@ -0,0 +1,431 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# /// +#MISE description="Create a spork (floating-branch soft-fork) of a mirrored upstream project" +#USAGE arg "" help="Repository name in the mirrors/ org on forge (e.g. kingfisher)" +#USAGE flag "--description " help="Repository description override" +#USAGE flag "--main-branch " help="Name of the upstream main branch (default: auto-detect)" +#USAGE flag "--dry-run" help="Show what would be done without creating" +#USAGE flag "--no-clone" help="Skip cloning to ~/code/3rd/" +"""Create a spork of a mirrored upstream project. + +A "spork" is a floating-branch soft-fork strategy. It creates a mutable +clone of a read-only mirror in the eblume/ org on Forge, sets up a +'blumeops' branch with a mirror-sync workflow, and optionally clones +locally with the three-remote setup (origin, mirror, upstream). + +Prerequisites: + - Mirror must already exist at mirrors/ on forge + - 1Password CLI authenticated (for Forge API token) + +See docs/explanation/spork-strategy.md for the full strategy. +""" + +import json +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Annotated, Optional + +import httpx +import typer +from rich.console import Console + +FORGE_URL = "https://forge.eblu.me" +FORGE_API = f"{FORGE_URL}/api/v1" +FORGE_SSH = "ssh://forgejo@forge.ops.eblu.me:2222" +MIRROR_ORG = "mirrors" +OWNER = "eblume" +LOCAL_BASE = Path.home() / "code" / "3rd" +OP_TOKEN_REF = "op://blumeops/w3663ffnvkewbftncqxtcpeavy/api-token" + +console = Console() +app = typer.Typer(add_completion=False) + + +def op_read(ref: str) -> str: + """Read a secret from 1Password.""" + result = subprocess.run( + ["op", "read", ref], capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +def forge_api( + client: httpx.Client, + method: str, + path: str, + token: str, + json_data: dict | None = None, +) -> httpx.Response: + """Make an authenticated Forge API request.""" + return client.request( + method, + f"{FORGE_API}{path}", + headers={"Authorization": f"token {token}"}, + json=json_data, + ) + + +def get_mirror_info(client: httpx.Client, token: str, name: str) -> dict: + """Get mirror repo info, or exit if it doesn't exist.""" + resp = forge_api(client, "GET", f"/repos/{MIRROR_ORG}/{name}", token) + if resp.status_code == 404: + console.print( + f"[red]Error:[/red] Mirror [bold]{MIRROR_ORG}/{name}[/bold] not found on forge." + ) + console.print("Create it first: [dim]mise run mirror-create [/dim]") + raise SystemExit(1) + resp.raise_for_status() + return resp.json() + + +def detect_main_branch(client: httpx.Client, token: str, name: str) -> str: + """Detect the default branch of the mirror.""" + info = get_mirror_info(client, token, name) + return info.get("default_branch", "main") + + +def repo_exists(client: httpx.Client, token: str, owner: str, name: str) -> bool: + """Check if a repo already exists.""" + resp = forge_api(client, "GET", f"/repos/{owner}/{name}", token) + return resp.status_code == 200 + + +def fork_mirror(client: httpx.Client, token: str, name: str) -> dict: + """Fork the mirror into the eblume org.""" + resp = forge_api( + client, + "POST", + f"/repos/{MIRROR_ORG}/{name}/forks", + token, + json_data={"name": name}, + ) + if resp.status_code == 409: + console.print(f"[yellow]Fork already exists:[/yellow] {OWNER}/{name}") + return forge_api(client, "GET", f"/repos/{OWNER}/{name}", token).json() + resp.raise_for_status() + return resp.json() + + +def mirror_sync_workflow(main_branch: str, repo_name: str) -> str: + """Generate the mirror-sync workflow YAML.""" + return textwrap.dedent(f"""\ + # Mirror Sync — Spork Strategy + # + # Keeps the '{main_branch}' branch tracking upstream (via mirror) and + # rebases the 'blumeops' branch on top. See docs/explanation/spork-strategy.md + # in the blumeops repo for the full strategy. + # + # On conflict: the workflow fails. Manual rebase resolution required. + + name: Mirror Sync + + on: + schedule: + - cron: '0 5 * * *' # Daily at 05:00 UTC + workflow_dispatch: + + jobs: + sync: + runs-on: k8s + steps: + - name: Checkout blumeops branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: blumeops + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "Forgejo Actions" + git config user.email "actions@forge.eblu.me" + + - name: Add mirror remote + run: | + git remote add mirror "${{{{ env.MIRROR_URL }}}}" || true + git fetch mirror + env: + MIRROR_URL: {FORGE_URL}/{MIRROR_ORG}/{repo_name}.git + + - name: Fast-forward {main_branch} from mirror + run: | + git checkout {main_branch} + git merge --ff-only mirror/{main_branch} + git push origin {main_branch} + + - name: Rebase blumeops onto {main_branch} + run: | + git checkout blumeops + git rebase {main_branch} + git push --force-with-lease origin blumeops + + - name: Rebase feature branches + run: | + # Rebase feature/local/* onto blumeops + for branch in $(git branch -r --list 'origin/feature/local/*'); do + local_name="${{branch#origin/}}" + echo "Rebasing $local_name onto blumeops..." + git checkout -B "$local_name" "$branch" + git rebase blumeops || {{ + echo "::error::Rebase conflict on $local_name" + git rebase --abort + continue + }} + git push --force-with-lease origin "$local_name" + done + + # Rebase feature/upstream/* onto {main_branch} + for branch in $(git branch -r --list 'origin/feature/upstream/*'); do + local_name="${{branch#origin/}}" + echo "Rebasing $local_name onto {main_branch}..." + git checkout -B "$local_name" "$branch" + git rebase {main_branch} || {{ + echo "::error::Rebase conflict on $local_name" + git rebase --abort + continue + }} + git push --force-with-lease origin "$local_name" + done + + - name: Build deploy branch + run: | + git checkout -B deploy blumeops + + # Merge all feature branches into deploy + for branch in $(git branch -r --list 'origin/feature/local/*' 'origin/feature/upstream/*'); do + local_name="${{branch#origin/}}" + echo "Merging $local_name into deploy..." + git merge --no-ff "$local_name" -m "deploy: merge $local_name" || {{ + echo "::error::Merge conflict on $local_name into deploy" + git merge --abort + continue + }} + done + + git push --force-with-lease origin deploy + """) + + +def set_default_branch( + client: httpx.Client, token: str, owner: str, name: str, branch: str +) -> None: + """Set the default branch of a repo.""" + resp = forge_api( + client, + "PATCH", + f"/repos/{owner}/{name}", + token, + json_data={"default_branch": branch}, + ) + resp.raise_for_status() + + +def get_upstream_url(client: httpx.Client, token: str, name: str) -> str | None: + """Try to find the upstream URL from the mirror's config.""" + info = get_mirror_info(client, token, name) + return info.get("original_url") or info.get("clone_url") + + +@app.command() +def main( + repo_name: Annotated[str, typer.Argument(help="Repository name in mirrors/ org")], + description: Annotated[Optional[str], typer.Option(help="Description override")] = None, + main_branch: Annotated[Optional[str], typer.Option("--main-branch", help="Upstream main branch name")] = None, + dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without creating")] = False, + no_clone: Annotated[bool, typer.Option("--no-clone", help="Skip local clone")] = False, +) -> None: + """Create a spork of a mirrored upstream project.""" + console.print(f"[bold]Sporking[/bold] {MIRROR_ORG}/{repo_name}") + console.print() + + token = op_read(OP_TOKEN_REF) + + with httpx.Client(timeout=30) as client: + # 1. Verify mirror exists and get info + mirror_info = get_mirror_info(client, token, repo_name) + detected_main = main_branch or mirror_info.get("default_branch", "main") + upstream_url = mirror_info.get("original_url", "unknown") + desc = description or mirror_info.get("description", "") + + console.print(f" Mirror: {MIRROR_ORG}/{repo_name}") + console.print(f" Upstream: {upstream_url}") + console.print(f" Main branch: {detected_main}") + console.print(f" Description: {desc}") + console.print(f" Fork target: {OWNER}/{repo_name}") + console.print(f" Local clone: {LOCAL_BASE / repo_name}") + console.print() + + if dry_run: + console.print("[dim][dry-run] Would perform the following:[/dim]") + console.print(f" 1. Fork {MIRROR_ORG}/{repo_name} → {OWNER}/{repo_name}") + console.print(f" 2. Create 'blumeops' branch from '{detected_main}'") + console.print(" 3. Add .forgejo/workflows/mirror-sync.yaml") + console.print(" 4. Set 'blumeops' as default branch") + if not no_clone: + console.print(f" 5. Clone to {LOCAL_BASE / repo_name} with 3 remotes") + return + + # 2. Fork (or confirm existing) + if repo_exists(client, token, OWNER, repo_name): + console.print(f"[yellow]Fork already exists:[/yellow] {OWNER}/{repo_name}") + else: + console.print("Forking mirror...") + fork_mirror(client, token, repo_name) + console.print(f"[green]Created fork:[/green] {OWNER}/{repo_name}") + + # 3. Clone to temp dir, create blumeops branch with workflow + console.print("Setting up blumeops branch...") + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + clone_url = f"{FORGE_SSH}/{OWNER}/{repo_name}.git" + subprocess.run( + ["git", "clone", clone_url, tmpdir], + check=True, + capture_output=True, + ) + + # Create blumeops branch from detected main + subprocess.run( + ["git", "checkout", "-b", "blumeops", f"origin/{detected_main}"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + + # Remove any upstream .forgejo/ directory (rare, but possible) + existing_forgejo = Path(tmpdir) / ".forgejo" + if existing_forgejo.exists(): + import shutil + shutil.rmtree(existing_forgejo) + console.print("[yellow]Removed upstream .forgejo/ directory[/yellow]") + + # Add mirror-sync workflow + workflow_dir = Path(tmpdir) / ".forgejo" / "workflows" + workflow_dir.mkdir(parents=True, exist_ok=True) + workflow_path = workflow_dir / "mirror-sync.yaml" + workflow_path.write_text(mirror_sync_workflow(detected_main, repo_name)) + + # Commit and push + subprocess.run( + ["git", "add", ".forgejo/"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Erich Blume"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.email", "blume.erich@gmail.com"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "commit", "-m", "spork: add mirror-sync workflow\n\nBootstrap the blumeops branch with the spork mirror-sync\nworkflow. See blumeops docs/explanation/spork-strategy.md."], + cwd=tmpdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "push", "-u", "origin", "blumeops"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + console.print("[green]Created and pushed blumeops branch[/green]") + + # 4. Set default branch to blumeops + console.print("Setting default branch to blumeops...") + set_default_branch(client, token, OWNER, repo_name, "blumeops") + console.print("[green]Default branch set to blumeops[/green]") + + # 5. Local clone with three remotes + if no_clone: + console.print("[dim]Skipping local clone (--no-clone)[/dim]") + else: + local_path = LOCAL_BASE / repo_name + if local_path.exists(): + console.print(f"[yellow]Local directory already exists:[/yellow] {local_path}") + console.print("Setting up remotes on existing clone...") + # Add missing remotes + mirror_url = f"{FORGE_SSH}/{MIRROR_ORG}/{repo_name}.git" + subprocess.run( + ["git", "remote", "add", "mirror", mirror_url], + cwd=local_path, + capture_output=True, # may already exist + ) + if upstream_url and upstream_url != "unknown": + subprocess.run( + ["git", "remote", "add", "upstream", upstream_url], + cwd=local_path, + capture_output=True, + ) + subprocess.run( + ["git", "fetch", "--all"], + cwd=local_path, + check=True, + capture_output=True, + ) + else: + console.print(f"Cloning to {local_path}...") + clone_url = f"{FORGE_SSH}/{OWNER}/{repo_name}.git" + subprocess.run( + ["git", "clone", clone_url, str(local_path)], + check=True, + capture_output=True, + ) + # Add mirror and upstream remotes + mirror_url = f"{FORGE_SSH}/{MIRROR_ORG}/{repo_name}.git" + subprocess.run( + ["git", "remote", "add", "mirror", mirror_url], + cwd=local_path, + check=True, + capture_output=True, + ) + if upstream_url and upstream_url != "unknown": + subprocess.run( + ["git", "remote", "add", "upstream", upstream_url], + cwd=local_path, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "fetch", "--all"], + cwd=local_path, + check=True, + capture_output=True, + ) + console.print(f"[green]Local clone ready at {local_path}[/green]") + + # Summary + console.print() + console.print("[bold green]Spork complete![/bold green]") + console.print() + console.print(" Remotes:") + console.print(f" origin → {FORGE_URL}/{OWNER}/{repo_name}") + console.print(f" mirror → {FORGE_URL}/{MIRROR_ORG}/{repo_name}") + console.print(f" upstream → {upstream_url}") + console.print() + console.print(" Branches:") + console.print(f" {detected_main:<12} — clean upstream tracking (never commit here)") + console.print(" blumeops — local infra + workflows (default branch)") + console.print() + console.print(" Next steps:") + console.print(" • Create feature branches:") + console.print(f" git checkout -b feature/upstream/my-change {detected_main}") + console.print(" git checkout -b feature/local/my-change blumeops") + console.print(" • Mirror-sync runs daily at 05:00 UTC") + console.print(" • See: docs/explanation/spork-strategy.md") + + +if __name__ == "__main__": + app()