gitacross 2.1.0__tar.gz → 2.3.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.
- {gitacross-2.1.0/src/gitacross.egg-info → gitacross-2.3.0}/PKG-INFO +23 -16
- {gitacross-2.1.0 → gitacross-2.3.0}/README.md +22 -15
- {gitacross-2.1.0 → gitacross-2.3.0}/pyproject.toml +1 -1
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/__init__.py +4 -4
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/cli.py +3 -3
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/config.py +4 -13
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/git.py +113 -15
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/fixer.py +26 -26
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/models.py +8 -8
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/validator.py +82 -88
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/main.py +50 -50
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/renderer.py +3 -3
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/target.py +7 -2
- {gitacross-2.1.0 → gitacross-2.3.0/src/gitacross.egg-info}/PKG-INFO +23 -16
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_git.py +172 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_integration.py +223 -11
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_linter.py +4 -3
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_source.py +30 -12
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_sync_api.py +32 -9
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_target.py +94 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/LICENSE +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/setup.cfg +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/__main__.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/__init__.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/keys.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/providers/__init__.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/providers/base.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/providers/gitea.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/providers/github.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/retry.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/source.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/state.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/SOURCES.txt +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/entry_points.txt +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/requires.txt +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/top_level.txt +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_config.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_providers.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_renderer.py +0 -0
- {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.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
|
|
@@ -112,7 +112,7 @@ gitacross --config config.yml --lint
|
|
|
112
112
|
gitacross --config config.yml
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
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.
|
|
116
116
|
|
|
117
117
|
## How it works
|
|
118
118
|
|
|
@@ -157,6 +157,13 @@ A config file starts with a `projects` list — each entry is one mirror and nee
|
|
|
157
157
|
|
|
158
158
|
Tokens use `${VAR}` syntax — resolved from environment variables.
|
|
159
159
|
|
|
160
|
+
For local **targets**, the `path` does not have to exist yet: if the directory is
|
|
161
|
+
missing or is not already a git repository, GitAcross creates the directory and
|
|
162
|
+
runs `git init` there before committing. If a repository already exists at that
|
|
163
|
+
path it is opened as-is — existing git metadata is never re-initialised or
|
|
164
|
+
overwritten (bare repositories and broken `.git` markers are refused with an
|
|
165
|
+
error). Local **sources** must point at an existing git repository.
|
|
166
|
+
|
|
160
167
|
| Option | Description |
|
|
161
168
|
|---|---|
|
|
162
169
|
| [`enabled`](#enabled) | Disable a project without deleting it |
|
|
@@ -176,11 +183,11 @@ Copy the source release notes/body to the target release. Default `true`. Set at
|
|
|
176
183
|
|
|
177
184
|
#### `release_description`
|
|
178
185
|
|
|
179
|
-
Format the target release notes from a template
|
|
186
|
+
Format the target release notes from a template. Placeholders: `{body}`, `{description}`, `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`.
|
|
180
187
|
|
|
181
188
|
#### `commit_message`
|
|
182
189
|
|
|
183
|
-
Custom commit message for the target commits
|
|
190
|
+
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}`.
|
|
184
191
|
|
|
185
192
|
#### `sync_assets`
|
|
186
193
|
|
|
@@ -436,13 +443,13 @@ GitAcross can be driven from the command line or called directly from Python.
|
|
|
436
443
|
### CLI
|
|
437
444
|
|
|
438
445
|
```
|
|
439
|
-
gitacross --config PATH [--project NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
|
|
446
|
+
gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
|
|
440
447
|
```
|
|
441
448
|
|
|
442
449
|
| Flag | Description |
|
|
443
450
|
|---|---|
|
|
444
451
|
| `--config PATH` | Config file to use (required) |
|
|
445
|
-
| `--project NAME` | Sync only
|
|
452
|
+
| `--project-name NAME` | Sync only the project with this name |
|
|
446
453
|
| `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
|
|
447
454
|
| `--dry-run` | Preview changes without committing or pushing |
|
|
448
455
|
| `--reset` | Clear saved state and cache before running (fresh start) |
|
|
@@ -477,7 +484,7 @@ for r in results:
|
|
|
477
484
|
# Preview only — nothing is committed or pushed
|
|
478
485
|
results = gitacross.run(
|
|
479
486
|
"config.yml",
|
|
480
|
-
|
|
487
|
+
project_name="my-project",
|
|
481
488
|
dry_run=True,
|
|
482
489
|
work_dir="/data/custom_dir",
|
|
483
490
|
)
|
|
@@ -501,9 +508,9 @@ results = gitacross.run("config.yml", reset=True)
|
|
|
501
508
|
```python
|
|
502
509
|
config = gitacross.Config("config.yml")
|
|
503
510
|
|
|
504
|
-
for
|
|
505
|
-
if
|
|
506
|
-
gitacross.sync_project(
|
|
511
|
+
for project_config in config.projects:
|
|
512
|
+
if project_config.enabled:
|
|
513
|
+
gitacross.sync_project(project_config, ".gitsync", dry_run=False)
|
|
507
514
|
```
|
|
508
515
|
|
|
509
516
|
</details>
|
|
@@ -513,7 +520,7 @@ for project in config.projects:
|
|
|
513
520
|
|
|
514
521
|
```python
|
|
515
522
|
# ${VAR} tokens still resolve from the environment
|
|
516
|
-
|
|
523
|
+
project_config = gitacross.ProjectConfig({
|
|
517
524
|
"name": "my-project",
|
|
518
525
|
"source": {
|
|
519
526
|
"type": "gitea",
|
|
@@ -528,7 +535,7 @@ project = gitacross.ProjectConfig({
|
|
|
528
535
|
"token": "${GITHUB_TOKEN}",
|
|
529
536
|
},
|
|
530
537
|
})
|
|
531
|
-
gitacross.sync_project(
|
|
538
|
+
gitacross.sync_project(project_config, ".gitsync")
|
|
532
539
|
```
|
|
533
540
|
|
|
534
541
|
</details>
|
|
@@ -573,16 +580,16 @@ All public symbols are importable directly from `gitacross`:
|
|
|
573
580
|
|
|
574
581
|
| Symbol | What it does |
|
|
575
582
|
|---|---|
|
|
576
|
-
| `run(config,
|
|
577
|
-
| `sync_project(
|
|
583
|
+
| `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
|
|
584
|
+
| `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` |
|
|
578
585
|
| `lint_config(config, print_output=True)` | Lint a config (path or open file object) → `LintReport` |
|
|
579
586
|
| `fix_config(config, write_back=True, print_output=True)` | Fix misplaced/redundant options (path only — writes back to the file) → `FixReport` |
|
|
580
587
|
| `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 |
|
|
581
588
|
| `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` |
|
|
582
589
|
| `ConfigLinter()` | Collect lint issues programmatically: `lint_file(config)`, `lint_yaml_string(content)`; results accumulate in `.issues` |
|
|
583
590
|
| `ConfigFixer()` | Fix a config programmatically: `fix_yaml_string(content)` → `FixReport`; actions recorded in `.fixes` |
|
|
584
|
-
| `LintIssue(severity, message,
|
|
585
|
-
| `FixIssue(message,
|
|
591
|
+
| `LintIssue(severity, message, project_name=None, key=None)` | One lint finding |
|
|
592
|
+
| `FixIssue(message, project_name=None)` | One applied fix |
|
|
586
593
|
| `LintReport(issues)` | Lint results: `.issues`, `.errors`, `.warnings`, `.redundant`, `.is_valid`, `.format_text()` |
|
|
587
594
|
| `FixReport(fixes, content, is_valid, error=None)` | Fix results: `.fixes`, `.content`, `.is_valid`, `.error`, `.format_text()` |
|
|
588
595
|
| `LintSeverity` | Severity levels used by `LintIssue`: `LintSeverity.ERROR`, `LintSeverity.WARNING`, `LintSeverity.REDUNDANT` |
|
|
@@ -83,7 +83,7 @@ gitacross --config config.yml --lint
|
|
|
83
83
|
gitacross --config config.yml
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
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.
|
|
87
87
|
|
|
88
88
|
## How it works
|
|
89
89
|
|
|
@@ -128,6 +128,13 @@ A config file starts with a `projects` list — each entry is one mirror and nee
|
|
|
128
128
|
|
|
129
129
|
Tokens use `${VAR}` syntax — resolved from environment variables.
|
|
130
130
|
|
|
131
|
+
For local **targets**, the `path` does not have to exist yet: if the directory is
|
|
132
|
+
missing or is not already a git repository, GitAcross creates the directory and
|
|
133
|
+
runs `git init` there before committing. If a repository already exists at that
|
|
134
|
+
path it is opened as-is — existing git metadata is never re-initialised or
|
|
135
|
+
overwritten (bare repositories and broken `.git` markers are refused with an
|
|
136
|
+
error). Local **sources** must point at an existing git repository.
|
|
137
|
+
|
|
131
138
|
| Option | Description |
|
|
132
139
|
|---|---|
|
|
133
140
|
| [`enabled`](#enabled) | Disable a project without deleting it |
|
|
@@ -147,11 +154,11 @@ Copy the source release notes/body to the target release. Default `true`. Set at
|
|
|
147
154
|
|
|
148
155
|
#### `release_description`
|
|
149
156
|
|
|
150
|
-
Format the target release notes from a template
|
|
157
|
+
Format the target release notes from a template. Placeholders: `{body}`, `{description}`, `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`.
|
|
151
158
|
|
|
152
159
|
#### `commit_message`
|
|
153
160
|
|
|
154
|
-
Custom commit message for the target commits
|
|
161
|
+
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}`.
|
|
155
162
|
|
|
156
163
|
#### `sync_assets`
|
|
157
164
|
|
|
@@ -407,13 +414,13 @@ GitAcross can be driven from the command line or called directly from Python.
|
|
|
407
414
|
### CLI
|
|
408
415
|
|
|
409
416
|
```
|
|
410
|
-
gitacross --config PATH [--project NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
|
|
417
|
+
gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
|
|
411
418
|
```
|
|
412
419
|
|
|
413
420
|
| Flag | Description |
|
|
414
421
|
|---|---|
|
|
415
422
|
| `--config PATH` | Config file to use (required) |
|
|
416
|
-
| `--project NAME` | Sync only
|
|
423
|
+
| `--project-name NAME` | Sync only the project with this name |
|
|
417
424
|
| `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
|
|
418
425
|
| `--dry-run` | Preview changes without committing or pushing |
|
|
419
426
|
| `--reset` | Clear saved state and cache before running (fresh start) |
|
|
@@ -448,7 +455,7 @@ for r in results:
|
|
|
448
455
|
# Preview only — nothing is committed or pushed
|
|
449
456
|
results = gitacross.run(
|
|
450
457
|
"config.yml",
|
|
451
|
-
|
|
458
|
+
project_name="my-project",
|
|
452
459
|
dry_run=True,
|
|
453
460
|
work_dir="/data/custom_dir",
|
|
454
461
|
)
|
|
@@ -472,9 +479,9 @@ results = gitacross.run("config.yml", reset=True)
|
|
|
472
479
|
```python
|
|
473
480
|
config = gitacross.Config("config.yml")
|
|
474
481
|
|
|
475
|
-
for
|
|
476
|
-
if
|
|
477
|
-
gitacross.sync_project(
|
|
482
|
+
for project_config in config.projects:
|
|
483
|
+
if project_config.enabled:
|
|
484
|
+
gitacross.sync_project(project_config, ".gitsync", dry_run=False)
|
|
478
485
|
```
|
|
479
486
|
|
|
480
487
|
</details>
|
|
@@ -484,7 +491,7 @@ for project in config.projects:
|
|
|
484
491
|
|
|
485
492
|
```python
|
|
486
493
|
# ${VAR} tokens still resolve from the environment
|
|
487
|
-
|
|
494
|
+
project_config = gitacross.ProjectConfig({
|
|
488
495
|
"name": "my-project",
|
|
489
496
|
"source": {
|
|
490
497
|
"type": "gitea",
|
|
@@ -499,7 +506,7 @@ project = gitacross.ProjectConfig({
|
|
|
499
506
|
"token": "${GITHUB_TOKEN}",
|
|
500
507
|
},
|
|
501
508
|
})
|
|
502
|
-
gitacross.sync_project(
|
|
509
|
+
gitacross.sync_project(project_config, ".gitsync")
|
|
503
510
|
```
|
|
504
511
|
|
|
505
512
|
</details>
|
|
@@ -544,16 +551,16 @@ All public symbols are importable directly from `gitacross`:
|
|
|
544
551
|
|
|
545
552
|
| Symbol | What it does |
|
|
546
553
|
|---|---|
|
|
547
|
-
| `run(config,
|
|
548
|
-
| `sync_project(
|
|
554
|
+
| `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
|
|
555
|
+
| `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` |
|
|
549
556
|
| `lint_config(config, print_output=True)` | Lint a config (path or open file object) → `LintReport` |
|
|
550
557
|
| `fix_config(config, write_back=True, print_output=True)` | Fix misplaced/redundant options (path only — writes back to the file) → `FixReport` |
|
|
551
558
|
| `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 |
|
|
552
559
|
| `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` |
|
|
553
560
|
| `ConfigLinter()` | Collect lint issues programmatically: `lint_file(config)`, `lint_yaml_string(content)`; results accumulate in `.issues` |
|
|
554
561
|
| `ConfigFixer()` | Fix a config programmatically: `fix_yaml_string(content)` → `FixReport`; actions recorded in `.fixes` |
|
|
555
|
-
| `LintIssue(severity, message,
|
|
556
|
-
| `FixIssue(message,
|
|
562
|
+
| `LintIssue(severity, message, project_name=None, key=None)` | One lint finding |
|
|
563
|
+
| `FixIssue(message, project_name=None)` | One applied fix |
|
|
557
564
|
| `LintReport(issues)` | Lint results: `.issues`, `.errors`, `.warnings`, `.redundant`, `.is_valid`, `.format_text()` |
|
|
558
565
|
| `FixReport(fixes, content, is_valid, error=None)` | Fix results: `.fixes`, `.content`, `.is_valid`, `.error`, `.format_text()` |
|
|
559
566
|
| `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.
|
|
7
|
+
version = "2.3.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"
|
|
@@ -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",
|
|
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
|
|
16
|
-
if
|
|
17
|
-
gitacross.sync_project(
|
|
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")
|
|
@@ -26,7 +26,7 @@ def main():
|
|
|
26
26
|
|
|
27
27
|
parser = argparse.ArgumentParser(description="Sync releases from source to target")
|
|
28
28
|
_ = parser.add_argument("--config", required=True, help="Path to config.yml")
|
|
29
|
-
_ = parser.add_argument("--project", help="Sync only this project (by name)")
|
|
29
|
+
_ = parser.add_argument("--project-name", help="Sync only this project (by name)")
|
|
30
30
|
_ = parser.add_argument(
|
|
31
31
|
"--dry-run", action="store_true", help="Print changes without pushing"
|
|
32
32
|
)
|
|
@@ -58,7 +58,7 @@ def main():
|
|
|
58
58
|
config_path = Path(cast(str, args.config))
|
|
59
59
|
fix_flag = cast(bool, args.fix)
|
|
60
60
|
lint_flag = cast(bool, args.lint)
|
|
61
|
-
|
|
61
|
+
project_name = None if args.project_name is None else cast(str, args.project_name)
|
|
62
62
|
dry_run_flag = cast(bool, args.dry_run)
|
|
63
63
|
reset_flag = cast(bool, args.reset)
|
|
64
64
|
workdir = cast(str, args.workdir)
|
|
@@ -92,7 +92,7 @@ def main():
|
|
|
92
92
|
try:
|
|
93
93
|
results = run(
|
|
94
94
|
config_path,
|
|
95
|
-
|
|
95
|
+
project_name=project_name,
|
|
96
96
|
dry_run=dry_run_flag,
|
|
97
97
|
reset=reset_flag,
|
|
98
98
|
work_dir=workdir,
|
|
@@ -22,10 +22,7 @@ VALID_PROJECT_KEYS = {
|
|
|
22
22
|
"sync_assets",
|
|
23
23
|
"stream_assets",
|
|
24
24
|
"commit_message",
|
|
25
|
-
"commit_template",
|
|
26
25
|
"release_description",
|
|
27
|
-
"release_notes_template",
|
|
28
|
-
"description_template",
|
|
29
26
|
}
|
|
30
27
|
|
|
31
28
|
KNOWN_SOURCE_KEYS = {
|
|
@@ -256,15 +253,10 @@ class ProjectConfig:
|
|
|
256
253
|
self.stream_assets = bool(raw.get("stream_assets", False))
|
|
257
254
|
|
|
258
255
|
# commit_message — project-level commit message template (e.g. "chore(sync): {tag}")
|
|
259
|
-
self.commit_message = raw.get("commit_message") or
|
|
256
|
+
self.commit_message = raw.get("commit_message") or None
|
|
260
257
|
|
|
261
258
|
# release_description — project-level release description template
|
|
262
|
-
self.release_description = (
|
|
263
|
-
raw.get("release_description")
|
|
264
|
-
or raw.get("release_notes_template")
|
|
265
|
-
or raw.get("description_template")
|
|
266
|
-
or None
|
|
267
|
-
)
|
|
259
|
+
self.release_description = raw.get("release_description") or None
|
|
268
260
|
|
|
269
261
|
def __repr__(self):
|
|
270
262
|
return (
|
|
@@ -309,9 +301,8 @@ def _cascade(raw_project, raw_source, raw_target, keys, default):
|
|
|
309
301
|
"""Return the first value found for any of *keys* across project, source, target.
|
|
310
302
|
|
|
311
303
|
Lookup order: project-level first (highest priority), then source-level,
|
|
312
|
-
then target-level, then *default*.
|
|
313
|
-
level
|
|
314
|
-
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.
|
|
315
306
|
"""
|
|
316
307
|
for raw in (raw_project, raw_source, raw_target):
|
|
317
308
|
for k in keys:
|
|
@@ -37,9 +37,16 @@ def _log_git_stderr(command, returncode, stderr, level):
|
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def _git(*args, check=True, input_data=None, text=True, env=None):
|
|
41
|
-
cmd = ["git"]
|
|
42
|
-
|
|
40
|
+
def _git(*args, check=True, input_data=None, text=True, env=None, safe_dir=None):
|
|
41
|
+
cmd = ["git"]
|
|
42
|
+
if safe_dir:
|
|
43
|
+
# Git refuses to operate in a repository owned by another user
|
|
44
|
+
# ("dubious ownership"). The bare mirrors under the cache dir are
|
|
45
|
+
# created and managed by gitacross itself, so whitelist exactly that
|
|
46
|
+
# directory for this one invocation via -c — never written to a config.
|
|
47
|
+
cmd += ["-c", f"safe.directory={Path(safe_dir).resolve()}"]
|
|
48
|
+
cmd += [str(a) for a in args]
|
|
49
|
+
logger.debug("> git %s", _redact(" ".join(str(a) for a in cmd[1:])))
|
|
43
50
|
try:
|
|
44
51
|
result = subprocess.run(
|
|
45
52
|
cmd,
|
|
@@ -56,6 +63,12 @@ def _git(*args, check=True, input_data=None, text=True, env=None):
|
|
|
56
63
|
if isinstance(part, str):
|
|
57
64
|
e.cmd[i] = _redact(part)
|
|
58
65
|
raise
|
|
66
|
+
except FileNotFoundError as exc:
|
|
67
|
+
# subprocess can't find the `git` binary at all — give a clear error
|
|
68
|
+
# instead of the raw "[Errno 2] No such file or directory: 'git'".
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"git executable not found — install Git and make sure it is on PATH."
|
|
71
|
+
) from exc
|
|
59
72
|
if result.returncode != 0:
|
|
60
73
|
# check=False path — callers inspect the return code themselves
|
|
61
74
|
_log_git_stderr(args[0], result.returncode, result.stderr, level=logging.DEBUG)
|
|
@@ -87,34 +100,117 @@ class GitRepo:
|
|
|
87
100
|
path = Path(dest)
|
|
88
101
|
if path.exists():
|
|
89
102
|
# Update remote URL *before* fetching so token rotation takes effect
|
|
90
|
-
current = _git(
|
|
103
|
+
current = _git(
|
|
104
|
+
"-C", str(path), "remote", "get-url", "origin",
|
|
105
|
+
check=False, safe_dir=path,
|
|
106
|
+
)
|
|
91
107
|
if current.returncode == 0 and current.stdout.strip() != url:
|
|
92
|
-
_ = _git(
|
|
108
|
+
_ = _git(
|
|
109
|
+
"-C", str(path), "remote", "set-url", "origin", url,
|
|
110
|
+
safe_dir=path,
|
|
111
|
+
)
|
|
93
112
|
logger.info("Updated remote URL for mirror at %s", dest)
|
|
94
|
-
_ = _git(
|
|
113
|
+
_ = _git(
|
|
114
|
+
"-C", str(path), "fetch", "--tags", "--prune", "origin",
|
|
115
|
+
safe_dir=path,
|
|
116
|
+
)
|
|
95
117
|
logger.info("Updated mirror at %s", dest)
|
|
96
118
|
else:
|
|
97
119
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
-
_ = _git("clone", "--mirror", url, str(path))
|
|
120
|
+
_ = _git("clone", "--mirror", url, str(path), safe_dir=path)
|
|
99
121
|
logger.info("Cloned mirror from %s", _redact(url))
|
|
100
122
|
# Ensure author identity for automated commits
|
|
101
|
-
_ = _git("-C", str(path), "config", "user.name", "GitAcross")
|
|
102
|
-
_ = _git("-C", str(path), "config", "user.email", "sync@gitacross")
|
|
123
|
+
_ = _git("-C", str(path), "config", "user.name", "GitAcross", safe_dir=path)
|
|
124
|
+
_ = _git("-C", str(path), "config", "user.email", "sync@gitacross", safe_dir=path)
|
|
103
125
|
return cls(path, is_bare=True)
|
|
104
126
|
|
|
105
127
|
@classmethod
|
|
106
128
|
def local(cls, path):
|
|
107
|
-
"""Open an existing local git repository.
|
|
129
|
+
"""Open an existing local git repository.
|
|
130
|
+
|
|
131
|
+
*path* must be the root of its own git working tree. Git resolves a
|
|
132
|
+
path that merely lives *inside* a repository to the nearest enclosing
|
|
133
|
+
repository, so without this check a configured path that is not itself
|
|
134
|
+
a git repository would silently commit to and reset that enclosing
|
|
135
|
+
repo — e.g. the directory the tool is being run from — instead of the
|
|
136
|
+
configured location.
|
|
137
|
+
"""
|
|
108
138
|
p = Path(path)
|
|
109
|
-
result = _git("-C", str(p), "rev-parse", "--
|
|
110
|
-
|
|
111
|
-
if not git_dir.exists():
|
|
139
|
+
result = _git("-C", str(p), "rev-parse", "--show-toplevel", check=False)
|
|
140
|
+
if result.returncode != 0:
|
|
112
141
|
raise ValueError(f"Not a git repository: {path}")
|
|
113
|
-
|
|
142
|
+
root = Path(result.stdout.strip())
|
|
143
|
+
if Path(p).resolve() != root.resolve():
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Not a git repository: {path} — it is inside the git repository "
|
|
146
|
+
+ f"'{root}'. A local endpoint path must point at a repository "
|
|
147
|
+
+ f"root; run 'git init' in {path} or point the path at {root}."
|
|
148
|
+
)
|
|
149
|
+
git_dir = _git("-C", str(root), "rev-parse", "--absolute-git-dir")
|
|
150
|
+
return cls(Path(git_dir.stdout.strip()), is_bare=False)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def ensure_local(cls, path):
|
|
154
|
+
"""Open a local git repository at *path*, creating it if necessary.
|
|
155
|
+
|
|
156
|
+
Unlike :meth:`local`, a missing directory or a plain directory that is
|
|
157
|
+
not yet a git repository is accepted: the directory is created
|
|
158
|
+
(including parents) and ``git init`` is run there. Used for local
|
|
159
|
+
*targets*, which receive commits. Local *sources* still go through
|
|
160
|
+
:meth:`local` so a misconfigured source path is reported instead of
|
|
161
|
+
silently yielding an empty repository.
|
|
162
|
+
|
|
163
|
+
An existing repository is opened as-is and never re-initialised. If
|
|
164
|
+
*path* already holds git metadata that cannot be used as a working tree
|
|
165
|
+
(a bare repository, a git-internal directory, or a broken ``.git``
|
|
166
|
+
marker), a :class:`ValueError` is raised instead of overwriting it.
|
|
167
|
+
"""
|
|
168
|
+
if not path or not str(path).strip():
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"Local target path is empty — set 'path' to the directory where "
|
|
171
|
+
+ "the mirrored repository should live."
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
return cls.local(path)
|
|
175
|
+
except ValueError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# Only auto-initialise when the directory is genuinely not a git
|
|
179
|
+
# repository yet — never run `git init` over existing git metadata
|
|
180
|
+
# (re-initialising a bare repo pollutes it with a nested .git).
|
|
181
|
+
p = Path(path)
|
|
182
|
+
if p.exists() and not p.is_dir():
|
|
183
|
+
raise ValueError(f"Local target path is not a directory: {path}")
|
|
184
|
+
if (p / ".git").exists():
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"Not initialising {path}: it already contains a '.git' entry "
|
|
187
|
+
+ "that is not a usable working tree. Refusing to overwrite it — "
|
|
188
|
+
+ "fix or remove that repository, or point the local target at a "
|
|
189
|
+
+ "missing or empty directory."
|
|
190
|
+
)
|
|
191
|
+
if (p / "HEAD").is_file() and (p / "objects").is_dir():
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Not initialising {path}: it is a bare git repository or git's "
|
|
194
|
+
+ "internal directory. Local targets need a working tree, and "
|
|
195
|
+
+ "existing repositories are never re-initialised."
|
|
196
|
+
)
|
|
197
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
_ = _git("init", str(p))
|
|
199
|
+
logger.info("Initialised git repository at %s", p)
|
|
200
|
+
return cls.local(path)
|
|
114
201
|
|
|
115
202
|
def _g(self, *args, text=True, env=None, **kwargs):
|
|
116
203
|
"""Run git command with --git-dir set."""
|
|
117
|
-
return _git(
|
|
204
|
+
return _git(
|
|
205
|
+
"--git-dir",
|
|
206
|
+
str(self.git_dir),
|
|
207
|
+
*args,
|
|
208
|
+
text=text,
|
|
209
|
+
env=env,
|
|
210
|
+
# Mirrors are gitacross-managed; whitelist them for the ownership check.
|
|
211
|
+
safe_dir=self.git_dir if self.is_bare else None,
|
|
212
|
+
**kwargs,
|
|
213
|
+
)
|
|
118
214
|
|
|
119
215
|
def _gw(self, work_dir, *args, env=None, **kwargs):
|
|
120
216
|
"""Run git command with --git-dir and --work-tree set."""
|
|
@@ -125,6 +221,8 @@ class GitRepo:
|
|
|
125
221
|
str(work_dir),
|
|
126
222
|
*args,
|
|
127
223
|
env=env,
|
|
224
|
+
# Mirrors are gitacross-managed; whitelist them for the ownership check.
|
|
225
|
+
safe_dir=self.git_dir if self.is_bare else None,
|
|
128
226
|
**kwargs,
|
|
129
227
|
)
|
|
130
228
|
|