diff --git a/docs/BUNDLE_GUIDE.md b/docs/BUNDLE_GUIDE.md index be01701..df88b82 100644 --- a/docs/BUNDLE_GUIDE.md +++ b/docs/BUNDLE_GUIDE.md @@ -1266,10 +1266,13 @@ Bundles support multiple source formats for modules: | Local path | `./modules/my-module` | Modules within the bundle | | Relative path | `../shared-module` | Sibling directories | | Git URL | `git+https://github.com/org/repo@main` | External modules | +| Git pinned to commit | `git+https://github.com/org/repo@32d4052dad46016f91ce698646580473e4121344` | Reproducible installs (eval runs, CI) | | Git with subpath | `git+https://github.com/org/repo@main#subdirectory=modules/foo` | Module within larger repo | **Local paths are resolved relative to the bundle's location.** +**Commit SHA pinning**: A ref that is a full 40-character hex commit SHA pins the source to that exact commit. Pinned sources are automatically skipped by `amplifier update` (a pinned ref can never have updates). Short/abbreviated SHAs are rejected by design — an ambiguous ref defeats reproducibility. Note that a typo'd but valid-looking SHA fails only after a full-history clone fallback: the fast single-commit fetch fails, git falls back to a full clone, and checkout then fails with a clear error (a failed pinned resolve can leave a cache entry behind, and a subsequent resolve of the same URI may serve it — clear the cache entry if a pin fails). + --- ## Composition with includes: diff --git a/docs/URI_FORMATS.md b/docs/URI_FORMATS.md index aea97bb..d689fe7 100644 --- a/docs/URI_FORMATS.md +++ b/docs/URI_FORMATS.md @@ -10,8 +10,17 @@ Quick reference for source URIs. For complete details, see `parse_uri()` docstri | **Local directory** | `/path/to/bundle/` (finds `bundle.md` inside) | | **Git HTTPS** | `git+https://github.com/org/repo@main` | | **Git SSH** | `git+ssh://git@github.com/org/repo@main` | +| **Git pinned to commit** | `git+https://github.com/org/repo@32d4052dad46016f91ce698646580473e4121344` | | **Subdirectory** | `git+https://github.com/org/repo@main#subdirectory=path/to/bundle` | +### Pinning to a Commit SHA + +A ref that is a full 40-character hex commit SHA pins the source to that exact commit. Use this for reproducible installs — evaluation runs, CI, anywhere "same URI, same code" must hold. + +- Pinned sources are automatically **skipped by `amplifier update`** — a pinned ref can never have updates. +- Short/abbreviated SHAs are **rejected by design**: an ambiguous ref defeats reproducibility. Use the full 40 characters. +- A typo'd but valid-looking SHA is expensive: the fast single-commit fetch fails, git falls back to a **full-history clone**, and checkout then fails with a clear error (a failed pinned resolve can leave a cache entry behind, and a subsequent resolve of the same URI may serve it — clear the cache entry if a pin fails). A wrong SHA costs a full-history download before it errors. + ## Common Examples ```python diff --git a/tests/test_sources.py b/tests/test_sources.py index 5576a5b..148b60a 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -12,6 +12,7 @@ from amplifier_foundation.sources.git import GitSourceHandler from amplifier_foundation.sources.git import _is_full_commit_sha from amplifier_foundation.sources.http import HttpSourceHandler +from amplifier_foundation.sources.protocol import SourceStatus from amplifier_foundation.sources.zip import ZipSourceHandler @@ -375,6 +376,50 @@ def test_rejects_branch_names(self) -> None: assert _is_full_commit_sha("v1.0.0") is False +class TestShaPredicatesAgree: + """Invariant: the full-SHA rule is encoded twice and must not drift. + + `_is_full_commit_sha` (regex, sources/git.py) and `SourceStatus.is_pinned` + (length + charset, sources/protocol.py) independently encode "40-char hex + commit SHA". A code comment claims they agree; this test checks it. + + Note: `is_pinned` additionally treats v-prefixed version tags as pinned. + That branch is deliberate divergence outside the shared SHA rule, so + tag-shaped refs are excluded from the equality set and covered separately. + """ + + @pytest.mark.parametrize( + "ref", + [ + "32d4052dad46016f91ce698646580473e4121344", # 40-hex lowercase + "32D4052DAD46016F91CE698646580473E4121344", # 40-hex uppercase + "32D4052dad46016F91ce698646580473E4121344", # 40-hex mixed case + "3" * 39, # one char too short + "3" * 41, # one char too long + "g" + "3" * 39, # 40 chars, non-hex + "main", # branch name + "feat/some-branch", # branch name with slash + ], + ) + def test_predicates_agree_on_sha_classification(self, ref: str) -> None: + status = SourceStatus( + source_uri=f"git+https://example.com/org/repo@{ref}", + is_cached=True, + cached_ref=ref, + ) + assert _is_full_commit_sha(ref) == status.is_pinned + + def test_version_tag_is_pinned_but_not_a_sha(self) -> None: + """Documents the intentional divergence: tags pin without being SHAs.""" + status = SourceStatus( + source_uri="git+https://example.com/org/repo@v1.0.0", + is_cached=True, + cached_ref="v1.0.0", + ) + assert _is_full_commit_sha("v1.0.0") is False + assert status.is_pinned is True + + class TestGitSourceHandlerShaRefs: """Tests for resolving git refs pinned to a commit SHA."""