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) <noreply@anthropic.com>
This commit is contained in:
parent
bb60369956
commit
6ecfaf02b6
6 changed files with 675 additions and 0 deletions
1
docs/changelog.d/+spork-strategy.feature.md
Normal file
1
docs/changelog.d/+spork-strategy.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add spork strategy: floating-branch soft-fork tooling (`mise run spork-create`) and documentation for maintaining local patches against upstream projects.
|
||||||
41
docs/explanation/spork-strategy.md
Normal file
41
docs/explanation/spork-strategy.md
Normal file
|
|
@ -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
|
||||||
84
docs/how-to/configuration/create-a-spork.md
Normal file
84
docs/how-to/configuration/create-a-spork.md
Normal file
|
|
@ -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/<project>` 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/<project>` on forge |
|
||||||
|
| `mirror` | Read-only upstream mirror at `mirrors/<project>` 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
|
||||||
|
|
@ -145,3 +145,5 @@ Trigger a manual sync on one mirror to confirm the new PAT works:
|
||||||
|
|
||||||
- [[forgejo]] — Forgejo service reference
|
- [[forgejo]] — Forgejo service reference
|
||||||
- [[gandi-operations]] — Similar PAT rotation workflow for Gandi DNS
|
- [[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
|
||||||
|
|
|
||||||
116
docs/how-to/configuration/manage-spork-branches.md
Normal file
116
docs/how-to/configuration/manage-spork-branches.md
Normal file
|
|
@ -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/<project> repo, PR targeting mirrors/<project>: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
|
||||||
431
mise-tasks/spork-create
Executable file
431
mise-tasks/spork-create
Executable file
|
|
@ -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 "<repo_name>" help="Repository name in the mirrors/ org on forge (e.g. kingfisher)"
|
||||||
|
#USAGE flag "--description <description>" help="Repository description override"
|
||||||
|
#USAGE flag "--main-branch <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/<repo_name> 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 <upstream-url>[/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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue