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.
- {gitacross-2.3.2/src/gitacross.egg-info → gitacross-2.4.0}/PKG-INFO +11 -6
- {gitacross-2.3.2 → gitacross-2.4.0}/README.md +10 -5
- {gitacross-2.3.2 → gitacross-2.4.0}/pyproject.toml +1 -1
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/fixer.py +10 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/validator.py +32 -5
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/renderer.py +39 -4
- {gitacross-2.3.2 → gitacross-2.4.0/src/gitacross.egg-info}/PKG-INFO +11 -6
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_linter.py +69 -1
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_renderer.py +89 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/LICENSE +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/setup.cfg +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/__init__.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/__main__.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/cli.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/config.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/git.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/__init__.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/keys.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/linter/models.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/main.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/providers/__init__.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/providers/base.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/providers/gitea.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/providers/github.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/retry.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/source.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/state.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross/target.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/SOURCES.txt +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/entry_points.txt +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/requires.txt +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/src/gitacross.egg-info/top_level.txt +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_config.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_git.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_integration.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_providers.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_source.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_state.py +0 -0
- {gitacross-2.3.2 → gitacross-2.4.0}/tests/test_sync_api.py +0 -0
- {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
|
+
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` |
|
|
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:
|
|
401
|
-
|
|
402
|
-
|
|
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` |
|
|
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:
|
|
372
|
-
|
|
373
|
-
|
|
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.
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
+
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` |
|
|
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:
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|