gitacross 2.3.2__tar.gz → 2.4.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.3.2/src/gitacross.egg-info → gitacross-2.4.0}/PKG-INFO +11 -6
  2. {gitacross-2.3.2 → gitacross-2.4.0}/README.md +10 -5
  3. {gitacross-2.3.2 → gitacross-2.4.0}/pyproject.toml +1 -1
  4. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/fixer.py +10 -0
  5. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/validator.py +32 -5
  6. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/renderer.py +39 -4
  7. {gitacross-2.3.2 → gitacross-2.4.0/src/gitacross.egg-info}/PKG-INFO +11 -6
  8. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_linter.py +69 -1
  9. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_renderer.py +89 -0
  10. {gitacross-2.3.2 → gitacross-2.4.0}/LICENSE +0 -0
  11. {gitacross-2.3.2 → gitacross-2.4.0}/setup.cfg +0 -0
  12. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/__init__.py +0 -0
  13. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/__main__.py +0 -0
  14. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/cli.py +0 -0
  15. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/config.py +0 -0
  16. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/git.py +0 -0
  17. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/__init__.py +0 -0
  18. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/keys.py +0 -0
  19. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/models.py +0 -0
  20. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/main.py +0 -0
  21. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/providers/__init__.py +0 -0
  22. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/providers/base.py +0 -0
  23. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/providers/gitea.py +0 -0
  24. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/providers/github.py +0 -0
  25. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/retry.py +0 -0
  26. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/source.py +0 -0
  27. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/state.py +0 -0
  28. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/target.py +0 -0
  29. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/SOURCES.txt +0 -0
  30. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
  31. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/entry_points.txt +0 -0
  32. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/requires.txt +0 -0
  33. {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/top_level.txt +0 -0
  34. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_config.py +0 -0
  35. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_git.py +0 -0
  36. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_integration.py +0 -0
  37. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_providers.py +0 -0
  38. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_source.py +0 -0
  39. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_state.py +0 -0
  40. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_sync_api.py +0 -0
  41. {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_target.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacross
3
- Version: 2.3.2
3
+ Version: 2.4.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
@@ -390,17 +390,22 @@ In `regex` mode, backreferences (e.g. `\1`) are expanded before the casing adapt
390
390
  | Field | Required | Description |
391
391
  |---|---|---|
392
392
  | `path` | yes | File path to create (parent dirs auto-created) |
393
- | `content` | yes | File contents |
393
+ | `content` | one of | Inline file contents. Takes precedence over `src` when both are given |
394
+ | `src` | one of | Path of an existing **regular file** to copy in — binary-safe, mode & timestamps preserved (relative to where you run gitacross) |
395
+
396
+ Provide inline `content` or point `src` at an existing file (`content` wins if both are set — use `content: ""` for an empty file). Inline `content` is written as UTF-8 text with `\n` line endings on every OS. `src` copies the file in with `shutil.copy2` — no decode/encode round-trip and no newline translation — so binary assets work and the result is byte-identical on any platform. `src` must point to a single file, not a directory.
394
397
 
395
398
  ```yaml
396
399
  - add:
397
400
  - path: .github/FUNDING.yml
398
401
  content: |
399
402
  github: myuser
400
- - path: RELEASE_NOTES.md
401
- content: |
402
- # Release Notes
403
- ...
403
+ - path: LICENSE
404
+ src: ../templates/LICENSE # copy an existing file instead of inlining it
405
+ - path: assets/favicon.ico
406
+ src: ../../branding/favicon.ico # binary files work too
407
+ - path: tools/deploy.sh
408
+ src: ./scripts/deploy.sh # executable bit is preserved (copy2)
404
409
  ```
405
410
 
406
411
  #### `validate`
@@ -361,17 +361,22 @@ In `regex` mode, backreferences (e.g. `\1`) are expanded before the casing adapt
361
361
  | Field | Required | Description |
362
362
  |---|---|---|
363
363
  | `path` | yes | File path to create (parent dirs auto-created) |
364
- | `content` | yes | File contents |
364
+ | `content` | one of | Inline file contents. Takes precedence over `src` when both are given |
365
+ | `src` | one of | Path of an existing **regular file** to copy in — binary-safe, mode & timestamps preserved (relative to where you run gitacross) |
366
+
367
+ Provide inline `content` or point `src` at an existing file (`content` wins if both are set — use `content: ""` for an empty file). Inline `content` is written as UTF-8 text with `\n` line endings on every OS. `src` copies the file in with `shutil.copy2` — no decode/encode round-trip and no newline translation — so binary assets work and the result is byte-identical on any platform. `src` must point to a single file, not a directory.
365
368
 
366
369
  ```yaml
367
370
  - add:
368
371
  - path: .github/FUNDING.yml
369
372
  content: |
370
373
  github: myuser
371
- - path: RELEASE_NOTES.md
372
- content: |
373
- # Release Notes
374
- ...
374
+ - path: LICENSE
375
+ src: ../templates/LICENSE # copy an existing file instead of inlining it
376
+ - path: assets/favicon.ico
377
+ src: ../../branding/favicon.ico # binary files work too
378
+ - path: tools/deploy.sh
379
+ src: ./scripts/deploy.sh # executable bit is preserved (copy2)
375
380
  ```
376
381
 
377
382
  #### `validate`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitacross"
7
- version = "2.3.2"
7
+ version = "2.4.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"
@@ -244,6 +244,16 @@ class ConfigFixer:
244
244
  "Removed redundant 'case_sensitive: true' in validate operation",
245
245
  project_name=p_name,
246
246
  )
247
+ if (
248
+ op_type == "add"
249
+ and item.get("content") is not None
250
+ and item.get("src")
251
+ ):
252
+ del item["src"]
253
+ self._add(
254
+ "Removed redundant 'src' in add operation ('content' takes precedence)",
255
+ project_name=p_name,
256
+ )
247
257
 
248
258
 
249
259
 
@@ -693,14 +693,41 @@ class ConfigLinter:
693
693
 
694
694
  elif op_type == "add":
695
695
  for item in items:
696
- if (
697
- not isinstance(item, dict)
698
- or not item.get("path")
699
- or "content" not in item
696
+ if not isinstance(item, dict):
697
+ self._add(
698
+ LintSeverity.ERROR,
699
+ "Add operation item requires 'path' and either 'content' or 'src'.",
700
+ project_name=p_name,
701
+ )
702
+ continue
703
+ if not item.get("path") or (
704
+ item.get("content") is None and not item.get("src")
705
+ ):
706
+ self._add(
707
+ LintSeverity.ERROR,
708
+ "Add operation item requires 'path' and either 'content' or 'src'.",
709
+ project_name=p_name,
710
+ )
711
+ if item.get("content") is not None and not isinstance(
712
+ item["content"], str
700
713
  ):
701
714
  self._add(
702
715
  LintSeverity.ERROR,
703
- "Add operation item requires 'path' and 'content'.",
716
+ "Add option 'content' must be a string.",
717
+ project_name=p_name,
718
+ )
719
+ if item.get("src") is not None and not isinstance(
720
+ item["src"], str
721
+ ):
722
+ self._add(
723
+ LintSeverity.ERROR,
724
+ "Add option 'src' must be a string path to an existing file.",
725
+ project_name=p_name,
726
+ )
727
+ if item.get("content") is not None and item.get("src"):
728
+ self._add(
729
+ LintSeverity.REDUNDANT,
730
+ "'src' is ignored when 'content' is present in add operation.",
704
731
  project_name=p_name,
705
732
  )
706
733
 
@@ -196,9 +196,17 @@ def _match_case(matched, replacement):
196
196
  def _op_add(work, ops):
197
197
  """Create files in the work tree before commit.
198
198
 
199
- Each op: {path: str, content: str}
200
- Raises if the path already exists (avoids silently overwriting).
201
- Creates parent directories automatically.
199
+ Each op: {path: str, content?: str, src?: str}
200
+ - content: inline file contents — takes precedence over ``src`` when both
201
+ are given (even an empty string). Written as UTF-8 text with ``\n`` line
202
+ endings on every OS.
203
+ - src: path of an existing regular file to copy in — binary-safe (no
204
+ decode/encode round-trip) via :func:`shutil.copy2`, so the bytes as well
205
+ as the mode/timestamps are preserved. Directories are rejected; relative
206
+ paths resolve against the current working directory.
207
+ If neither is given the file is created empty. Raises if the path already
208
+ exists (avoids silently overwriting). Creates parent directories
209
+ automatically.
202
210
  """
203
211
  for op in ops:
204
212
  path = op["path"]
@@ -206,10 +214,37 @@ def _op_add(work, ops):
206
214
  if target.exists():
207
215
  raise RuntimeError(f"Add conflict: '{path}' already exists")
208
216
  target.parent.mkdir(parents=True, exist_ok=True)
209
- target.write_text(op.get("content", ""), encoding="utf-8")
217
+ content = op.get("content")
218
+ if content is not None:
219
+ _write_text_lf(target, content)
220
+ else:
221
+ src = op.get("src")
222
+ if not src:
223
+ _write_text_lf(target, "")
224
+ else:
225
+ source = Path(src)
226
+ if not source.is_file():
227
+ if source.exists():
228
+ raise RuntimeError(
229
+ f"Add failed: 'src' '{src}' is not a file (path: '{path}') — only regular files can be added"
230
+ )
231
+ raise RuntimeError(
232
+ f"Add failed: source file '{src}' not found for '{path}'"
233
+ )
234
+ _ = shutil.copy2(source, target)
210
235
  logger.debug("Added file: %s", path)
211
236
 
212
237
 
238
+ def _write_text_lf(path, text):
239
+ """Write *text* as UTF-8 with ``\n`` endings regardless of the host OS.
240
+
241
+ Text mode on Windows would otherwise translate ``\n`` to ``\r\n``
242
+ (``os.linesep``), making inline content depend on where gitacross runs.
243
+ """
244
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
245
+ _ = f.write(text)
246
+
247
+
213
248
  def _op_validate(work, ops):
214
249
  """Assert conditions on the final tree. Aborts on failure.
215
250
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacross
3
- Version: 2.3.2
3
+ Version: 2.4.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
@@ -390,17 +390,22 @@ In `regex` mode, backreferences (e.g. `\1`) are expanded before the casing adapt
390
390
  | Field | Required | Description |
391
391
  |---|---|---|
392
392
  | `path` | yes | File path to create (parent dirs auto-created) |
393
- | `content` | yes | File contents |
393
+ | `content` | one of | Inline file contents. Takes precedence over `src` when both are given |
394
+ | `src` | one of | Path of an existing **regular file** to copy in — binary-safe, mode & timestamps preserved (relative to where you run gitacross) |
395
+
396
+ Provide inline `content` or point `src` at an existing file (`content` wins if both are set — use `content: ""` for an empty file). Inline `content` is written as UTF-8 text with `\n` line endings on every OS. `src` copies the file in with `shutil.copy2` — no decode/encode round-trip and no newline translation — so binary assets work and the result is byte-identical on any platform. `src` must point to a single file, not a directory.
394
397
 
395
398
  ```yaml
396
399
  - add:
397
400
  - path: .github/FUNDING.yml
398
401
  content: |
399
402
  github: myuser
400
- - path: RELEASE_NOTES.md
401
- content: |
402
- # Release Notes
403
- ...
403
+ - path: LICENSE
404
+ src: ../templates/LICENSE # copy an existing file instead of inlining it
405
+ - path: assets/favicon.ico
406
+ src: ../../branding/favicon.ico # binary files work too
407
+ - path: tools/deploy.sh
408
+ src: ./scripts/deploy.sh # executable bit is preserved (copy2)
404
409
  ```
405
410
 
406
411
  #### `validate`
@@ -859,6 +859,9 @@ def test_linter_operations_kitchen_sink():
859
859
  - replace: [search, replace]
860
860
  - replace: [{search: a, replace: b, pattern: banana}]
861
861
  - add: [{}]
862
+ - add: [{path: no-body}]
863
+ - add: [{path: n, content: 123}]
864
+ - add: [{path: n, src: 123}]
862
865
  - validate: [not-a-dict]
863
866
  """)
864
867
  assert not report.is_valid
@@ -871,10 +874,35 @@ def test_linter_operations_kitchen_sink():
871
874
  assert "Rename pattern mode 'glob' is not supported" in msgs
872
875
  assert "Replace operation item requires 'search' and 'replace'" in msgs
873
876
  assert "Invalid replace pattern mode 'banana'" in msgs
874
- assert "Add operation item requires 'path' and 'content'" in msgs
877
+ assert "Add operation item requires 'path' and either 'content' or 'src'" in msgs
878
+ assert "Add option 'content' must be a string" in msgs
879
+ assert "Add option 'src' must be a string path" in msgs
875
880
  assert "Validate operation item requires 'path'" in msgs
876
881
  print(" ✓ linter: operation kitchen-sink validation")
877
882
 
883
+
884
+ def test_linter_add_src_and_precedence():
885
+ """add accepts 'content' or 'src'; both together is redundant, not an error."""
886
+ from gitacross.linter import ConfigLinter
887
+
888
+ linter = ConfigLinter()
889
+ report = linter.lint_yaml_string("""
890
+ - name: p
891
+ source: {type: local, path: /a}
892
+ target: {type: local, path: /b}
893
+ renderer:
894
+ operations:
895
+ - add: [{path: a.txt, content: hi}]
896
+ - add: [{path: b.txt, src: tmpl.txt}]
897
+ - add: [{path: c.txt, content: hi, src: tmpl.txt}]
898
+ - add: [{path: d.txt, content: ""}]
899
+ """)
900
+ assert report.is_valid # redundant 'src' + no type errors
901
+ red = " | ".join(r.message for r in report.redundant)
902
+ assert "'src' is ignored when 'content' is present" in red
903
+ assert len(report.errors) == 0
904
+ print(" ✓ linter: add accepts content or src (content wins when both given)")
905
+
878
906
  def test_fix_report_and_issue_formatting():
879
907
  """FixIssue strings and FixReport.format_text variants."""
880
908
  from gitacross.linter import FixIssue, FixReport
@@ -1029,3 +1057,43 @@ def test_fixer_missing_endpoint_sections():
1029
1057
  ).is_valid
1030
1058
  print(" ✓ fixer: missing endpoint sections and junk operations handled")
1031
1059
 
1060
+
1061
+ def test_fixer_add_drops_src_shadowed_by_content():
1062
+ """'src' is dead weight next to 'content' — --fix removes it, keeps src alone."""
1063
+ import yaml
1064
+
1065
+ from gitacross.linter import ConfigFixer
1066
+
1067
+ fixer = ConfigFixer()
1068
+ r = fixer.fix_yaml_string(
1069
+ "- name: p\n"
1070
+ + " source: {type: local, path: /a}\n"
1071
+ + " target: {type: local, path: /b}\n"
1072
+ + " renderer:\n"
1073
+ + " operations:\n"
1074
+ + " - add:\n"
1075
+ + " - path: a.txt\n"
1076
+ + " content: hi\n"
1077
+ + " src: tmpl.txt\n"
1078
+ + " - path: b.txt\n"
1079
+ + " src: tmpl.txt\n"
1080
+ )
1081
+ assert r.is_valid
1082
+ assert any("'src' in add operation" in f.message for f in r.fixes)
1083
+ adds = yaml.safe_load(r.content)[0]["renderer"]["operations"][0]["add"]
1084
+ assert "src" not in adds[0]
1085
+ assert adds[0]["content"] == "hi"
1086
+ assert adds[1]["src"] == "tmpl.txt" # src alone is untouched
1087
+
1088
+ # content: null does NOT shadow src → src kept
1089
+ r = fixer.fix_yaml_string(
1090
+ "- name: p\n"
1091
+ + " source: {type: local, path: /a}\n"
1092
+ + " target: {type: local, path: /b}\n"
1093
+ + " renderer:\n"
1094
+ + " operations:\n"
1095
+ + " - add: [{path: a.txt, content: null, src: tmpl.txt}]\n"
1096
+ )
1097
+ assert r.is_valid and not r.fixes
1098
+ print(" ✓ fixer: add op drops 'src' shadowed by 'content'")
1099
+
@@ -431,6 +431,95 @@ def test_renderer_add():
431
431
  print(" ✓ renderer add")
432
432
 
433
433
 
434
+ def test_renderer_add_from_src():
435
+ """add can copy an existing file via 'src'; inline 'content' wins over it."""
436
+ from gitacross.config import ProjectConfig
437
+ from gitacross.renderer import apply_operations
438
+
439
+ with tempfile.TemporaryDirectory() as tmp:
440
+ root = Path(tmp)
441
+ # Source template lives *outside* the exported work tree (as in real
442
+ # runs — the work tree is an ephemeral export, so 'src' resolves
443
+ # against the process working directory).
444
+ template = Path(tmp) / "FUNDING.tmpl"
445
+ _ = template.write_text("github: myuser\n", encoding="utf-8")
446
+ _ = _make_file(root, "README.md")
447
+
448
+ ops = [
449
+ {
450
+ "add": [
451
+ {"path": ".github/FUNDING.yml", "src": str(template)},
452
+ # content wins, even an empty string
453
+ {"path": "inline.txt", "content": "inline", "src": str(template)},
454
+ {"path": "empty.txt", "content": "", "src": str(template)},
455
+ ]
456
+ }
457
+ ]
458
+ project = ProjectConfig(_make_project_raw(ops))
459
+ apply_operations(root, project)
460
+
461
+ assert (root / ".github/FUNDING.yml").read_text() == "github: myuser\n"
462
+ assert (root / "inline.txt").read_text() == "inline"
463
+ assert (root / "empty.txt").read_text() == ""
464
+
465
+ # Missing source file -> loud error naming both paths
466
+ ops = [{"add": [{"path": "x.txt", "src": str(root / "nope.txt")}]}]
467
+ project = ProjectConfig(_make_project_raw(ops))
468
+ try:
469
+ apply_operations(root, project)
470
+ assert False, "should raise"
471
+ except RuntimeError as e:
472
+ assert "source file" in str(e) and "x.txt" in str(e)
473
+
474
+ # Directory as src -> explicit rejection, not a confusing copy
475
+ (root / "adir").mkdir()
476
+ ops = [{"add": [{"path": "y.txt", "src": str(root / "adir")}]}]
477
+ project = ProjectConfig(_make_project_raw(ops))
478
+ try:
479
+ apply_operations(root, project)
480
+ assert False, "should raise"
481
+ except RuntimeError as e:
482
+ assert "is not a file" in str(e) and "y.txt" in str(e)
483
+
484
+ print(" ✓ renderer add from src file")
485
+
486
+
487
+ def test_renderer_add_src_binary():
488
+ """src copies non-UTF-8/binary files byte-for-byte; copy2 keeps the mode."""
489
+ from gitacross.config import ProjectConfig
490
+ from gitacross.renderer import apply_operations
491
+
492
+ with tempfile.TemporaryDirectory() as tmp:
493
+ root = Path(tmp)
494
+ # Every byte value 0..255 (invalid UTF-8) + NULs + no trailing newline
495
+ payload = bytes(range(256)) * 3 + b"\x00\xfftail"
496
+ asset = root / "logo.bin"
497
+ _ = asset.write_bytes(payload)
498
+ _ = _make_file(root, "README.md")
499
+ if os.name != "nt": # exec bit is a POSIX concept
500
+ os.chmod(asset, 0o755)
501
+
502
+ ops = [{"add": [{"path": "assets/deep/logo.bin", "src": str(asset)}]}]
503
+ project = ProjectConfig(_make_project_raw(ops))
504
+ apply_operations(root, project)
505
+
506
+ copied = root / "assets/deep/logo.bin"
507
+ assert copied.exists()
508
+ assert copied.read_bytes() == payload # exact byte identity
509
+ assert asset.read_bytes() == payload # source untouched
510
+ assert (root / "README.md").exists()
511
+ if os.name != "nt":
512
+ assert os.stat(copied).st_mode & 0o777 == 0o755 # mode preserved
513
+
514
+ # empty add item (no content/src) still creates an empty file
515
+ ops = [{"add": [{"path": "bare.txt"}]}]
516
+ project = ProjectConfig(_make_project_raw(ops))
517
+ apply_operations(root, project)
518
+ assert (root / "bare.txt").read_bytes() == b""
519
+
520
+ print(" ✓ renderer add copies binary src byte-for-byte (mode preserved)")
521
+
522
+
434
523
  def test_renderer_validate_fail():
435
524
  from gitacross.config import ProjectConfig
436
525
  from gitacross.renderer import apply_operations
File without changes
File without changes
File without changes
File without changes