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.
- provenance_core-0.3.0/.gitignore +45 -0
- provenance_core-0.3.0/PKG-INFO +43 -0
- provenance_core-0.3.0/README.md +22 -0
- provenance_core-0.3.0/pyproject.toml +30 -0
- provenance_core-0.3.0/src/provenance_core/__init__.py +23 -0
- provenance_core-0.3.0/src/provenance_core/digests.py +42 -0
- provenance_core-0.3.0/src/provenance_core/gitref.py +154 -0
- provenance_core-0.3.0/tests/test_primitives.py +248 -0
|
@@ -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
|
+
[](https://pypi.org/project/provenance-core/)
|
|
25
|
+
[](https://pypi.org/project/provenance-core/)
|
|
26
|
+
[](https://github.com/elliottower/reproducible-science/blob/main/LICENSE)
|
|
27
|
+
[](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
|
+
[](https://pypi.org/project/provenance-core/)
|
|
4
|
+
[](https://pypi.org/project/provenance-core/)
|
|
5
|
+
[](https://github.com/elliottower/reproducible-science/blob/main/LICENSE)
|
|
6
|
+
[](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")
|