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.
Files changed (41) hide show
  1. {gitacross-2.1.0/src/gitacross.egg-info → gitacross-2.3.0}/PKG-INFO +23 -16
  2. {gitacross-2.1.0 → gitacross-2.3.0}/README.md +22 -15
  3. {gitacross-2.1.0 → gitacross-2.3.0}/pyproject.toml +1 -1
  4. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/__init__.py +4 -4
  5. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/cli.py +3 -3
  6. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/config.py +4 -13
  7. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/git.py +113 -15
  8. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/fixer.py +26 -26
  9. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/models.py +8 -8
  10. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/validator.py +82 -88
  11. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/main.py +50 -50
  12. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/renderer.py +3 -3
  13. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/target.py +7 -2
  14. {gitacross-2.1.0 → gitacross-2.3.0/src/gitacross.egg-info}/PKG-INFO +23 -16
  15. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_git.py +172 -0
  16. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_integration.py +223 -11
  17. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_linter.py +4 -3
  18. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_source.py +30 -12
  19. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_sync_api.py +32 -9
  20. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_target.py +94 -0
  21. {gitacross-2.1.0 → gitacross-2.3.0}/LICENSE +0 -0
  22. {gitacross-2.1.0 → gitacross-2.3.0}/setup.cfg +0 -0
  23. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/__main__.py +0 -0
  24. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/__init__.py +0 -0
  25. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/linter/keys.py +0 -0
  26. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/providers/__init__.py +0 -0
  27. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/providers/base.py +0 -0
  28. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/providers/gitea.py +0 -0
  29. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/providers/github.py +0 -0
  30. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/retry.py +0 -0
  31. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/source.py +0 -0
  32. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross/state.py +0 -0
  33. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/SOURCES.txt +0 -0
  34. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
  35. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/entry_points.txt +0 -0
  36. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/requires.txt +0 -0
  37. {gitacross-2.1.0 → gitacross-2.3.0}/src/gitacross.egg-info/top_level.txt +0 -0
  38. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_config.py +0 -0
  39. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_providers.py +0 -0
  40. {gitacross-2.1.0 → gitacross-2.3.0}/tests/test_renderer.py +0 -0
  41. {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.1.0
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 (aliases: `release_notes_template`, `description_template`). Placeholders: `{body}`, `{description}`, `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`.
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 (alias: `commit_template`). Default: `"Release {tag}"` or `"Sync commit {short_sha}"`. Placeholders: `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`, `{body}`, `{description}`.
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 this project |
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
- project="my-project",
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 project in config.projects:
505
- if project.enabled:
506
- gitacross.sync_project(project, ".gitsync", dry_run=False)
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
- project = gitacross.ProjectConfig({
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(project, ".gitsync")
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, 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
577
- | `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` |
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, project=None, key=None)` | One lint finding |
585
- | `FixIssue(message, project=None)` | One applied fix |
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 (aliases: `release_notes_template`, `description_template`). Placeholders: `{body}`, `{description}`, `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`.
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 (alias: `commit_template`). Default: `"Release {tag}"` or `"Sync commit {short_sha}"`. Placeholders: `{tag}`, `{commit_sha}`, `{short_sha}`, `{project_name}`, `{name}`, `{source_date}`, `{body}`, `{description}`.
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 this project |
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
- project="my-project",
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 project in config.projects:
476
- if project.enabled:
477
- gitacross.sync_project(project, ".gitsync", dry_run=False)
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
- project = gitacross.ProjectConfig({
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(project, ".gitsync")
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, 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
548
- | `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` |
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, project=None, key=None)` | One lint finding |
556
- | `FixIssue(message, project=None)` | One applied fix |
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.1.0"
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", 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")
@@ -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
- project_filter = None if args.project is None else cast(str, args.project)
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
- project=project_filter,
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 raw.get("commit_template") or None
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*. All alias keys are checked at each
313
- level before moving to the next — so a project-level alias wins over a
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"] + [str(a) for a in args]
42
- logger.debug("> git %s", _redact(" ".join(str(a) for a in args)))
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("-C", str(path), "remote", "get-url", "origin", check=False)
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("-C", str(path), "remote", "set-url", "origin", url)
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("-C", str(path), "fetch", "--tags", "--prune", "origin")
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", "--git-dir")
110
- git_dir = p / result.stdout.strip()
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
- return cls(git_dir, is_bare=False)
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("--git-dir", str(self.git_dir), *args, text=text, env=env, **kwargs)
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