provenance-core 0.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.
@@ -0,0 +1,45 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .DS_Store
7
+
8
+ # adduce writes its report and the repro sidecar here
9
+ .adduce/
10
+
11
+ # Publisher PDFs for the prior-art dig. Kept locally so `citations verify` can resolve
12
+ # quotations against them; not redistributable, and this repository is public.
13
+
14
+ # coverage byproducts
15
+ .coverage
16
+ .coverage.*
17
+ htmlcov/
18
+ coverage.xml
19
+
20
+ # Internal review packets: simulated-referee rounds and self-critique.
21
+ # Local only -- this repository may go public.
22
+ REVIEW_PACKET*.md
23
+
24
+ # Development corpus: the downloaded articles and shallow clones are rebuildable from
25
+ # frame.json, but the measurements taken from them are not -- ignoring the whole directory
26
+ # lost one 32-article run when the tree was tidied. Subdirectories only, as arxiv_corpus does.
27
+ # documentation site (Docusaurus)
28
+ website/node_modules/
29
+ website/build/
30
+ website/.docusaurus/
31
+
32
+ # nbconvert output from verifying the demo notebook runs
33
+ notebooks/executed.ipynb
34
+
35
+ # jupyter lite build state
36
+ .jupyterlite.doit.db
37
+
38
+ # arXiv LaTeX sources fetched for testing, rebuildable from resolved.json
39
+
40
+ # CORE-Bench capsules: ~39 MB each, rebuildable from core_train.json
41
+
42
+ # CORE-Bench capsule metadata: 4.3 MB, fetched from siegelz/core-bench
43
+
44
+ # Internal reviews are never tracked. They live in reproducible-science-internal-audits.
45
+ docs/reviews/
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.5
2
+ Name: provenance-core
3
+ Version: 0.3.0
4
+ Summary: Primitives shared by the reproducible-science tools: content digests and git references
5
+ Project-URL: Changelog, https://github.com/elliottower/reproducible-science/blob/main/CHANGELOG.md
6
+ Project-URL: Homepage, https://github.com/elliottower/reproducible-science
7
+ Project-URL: Issues, https://github.com/elliottower/reproducible-science/issues
8
+ Project-URL: Source, https://github.com/elliottower/reproducible-science/tree/main/packages/provenance-core
9
+ Author-email: Elliot Tower <elliot@elliottower.ai>
10
+ License-Expression: MIT
11
+ Keywords: digest,git,provenance,reproducibility,sha256
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+
22
+ # provenance-core
23
+
24
+ [![pypi](https://img.shields.io/pypi/v/provenance-core)](https://pypi.org/project/provenance-core/)
25
+ [![python](https://img.shields.io/pypi/pyversions/provenance-core)](https://pypi.org/project/provenance-core/)
26
+ [![license](https://img.shields.io/pypi/l/provenance-core)](https://github.com/elliottower/reproducible-science/blob/main/LICENSE)
27
+ [![docs](https://img.shields.io/badge/docs-live-blue)](https://elliottower.github.io/reproducible-science/)
28
+
29
+ Content digests and git references, shared by the reproducible-science tools.
30
+
31
+ Internal to [reproducible-science](https://github.com/elliottower/reproducible-science). Nothing installs it directly; the tools that need it depend on it.
32
+
33
+ Two things lived in four copies each before this package existed: hashing a file or a string,
34
+ and asking git a question. The copies had drifted — one hashed in 64 KB blocks and another in
35
+ 1 MB, one encoded a string implicitly and the others explicitly, and one reported a missing
36
+ `git` binary as success.
37
+
38
+ ```python
39
+ from provenance_core import sha256_of_file, sha256_of_text
40
+ from provenance_core.gitref import commit, is_dirty, try_run
41
+ ```
42
+
43
+ MIT licensed.
@@ -0,0 +1,22 @@
1
+ # provenance-core
2
+
3
+ [![pypi](https://img.shields.io/pypi/v/provenance-core)](https://pypi.org/project/provenance-core/)
4
+ [![python](https://img.shields.io/pypi/pyversions/provenance-core)](https://pypi.org/project/provenance-core/)
5
+ [![license](https://img.shields.io/pypi/l/provenance-core)](https://github.com/elliottower/reproducible-science/blob/main/LICENSE)
6
+ [![docs](https://img.shields.io/badge/docs-live-blue)](https://elliottower.github.io/reproducible-science/)
7
+
8
+ Content digests and git references, shared by the reproducible-science tools.
9
+
10
+ Internal to [reproducible-science](https://github.com/elliottower/reproducible-science). Nothing installs it directly; the tools that need it depend on it.
11
+
12
+ Two things lived in four copies each before this package existed: hashing a file or a string,
13
+ and asking git a question. The copies had drifted — one hashed in 64 KB blocks and another in
14
+ 1 MB, one encoded a string implicitly and the others explicitly, and one reported a missing
15
+ `git` binary as success.
16
+
17
+ ```python
18
+ from provenance_core import sha256_of_file, sha256_of_text
19
+ from provenance_core.gitref import commit, is_dirty, try_run
20
+ ```
21
+
22
+ MIT licensed.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ build-backend = "hatchling.build"
3
+ requires = [ "hatchling" ]
4
+
5
+ [project]
6
+ name = "provenance-core"
7
+ version = "0.3.0"
8
+ description = "Primitives shared by the reproducible-science tools: content digests and git references"
9
+ readme = "README.md"
10
+ keywords = [ "digest", "git", "provenance", "reproducibility", "sha256" ]
11
+ license = "MIT"
12
+ authors = [ { name = "Elliot Tower", email = "elliot@elliottower.ai" } ]
13
+ requires-python = ">=3.11"
14
+ classifiers = [
15
+ "Intended Audience :: Science/Research",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Scientific/Engineering",
22
+ ]
23
+ dependencies = [ ]
24
+ urls.Changelog = "https://github.com/elliottower/reproducible-science/blob/main/CHANGELOG.md"
25
+ urls.Homepage = "https://github.com/elliottower/reproducible-science"
26
+ urls.Issues = "https://github.com/elliottower/reproducible-science/issues"
27
+ urls.Source = "https://github.com/elliottower/reproducible-science/tree/main/packages/provenance-core"
28
+
29
+ [tool.ruff]
30
+ lint.isort.known-first-party = [ "provenance_core" ]
@@ -0,0 +1,23 @@
1
+ """Primitives shared by the reproducible-science tools.
2
+
3
+ Digests and git references lived in four copies each, drifting quietly. This package is the
4
+ one implementation, published only because the tools that need it are published separately
5
+ and a workspace path does not survive into a wheel.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .digests import ZERO, sha256_of_file, sha256_of_text
11
+ from .gitref import GitError, at_commit, commit, is_dirty, run, try_run
12
+
13
+ __all__ = [
14
+ "ZERO",
15
+ "GitError",
16
+ "at_commit",
17
+ "commit",
18
+ "is_dirty",
19
+ "run",
20
+ "sha256_of_file",
21
+ "sha256_of_text",
22
+ "try_run",
23
+ ]
@@ -0,0 +1,42 @@
1
+ """Content addresses: hashing a file, and hashing a string.
2
+
3
+ Four packages hashed independently before this module existed, and the copies had drifted.
4
+ Two read the file in 1 MB blocks and one in 64 KB; one encoded a string with a bare
5
+ `.encode()` and the others named UTF-8 explicitly. Neither difference changed a digest on the
6
+ machines this ran on, which is what made them survive: a divergence that produces the same
7
+ answer today is the kind that produces a different one later, on a platform whose default
8
+ encoding is not UTF-8, and then two tools disagree about whether a file is the pinned one.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import pathlib
15
+
16
+ #: The prev_hash of a chain's first entry: a digest that addresses nothing.
17
+ ZERO = "0" * 64
18
+
19
+ #: Read size. Large enough that hashing a PDF is not dominated by syscalls, small enough that
20
+ #: a multi-gigabyte artifact does not have to fit in memory.
21
+ _BLOCK = 1 << 20
22
+
23
+
24
+ def sha256_of_file(path: pathlib.Path) -> str:
25
+ """Hash of a file on disk, streamed so a large artifact is never held in memory."""
26
+ h = hashlib.sha256()
27
+ with pathlib.Path(path).open("rb") as fh:
28
+ for block in iter(lambda: fh.read(_BLOCK), b""):
29
+ h.update(block)
30
+ return h.hexdigest()
31
+
32
+
33
+ def sha256_of_text(text: str) -> str:
34
+ """Hash of a string, encoded as UTF-8.
35
+
36
+ The encoding is named rather than left to `str.encode()`'s default, because a digest that
37
+ depends on a platform default is not a content address.
38
+ """
39
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
40
+
41
+
42
+ __all__ = ["ZERO", "sha256_of_file", "sha256_of_text"]
@@ -0,0 +1,154 @@
1
+ """Asking git a question, with one policy for what a failure means.
2
+
3
+ Three copies of this existed and disagreed about failure. One returned `None`, one raised,
4
+ and one returned the empty string -- which made a missing `git` binary, a locked index and a
5
+ directory outside a repository all indistinguishable from success. A freeze recorded a
6
+ commit-shaped string in place of a commit because of it.
7
+
8
+ The disagreement was not arbitrary: a provenance record wants "unknown" and a fetch wants an
9
+ error. Both are offered, so a caller chooses deliberately rather than inheriting whichever
10
+ policy its copy happened to have.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import pathlib
17
+ import subprocess
18
+
19
+ #: Long enough for a fetch over a slow link, short enough that a hung command is not forever.
20
+ DEFAULT_TIMEOUT = 30
21
+
22
+ #: Environment variables that decide which repository git acts on, overriding `cwd`.
23
+ #:
24
+ #: Git resolves its repository from the environment first and the working directory second, so
25
+ #: `cwd` alone does not name a repository. Every git hook exports `GIT_DIR`, and `pre-commit`
26
+ #: exports `GIT_INDEX_FILE` while it stashes, which is enough to make `of_tree(path)` report the
27
+ #: commit and dirty state of whatever repository invoked it rather than of `path`.
28
+ #:
29
+ #: Only the redirecting variables are dropped. `GIT_SSH_COMMAND`, `GIT_CONFIG_GLOBAL`,
30
+ #: `GIT_AUTHOR_DATE` and the rest configure how git behaves once it knows where it is, and a
31
+ #: caller that set them meant them.
32
+ REDIRECTS = (
33
+ "GIT_DIR",
34
+ "GIT_WORK_TREE",
35
+ "GIT_COMMON_DIR",
36
+ "GIT_INDEX_FILE",
37
+ "GIT_OBJECT_DIRECTORY",
38
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
39
+ "GIT_NAMESPACE",
40
+ "GIT_PREFIX",
41
+ "GIT_CEILING_DIRECTORIES",
42
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
43
+ )
44
+
45
+
46
+ def clean_env(**overrides: str) -> dict[str, str]:
47
+ """This process's environment with `REDIRECTS` dropped, plus any overrides.
48
+
49
+ Exported because callers that run git themselves need the same guarantee and would
50
+ otherwise each keep their own copy of the list. `GIT_COMMITTER_DATE` and the other
51
+ variables that configure git rather than relocate it survive, so a caller can still set
52
+ them: `clean_env(GIT_COMMITTER_DATE=when)`.
53
+ """
54
+ return {**{k: v for k, v in os.environ.items() if k not in REDIRECTS}, **overrides}
55
+
56
+
57
+ def _env(cwd: pathlib.Path | None) -> dict[str, str] | None:
58
+ """The environment to run git in, given the directory the caller named.
59
+
60
+ Naming a directory means that directory. Where `cwd` is None the caller has said "wherever
61
+ we are", and an inherited `GIT_DIR` is then the answer rather than an override, so the
62
+ environment is passed through untouched.
63
+ """
64
+ return None if cwd is None else clean_env()
65
+
66
+
67
+ class GitError(Exception):
68
+ """Git could not answer: absent, timed out, or the command failed."""
69
+
70
+ def __init__(self, args: tuple[str, ...], detail: str) -> None:
71
+ self.args_run = args
72
+ self.detail = detail
73
+ super().__init__(f"git {' '.join(args)}: {detail}")
74
+
75
+
76
+ def run(
77
+ *args: str, cwd: pathlib.Path | None = None, timeout: int = DEFAULT_TIMEOUT
78
+ ) -> str:
79
+ """Stdout of one git command. Raises `GitError` on any failure.
80
+
81
+ Use where a failure is a fact the caller must handle. Reporting it as an empty string is
82
+ how a repository with no commit reads as one with a commit.
83
+ """
84
+ try:
85
+ proc = subprocess.run(
86
+ ["git", *args],
87
+ cwd=cwd,
88
+ env=_env(cwd),
89
+ capture_output=True,
90
+ text=True,
91
+ timeout=timeout,
92
+ check=False, # the return code is inspected below and becomes a typed error
93
+ )
94
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
95
+ raise GitError(args, str(e)) from e
96
+ if proc.returncode != 0:
97
+ raise GitError(
98
+ args, (proc.stderr or "").strip()[:200] or f"exit {proc.returncode}"
99
+ )
100
+ return proc.stdout.strip()
101
+
102
+
103
+ def try_run(
104
+ *args: str, cwd: pathlib.Path | None = None, timeout: int = DEFAULT_TIMEOUT
105
+ ):
106
+ """Stdout, or None when git could not answer.
107
+
108
+ Use where absence is a legitimate result -- a provenance record for a directory that is
109
+ not a repository is empty, not an error.
110
+ """
111
+ try:
112
+ return run(*args, cwd=cwd, timeout=timeout)
113
+ except GitError:
114
+ return None
115
+
116
+
117
+ def commit(cwd: pathlib.Path | None = None) -> str | None:
118
+ """The full SHA of HEAD, or None where there is no commit to name."""
119
+ return try_run("rev-parse", "HEAD", cwd=cwd)
120
+
121
+
122
+ def is_dirty(cwd: pathlib.Path | None = None) -> bool:
123
+ """Whether the working tree has uncommitted changes.
124
+
125
+ A tree at the right commit with edited files holds bytes that exist only on that machine,
126
+ so a result verified against it is not one anybody else can reproduce. An unreadable tree
127
+ answers False: not knowing is not the same as knowing it is dirty, and the caller that
128
+ cares has already checked that git can answer.
129
+ """
130
+ return bool(try_run("status", "--porcelain", cwd=cwd))
131
+
132
+
133
+ def at_commit(cwd: pathlib.Path, expected: str) -> bool:
134
+ """Whether HEAD is exactly the commit named.
135
+
136
+ Exact, not a prefix match: an abbreviated SHA that happens to prefix another commit would
137
+ silently accept the wrong revision. A tree git cannot read answers False rather than
138
+ matching an empty expectation.
139
+ """
140
+ head = commit(cwd)
141
+ return head is not None and head == expected
142
+
143
+
144
+ __all__ = [
145
+ "DEFAULT_TIMEOUT",
146
+ "REDIRECTS",
147
+ "GitError",
148
+ "at_commit",
149
+ "clean_env",
150
+ "commit",
151
+ "is_dirty",
152
+ "run",
153
+ "try_run",
154
+ ]
@@ -0,0 +1,248 @@
1
+ """What the four copies disagreed about, asserted once."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import pathlib
8
+ import subprocess
9
+
10
+ import pytest
11
+
12
+ from provenance_core import gitref
13
+ from provenance_core.digests import ZERO, sha256_of_file, sha256_of_text
14
+
15
+
16
+ def test_a_file_and_its_bytes_hash_alike(tmp_path):
17
+ p = tmp_path / "a.bin"
18
+ body = b"the measured angle matches the Haar expectation\n"
19
+ p.write_bytes(body)
20
+ assert sha256_of_file(p) == hashlib.sha256(body).hexdigest()
21
+
22
+
23
+ def test_a_file_larger_than_one_block_hashes_correctly(tmp_path):
24
+ # The copies read in 1 MB and 64 KB blocks. Both are right only if the loop is right.
25
+ p = tmp_path / "big.bin"
26
+ body = bytes(range(256)) * 20_000 # ~5 MB, several blocks
27
+ p.write_bytes(body)
28
+ assert sha256_of_file(p) == hashlib.sha256(body).hexdigest()
29
+
30
+
31
+ def test_text_is_hashed_as_utf8_whatever_the_platform_default(tmp_path):
32
+ # One copy used a bare `.encode()`. A digest that depends on a platform default is not a
33
+ # content address.
34
+ text = "ångström — naïve — 日本語"
35
+ assert sha256_of_text(text) == hashlib.sha256(text.encode("utf-8")).hexdigest()
36
+
37
+
38
+ def test_the_zero_digest_is_a_sha256_shaped_string_addressing_nothing():
39
+ assert len(ZERO) == 64 and set(ZERO) == {"0"}
40
+
41
+
42
+ # -- git: one policy per caller, chosen rather than inherited --------------------------------
43
+
44
+
45
+ def git(*args, cwd):
46
+ """Run git against `cwd` and nowhere else.
47
+
48
+ `cwd` does not decide which repository git acts on: `GIT_INDEX_FILE`, `GIT_DIR` and
49
+ `GIT_WORK_TREE` outrank it. Pre-commit sets `GIT_INDEX_FILE` while it stashes, so a test
50
+ inheriting the environment stages into the *outer* worktree's index, and a commit against
51
+ that index with one file staged leaves every other tracked file staged as deleted. That is
52
+ a wrecked checkout produced by a passing test, and under `pytest -n auto` the workers race
53
+ on the same index. Dropping every `GIT_*` variable is what makes `cwd` mean what it reads
54
+ as meaning.
55
+ """
56
+ env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
57
+ return subprocess.run(
58
+ args, cwd=cwd, env=env, check=True, capture_output=True, text=True
59
+ )
60
+
61
+
62
+ @pytest.fixture
63
+ def repo(tmp_path):
64
+ git("git", "init", "-q", cwd=tmp_path)
65
+ (tmp_path / "f.txt").write_text("one\n")
66
+ git("git", "add", "f.txt", cwd=tmp_path)
67
+ git(
68
+ "git",
69
+ "-c",
70
+ "user.email=t@t",
71
+ "-c",
72
+ "user.name=t",
73
+ "commit",
74
+ "-q",
75
+ "-m",
76
+ "c",
77
+ cwd=tmp_path,
78
+ )
79
+ return tmp_path
80
+
81
+
82
+ def test_run_raises_where_try_run_returns_none(tmp_path):
83
+ """The distinction the three copies disagreed about, in one assertion.
84
+
85
+ A directory that is not a repository is a fact a provenance record reports as unknown and
86
+ a fetch reports as an error. Returning "" for it, as one copy did, made a repository with
87
+ no commit indistinguishable from one with a commit.
88
+ """
89
+ with pytest.raises(gitref.GitError):
90
+ gitref.run("rev-parse", "HEAD", cwd=tmp_path)
91
+ assert gitref.try_run("rev-parse", "HEAD", cwd=tmp_path) is None
92
+ assert gitref.commit(tmp_path) is None
93
+
94
+
95
+ def test_commit_names_head(repo):
96
+ head = git("git", "rev-parse", "HEAD", cwd=repo).stdout.strip()
97
+ assert gitref.commit(repo) == head
98
+ assert gitref.at_commit(repo, head)
99
+
100
+
101
+ def test_at_commit_is_exact_and_refuses_a_prefix(repo):
102
+ head = gitref.commit(repo)
103
+ assert head is not None
104
+ assert not gitref.at_commit(repo, head[:8]), (
105
+ "an abbreviation could prefix another commit"
106
+ )
107
+ assert not gitref.at_commit(repo, "")
108
+ assert not gitref.at_commit(pathlib.Path(repo) / "nope", head)
109
+
110
+
111
+ def test_a_directory_that_is_not_a_repository_is_not_at_any_commit(tmp_path):
112
+ assert not gitref.at_commit(tmp_path, "0" * 40)
113
+ assert not gitref.at_commit(tmp_path, "")
114
+
115
+
116
+ def test_dirty_tracks_the_working_tree(repo):
117
+ assert not gitref.is_dirty(repo)
118
+ (repo / "f.txt").write_text("two\n")
119
+ assert gitref.is_dirty(repo)
120
+
121
+
122
+ def test_a_missing_git_binary_is_an_error_not_an_empty_answer(repo, monkeypatch):
123
+ def absent(*a, **kw):
124
+ raise FileNotFoundError("git")
125
+
126
+ monkeypatch.setattr(gitref.subprocess, "run", absent)
127
+ with pytest.raises(gitref.GitError) as e:
128
+ gitref.run("rev-parse", "HEAD", cwd=repo)
129
+ assert "git" in str(e.value)
130
+ assert gitref.try_run("rev-parse", "HEAD", cwd=repo) is None
131
+
132
+
133
+ def test_git_acts_on_cwd_even_when_the_environment_names_another_index(tmp_path):
134
+ outer, inner = tmp_path / "outer", tmp_path / "inner"
135
+ for d in (outer, inner):
136
+ d.mkdir()
137
+ git("git", "init", "-q", cwd=d)
138
+ (outer / "kept.txt").write_text("x\n")
139
+ git("git", "add", "kept.txt", cwd=outer)
140
+ git(
141
+ "git",
142
+ "-c",
143
+ "user.email=t@t",
144
+ "-c",
145
+ "user.name=t",
146
+ "commit",
147
+ "-q",
148
+ "-m",
149
+ "c",
150
+ cwd=outer,
151
+ )
152
+ (inner / "f.txt").write_text("one\n")
153
+
154
+ poisoned = dict(os.environ, GIT_INDEX_FILE=str(outer / ".git" / "index"))
155
+ subprocess.run(["git", "add", "f.txt"], cwd=inner, env=poisoned, check=False)
156
+ assert git("git", "status", "--short", cwd=outer).stdout.strip(), (
157
+ "the inherited environment should have staged into the outer index; if this is empty "
158
+ "the demonstration no longer demonstrates anything and the test below proves nothing"
159
+ )
160
+
161
+ git("git", "reset", "-q", "--hard", cwd=outer)
162
+ git("git", "add", "f.txt", cwd=inner)
163
+ assert git("git", "status", "--short", cwd=outer).stdout.strip() == ""
164
+
165
+
166
+ # -- cwd names the repository, and outranks an environment that names another ------------------
167
+
168
+
169
+ @pytest.fixture
170
+ def elsewhere(tmp_path_factory):
171
+ """A second repository with one commit, to be named by the environment and not obeyed."""
172
+ d = tmp_path_factory.mktemp("elsewhere")
173
+ git("git", "init", "-q", cwd=d)
174
+ (d / "other.txt").write_text("other\n")
175
+ git("git", "add", "other.txt", cwd=d)
176
+ git(
177
+ "git",
178
+ "-c",
179
+ "user.email=t@t",
180
+ "-c",
181
+ "user.name=t",
182
+ "commit",
183
+ "-q",
184
+ "-m",
185
+ "o",
186
+ cwd=d,
187
+ )
188
+ return d
189
+
190
+
191
+ @pytest.mark.parametrize("var", ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"])
192
+ def test_the_commit_named_is_the_one_in_cwd_not_the_one_in_the_environment(
193
+ repo, elsewhere, monkeypatch, var
194
+ ):
195
+ # Every git hook exports GIT_DIR, and pre-commit exports GIT_INDEX_FILE while it stashes.
196
+ # Under either, `of_tree(path)` used to record the invoking repository's commit as the
197
+ # provenance of `path` -- a provenance record naming a repository nobody asked about.
198
+ target = elsewhere / ".git" if var != "GIT_WORK_TREE" else elsewhere
199
+ monkeypatch.setenv(
200
+ var, str(target / "index" if var == "GIT_INDEX_FILE" else target)
201
+ )
202
+ mine = git("git", "rev-parse", "HEAD", cwd=repo).stdout.strip()
203
+ theirs = git("git", "rev-parse", "HEAD", cwd=elsewhere).stdout.strip()
204
+ assert mine != theirs
205
+ assert gitref.commit(repo) == mine
206
+
207
+
208
+ def test_a_directory_outside_any_repository_is_still_outside_one(
209
+ tmp_path, elsewhere, monkeypatch
210
+ ):
211
+ monkeypatch.setenv("GIT_DIR", str(elsewhere / ".git"))
212
+ plain = tmp_path / "plain"
213
+ plain.mkdir()
214
+ assert gitref.commit(plain) is None
215
+ with pytest.raises(gitref.GitError):
216
+ gitref.run("rev-parse", "HEAD", cwd=plain)
217
+
218
+
219
+ def test_a_clean_tree_reads_clean_while_the_environment_names_a_dirty_one(
220
+ repo, elsewhere, monkeypatch
221
+ ):
222
+ (elsewhere / "scratch.txt").write_text("uncommitted\n")
223
+ monkeypatch.setenv("GIT_DIR", str(elsewhere / ".git"))
224
+ monkeypatch.setenv("GIT_WORK_TREE", str(elsewhere))
225
+ assert gitref.is_dirty(elsewhere)
226
+ assert not gitref.is_dirty(repo)
227
+
228
+
229
+ def test_a_caller_that_names_no_directory_still_inherits_the_environment(
230
+ elsewhere, monkeypatch
231
+ ):
232
+ # The other half of the rule. `cwd=None` means "wherever we are", so an inherited GIT_DIR
233
+ # is the answer rather than an override, and dropping it would break a caller inside a hook
234
+ # that meant the hook's repository.
235
+ monkeypatch.setenv("GIT_DIR", str(elsewhere / ".git"))
236
+ monkeypatch.setenv("GIT_WORK_TREE", str(elsewhere))
237
+ theirs = git("git", "rev-parse", "HEAD", cwd=elsewhere).stdout.strip()
238
+ assert gitref.commit() == theirs
239
+
240
+
241
+ def test_a_variable_that_configures_git_rather_than_relocating_it_survives(
242
+ repo, monkeypatch
243
+ ):
244
+ # The list is narrow on purpose: dropping every GIT_* would take GIT_SSH_COMMAND and the
245
+ # identity variables with it, which a caller that set them meant.
246
+ monkeypatch.setenv("GIT_AUTHOR_NAME", "Someone Named")
247
+ assert "GIT_AUTHOR_NAME" not in gitref.REDIRECTS
248
+ assert gitref.run("var", "GIT_AUTHOR_IDENT", cwd=repo).startswith("Someone Named")