gitacross 2.0.0__tar.gz → 2.2.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 (43) hide show
  1. {gitacross-2.0.0/src/gitacross.egg-info → gitacross-2.2.0}/PKG-INFO +26 -16
  2. {gitacross-2.0.0 → gitacross-2.2.0}/README.md +24 -15
  3. {gitacross-2.0.0 → gitacross-2.2.0}/pyproject.toml +14 -1
  4. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/__init__.py +6 -5
  5. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/__main__.py +0 -1
  6. gitacross-2.2.0/src/gitacross/cli.py +105 -0
  7. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/config.py +12 -14
  8. gitacross-2.2.0/src/gitacross/linter/__init__.py +26 -0
  9. gitacross-2.2.0/src/gitacross/linter/fixer.py +282 -0
  10. gitacross-2.2.0/src/gitacross/linter/keys.py +39 -0
  11. gitacross-2.2.0/src/gitacross/linter/models.py +145 -0
  12. gitacross-2.0.0/src/gitacross/linter.py → gitacross-2.2.0/src/gitacross/linter/validator.py +106 -532
  13. gitacross-2.2.0/src/gitacross/main.py +562 -0
  14. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/providers/__init__.py +16 -3
  15. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/providers/base.py +8 -1
  16. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/providers/gitea.py +10 -5
  17. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/providers/github.py +0 -1
  18. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/renderer.py +11 -13
  19. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/source.py +10 -9
  20. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/target.py +6 -6
  21. {gitacross-2.0.0 → gitacross-2.2.0/src/gitacross.egg-info}/PKG-INFO +26 -16
  22. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross.egg-info/SOURCES.txt +16 -2
  23. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross.egg-info/requires.txt +1 -0
  24. gitacross-2.2.0/tests/test_config.py +488 -0
  25. gitacross-2.2.0/tests/test_git.py +341 -0
  26. gitacross-2.2.0/tests/test_integration.py +1196 -0
  27. gitacross-2.2.0/tests/test_linter.py +1031 -0
  28. gitacross-2.2.0/tests/test_providers.py +643 -0
  29. gitacross-2.2.0/tests/test_renderer.py +648 -0
  30. gitacross-2.2.0/tests/test_source.py +779 -0
  31. gitacross-2.2.0/tests/test_state.py +77 -0
  32. gitacross-2.2.0/tests/test_sync_api.py +1142 -0
  33. gitacross-2.2.0/tests/test_target.py +223 -0
  34. gitacross-2.0.0/src/gitacross/main.py +0 -614
  35. gitacross-2.0.0/tests/test_sync.py +0 -4560
  36. {gitacross-2.0.0 → gitacross-2.2.0}/LICENSE +0 -0
  37. {gitacross-2.0.0 → gitacross-2.2.0}/setup.cfg +0 -0
  38. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/git.py +0 -0
  39. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/retry.py +0 -0
  40. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross/state.py +0 -0
  41. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
  42. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross.egg-info/entry_points.txt +0 -0
  43. {gitacross-2.0.0 → gitacross-2.2.0}/src/gitacross.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacross
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
5
5
  Author: Matthew Deik
6
6
  License-Expression: MIT
@@ -22,6 +22,7 @@ License-File: LICENSE
22
22
  Requires-Dist: PyYAML<7.0,>=6.0
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
25
26
  Requires-Dist: build; extra == "dev"
26
27
  Requires-Dist: twine; extra == "dev"
27
28
  Dynamic: license-file
@@ -77,6 +78,15 @@ For development, install the local checkout:
77
78
  pip install -e .
78
79
  ```
79
80
 
81
+ ### Tests
82
+
83
+ ```bash
84
+ pip install -e .[dev] # pytest + pytest-cov
85
+ pytest # run the test suite
86
+ pytest --cov=gitacross # run with a coverage floor (90% line + branch)
87
+ ruff check src tests # lint
88
+ ```
89
+
80
90
  ### Config
81
91
 
82
92
  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).
@@ -102,7 +112,7 @@ gitacross --config config.yml --lint
102
112
  gitacross --config config.yml
103
113
  ```
104
114
 
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.
115
+ Run it again later — releases that were already synced are skipped, so nothing is duplicated. Use `--project-name 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
116
 
107
117
  ## How it works
108
118
 
@@ -166,11 +176,11 @@ Copy the source release notes/body to the target release. Default `true`. Set at
166
176
 
167
177
  #### `release_description`
168
178
 
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}`.
179
+ Format the target release notes from a template. Placeholders: `{body}`, `{description}`, `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`.
170
180
 
171
181
  #### `commit_message`
172
182
 
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}`.
183
+ Custom commit message for the target commits. Default: `"Release {tag}"` or `"Sync commit {short_sha}"`. Placeholders: `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`, `{body}`, `{description}`.
174
184
 
175
185
  #### `sync_assets`
176
186
 
@@ -426,13 +436,13 @@ GitAcross can be driven from the command line or called directly from Python.
426
436
  ### CLI
427
437
 
428
438
  ```
429
- gitacross --config PATH [--project NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
439
+ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
430
440
  ```
431
441
 
432
442
  | Flag | Description |
433
443
  |---|---|
434
444
  | `--config PATH` | Config file to use (required) |
435
- | `--project NAME` | Sync only this project |
445
+ | `--project-name NAME` | Sync only the project with this name |
436
446
  | `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
437
447
  | `--dry-run` | Preview changes without committing or pushing |
438
448
  | `--reset` | Clear saved state and cache before running (fresh start) |
@@ -467,7 +477,7 @@ for r in results:
467
477
  # Preview only — nothing is committed or pushed
468
478
  results = gitacross.run(
469
479
  "config.yml",
470
- project="my-project",
480
+ project_name="my-project",
471
481
  dry_run=True,
472
482
  work_dir="/data/custom_dir",
473
483
  )
@@ -491,9 +501,9 @@ results = gitacross.run("config.yml", reset=True)
491
501
  ```python
492
502
  config = gitacross.Config("config.yml")
493
503
 
494
- for project in config.projects:
495
- if project.enabled:
496
- gitacross.sync_project(project, ".gitsync", dry_run=False)
504
+ for project_config in config.projects:
505
+ if project_config.enabled:
506
+ gitacross.sync_project(project_config, ".gitsync", dry_run=False)
497
507
  ```
498
508
 
499
509
  </details>
@@ -503,7 +513,7 @@ for project in config.projects:
503
513
 
504
514
  ```python
505
515
  # ${VAR} tokens still resolve from the environment
506
- project = gitacross.ProjectConfig({
516
+ project_config = gitacross.ProjectConfig({
507
517
  "name": "my-project",
508
518
  "source": {
509
519
  "type": "gitea",
@@ -518,7 +528,7 @@ project = gitacross.ProjectConfig({
518
528
  "token": "${GITHUB_TOKEN}",
519
529
  },
520
530
  })
521
- gitacross.sync_project(project, ".gitsync")
531
+ gitacross.sync_project(project_config, ".gitsync")
522
532
  ```
523
533
 
524
534
  </details>
@@ -563,16 +573,16 @@ All public symbols are importable directly from `gitacross`:
563
573
 
564
574
  | Symbol | What it does |
565
575
  |---|---|
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` |
576
+ | `run(config, project_name=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
577
+ | `sync_project(project_config, work_dir=".gitsync", dry_run=False)` | Sync one project's new releases (respects `project_config.enabled`); state and cache live in `work_dir`. Returns dicts with `tag`, `source_commit`, `target_commit`, `source_date` |
568
578
  | `lint_config(config, print_output=True)` | Lint a config (path or open file object) → `LintReport` |
569
579
  | `fix_config(config, write_back=True, print_output=True)` | Fix misplaced/redundant options (path only — writes back to the file) → `FixReport` |
570
580
  | `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
581
  | `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
582
  | `ConfigLinter()` | Collect lint issues programmatically: `lint_file(config)`, `lint_yaml_string(content)`; results accumulate in `.issues` |
573
583
  | `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 |
584
+ | `LintIssue(severity, message, project_name=None, key=None)` | One lint finding |
585
+ | `FixIssue(message, project_name=None)` | One applied fix |
576
586
  | `LintReport(issues)` | Lint results: `.issues`, `.errors`, `.warnings`, `.redundant`, `.is_valid`, `.format_text()` |
577
587
  | `FixReport(fixes, content, is_valid, error=None)` | Fix results: `.fixes`, `.content`, `.is_valid`, `.error`, `.format_text()` |
578
588
  | `LintSeverity` | Severity levels used by `LintIssue`: `LintSeverity.ERROR`, `LintSeverity.WARNING`, `LintSeverity.REDUNDANT` |
@@ -49,6 +49,15 @@ For development, install the local checkout:
49
49
  pip install -e .
50
50
  ```
51
51
 
52
+ ### Tests
53
+
54
+ ```bash
55
+ pip install -e .[dev] # pytest + pytest-cov
56
+ pytest # run the test suite
57
+ pytest --cov=gitacross # run with a coverage floor (90% line + branch)
58
+ ruff check src tests # lint
59
+ ```
60
+
52
61
  ### Config
53
62
 
54
63
  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).
@@ -74,7 +83,7 @@ gitacross --config config.yml --lint
74
83
  gitacross --config config.yml
75
84
  ```
76
85
 
77
- 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.
86
+ Run it again later — releases that were already synced are skipped, so nothing is duplicated. Use `--project-name my-project` to sync a single project. See [CLI](#cli) for all flags, or the [Python API](#python-api) to drive GitAcross from code.
78
87
 
79
88
  ## How it works
80
89
 
@@ -138,11 +147,11 @@ Copy the source release notes/body to the target release. Default `true`. Set at
138
147
 
139
148
  #### `release_description`
140
149
 
141
- 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}`.
150
+ Format the target release notes from a template. Placeholders: `{body}`, `{description}`, `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`.
142
151
 
143
152
  #### `commit_message`
144
153
 
145
- 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}`.
154
+ Custom commit message for the target commits. Default: `"Release {tag}"` or `"Sync commit {short_sha}"`. Placeholders: `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`, `{body}`, `{description}`.
146
155
 
147
156
  #### `sync_assets`
148
157
 
@@ -398,13 +407,13 @@ GitAcross can be driven from the command line or called directly from Python.
398
407
  ### CLI
399
408
 
400
409
  ```
401
- gitacross --config PATH [--project NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
410
+ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
402
411
  ```
403
412
 
404
413
  | Flag | Description |
405
414
  |---|---|
406
415
  | `--config PATH` | Config file to use (required) |
407
- | `--project NAME` | Sync only this project |
416
+ | `--project-name NAME` | Sync only the project with this name |
408
417
  | `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
409
418
  | `--dry-run` | Preview changes without committing or pushing |
410
419
  | `--reset` | Clear saved state and cache before running (fresh start) |
@@ -439,7 +448,7 @@ for r in results:
439
448
  # Preview only — nothing is committed or pushed
440
449
  results = gitacross.run(
441
450
  "config.yml",
442
- project="my-project",
451
+ project_name="my-project",
443
452
  dry_run=True,
444
453
  work_dir="/data/custom_dir",
445
454
  )
@@ -463,9 +472,9 @@ results = gitacross.run("config.yml", reset=True)
463
472
  ```python
464
473
  config = gitacross.Config("config.yml")
465
474
 
466
- for project in config.projects:
467
- if project.enabled:
468
- gitacross.sync_project(project, ".gitsync", dry_run=False)
475
+ for project_config in config.projects:
476
+ if project_config.enabled:
477
+ gitacross.sync_project(project_config, ".gitsync", dry_run=False)
469
478
  ```
470
479
 
471
480
  </details>
@@ -475,7 +484,7 @@ for project in config.projects:
475
484
 
476
485
  ```python
477
486
  # ${VAR} tokens still resolve from the environment
478
- project = gitacross.ProjectConfig({
487
+ project_config = gitacross.ProjectConfig({
479
488
  "name": "my-project",
480
489
  "source": {
481
490
  "type": "gitea",
@@ -490,7 +499,7 @@ project = gitacross.ProjectConfig({
490
499
  "token": "${GITHUB_TOKEN}",
491
500
  },
492
501
  })
493
- gitacross.sync_project(project, ".gitsync")
502
+ gitacross.sync_project(project_config, ".gitsync")
494
503
  ```
495
504
 
496
505
  </details>
@@ -535,16 +544,16 @@ All public symbols are importable directly from `gitacross`:
535
544
 
536
545
  | Symbol | What it does |
537
546
  |---|---|
538
- | `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
539
- | `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` |
547
+ | `run(config, project_name=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
548
+ | `sync_project(project_config, work_dir=".gitsync", dry_run=False)` | Sync one project's new releases (respects `project_config.enabled`); state and cache live in `work_dir`. Returns dicts with `tag`, `source_commit`, `target_commit`, `source_date` |
540
549
  | `lint_config(config, print_output=True)` | Lint a config (path or open file object) → `LintReport` |
541
550
  | `fix_config(config, write_back=True, print_output=True)` | Fix misplaced/redundant options (path only — writes back to the file) → `FixReport` |
542
551
  | `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 |
543
552
  | `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` |
544
553
  | `ConfigLinter()` | Collect lint issues programmatically: `lint_file(config)`, `lint_yaml_string(content)`; results accumulate in `.issues` |
545
554
  | `ConfigFixer()` | Fix a config programmatically: `fix_yaml_string(content)` → `FixReport`; actions recorded in `.fixes` |
546
- | `LintIssue(severity, message, project=None, key=None)` | One lint finding |
547
- | `FixIssue(message, project=None)` | One applied fix |
555
+ | `LintIssue(severity, message, project_name=None, key=None)` | One lint finding |
556
+ | `FixIssue(message, project_name=None)` | One applied fix |
548
557
  | `LintReport(issues)` | Lint results: `.issues`, `.errors`, `.warnings`, `.redundant`, `.is_valid`, `.format_text()` |
549
558
  | `FixReport(fixes, content, is_valid, error=None)` | Fix results: `.fixes`, `.content`, `.is_valid`, `.error`, `.format_text()` |
550
559
  | `LintSeverity` | Severity levels used by `LintIssue`: `LintSeverity.ERROR`, `LintSeverity.WARNING`, `LintSeverity.REDUNDANT` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitacross"
7
- version = "2.0.0"
7
+ version = "2.2.0"
8
8
  description = "Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -33,6 +33,7 @@ dependencies = [
33
33
  [project.optional-dependencies]
34
34
  dev = [
35
35
  "pytest>=7.0",
36
+ "pytest-cov>=4.0",
36
37
  "build",
37
38
  "twine",
38
39
  ]
@@ -46,3 +47,15 @@ where = ["src"]
46
47
  [tool.pytest.ini_options]
47
48
  pythonpath = ["src"]
48
49
  testpaths = ["tests"]
50
+
51
+ [tool.coverage.run]
52
+ source = ["gitacross"]
53
+ # Keep branch measurement on for the report, but don't gate on it —
54
+ # a 100% branch gate incentivizes line-touching tests over behavior tests.
55
+ branch = true
56
+
57
+ [tool.coverage.report]
58
+ # Enforcement floor with headroom: catches regressions without demanding
59
+ # 100% (which encourages tests-for-the-sake-of-it on defensive branches).
60
+ fail_under = 90
61
+ show_missing = true
@@ -8,13 +8,13 @@ Quickstart (Python API)::
8
8
  results = gitacross.run("config.yml")
9
9
 
10
10
  # Sync a single project with dry-run mode:
11
- results = gitacross.run("config.yml", project="my-mirror", dry_run=True)
11
+ results = gitacross.run("config.yml", project_name="my-mirror", dry_run=True)
12
12
 
13
13
  # Lower-level: build objects yourself for full control:
14
14
  config = gitacross.Config("config.yml")
15
- for project in config.projects:
16
- if project.enabled:
17
- gitacross.sync_project(project, ".gitsync")
15
+ for project_config in config.projects:
16
+ if project_config.enabled:
17
+ gitacross.sync_project(project_config, ".gitsync")
18
18
 
19
19
  # Lint / fix a config file:
20
20
  report = gitacross.lint_config("config.yml")
@@ -28,6 +28,7 @@ from importlib.metadata import PackageNotFoundError
28
28
  from importlib.metadata import version as _installed_version
29
29
  from pathlib import Path
30
30
 
31
+ from .cli import main # the CLI lives in cli.py; main.py re-exports it too
31
32
  from .config import Config, ProjectConfig
32
33
  from .linter import (
33
34
  ConfigFixer,
@@ -40,7 +41,7 @@ from .linter import (
40
41
  fix_config,
41
42
  lint_config,
42
43
  )
43
- from .main import main, run, sync_project
44
+ from .main import run, sync_project
44
45
 
45
46
 
46
47
  def _package_version() -> str:
@@ -1,7 +1,6 @@
1
1
  """Entry point when invoked with `python -m gitacross`."""
2
2
  from __future__ import annotations
3
3
 
4
-
5
4
  from .main import main
6
5
 
7
6
  if __name__ == "__main__":
@@ -0,0 +1,105 @@
1
+ """Command-line interface for GitAcross.
2
+
3
+ The sync logic lives in :mod:`gitacross.main`; this module only parses
4
+ ``argv`` and maps results to process exit codes.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import logging
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import cast
13
+
14
+
15
+ def _setup_logging(verbose):
16
+ level = logging.DEBUG if verbose else logging.INFO
17
+ logging.basicConfig(
18
+ level=level,
19
+ format="%(asctime)s [%(levelname)s] %(message)s",
20
+ datefmt="%H:%M:%S",
21
+ )
22
+
23
+
24
+ def main():
25
+ from .main import DEFAULT_WORK_DIR, run
26
+
27
+ parser = argparse.ArgumentParser(description="Sync releases from source to target")
28
+ _ = parser.add_argument("--config", required=True, help="Path to config.yml")
29
+ _ = parser.add_argument("--project-name", help="Sync only this project (by name)")
30
+ _ = parser.add_argument(
31
+ "--dry-run", action="store_true", help="Print changes without pushing"
32
+ )
33
+ _ = parser.add_argument(
34
+ "--reset",
35
+ action="store_true",
36
+ help="Clear state and cache before running (fresh start)",
37
+ )
38
+ _ = parser.add_argument(
39
+ "--lint",
40
+ action="store_true",
41
+ help="Lint config file for YAML errors, invalid settings, and redundant options",
42
+ )
43
+ _ = parser.add_argument(
44
+ "--fix",
45
+ action="store_true",
46
+ help="Automatically fix misplaced keys and remove redundant options in config file",
47
+ )
48
+ _ = parser.add_argument(
49
+ "--workdir",
50
+ default=DEFAULT_WORK_DIR,
51
+ help="Directory for state.yml and cache/ (default: .gitsync)",
52
+ )
53
+ _ = parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
54
+ args = parser.parse_args()
55
+
56
+ # Convert argparse's Any values into concrete types (cast is the escape hatch).
57
+ verbose = cast(bool, args.verbose)
58
+ config_path = Path(cast(str, args.config))
59
+ fix_flag = cast(bool, args.fix)
60
+ lint_flag = cast(bool, args.lint)
61
+ project_name = None if args.project_name is None else cast(str, args.project_name)
62
+ dry_run_flag = cast(bool, args.dry_run)
63
+ reset_flag = cast(bool, args.reset)
64
+ workdir = cast(str, args.workdir)
65
+
66
+ _setup_logging(verbose)
67
+
68
+ if fix_flag:
69
+ if not config_path.exists():
70
+ sys.exit(f"Config file not found: {config_path}")
71
+ from .linter import fix_config, lint_config
72
+
73
+ fix_report = fix_config(
74
+ config_path, write_back=not dry_run_flag, dry_run=dry_run_flag
75
+ )
76
+ if not fix_report.is_valid:
77
+ sys.exit(1)
78
+ if lint_flag:
79
+ print()
80
+ lint_report = lint_config(config_path)
81
+ sys.exit(0 if lint_report.is_valid else 1)
82
+ sys.exit(0)
83
+
84
+ if lint_flag:
85
+ if not config_path.exists():
86
+ sys.exit(f"Config file not found: {config_path}")
87
+ from .linter import lint_config
88
+
89
+ report = lint_config(config_path)
90
+ sys.exit(0 if report.is_valid else 1)
91
+
92
+ try:
93
+ results = run(
94
+ config_path,
95
+ project_name=project_name,
96
+ dry_run=dry_run_flag,
97
+ reset=reset_flag,
98
+ work_dir=workdir,
99
+ )
100
+ except (FileNotFoundError, ValueError) as exc:
101
+ sys.exit(str(exc))
102
+
103
+ if any(not r["synced"] for r in results):
104
+ sys.exit(1)
105
+
@@ -7,6 +7,8 @@ from typing import final
7
7
 
8
8
  import yaml
9
9
 
10
+ from .providers import REMOTE_TYPES
11
+
10
12
  logger = logging.getLogger(__name__)
11
13
 
12
14
  VALID_PROJECT_KEYS = {
@@ -20,10 +22,7 @@ VALID_PROJECT_KEYS = {
20
22
  "sync_assets",
21
23
  "stream_assets",
22
24
  "commit_message",
23
- "commit_template",
24
25
  "release_description",
25
- "release_notes_template",
26
- "description_template",
27
26
  }
28
27
 
29
28
  KNOWN_SOURCE_KEYS = {
@@ -164,7 +163,12 @@ class _EndpointConfig:
164
163
 
165
164
  @property
166
165
  def is_remote(self):
167
- return self.type in ("gitea", "github")
166
+ return self.type in REMOTE_TYPES
167
+
168
+ @property
169
+ def repo_slug(self):
170
+ """Filesystem-safe repo identifier (``owner/name`` → ``owner_name``)."""
171
+ return self.repo.replace("/", "_")
168
172
 
169
173
  @property
170
174
  def owner(self):
@@ -249,15 +253,10 @@ class ProjectConfig:
249
253
  self.stream_assets = bool(raw.get("stream_assets", False))
250
254
 
251
255
  # commit_message — project-level commit message template (e.g. "chore(sync): {tag}")
252
- self.commit_message = raw.get("commit_message") or raw.get("commit_template") or None
256
+ self.commit_message = raw.get("commit_message") or None
253
257
 
254
258
  # release_description — project-level release description template
255
- self.release_description = (
256
- raw.get("release_description")
257
- or raw.get("release_notes_template")
258
- or raw.get("description_template")
259
- or None
260
- )
259
+ self.release_description = raw.get("release_description") or None
261
260
 
262
261
  def __repr__(self):
263
262
  return (
@@ -302,9 +301,8 @@ def _cascade(raw_project, raw_source, raw_target, keys, default):
302
301
  """Return the first value found for any of *keys* across project, source, target.
303
302
 
304
303
  Lookup order: project-level first (highest priority), then source-level,
305
- then target-level, then *default*. All alias keys are checked at each
306
- level before moving to the next — so a project-level alias wins over a
307
- source-level primary key.
304
+ then target-level, then *default*. The first *key* present at the first
305
+ level wins — so a project-level value beats a source-level one.
308
306
  """
309
307
  for raw in (raw_project, raw_source, raw_target):
310
308
  for k in keys:
@@ -0,0 +1,26 @@
1
+ """Config linter and fixer for GitAcross.
2
+
3
+ ``lint_config`` validates a config; ``fix_config`` rewrites it in place to
4
+ remove redundant and misplaced options.
5
+
6
+ Implementation lives in three modules:
7
+
8
+ * :mod:`gitacross.linter.models` — report/issue data types and YAML dump helper
9
+ * :mod:`gitacross.linter.validator` — :class:`ConfigLinter` (the checks)
10
+ * :mod:`gitacross.linter.fixer` — :class:`ConfigFixer` (the rewrites)
11
+ """
12
+ from .fixer import ConfigFixer, fix_config
13
+ from .models import FixIssue, FixReport, LintIssue, LintReport, LintSeverity
14
+ from .validator import ConfigLinter, lint_config
15
+
16
+ __all__ = [
17
+ "ConfigFixer",
18
+ "ConfigLinter",
19
+ "FixIssue",
20
+ "FixReport",
21
+ "LintIssue",
22
+ "LintReport",
23
+ "LintSeverity",
24
+ "fix_config",
25
+ "lint_config",
26
+ ]