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:
Erich Blume 2026-03-28 22:58:10 -07:00
commit 6ecfaf02b6
6 changed files with 675 additions and 0 deletions

View 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.

View 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

View 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

View file

@ -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

View 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
View 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()