Mirror releases between git hosts (Gitea, GitHub, local). One commit per release, linear history, with file transforms.
local repo ──→ GitHub (publish local tags as releases)
Gitea ──→ GitHub (mirror dev to public)
GitHub ──→ local repo (backup)
...any combo (gitea, github, local)
Install from PyPI:
pip install gitacrossFor development, install the local checkout in editable mode:
pip install -e .# Lint configuration for errors, invalid keys, and redundant defaults
gitacross --config config.yml --lint
# Automatically fix misplaced keys and remove redundant default options
gitacross --config config.yml --fix
# Preview changes without modifying targets
gitacross --config config.yml --dry-run
# Run full sync
gitacross --config config.yml
# Sync only a specific project
gitacross --config config.yml --project my-project
# Or run directly from repository root
python main.py --config config.yml --lint
python main.py --config config.yml --fix
python main.py --config config.yml
# Or run as a Python module
python -m gitacross --config config.yml --lint
python -m gitacross --config config.yml --fix
python -m gitacross --config config.yml| Type | source fields |
target fields |
|---|---|---|
| gitea / github | repo, api, tokenmode (default release, or tag, or commit)include_prereleases (default false)include_drafts (default false) |
repo, api, tokenbranch (default main) |
| local | path, tag_pattern (default *) |
path, branch (default main) |
Tokens use ${VAR} syntax — resolved from environment.
enabled (default true) can be set to false on any project to temporarily disable or skip it without removing it from your configuration file.
preserve_description (default true, alias preserve_release_description) can be set at the project or endpoint level to preserve the source release notes/body on the target release, or set to false to leave the target release description empty.
sync_assets (alias preserve_assets, include_assets) is a project-level field that mirrors prebuilt release packages from the source to the target release — so you only need CI on the source platform:
| Value | Behaviour |
|---|---|
false (default) |
No assets synced |
true |
All assets synced |
"*.tar.gz" |
Only assets matching the glob |
["*.tar.gz", "*.zip"] |
Only assets matching any listed glob |
stream_assets: true pairs with sync_assets to stream each asset upload from disk rather than buffering the full file in RAM. Default is false. Set to true when syncing large prebuilt binaries (hundreds of MB) to avoid out-of-memory errors.
- name: my-project
sync_assets: # build on Gitea, upload prebuilts to GitHub
- "*.tar.gz"
- "*.zip"
- "*.deb"
- "*-checksums.txt"
stream_assets: true # stream uploads from disk — avoids buffering in RAM
source:
type: gitea
...
target:
type: github
...Remote sources sync from the host's API releases by default (mode: release): prerelease/draft filtering applies, and sync_from must be an API release. Set mode: tag to treat git tags as releases instead — useful when tags were pushed without creating release objects:
source:
type: gitea
repo: owner/repo
api: https://gitea.example.com/api/v1
token: ${GITEA_TOKEN}
mode: tag
sync_from: v2.0.0In release mode, a sync_from tag that exists only in git (no release object) or is filtered out as prerelease/draft produces a warning and syncs nothing, pointing you at the right option — mode: tag or include_prereleases/include_drafts — instead of silently treating tags as releases.
Synced state is keyed by tag name, so switching a repo between release and tag modes is safe: already-synced tags are skipped regardless of the current mode (older state files keyed by API release id are migrated automatically).
Set mode: commit to sync the current HEAD of the source branch each time the script runs, rather than iterating over releases or tags. No tag or release is created on the target — only a plain commit is pushed.
source:
type: gitea
repo: owner/repo
api: https://gitea.example.com/api/v1
token: ${GITEA_TOKEN}
mode: commit
branch: main # optional — which branch to read HEAD from (auto-detected if omitted)| Behaviour | Detail |
|---|---|
| What gets synced | Single snapshot of the current branch HEAD |
| No tag or release | Only a plain commit is pushed to the target branch |
branch |
Which source branch to read HEAD from. Auto-detects origin/HEAD, then tries main/master/trunk |
| State key | Commit SHA (not tag name). Already-synced SHAs are skipped |
| Idempotent | Re-running with same HEAD is a no-op (same SHA already in state) |
| State purged | Re-commits current HEAD snapshot; git sees no diff if nothing changed → no-op commit |
A list of glob patterns. Matched paths are removed before any operations run.
| Wildcard | Meaning |
|---|---|
* |
Matches within a single path segment (does not cross /) |
** |
Matches across any number of directory levels (recursive) |
So .git/* would only match direct children like .git/config, but miss .git/refs/heads/main. Use .git/** to delete everything inside. Also, matching the directory name directly (node_modules) will remove the whole tree in one shot via shutil.rmtree, which is slightly faster than matching each file individually with node_modules/**.
ignore:
- node_modules # any node_modules/ dir, at any depth
- "*.secret" # only in root (single *, no /)
- "build/**/*.o" # any .o file under any build/ dir
- ToDo.md # any file named ToDo.md, at any depth
- some_folder/node_modules # node_modules only when inside some_folder/
- "./some_folder/node_modules" # root-only variant (anchored to ./)All operations run top-to-bottom in the order they're listed. This applies at every level:
- Operation blocks run in order (e.g.
removebeforereplacebeforeadd) - Items inside each block also run in order (e.g. second
replaceitem runs after the first)
This matters when later steps depend on earlier ones — for example, a rename moving a file, then a replace modifying the renamed target.
| Field | Required | Default | Description |
|---|---|---|---|
path |
yes | — | Path or pattern to remove |
pattern |
no | literal |
literal, glob, or regex |
- remove:
- path: .gitea # literal path
- path: "*.secret"
pattern: glob # glob pattern
- path: "build\\d+" # regex matches path
pattern: regex| Field | Required | Default | Description |
|---|---|---|---|
from |
yes | — | Source path |
to |
yes | — | Destination path |
pattern |
no | literal |
Only literal is implemented |
- rename:
- from: .gitea
to: .github
pattern: literal| Field | Required | Default | Description |
|---|---|---|---|
search |
yes | — | String (literal) or pattern (regex) to find |
replace |
yes | — | Replacement text |
pattern |
no | literal |
literal or regex |
glob |
no | all files | Only modify files matching this glob |
path |
no | — | Only modify this exact relative file path (takes precedence over glob) |
Only UTF-8 text files are scanned. Binary files are skipped.
- replace:
- search: https://gitea\.example\.com
replace: https://github.com
pattern: regex
glob: "*.md"
- search: http://old-url.com
replace: https://new-url.com
pattern: literal| Field | Required | Description |
|---|---|---|
path |
yes | File path to create (parent dirs auto-created) |
content |
yes | File contents |
- add:
- path: .github/FUNDING.yml
content: |
github: myuser
- path: RELEASE_NOTES.md
content: |
# Release Notes
...| Field | Required | Description |
|---|---|---|
assert |
yes | file_exists, file_absent, string_exists, string_absent |
path |
yes | File path to check |
pattern |
for string checks | Text to search for |
Aborts the entire release if any assertion fails.
- validate:
- assert: file_exists
path: README.md
- assert: string_absent
path: LICENSE
pattern: "Gitea"
- assert: string_exists
path: README.md
pattern: "MIT"| Field | Default | Description |
|---|---|---|
max_attempts |
3 | Number of retries before giving up |
backoff_seconds |
2 | Base delay (doubles each attempt) |
python -m sync.main --config PATH [--project NAME] [--dry-run] [-v]
| Flag | Description |
|---|---|
--config |
Path to config file (required) |
--project |
Sync only one project (by name) |
--dry-run |
Preview changes without committing or pushing |
-v |
Debug logging |
- Fetch releases from source (paginated API or local tags)
- Filter out already-synced releases (tracked in
.gitsync/state.yml) - For each new release (oldest first):
- Export tag's file tree via
git archive - Apply
ignorepatterns, thenoperationsin order - Commit on top of target branch (linear history)
- Create annotated tag
- Push (remote targets) or populate working tree (local targets)
- Create release via API (remote targets)
- Export tag's file tree via
- Persist state atomically
Re-running is idempotent — already-synced releases are skipped.