gitacross 2.3.1__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.1/src/gitacross.egg-info → gitacross-2.4.0}/PKG-INFO +12 -7
  2. {gitacross-2.3.1 → gitacross-2.4.0}/README.md +11 -6
  3. {gitacross-2.3.1 → gitacross-2.4.0}/pyproject.toml +1 -1
  4. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/fixer.py +10 -0
  5. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/validator.py +32 -5
  6. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/renderer.py +39 -4
  7. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/source.py +110 -7
  8. {gitacross-2.3.1 → gitacross-2.4.0/src/gitacross.egg-info}/PKG-INFO +12 -7
  9. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_linter.py +69 -1
  10. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_renderer.py +89 -0
  11. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_source.py +163 -0
  12. {gitacross-2.3.1 → gitacross-2.4.0}/LICENSE +0 -0
  13. {gitacross-2.3.1 → gitacross-2.4.0}/setup.cfg +0 -0
  14. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/__init__.py +0 -0
  15. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/__main__.py +0 -0
  16. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/cli.py +0 -0
  17. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/config.py +0 -0
  18. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/git.py +0 -0
  19. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/__init__.py +0 -0
  20. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/keys.py +0 -0
  21. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/models.py +0 -0
  22. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/main.py +0 -0
  23. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/providers/__init__.py +0 -0
  24. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/providers/base.py +0 -0
  25. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/providers/gitea.py +0 -0
  26. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/providers/github.py +0 -0
  27. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/retry.py +0 -0
  28. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/state.py +0 -0
  29. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/target.py +0 -0
  30. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/SOURCES.txt +0 -0
  31. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
  32. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/entry_points.txt +0 -0
  33. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/requires.txt +0 -0
  34. {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/top_level.txt +0 -0
  35. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_config.py +0 -0
  36. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_git.py +0 -0
  37. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_integration.py +0 -0
  38. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_providers.py +0 -0
  39. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_state.py +0 -0
  40. {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_sync_api.py +0 -0
  41. {gitacross-2.3.1 → 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.1
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
@@ -223,7 +223,7 @@ Stream each asset upload directly from the temporary download directory on disk
223
223
 
224
224
  ### Source mode: release, tag, or commit
225
225
 
226
- Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced.
226
+ Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced. If the exact tag no longer exists (renamed or deleted upstream), releases with a **higher version** are synced instead, so an absent anchor never blocks newer releases. Both SemVer tags (`v1.2.3`) and calendar tags (`2024.05.01`, `2024-05-01`) are compared version-wise; the two schemes are never confused with each other.
227
227
 
228
228
  Synced state is keyed by **tag name**, so switching a repo between `release` and `tag` modes is safe: already-synced tags are skipped regardless of the current mode (older state files keyed by API release id are migrated automatically).
229
229
 
@@ -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`
@@ -194,7 +194,7 @@ Stream each asset upload directly from the temporary download directory on disk
194
194
 
195
195
  ### Source mode: release, tag, or commit
196
196
 
197
- Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced.
197
+ Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced. If the exact tag no longer exists (renamed or deleted upstream), releases with a **higher version** are synced instead, so an absent anchor never blocks newer releases. Both SemVer tags (`v1.2.3`) and calendar tags (`2024.05.01`, `2024-05-01`) are compared version-wise; the two schemes are never confused with each other.
198
198
 
199
199
  Synced state is keyed by **tag name**, so switching a repo between `release` and `tag` modes is safe: already-synced tags are skipped regardless of the current mode (older state files keyed by API release id are migrated automatically).
200
200
 
@@ -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.1"
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
 
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import fnmatch
8
8
  import logging
9
+ import re
9
10
  from pathlib import Path
10
11
  from typing import final
11
12
 
@@ -31,20 +32,122 @@ def create_source(config, cache_dir, dry_run=False):
31
32
  )
32
33
 
33
34
 
34
- def _filter_from_sync_point(releases, sync_from):
35
- """Filter to releases at or after the sync_from tag (inclusive).
35
+ # Calendar versions start with a 4-digit year (YYYY[.MM[.DD[...]]], e.g.
36
+ # 2024.05.01 or 2024-05-01). Every dot/dash/underscore-separated numeric
37
+ # field is part of the date sequence.
38
+ _CALENDAR_RE = re.compile(r"[^0-9]*(\d{4}(?:[._-]\d{1,4})*)")
39
+ # SemVer-style cores are dot-separated numbers; a hyphen introduces a
40
+ # prerelease, never another core field.
41
+ _SEMVER_CORE_RE = re.compile(r"(\d+(?:\.\d+)*)")
42
+
43
+
44
+ def _version_key(tag):
45
+ """Parse a version-like tag into a comparable ``(fields, prerelease)`` key.
46
+
47
+ Two schemes are supported, parsed separately because they are semantically
48
+ different:
36
49
 
37
- Releases must already be sorted oldest-first. If sync_from is empty
38
- return all releases. If the tag isn't found, return nothing —
39
- better to skip than silently backfill history.
50
+ * **SemVer** — ``v1.2.3``, ``v1.2.3-rc.1``. Dot-separated core; a hyphen
51
+ starts a prerelease that sorts before the final release.
52
+ * **Calendar (CalVer)** — ``2024.05.01``, ``2024-05-01``, ``2024.05``.
53
+ Date-based: a leading 4-digit year marks it, and every numeric field
54
+ (dot *or* dash separated) belongs to the (year, month, day, ...)
55
+ sequence, compared as plain numbers. No major/minor/patch or SemVer
56
+ prerelease semantics apply to the date fields themselves.
57
+
58
+ Returns ``None`` when the tag carries no parseable version.
59
+ """
60
+ if not isinstance(tag, str):
61
+ return None
62
+ calendar = _CALENDAR_RE.match(tag)
63
+ if calendar:
64
+ fields = tuple(int(part) for part in re.split(r"[._-]", calendar.group(1)))
65
+ rest = tag[calendar.end():].split("+", 1)[0]
66
+ else:
67
+ match = _SEMVER_CORE_RE.search(tag)
68
+ if not match:
69
+ return None
70
+ fields = tuple(int(part) for part in match.group(1).split("."))
71
+ rest = tag[match.end():].split("+", 1)[0]
72
+ # Anything remaining after the version fields is a prerelease; build
73
+ # metadata ("+...") is ignored for ordering, per SemVer.
74
+ ids = tuple(re.findall(r"[0-9A-Za-z]+", rest))
75
+ return fields, (ids or None)
76
+
77
+
78
+ def _cmp_version_key(a, b):
79
+ """Compare two ``_version_key`` results: -1/0/1 (SemVer-ish ordering)."""
80
+ (ca, pa), (cb, pb) = a, b
81
+ width = max(len(ca), len(cb))
82
+ ca += (0,) * (width - len(ca))
83
+ cb += (0,) * (width - len(cb))
84
+ if ca != cb:
85
+ return 1 if ca > cb else -1
86
+ # Same core: a final release outranks any prerelease of it
87
+ if pa is None and pb is None:
88
+ return 0
89
+ if pa is None:
90
+ return 1
91
+ if pb is None:
92
+ return -1
93
+ # Compare prerelease identifiers: numeric < alphanumeric, identifiers
94
+ # compare numerically when both numeric, lexically when both alphanumeric
95
+ for x, y in zip(pa, pb):
96
+ xn, yn = x.isdigit(), y.isdigit()
97
+ if xn and yn:
98
+ xi, yi = int(x), int(y)
99
+ if xi != yi:
100
+ return 1 if xi > yi else -1
101
+ elif xn != yn:
102
+ return -1 if xn else 1
103
+ elif x != y:
104
+ return 1 if x > y else -1
105
+ return 1 if len(pa) > len(pb) else (-1 if len(pa) < len(pb) else 0)
106
+
107
+
108
+ def _newer_than_version(tag, cutoff):
109
+ """True when *tag* parses as a version strictly newer than *cutoff*."""
110
+ key = _version_key(tag)
111
+ return key is not None and _cmp_version_key(key, cutoff) > 0
112
+
113
+
114
+ def _filter_from_sync_point(releases, sync_from):
115
+ """Filter to releases at or after the sync_from tag.
116
+
117
+ Releases must already be sorted oldest-first. If sync_from is empty return
118
+ all releases. When the exact tag is present, everything from it onwards is
119
+ included; when it is missing (renamed, deleted, or never released), releases
120
+ whose version is strictly newer than sync_from are synced instead. Tags that
121
+ cannot be compared (no version digits) are skipped rather than silently
122
+ backfilling history.
40
123
  """
41
124
  if not sync_from:
42
125
  return releases
43
126
  for i, rel in enumerate(releases):
44
127
  if rel["tag_name"] == sync_from:
45
128
  return releases[i:]
46
- logger.warning("sync_from tag '%s' not found — syncing nothing", sync_from)
47
- return []
129
+
130
+ # Exact tag not found — fall back to releases with a newer version.
131
+ cutoff = _version_key(sync_from)
132
+ if cutoff is None:
133
+ logger.warning(
134
+ "sync_from '%s' not found and is not version-like — syncing nothing",
135
+ sync_from,
136
+ )
137
+ return []
138
+ newer = [r for r in releases if _newer_than_version(r.get("tag_name"), cutoff)]
139
+ if not newer:
140
+ logger.warning(
141
+ "sync_from tag '%s' not found and no releases are newer — syncing nothing",
142
+ sync_from,
143
+ )
144
+ else:
145
+ logger.info(
146
+ "sync_from tag '%s' not found — syncing %d release(s) newer than it",
147
+ sync_from,
148
+ len(newer),
149
+ )
150
+ return newer
48
151
 
49
152
 
50
153
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacross
3
- Version: 2.3.1
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
@@ -223,7 +223,7 @@ Stream each asset upload directly from the temporary download directory on disk
223
223
 
224
224
  ### Source mode: release, tag, or commit
225
225
 
226
- Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced.
226
+ Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced. If the exact tag no longer exists (renamed or deleted upstream), releases with a **higher version** are synced instead, so an absent anchor never blocks newer releases. Both SemVer tags (`v1.2.3`) and calendar tags (`2024.05.01`, `2024-05-01`) are compared version-wise; the two schemes are never confused with each other.
227
227
 
228
228
  Synced state is keyed by **tag name**, so switching a repo between `release` and `tag` modes is safe: already-synced tags are skipped regardless of the current mode (older state files keyed by API release id are migrated automatically).
229
229
 
@@ -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
@@ -103,9 +103,172 @@ def test_source_local_sync_from():
103
103
  releases2 = src2.fetch_releases()
104
104
  assert releases2 == []
105
105
 
106
+ # sync_from = missing tag but with newer releases present — the newer
107
+ # releases are synced instead of syncing nothing
108
+ cfg3 = _EndpointConfig(
109
+ {
110
+ "type": "local",
111
+ "path": str(repo),
112
+ "tag_pattern": "v*",
113
+ "sync_from": "v1.5",
114
+ },
115
+ is_source=True,
116
+ )
117
+ src3 = create_source(cfg3, tmp)
118
+ tags3 = [r["tag_name"] for r in src3.fetch_releases()]
119
+ assert tags3 == ["v2.0", "v3.0"]
120
+
106
121
  print(" ✓ source: local sync_from")
107
122
 
108
123
 
124
+ def test_filter_from_sync_point_fallback_newer_versions():
125
+ """A missing sync_from tag falls back to releases with a newer version."""
126
+ from gitacross.source import _filter_from_sync_point
127
+
128
+ def rel(tag):
129
+ return {"tag_name": tag}
130
+
131
+ all_rel = [rel("v0.8.0"), rel("v0.9.0"), rel("v1.0.0"), rel("v1.2.0"), rel("v2.0.0")]
132
+
133
+ # Exact match: everything from the tag onwards (unchanged behaviour)
134
+ assert [r["tag_name"] for r in _filter_from_sync_point(all_rel, "v0.9.0")] == [
135
+ "v0.9.0", "v1.0.0", "v1.2.0", "v2.0.0",
136
+ ]
137
+
138
+ # Missing tag: only strictly-newer versions are kept (v0.8.0 < v0.9.0)
139
+ assert [r["tag_name"] for r in _filter_from_sync_point(all_rel, "v0.9.1")] == [
140
+ "v1.0.0", "v1.2.0", "v2.0.0",
141
+ ]
142
+
143
+ # Numeric (not lexicographic) comparison: v0.10.0 > v0.9.9
144
+ assert [
145
+ r["tag_name"]
146
+ for r in _filter_from_sync_point([rel("v0.10.0"), rel("v0.9.0")], "v0.9.9")
147
+ ] == ["v0.10.0"]
148
+
149
+ # Zero-padding: v1.2 == v1.2.0, so it is NOT counted as newer
150
+ assert [
151
+ r["tag_name"]
152
+ for r in _filter_from_sync_point([rel("v1.2"), rel("v1.3")], "v1.2.0")
153
+ ] == ["v1.3"]
154
+
155
+ # No newer release and nothing comparable -> nothing synced
156
+ assert _filter_from_sync_point([rel("v0.5.0")], "v0.9.0") == []
157
+ assert _filter_from_sync_point([rel("v0.9.0"), rel("latest")], "v0.9.1") == []
158
+
159
+ # A non-version sync_from cannot be compared -> nothing synced
160
+ assert _filter_from_sync_point(all_rel, "alpha") == []
161
+
162
+ # Unversioned / non-string release tags are never counted as newer
163
+ assert _filter_from_sync_point(
164
+ [rel("latest"), {"tag_name": None}, rel("v1.0.0")], "v0.9.0"
165
+ ) == [rel("v1.0.0")]
166
+
167
+ # SemVer: a prerelease of a version is OLDER than its final release, so a
168
+ # missing v1.2.3 never pulls in v1.2.3-rc.1 — only truly newer versions do
169
+ assert [
170
+ r["tag_name"]
171
+ for r in _filter_from_sync_point([rel("v1.2.3-rc.1"), rel("v1.2.4")], "v1.2.3")
172
+ ] == ["v1.2.4"]
173
+
174
+ # Prerelease identifier ordering: numeric pre identifiers compare
175
+ # numerically, and a final release outranks any prerelease of its version
176
+ assert [
177
+ r["tag_name"]
178
+ for r in _filter_from_sync_point(
179
+ [rel("v1.2.3-rc.2"), rel("v1.2.3")], "v1.2.3-rc.1"
180
+ )
181
+ ] == ["v1.2.3-rc.2", "v1.2.3"]
182
+
183
+ # SemVer: numeric prerelease identifiers sort BEFORE alphanumeric ones, and
184
+ # a final release outranks every prerelease of its version
185
+ assert [
186
+ r["tag_name"]
187
+ for r in _filter_from_sync_point(
188
+ [rel("v1.0.0-1"), rel("v1.0.0-10"), rel("v1.0.0-beta"), rel("v1.0.0")],
189
+ "v1.0.0-2",
190
+ )
191
+ ] == ["v1.0.0-10", "v1.0.0-beta", "v1.0.0"]
192
+
193
+ # Build metadata is ignored for ordering
194
+ assert [
195
+ r["tag_name"]
196
+ for r in _filter_from_sync_point([rel("v1.2.3+meta"), rel("v1.2.4")], "v1.2.3")
197
+ ] == ["v1.2.4"]
198
+
199
+ # Alphanumeric prerelease identifiers compare lexically (beta > alpha), and
200
+ # longer identifier lists after an equal prefix sort later
201
+ assert [
202
+ r["tag_name"]
203
+ for r in _filter_from_sync_point(
204
+ [rel("v1.0.0-alpha.1"), rel("v1.0.0-beta"), rel("v1.0.0")],
205
+ "v1.0.0-alpha",
206
+ )
207
+ ] == ["v1.0.0-alpha.1", "v1.0.0-beta", "v1.0.0"]
208
+ assert [
209
+ r["tag_name"]
210
+ for r in _filter_from_sync_point(
211
+ [
212
+ rel("v1.0.0-alpha"),
213
+ rel("v1.0.0-beta"),
214
+ rel("v1.0.0-alpha.2"),
215
+ rel("v1.0.0-alpha.1.beta"),
216
+ ],
217
+ "v1.0.0-alpha.1",
218
+ )
219
+ ] == ["v1.0.0-beta", "v1.0.0-alpha.2", "v1.0.0-alpha.1.beta"]
220
+
221
+ # ── Calendar versions (CalVer) are date-based, not SemVer ──
222
+ # Dot-shaped dates compare as plain (year, month, day) numbers
223
+ assert [
224
+ r["tag_name"]
225
+ for r in _filter_from_sync_point(
226
+ [rel("2024.01.15"), rel("2024.04.30"), rel("2024.05.01")], "2024.04.01"
227
+ )
228
+ ] == ["2024.04.30", "2024.05.01"]
229
+
230
+ # Dash-separated dates: '-' joins date fields; it is NOT a prerelease
231
+ # marker, so 2024-06-01 is newer than 2024-05-01
232
+ assert [
233
+ r["tag_name"]
234
+ for r in _filter_from_sync_point(
235
+ [rel("2024-04-15"), rel("2024-06-01")], "2024-05-01"
236
+ )
237
+ ] == ["2024-06-01"]
238
+
239
+ # Month-only anchor (2024.05 = May) vs day releases
240
+ assert [
241
+ r["tag_name"]
242
+ for r in _filter_from_sync_point([rel("2024.04"), rel("2024.05.10")], "2024.05")
243
+ ] == ["2024.05.10"]
244
+
245
+ # A prerelease of the same calendar date is not newer than the date itself
246
+ assert [
247
+ r["tag_name"]
248
+ for r in _filter_from_sync_point(
249
+ [rel("2024.05.01-rc.1"), rel("2024.05.02")], "2024.05.01"
250
+ )
251
+ ] == ["2024.05.02"]
252
+
253
+ # Scheme switch on one repo: a 2024.x calendar tag clears a v2-era SemVer
254
+ # anchor, while a v9.x tag never clears a 2024 calendar anchor
255
+ assert [
256
+ r["tag_name"]
257
+ for r in _filter_from_sync_point([rel("v2.9.0"), rel("2024.01.15")], "v2.9.9")
258
+ ] == ["2024.01.15"]
259
+ assert [
260
+ r["tag_name"]
261
+ for r in _filter_from_sync_point(
262
+ [rel("2024.02.01"), rel("v9.9.9")], "2024.01.15"
263
+ )
264
+ ] == ["2024.02.01"]
265
+
266
+ # No sync_from -> everything
267
+ assert len(_filter_from_sync_point(all_rel, "")) == 5
268
+
269
+ print(" ✓ source: missing sync_from falls back to newer versions")
270
+
271
+
109
272
  def test_source_remote_mode_tag_sync_from():
110
273
  """mode: tag reads releases straight from git tags, no API involved."""
111
274
  from unittest import mock
File without changes
File without changes
File without changes
File without changes