gitacross 1.0.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. gitacross-2.0.0/PKG-INFO +578 -0
  2. gitacross-2.0.0/README.md +550 -0
  3. {gitacross-1.0.0 → gitacross-2.0.0}/pyproject.toml +2 -2
  4. gitacross-2.0.0/src/gitacross/__init__.py +86 -0
  5. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/__main__.py +2 -0
  6. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/config.py +125 -24
  7. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/git.py +25 -16
  8. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/linter.py +195 -99
  9. gitacross-2.0.0/src/gitacross/main.py +614 -0
  10. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/providers/__init__.py +2 -0
  11. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/providers/base.py +20 -19
  12. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/providers/gitea.py +2 -0
  13. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/providers/github.py +2 -0
  14. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/renderer.py +68 -8
  15. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/retry.py +4 -2
  16. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/source.py +13 -9
  17. gitacross-2.0.0/src/gitacross/state.py +53 -0
  18. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross/target.py +3 -1
  19. gitacross-2.0.0/src/gitacross.egg-info/PKG-INFO +578 -0
  20. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross.egg-info/SOURCES.txt +0 -2
  21. {gitacross-1.0.0 → gitacross-2.0.0}/tests/test_sync.py +2138 -353
  22. gitacross-1.0.0/PKG-INFO +0 -318
  23. gitacross-1.0.0/README.md +0 -290
  24. gitacross-1.0.0/src/gitacross/__init__.py +0 -34
  25. gitacross-1.0.0/src/gitacross/gitea.py +0 -9
  26. gitacross-1.0.0/src/gitacross/github.py +0 -9
  27. gitacross-1.0.0/src/gitacross/main.py +0 -360
  28. gitacross-1.0.0/src/gitacross/state.py +0 -67
  29. gitacross-1.0.0/src/gitacross.egg-info/PKG-INFO +0 -318
  30. {gitacross-1.0.0 → gitacross-2.0.0}/LICENSE +0 -0
  31. {gitacross-1.0.0 → gitacross-2.0.0}/setup.cfg +0 -0
  32. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
  33. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross.egg-info/entry_points.txt +0 -0
  34. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross.egg-info/requires.txt +0 -0
  35. {gitacross-1.0.0 → gitacross-2.0.0}/src/gitacross.egg-info/top_level.txt +0 -0
@@ -0,0 +1,578 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitacross
3
+ Version: 2.0.0
4
+ Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
5
+ Author: Matthew Deik
6
+ License-Expression: MIT
7
+ Keywords: git,gitea,github,mirror,sync,release
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: PyYAML<7.0,>=6.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: build; extra == "dev"
26
+ Requires-Dist: twine; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # GitAcross
30
+
31
+ **Mirror releases between git hosts.** When a new release appears on one host, GitAcross copies it to another — one clean commit per release, with the option to remove or rewrite files along the way.
32
+
33
+ ```
34
+ local repo ──> GitHub (publish local tags as releases)
35
+ Gitea ──> GitHub (mirror dev to public)
36
+ GitHub ──> local repo (backup)
37
+ ...any combo (gitea, github, local)
38
+ ```
39
+
40
+ **Highlights:**
41
+
42
+ - **Clean history** — every release lands as one commit on top of the last, so the target branch stays linear and readable
43
+ - **Safe to re-run** — already-synced releases are skipped, so it works on a schedule or in CI
44
+ - **File transforms** — exclude files or rewrite their contents before publishing
45
+ - **Flexible endpoints** — Gitea, GitHub, and local repositories, in any combination
46
+ - **Config guardrails** — lint and auto-fix your config before it runs
47
+
48
+ ## Contents
49
+
50
+ - [Quick start](#quick-start)
51
+ - [Install](#install)
52
+ - [Config](#config)
53
+ - [Run](#run)
54
+ - [How it works](#how-it-works)
55
+ - [Reference](#reference)
56
+ - [Project anatomy](#project-anatomy)
57
+ - [Endpoints — source & target](#endpoints--source--target)
58
+ - [Source mode: release, tag, or commit](#source-mode-release-tag-or-commit)
59
+ - [Excluding files](#excluding-files)
60
+ - [Transforming files](#transforming-files)
61
+ - [Retries](#retries)
62
+ - [How to use](#how-to-use)
63
+ - [CLI](#cli)
64
+ - [Python API](#python-api)
65
+
66
+ ## Quick start
67
+
68
+ ### Install
69
+
70
+ ```bash
71
+ pip install gitacross
72
+ ```
73
+
74
+ For development, install the local checkout:
75
+
76
+ ```bash
77
+ pip install -e .
78
+ ```
79
+
80
+ ### Config
81
+
82
+ GitAcross reads a YAML file listing the mirrors you want. Each entry in the `projects` list is a **project**: it has a `source` (where releases come from) and a `target` (where they go).
83
+
84
+ Start from the fully commented [config.yml.example](config.yml.example) — it covers remote-to-remote mirrors, prebuilt asset sync, local repos, backups, and commit-mode branch syncing:
85
+
86
+ ```bash
87
+ cp config.yml.example config.yml
88
+ ```
89
+
90
+ Then edit `config.yml` to fill in your own repos and tokens. Tokens like `${GITEA_TOKEN}` are read from environment variables — keep secrets out of the file. See [Project anatomy](#project-anatomy) for everything else a project can have.
91
+
92
+ ### Run
93
+
94
+ ```bash
95
+ # Preview what would change (no commits, no pushes)
96
+ gitacross --config config.yml --dry-run
97
+
98
+ # Check the config for errors and redundant settings
99
+ gitacross --config config.yml --lint
100
+
101
+ # Do the sync
102
+ gitacross --config config.yml
103
+ ```
104
+
105
+ Run it again later — releases that were already synced are skipped, so nothing is duplicated. Use `--project my-project` to sync a single project. See [CLI](#cli) for all flags, or the [Python API](#python-api) to drive GitAcross from code.
106
+
107
+ ## How it works
108
+
109
+ For each project, GitAcross watches the **source** and mirrors new releases to the **target**:
110
+
111
+ 1. Fetch the list of releases from the source
112
+ 2. Skip releases that were already synced (remembered in a local state file)
113
+ 3. For each new release, oldest first:
114
+ - Check out the release's file tree
115
+ - Remove excluded files, then apply any file transforms
116
+ - Commit the result on the target's branch — one commit per release
117
+ - Tag the commit and publish the release on the target
118
+ 4. Save the sync state
119
+
120
+ On remote targets the commit is pushed; on local targets the working tree is updated instead. Re-running the same command later only syncs new releases — already-synced ones are skipped.
121
+
122
+ ## Reference
123
+
124
+ ### Project anatomy
125
+
126
+ A config file starts with a `projects` list — each entry is one mirror and needs a `name`, a `source`, and a `target`; everything else is optional.
127
+
128
+ | Key | What it does | More |
129
+ |---|---|---|
130
+ | `name` | Unique name for the project | — |
131
+ | `source` | Where releases come from | [Endpoints](#endpoints--source--target) |
132
+ | `target` | Where releases are mirrored to | [Endpoints](#endpoints--source--target) |
133
+ | `enabled` | `false` pauses the project without deleting it | [Endpoints](#endpoints--source--target) |
134
+ | `renderer` | File handling: `ignore` (exclude), `operations` (transform), `author` (commit identity) | [Excluding files](#excluding-files) · [Transforming files](#transforming-files) |
135
+ | `retry` | Retry settings for API calls | [Retries](#retries) |
136
+ | `sync_assets`, `stream_assets` | Mirror prebuilt release files to the target | [Endpoints](#endpoints--source--target) |
137
+ | `preserve_description`, `release_description`, `commit_message` | Release notes and commit messages | [Endpoints](#endpoints--source--target) |
138
+
139
+ ### Endpoints — source & target
140
+
141
+ `source` and `target` each describe one git host:
142
+
143
+ | Type | `source` fields | `target` fields |
144
+ |---|---|---|
145
+ | **gitea** / **github** | `repo`, `api`, `token`<br>`mode` (default `release`, or `tag`, or `commit`)<br>`include_prereleases` (default false)<br>`include_drafts` (default false) | `repo`, `api`, `token`<br>`branch` (default main) |
146
+ | **local** | `path`, `tag_pattern` (default `*`) | `path`, `branch` (default main) |
147
+
148
+ Tokens use `${VAR}` syntax — resolved from environment variables.
149
+
150
+ | Option | Description |
151
+ |---|---|
152
+ | [`enabled`](#enabled) | Disable a project without deleting it |
153
+ | [`preserve_description`](#preserve_description) | Copy source release notes to the target release |
154
+ | [`release_description`](#release_description) | Format target release notes from a template |
155
+ | [`commit_message`](#commit_message) | Override target commit messages |
156
+ | [`sync_assets`](#sync_assets) | Mirror prebuilt release assets to the target |
157
+ | [`stream_assets`](#stream_assets) | Stream asset uploads from disk (low memory) |
158
+
159
+ #### `enabled`
160
+
161
+ Set to `false` to pause a project without removing it from the config. Default `true`.
162
+
163
+ #### `preserve_description`
164
+
165
+ Copy the source release notes/body to the target release. Default `true`. Set at the project or endpoint level; `false` leaves the target release description empty.
166
+
167
+ #### `release_description`
168
+
169
+ Format the target release notes from a template (aliases: `release_notes_template`, `description_template`). Placeholders: `{body}`, `{description}`, `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`.
170
+
171
+ #### `commit_message`
172
+
173
+ Custom commit message for the target commits (alias: `commit_template`). Default: `"Release {tag}"` or `"Sync commit {short_sha}"`. Placeholders: `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`, `{body}`, `{description}`.
174
+
175
+ #### `sync_assets`
176
+
177
+ Mirror prebuilt release packages from the source to the target release — so you only need CI on the source platform. Project-level field:
178
+
179
+ | Value | Behaviour |
180
+ |---|---|
181
+ | `false` (default) | No assets synced |
182
+ | `true` | All assets synced |
183
+ | `"*.tar.gz"` | Only assets matching the glob |
184
+ | `["*.tar.gz", "*.zip"]` | Only assets matching any listed glob |
185
+
186
+ ```yaml
187
+ projects:
188
+ - name: my-project
189
+ sync_assets: # build on Gitea, upload prebuilts to GitHub
190
+ - "*.tar.gz"
191
+ - "*.zip"
192
+ - "*.deb"
193
+ - "*-checksums.txt"
194
+ stream_assets: true # stream uploads from disk — avoids buffering in RAM
195
+ source:
196
+ type: gitea
197
+ ...
198
+ target:
199
+ type: github
200
+ ...
201
+ ```
202
+
203
+ #### `stream_assets`
204
+
205
+ Stream each asset upload directly from the temporary download directory on disk (cleaned up after syncing) instead of buffering the whole file in memory. Default `false`; set to `true` when syncing large prebuilt binaries (hundreds of MB) to avoid out-of-memory errors.
206
+
207
+ ### Source mode: release, tag, or commit
208
+
209
+ Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced.
210
+
211
+ 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).
212
+
213
+ | Mode | What gets synced | When to use |
214
+ |---|---|---|
215
+ | [`release`](#release--api-releases-default) (default) | API releases, with prerelease/draft filtering | Normal release workflow |
216
+ | [`tag`](#tag--git-tags) | Git tags (no release objects needed) | Tags pushed without releases |
217
+ | [`commit`](#commit--sync-latest-head) | Latest commit of the source branch | Keep the target permanently in sync |
218
+
219
+ #### `release` — API releases (default)
220
+
221
+ Remote sources sync from the host's **API releases** by default: prerelease/draft filtering applies, and `sync_from` must be an API release.
222
+
223
+ In `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. That points you at the right option, `mode: tag` or `include_prereleases`/`include_drafts`, instead of silently treating tags as releases.
224
+
225
+ #### `tag` — git tags
226
+
227
+ Set `mode: tag` to treat **git tags** as releases instead — useful when tags were pushed without creating release objects. `sync_from: v2.0.0` starts at that tag, skipping older ones:
228
+
229
+ ```yaml
230
+ source:
231
+ type: gitea
232
+ repo: owner/repo
233
+ api: https://gitea.example.com/api/v1
234
+ token: ${GITEA_TOKEN}
235
+ mode: tag
236
+ sync_from: v2.0.0
237
+ ```
238
+
239
+ #### `commit` — sync latest HEAD
240
+
241
+ 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.
242
+
243
+ ```yaml
244
+ source:
245
+ type: gitea
246
+ repo: owner/repo
247
+ api: https://gitea.example.com/api/v1
248
+ token: ${GITEA_TOKEN}
249
+ mode: commit
250
+ branch: main # optional — which branch to read HEAD from (auto-detected if omitted)
251
+ ```
252
+
253
+ | Behaviour | Detail |
254
+ |---|---|
255
+ | **What gets synced** | Single snapshot of the current branch HEAD |
256
+ | **No tag or release** | Only a plain commit is pushed to the target branch |
257
+ | **`branch`** | Which source branch to read HEAD from. Auto-detects `origin/HEAD`, then tries `main`/`master`/`trunk` |
258
+ | **State key** | Commit SHA (not tag name). Already-synced SHAs are skipped |
259
+ | **Idempotent** | Re-running with same HEAD is a no-op (same SHA already in state) |
260
+ | **State purged** | Re-commits current HEAD snapshot; git sees no diff if nothing changed → no-op commit |
261
+
262
+ ### Excluding files
263
+
264
+ Some files shouldn't be mirrored at all. The project's `renderer` block accepts an `ignore` list of glob patterns — matched paths are removed from every release before anything else runs:
265
+
266
+ ```yaml
267
+ renderer:
268
+ ignore:
269
+ - node_modules # any node_modules/ dir, at any depth
270
+ - "*.secret" # only in root (single *, no /)
271
+ - "build/**/*.o" # any .o file under any build/ dir
272
+ - ToDo.md # any file named ToDo.md, at any depth
273
+ - some_folder/node_modules # node_modules only when inside some_folder/
274
+ - "./some_folder/node_modules" # root-only variant (anchored to ./)
275
+ ```
276
+
277
+ | Wildcard | Meaning |
278
+ |---|---|
279
+ | `*` | Within a single path segment (does **not** cross `/`) |
280
+ | `**` | Across any number of directory levels (recursive) |
281
+
282
+ Two shortcuts worth knowing:
283
+
284
+ - `.git/*` matches only direct children like `.git/config` and misses deeper files such as `.git/refs/heads/main` — use `.git/**` to delete everything inside.
285
+ - Naming a directory directly (`node_modules`) removes the whole tree in one shot, which is slightly faster than listing `node_modules/**`.
286
+
287
+ ### Transforming files
288
+
289
+ The project's `renderer` block also accepts an `operations` list of file transformations. They run top-to-bottom in the order listed, both across blocks and within them — a later step can rely on an earlier one (e.g. `rename` a file, then `replace` text inside it).
290
+
291
+ | Operation | What it does |
292
+ |---|---|
293
+ | [`remove`](#remove) | Delete files or paths |
294
+ | [`rename`](#rename) | Move or rename a file |
295
+ | [`replace`](#replace) | Find-and-replace text in files |
296
+ | [`add`](#add) | Create new files (parent dirs auto-created) |
297
+ | [`validate`](#validate) | Assert file/string conditions, abort on failure |
298
+
299
+ #### `remove`
300
+
301
+ | Field | Required | Default | Description |
302
+ |---|---|---|---|
303
+ | `path` | yes | — | Path or pattern to remove |
304
+ | `pattern` | no | `literal` | How to match: `literal`, `glob`, or `regex` |
305
+
306
+ ```yaml
307
+ - remove:
308
+ - path: .gitea # literal path
309
+ - path: "*.secret"
310
+ pattern: glob # glob pattern
311
+ - path: "build\\d+" # regex matches path
312
+ pattern: regex
313
+ ```
314
+
315
+ #### `rename`
316
+
317
+ | Field | Required | Default | Description |
318
+ |---|---|---|---|
319
+ | `from` | yes | — | Source path |
320
+ | `to` | yes | — | Destination path |
321
+ | `pattern` | no | `literal` | Only `literal` is implemented |
322
+
323
+ ```yaml
324
+ - rename:
325
+ - from: .gitea
326
+ to: .github
327
+ pattern: literal
328
+ ```
329
+
330
+ #### `replace`
331
+
332
+ | Field | Required | Default | Description |
333
+ |---|---|---|---|
334
+ | `search` | yes | — | String (literal) or pattern (regex) to find |
335
+ | `replace` | yes | — | Replacement text |
336
+ | `pattern` | no | `literal` | `literal` or `regex` |
337
+ | `glob` | no | all files | Only modify files matching this glob |
338
+ | `path` | no | — | Only modify this exact relative file path (takes precedence over `glob`) |
339
+ | `case_sensitive` | no | `true` | `false` matches any casing |
340
+ | `match_case` | no | `false` | `true` adapts each replacement to the casing it matched (see below) |
341
+
342
+ Only UTF-8 text files are scanned. Binary files are skipped.
343
+
344
+ `case_sensitive: false` makes the search case-insensitive — `search: gitea` also matches `Gitea` and `GITEA` (combines with `pattern: regex` too).
345
+
346
+ `match_case: true` (handy with `case_sensitive: false`) adjusts each replacement to the casing of the matched text instead of writing it verbatim. With `search: gitea`, `replace: github`:
347
+
348
+ | Matched text | Replacement |
349
+ |---|---|
350
+ | `gitea` | `github` |
351
+ | `Gitea` | `Github` |
352
+ | `GITEA` | `GITHUB` |
353
+
354
+ In `regex` mode, backreferences (e.g. `\1`) are expanded before the casing adaptation is applied.
355
+
356
+ ```yaml
357
+ - replace:
358
+ - search: https://gitea\.example\.com
359
+ replace: https://github.com
360
+ pattern: regex
361
+ glob: "*.md"
362
+ - search: http://old-url.com
363
+ replace: https://new-url.com
364
+ pattern: literal
365
+ - search: gitea
366
+ replace: github
367
+ case_sensitive: false
368
+ match_case: true
369
+ ```
370
+
371
+ #### `add`
372
+
373
+ | Field | Required | Description |
374
+ |---|---|---|
375
+ | `path` | yes | File path to create (parent dirs auto-created) |
376
+ | `content` | yes | File contents |
377
+
378
+ ```yaml
379
+ - add:
380
+ - path: .github/FUNDING.yml
381
+ content: |
382
+ github: myuser
383
+ - path: RELEASE_NOTES.md
384
+ content: |
385
+ # Release Notes
386
+ ...
387
+ ```
388
+
389
+ #### `validate`
390
+
391
+ | Field | Required | Description |
392
+ |---|---|---|
393
+ | `assert` | yes | `file_exists`, `file_absent`, `string_exists`, `string_absent` |
394
+ | `path` | yes | File path to check |
395
+ | `pattern` | for string checks | Text to search for |
396
+ | `case_sensitive` | no (default `true`) | `false` makes `string_exists`/`string_absent` match any casing |
397
+
398
+ Aborts the entire release if any assertion fails.
399
+
400
+ ```yaml
401
+ - validate:
402
+ - assert: file_exists
403
+ path: README.md
404
+ - assert: string_absent
405
+ path: LICENSE
406
+ pattern: "Gitea"
407
+ - assert: string_exists
408
+ path: README.md
409
+ pattern: "mit"
410
+ case_sensitive: false
411
+ ```
412
+
413
+ ### Retries
414
+
415
+ Retry settings for API calls, configured in the project's `retry` block.
416
+
417
+ | Field | Default | Description |
418
+ |---|---|---|
419
+ | `max_attempts` | 3 | Number of retries before giving up |
420
+ | `backoff_seconds` | 2 | Base delay (doubles each attempt) |
421
+
422
+ ## How to use
423
+
424
+ GitAcross can be driven from the command line or called directly from Python.
425
+
426
+ ### CLI
427
+
428
+ ```
429
+ gitacross --config PATH [--project NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
430
+ ```
431
+
432
+ | Flag | Description |
433
+ |---|---|
434
+ | `--config PATH` | Config file to use (required) |
435
+ | `--project NAME` | Sync only this project |
436
+ | `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
437
+ | `--dry-run` | Preview changes without committing or pushing |
438
+ | `--reset` | Clear saved state and cache before running (fresh start) |
439
+ | `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
440
+ | `--fix` | Fix misplaced keys and remove redundant options in the config |
441
+ | `-v, --verbose` | Debug logging |
442
+
443
+ ### Python API
444
+
445
+ Prefer code over the CLI? GitAcross is importable from Python — handy for CI scripts and webhooks. Expand the use case that fits your situation:
446
+
447
+ <details>
448
+ <summary>Sync everything — one call</summary>
449
+
450
+ ```python
451
+ import gitacross
452
+
453
+ results = gitacross.run("config.yml")
454
+
455
+ for r in results:
456
+ print(f"{r['project']}: synced={r['synced']} releases={r['releases_synced']}")
457
+ if r["error"]:
458
+ print(f" error: {r['error']}")
459
+ ```
460
+
461
+ </details>
462
+
463
+ <details>
464
+ <summary>Sync one project, preview first</summary>
465
+
466
+ ```python
467
+ # Preview only — nothing is committed or pushed
468
+ results = gitacross.run(
469
+ "config.yml",
470
+ project="my-project",
471
+ dry_run=True,
472
+ work_dir="/data/custom_dir",
473
+ )
474
+ ```
475
+
476
+ </details>
477
+
478
+ <details>
479
+ <summary>Start fresh — ignore saved state</summary>
480
+
481
+ ```python
482
+ # Clears saved state and cache, so every release is treated as new
483
+ results = gitacross.run("config.yml", reset=True)
484
+ ```
485
+
486
+ </details>
487
+
488
+ <details>
489
+ <summary>Full control — loop over projects yourself</summary>
490
+
491
+ ```python
492
+ config = gitacross.Config("config.yml")
493
+
494
+ for project in config.projects:
495
+ if project.enabled:
496
+ gitacross.sync_project(project, ".gitsync", dry_run=False)
497
+ ```
498
+
499
+ </details>
500
+
501
+ <details>
502
+ <summary>No config file — build a project inline</summary>
503
+
504
+ ```python
505
+ # ${VAR} tokens still resolve from the environment
506
+ project = gitacross.ProjectConfig({
507
+ "name": "my-project",
508
+ "source": {
509
+ "type": "gitea",
510
+ "repo": "owner/repo",
511
+ "api": "https://gitea.example.com/api/v1",
512
+ "token": "${GITEA_TOKEN}",
513
+ },
514
+ "target": {
515
+ "type": "github",
516
+ "repo": "owner/repo",
517
+ "api": "https://api.github.com",
518
+ "token": "${GITHUB_TOKEN}",
519
+ },
520
+ })
521
+ gitacross.sync_project(project, ".gitsync")
522
+ ```
523
+
524
+ </details>
525
+
526
+ <details>
527
+ <summary>Lint and auto-fix the config from code</summary>
528
+
529
+ ```python
530
+ report = gitacross.lint_config("config.yml", print_output=False)
531
+ if not report.is_valid:
532
+ print([e.message for e in report.errors])
533
+ gitacross.fix_config("config.yml", write_back=True)
534
+ ```
535
+
536
+ </details>
537
+
538
+ <details>
539
+ <summary>Sync from YAML held in a variable — no file needed</summary>
540
+
541
+ ```python
542
+ # ${VAR} tokens still resolve from the environment
543
+ config = gitacross.Config.from_yaml_string("""
544
+ projects:
545
+ - name: my-mirror
546
+ source:
547
+ type: gitea
548
+ repo: owner/repo
549
+ api: https://gitea.example.com/api/v1
550
+ token: ${GITEA_TOKEN}
551
+ target:
552
+ type: github
553
+ repo: owner/repo
554
+ api: https://api.github.com
555
+ token: ${GITHUB_TOKEN}
556
+ """)
557
+ gitacross.run(config, dry_run=True)
558
+ ```
559
+
560
+ </details>
561
+
562
+ All public symbols are importable directly from `gitacross`:
563
+
564
+ | Symbol | What it does |
565
+ |---|---|
566
+ | `run(config, project=None, dry_run=False, reset=False, work_dir=".gitsync")` | **Primary entry point.** Sync from a config — a `Config` instance, a path, or an open file object
567
+ | `sync_project(project, work_dir=".gitsync", dry_run=False)` | Sync one project's new releases (respects `project.enabled`); state and cache live in `work_dir`. Returns dicts with `tag`, `source_commit`, `target_commit`, `source_date` |
568
+ | `lint_config(config, print_output=True)` | Lint a config (path or open file object) → `LintReport` |
569
+ | `fix_config(config, write_back=True, print_output=True)` | Fix misplaced/redundant options (path only — writes back to the file) → `FixReport` |
570
+ | `Config(config_source)` | Load a config from a path or open file object; exposes `.projects`. `Config.from_yaml_string(content)` loads a config from raw YAML text (`str` or `bytes`) — no file or stream needed |
571
+ | `ProjectConfig(raw)` | Build one mirror project from a raw config dict (see the “No config file” example). Fields: `name`, `enabled`, `source`, `target`, `renderer`, `retry`, `preserve_description`, `sync_assets`, `stream_assets`, `commit_message`, `release_description` |
572
+ | `ConfigLinter()` | Collect lint issues programmatically: `lint_file(config)`, `lint_yaml_string(content)`; results accumulate in `.issues` |
573
+ | `ConfigFixer()` | Fix a config programmatically: `fix_yaml_string(content)` → `FixReport`; actions recorded in `.fixes` |
574
+ | `LintIssue(severity, message, project=None, key=None)` | One lint finding |
575
+ | `FixIssue(message, project=None)` | One applied fix |
576
+ | `LintReport(issues)` | Lint results: `.issues`, `.errors`, `.warnings`, `.redundant`, `.is_valid`, `.format_text()` |
577
+ | `FixReport(fixes, content, is_valid, error=None)` | Fix results: `.fixes`, `.content`, `.is_valid`, `.error`, `.format_text()` |
578
+ | `LintSeverity` | Severity levels used by `LintIssue`: `LintSeverity.ERROR`, `LintSeverity.WARNING`, `LintSeverity.REDUNDANT` |