patchground 0.1.0__py3-none-any.whl
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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Filtered immutable checkpoints; learner Git supplies read-only exclusion data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import uuid
|
|
13
|
+
import zlib
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from patchground.sessions import store
|
|
19
|
+
from patchground.sessions.files import copy_tree, open_directory, open_entry
|
|
20
|
+
from patchground.sessions.history_ignore import IgnoreRules, resolve
|
|
21
|
+
from patchground.sessions.models import (
|
|
22
|
+
HistoryEntry,
|
|
23
|
+
HistoryResult,
|
|
24
|
+
SessionError,
|
|
25
|
+
SnapshotStatus,
|
|
26
|
+
now,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_CONFIG = b"[core]\n\tbare = true\n\trepositoryformatversion = 0\n"
|
|
30
|
+
_HEAD = b"ref: refs/heads/unused\n"
|
|
31
|
+
_HEX = re.compile(r"[0-9a-f]{40}\Z")
|
|
32
|
+
_REF = re.compile(r"refs/patchground/(initial|check)/([0-9a-f]{32})\Z")
|
|
33
|
+
_UNAVAILABLE = SnapshotStatus(state="unavailable", detail="Checkpoint capture unavailable")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _repository(source: Path, destination: Path, entry: HistoryEntry, rules: IgnoreRules) -> None:
|
|
37
|
+
# Configuration discovery already bound this trusted absolute executable before
|
|
38
|
+
# capture. Never search PATH again from the learner or captured-input context.
|
|
39
|
+
executable = rules.executable
|
|
40
|
+
(destination / "objects").mkdir()
|
|
41
|
+
(destination / "refs").mkdir()
|
|
42
|
+
(destination / "HEAD").write_bytes(_HEAD)
|
|
43
|
+
(destination / "config").write_bytes(_CONFIG)
|
|
44
|
+
with tempfile.TemporaryDirectory(prefix="patchground-history-home-") as home:
|
|
45
|
+
env = {
|
|
46
|
+
"PATH": os.defpath,
|
|
47
|
+
"HOME": home,
|
|
48
|
+
"XDG_CONFIG_HOME": home,
|
|
49
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
50
|
+
"GIT_CONFIG_SYSTEM": os.devnull,
|
|
51
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
52
|
+
"GIT_ATTR_NOSYSTEM": "1",
|
|
53
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
54
|
+
"GIT_ALLOW_PROTOCOL": "",
|
|
55
|
+
"GIT_PAGER": "cat",
|
|
56
|
+
"LC_ALL": "C",
|
|
57
|
+
"GIT_AUTHOR_NAME": "Patchground",
|
|
58
|
+
"GIT_COMMITTER_NAME": "Patchground",
|
|
59
|
+
"GIT_AUTHOR_EMAIL": "0+patchground@users.noreply.github.com",
|
|
60
|
+
"GIT_COMMITTER_EMAIL": "0+patchground@users.noreply.github.com",
|
|
61
|
+
"GIT_AUTHOR_DATE": entry.ts.isoformat(),
|
|
62
|
+
"GIT_COMMITTER_DATE": entry.ts.isoformat(),
|
|
63
|
+
}
|
|
64
|
+
command = [
|
|
65
|
+
executable,
|
|
66
|
+
"--no-pager",
|
|
67
|
+
"--git-dir=" + str(destination),
|
|
68
|
+
"-c",
|
|
69
|
+
"core.hooksPath=" + os.devnull,
|
|
70
|
+
"-c",
|
|
71
|
+
"core.fsmonitor=false",
|
|
72
|
+
"-c",
|
|
73
|
+
"core.attributesFile=" + os.devnull,
|
|
74
|
+
"-c",
|
|
75
|
+
"core.autocrlf=false",
|
|
76
|
+
"-c",
|
|
77
|
+
"commit.gpgSign=false",
|
|
78
|
+
"-c",
|
|
79
|
+
"protocol.allow=never",
|
|
80
|
+
"-c",
|
|
81
|
+
"gc.auto=0",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def run(*args: str, data: bytes = b"") -> str:
|
|
85
|
+
return (
|
|
86
|
+
subprocess.run(
|
|
87
|
+
[*command, *args],
|
|
88
|
+
input=data,
|
|
89
|
+
cwd=home,
|
|
90
|
+
env=env,
|
|
91
|
+
check=True,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
timeout=60,
|
|
94
|
+
)
|
|
95
|
+
.stdout.decode("ascii")
|
|
96
|
+
.strip()
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Native Git matches captured .gitignore files, copied info/global rules,
|
|
100
|
+
# and no learner metadata/configuration. Only trusted temporary metadata writes.
|
|
101
|
+
(destination / "info").mkdir()
|
|
102
|
+
(destination / "info/exclude").write_bytes(rules.info)
|
|
103
|
+
excludes = Path(home) / "ignore"
|
|
104
|
+
excludes.write_bytes(rules.global_rules)
|
|
105
|
+
command.extend(
|
|
106
|
+
[
|
|
107
|
+
"--work-tree=" + str(source),
|
|
108
|
+
"-c",
|
|
109
|
+
"core.bare=false",
|
|
110
|
+
"-c",
|
|
111
|
+
"core.excludesFile=" + str(excludes),
|
|
112
|
+
"-c",
|
|
113
|
+
"core.ignoreCase=" + str(rules.ignore_case).lower(),
|
|
114
|
+
]
|
|
115
|
+
)
|
|
116
|
+
paths = [path for path in source.rglob("*") if path.is_file()]
|
|
117
|
+
names = [os.fsencode(path.relative_to(source).as_posix()) for path in paths]
|
|
118
|
+
matched = subprocess.run(
|
|
119
|
+
[*command, "check-ignore", "--no-index", "-z", "--stdin"],
|
|
120
|
+
input=b"\0".join(names) + (b"\0" if names else b""),
|
|
121
|
+
cwd=home,
|
|
122
|
+
env=env,
|
|
123
|
+
capture_output=True,
|
|
124
|
+
timeout=60,
|
|
125
|
+
)
|
|
126
|
+
if matched.returncode not in (0, 1):
|
|
127
|
+
raise SessionError("ignore matching unavailable")
|
|
128
|
+
ignored = set(matched.stdout.split(b"\0"))
|
|
129
|
+
# Exclusion inputs are temporary, never part of published Git metadata.
|
|
130
|
+
(destination / "info/exclude").unlink()
|
|
131
|
+
(destination / "info").rmdir()
|
|
132
|
+
# Plumbing takes raw bytes, never git-add or attributes/clean/smudge filters.
|
|
133
|
+
index = b""
|
|
134
|
+
for path, name in zip(paths, names, strict=True):
|
|
135
|
+
if name in ignored:
|
|
136
|
+
continue
|
|
137
|
+
blob = run("hash-object", "-w", "--stdin", data=path.read_bytes())
|
|
138
|
+
mode = "100755" if path.stat().st_mode & 0o111 else "100644"
|
|
139
|
+
index += (
|
|
140
|
+
f"{mode} {blob}\t".encode("ascii")
|
|
141
|
+
+ os.fsencode(path.relative_to(source).as_posix())
|
|
142
|
+
+ b"\0"
|
|
143
|
+
)
|
|
144
|
+
env["GIT_INDEX_FILE"] = str(destination / "index")
|
|
145
|
+
run("read-tree", "--empty")
|
|
146
|
+
run("update-index", "-z", "--index-info", data=index)
|
|
147
|
+
tree = run("write-tree")
|
|
148
|
+
message = entry.model_dump(mode="json", exclude={"commit"})
|
|
149
|
+
commit = run("commit-tree", tree, data=json.dumps(message).encode("utf-8"))
|
|
150
|
+
run("update-ref", entry.ref, commit, "0" * 40)
|
|
151
|
+
(destination / "index").unlink()
|
|
152
|
+
# update-ref may create empty log directories; logs are not used or accepted.
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def capture(
|
|
156
|
+
meta: Path,
|
|
157
|
+
source: Path,
|
|
158
|
+
*,
|
|
159
|
+
enabled: bool,
|
|
160
|
+
kind: Literal["initial", "check"],
|
|
161
|
+
check_outcome: str | None = None,
|
|
162
|
+
rules: IgnoreRules | None = None,
|
|
163
|
+
) -> SnapshotStatus:
|
|
164
|
+
"""Best effort only; callers persist returned status with their completed evidence."""
|
|
165
|
+
if not enabled:
|
|
166
|
+
return SnapshotStatus(state="disabled")
|
|
167
|
+
try:
|
|
168
|
+
rules = rules if rules is not None else resolve(source)
|
|
169
|
+
if rules is None:
|
|
170
|
+
return _UNAVAILABLE
|
|
171
|
+
identifier = uuid.uuid4().hex
|
|
172
|
+
ref = f"refs/patchground/{kind}/{identifier}"
|
|
173
|
+
entry = HistoryEntry(ref=ref, commit="", kind=kind, ts=now(), check_outcome=check_outcome)
|
|
174
|
+
with tempfile.TemporaryDirectory(
|
|
175
|
+
prefix="patchground-history-", dir=Path(tempfile.gettempdir()).resolve(strict=True)
|
|
176
|
+
) as temporary:
|
|
177
|
+
staging = Path(temporary)
|
|
178
|
+
captured = staging / "input"
|
|
179
|
+
captured.mkdir()
|
|
180
|
+
copy_tree(source, captured)
|
|
181
|
+
repository = staging / "repository"
|
|
182
|
+
repository.mkdir()
|
|
183
|
+
_repository(captured, repository, entry, rules)
|
|
184
|
+
with open_directory(meta) as parent:
|
|
185
|
+
with suppress(FileExistsError):
|
|
186
|
+
os.mkdir("history.git", mode=0o700, dir_fd=parent)
|
|
187
|
+
with open_entry(parent, "history.git", os.O_RDONLY | os.O_DIRECTORY) as history:
|
|
188
|
+
existing = _inventory(history)
|
|
189
|
+
_objects(existing)
|
|
190
|
+
_publish(repository, history)
|
|
191
|
+
os.fsync(history)
|
|
192
|
+
os.fsync(parent)
|
|
193
|
+
return SnapshotStatus(state="recorded", ref=ref)
|
|
194
|
+
except (Exception, KeyboardInterrupt):
|
|
195
|
+
# This optional boundary must preserve completed check evidence, including
|
|
196
|
+
# unexpected plumbing errors or an interrupt while creating a checkpoint.
|
|
197
|
+
return _UNAVAILABLE
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _publish(source: Path, target: int) -> None:
|
|
201
|
+
"""Append only: share validated objects, publish each new ref exactly once."""
|
|
202
|
+
with open_directory(source) as parent:
|
|
203
|
+
|
|
204
|
+
def copy(folder: int, destination: int, prefix: str) -> None:
|
|
205
|
+
# Publish refs last, after all objects and fixed repository metadata are durable.
|
|
206
|
+
for name in sorted(os.listdir(folder), key=lambda value: value == "refs"):
|
|
207
|
+
with open_entry(folder, name) as descriptor:
|
|
208
|
+
path = prefix + name
|
|
209
|
+
if stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
|
210
|
+
with suppress(FileExistsError):
|
|
211
|
+
os.mkdir(name, mode=0o700, dir_fd=destination)
|
|
212
|
+
with open_entry(destination, name, os.O_RDONLY | os.O_DIRECTORY) as child:
|
|
213
|
+
copy(descriptor, child, path + "/")
|
|
214
|
+
else:
|
|
215
|
+
with os.fdopen(os.dup(descriptor), "rb") as reader:
|
|
216
|
+
data = reader.read()
|
|
217
|
+
try:
|
|
218
|
+
output = os.open(
|
|
219
|
+
name,
|
|
220
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
221
|
+
0o600,
|
|
222
|
+
dir_fd=destination,
|
|
223
|
+
)
|
|
224
|
+
except FileExistsError:
|
|
225
|
+
if path.startswith("refs/"):
|
|
226
|
+
raise SessionError("checkpoint ref already exists") from None
|
|
227
|
+
with (
|
|
228
|
+
store.metadata_file(destination, name) as existing,
|
|
229
|
+
os.fdopen(os.dup(existing), "rb") as stream,
|
|
230
|
+
):
|
|
231
|
+
saved = stream.read()
|
|
232
|
+
if path.startswith("objects/"):
|
|
233
|
+
equal = zlib.decompress(saved) == zlib.decompress(data)
|
|
234
|
+
else:
|
|
235
|
+
equal = saved == data
|
|
236
|
+
if not equal:
|
|
237
|
+
raise SessionError(
|
|
238
|
+
"refusing to overwrite private Git metadata"
|
|
239
|
+
) from None
|
|
240
|
+
else:
|
|
241
|
+
with os.fdopen(output, "wb") as writer:
|
|
242
|
+
writer.write(data)
|
|
243
|
+
writer.flush()
|
|
244
|
+
os.fsync(writer.fileno())
|
|
245
|
+
os.fsync(destination)
|
|
246
|
+
|
|
247
|
+
copy(parent, target, "")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _inventory(root: int) -> dict[str, bytes]:
|
|
251
|
+
contents: dict[str, bytes] = {}
|
|
252
|
+
|
|
253
|
+
def visit(parent: int, prefix: str) -> None:
|
|
254
|
+
for name in os.listdir(parent):
|
|
255
|
+
with open_entry(parent, name) as descriptor:
|
|
256
|
+
info = os.fstat(descriptor)
|
|
257
|
+
path = prefix + name
|
|
258
|
+
if stat.S_ISDIR(info.st_mode):
|
|
259
|
+
if path not in (
|
|
260
|
+
"objects",
|
|
261
|
+
"refs",
|
|
262
|
+
"refs/patchground",
|
|
263
|
+
"refs/patchground/initial",
|
|
264
|
+
"refs/patchground/check",
|
|
265
|
+
) and not re.fullmatch(r"objects/[0-9a-f]{2}", path):
|
|
266
|
+
raise SessionError("unsafe checkpoint metadata")
|
|
267
|
+
visit(descriptor, path + "/")
|
|
268
|
+
else:
|
|
269
|
+
if info.st_nlink != 1 or not (
|
|
270
|
+
path in ("HEAD", "config")
|
|
271
|
+
or _REF.fullmatch(path)
|
|
272
|
+
or re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{38}", path)
|
|
273
|
+
):
|
|
274
|
+
raise SessionError("unsafe checkpoint metadata")
|
|
275
|
+
with os.fdopen(os.dup(descriptor), "rb") as stream:
|
|
276
|
+
contents[path] = stream.read()
|
|
277
|
+
|
|
278
|
+
visit(root, "")
|
|
279
|
+
# Missing fixed files can be completed on retry; existing bytes are never replaced.
|
|
280
|
+
if contents.get("HEAD", _HEAD) != _HEAD or contents.get("config", _CONFIG) != _CONFIG:
|
|
281
|
+
raise SessionError("unsafe checkpoint configuration")
|
|
282
|
+
return contents
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _objects(contents: dict[str, bytes]) -> dict[str, bytes]:
|
|
286
|
+
objects: dict[str, bytes] = {}
|
|
287
|
+
for path, data in contents.items():
|
|
288
|
+
if path.startswith("objects/"):
|
|
289
|
+
oid = path.removeprefix("objects/").replace("/", "")
|
|
290
|
+
expanded = zlib.decompress(data)
|
|
291
|
+
if hashlib.sha1(expanded, usedforsecurity=False).hexdigest() != oid:
|
|
292
|
+
raise SessionError("corrupt checkpoint object")
|
|
293
|
+
header, body = expanded.split(b"\0", 1)
|
|
294
|
+
if int(header.split(b" ")[1]) != len(body):
|
|
295
|
+
raise SessionError("corrupt checkpoint object")
|
|
296
|
+
objects[oid] = expanded
|
|
297
|
+
return objects
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _read(meta: Path, ref: str) -> HistoryEntry:
|
|
301
|
+
match = _REF.fullmatch(ref)
|
|
302
|
+
if match is None:
|
|
303
|
+
raise SessionError("invalid checkpoint reference")
|
|
304
|
+
with open_directory(meta / "history.git") as root:
|
|
305
|
+
contents = _inventory(root)
|
|
306
|
+
if contents.get("HEAD") != _HEAD or contents.get("config") != _CONFIG:
|
|
307
|
+
raise SessionError("incomplete checkpoint configuration")
|
|
308
|
+
commit = contents.get(ref, b"").decode("ascii").strip()
|
|
309
|
+
if not _HEX.fullmatch(commit):
|
|
310
|
+
raise SessionError("missing checkpoint commit")
|
|
311
|
+
objects = _objects(contents)
|
|
312
|
+
|
|
313
|
+
def validate_tree(oid: str) -> None:
|
|
314
|
+
raw = objects[oid]
|
|
315
|
+
if not raw.startswith(b"tree "):
|
|
316
|
+
raise SessionError("missing checkpoint tree")
|
|
317
|
+
remaining = raw.split(b"\0", 1)[1]
|
|
318
|
+
while remaining:
|
|
319
|
+
header, rest = remaining.split(b"\0", 1)
|
|
320
|
+
mode, name = header.split(b" ", 1)
|
|
321
|
+
if not name or b"/" in name or len(rest) < 20:
|
|
322
|
+
raise SessionError("invalid checkpoint tree")
|
|
323
|
+
child = rest[:20].hex()
|
|
324
|
+
remaining = rest[20:]
|
|
325
|
+
if mode == b"40000":
|
|
326
|
+
validate_tree(child)
|
|
327
|
+
elif mode not in (b"100644", b"100755") or not objects[child].startswith(b"blob "):
|
|
328
|
+
raise SessionError("invalid checkpoint file")
|
|
329
|
+
|
|
330
|
+
raw = objects[commit]
|
|
331
|
+
if not raw.startswith(b"commit "):
|
|
332
|
+
raise SessionError("invalid checkpoint commit")
|
|
333
|
+
body = raw.split(b"\0", 1)[1]
|
|
334
|
+
tree = body.split(b"\n", 1)[0].removeprefix(b"tree ").decode("ascii")
|
|
335
|
+
validate_tree(tree)
|
|
336
|
+
entry = HistoryEntry.model_validate({**json.loads(body.split(b"\n\n", 1)[1]), "commit": commit})
|
|
337
|
+
if entry.ref != ref or entry.kind != match[1]:
|
|
338
|
+
raise SessionError("inconsistent checkpoint metadata")
|
|
339
|
+
return entry
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def history_for_session(session: Path | None = None, *, root: Path | None = None) -> HistoryResult:
|
|
343
|
+
"""Read evidence-backed checkpoints, including after submission, without repair."""
|
|
344
|
+
root = (root if root is not None else store.default_root()).absolute()
|
|
345
|
+
path = store.resolve(session, root)
|
|
346
|
+
record, _manifest, events = store.load(path)
|
|
347
|
+
if not record.history_enabled:
|
|
348
|
+
return HistoryResult(session=path, enabled=False, availability="disabled", snapshots=())
|
|
349
|
+
snapshots: list[HistoryEntry] = []
|
|
350
|
+
unavailable = False
|
|
351
|
+
seen: set[str] = set()
|
|
352
|
+
for event in events:
|
|
353
|
+
status = None
|
|
354
|
+
try:
|
|
355
|
+
if event.type == "history_recorded":
|
|
356
|
+
status = SnapshotStatus.model_validate(event.data)
|
|
357
|
+
elif event.type == "check_run" and event.data.get("history") is not None:
|
|
358
|
+
status = SnapshotStatus.model_validate(event.data["history"])
|
|
359
|
+
except ValueError:
|
|
360
|
+
unavailable = True
|
|
361
|
+
continue
|
|
362
|
+
if status is None:
|
|
363
|
+
continue
|
|
364
|
+
if status.state != "recorded" or status.ref is None:
|
|
365
|
+
unavailable = True
|
|
366
|
+
continue
|
|
367
|
+
try:
|
|
368
|
+
if status.ref in seen:
|
|
369
|
+
raise SessionError("duplicate checkpoint evidence")
|
|
370
|
+
entry = _read(path / ".session", status.ref)
|
|
371
|
+
expected = "initial" if event.type == "history_recorded" else "check"
|
|
372
|
+
if entry.kind != expected or entry.check_outcome != (
|
|
373
|
+
str(event.data["outcome"]) if expected == "check" else None
|
|
374
|
+
):
|
|
375
|
+
raise SessionError("checkpoint does not match evidence")
|
|
376
|
+
snapshots.append(entry)
|
|
377
|
+
seen.add(status.ref)
|
|
378
|
+
except Exception:
|
|
379
|
+
# Untrusted private checkpoint bytes never invalidate practice evidence.
|
|
380
|
+
unavailable = True
|
|
381
|
+
return HistoryResult(
|
|
382
|
+
session=path,
|
|
383
|
+
enabled=True,
|
|
384
|
+
availability="unavailable" if unavailable or not snapshots else "available",
|
|
385
|
+
snapshots=tuple(snapshots),
|
|
386
|
+
)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Read exclusion data conservatively; never run Git against learner metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from contextlib import ExitStack
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from patchground.sessions.files import open_directory, open_entry
|
|
14
|
+
from patchground.sessions.git import resolve_executable
|
|
15
|
+
from patchground.sessions.models import SessionError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class IgnoreRules:
|
|
20
|
+
executable: str
|
|
21
|
+
info: bytes
|
|
22
|
+
global_rules: bytes
|
|
23
|
+
ignore_case: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Variables that editors, terminals and credential helpers export routinely. None of them can
|
|
27
|
+
# change repository discovery or ignore matching, so they do not make capture unavailable.
|
|
28
|
+
_INERT_VARIABLES = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"GIT_ASKPASS",
|
|
31
|
+
"GIT_TERMINAL_PROMPT",
|
|
32
|
+
"GIT_EDITOR",
|
|
33
|
+
"GIT_SEQUENCE_EDITOR",
|
|
34
|
+
"GIT_PAGER",
|
|
35
|
+
"GIT_SSH",
|
|
36
|
+
"GIT_SSH_COMMAND",
|
|
37
|
+
"GIT_SSH_VARIANT",
|
|
38
|
+
"GIT_AUTHOR_NAME",
|
|
39
|
+
"GIT_AUTHOR_EMAIL",
|
|
40
|
+
"GIT_AUTHOR_DATE",
|
|
41
|
+
"GIT_COMMITTER_NAME",
|
|
42
|
+
"GIT_COMMITTER_EMAIL",
|
|
43
|
+
"GIT_COMMITTER_DATE",
|
|
44
|
+
"GIT_MERGE_AUTOEDIT",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
_CONFIG_VARIABLE_PREFIXES = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")
|
|
48
|
+
# Injected keys that would change what the learner's Git ignores or how it finds the repository.
|
|
49
|
+
_RELEVANT_CONFIG = ("core.excludesfile", "core.ignorecase", "core.worktree")
|
|
50
|
+
_RELEVANT_CONFIG_PREFIXES = ("include.", "includeif.", "extensions.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_environment() -> None:
|
|
54
|
+
"""Fail closed on any Git override this resolver cannot reproduce."""
|
|
55
|
+
allowed = {"GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_COUNT"}
|
|
56
|
+
for name in os.environ:
|
|
57
|
+
if not name.startswith("GIT_") or name in allowed or name in _INERT_VARIABLES:
|
|
58
|
+
continue
|
|
59
|
+
if name.startswith(_CONFIG_VARIABLE_PREFIXES):
|
|
60
|
+
continue
|
|
61
|
+
raise SessionError("unsupported Git context")
|
|
62
|
+
declared = [name for name in os.environ if name.startswith(_CONFIG_VARIABLE_PREFIXES)]
|
|
63
|
+
count_text = os.environ.get("GIT_CONFIG_COUNT")
|
|
64
|
+
if count_text is None:
|
|
65
|
+
if declared:
|
|
66
|
+
raise SessionError("unsupported Git context")
|
|
67
|
+
return
|
|
68
|
+
if not count_text.isascii() or not count_text.isdigit() or int(count_text) > 64:
|
|
69
|
+
raise SessionError("unsupported Git context")
|
|
70
|
+
count = int(count_text)
|
|
71
|
+
expected = {
|
|
72
|
+
f"{prefix}{index}" for prefix in _CONFIG_VARIABLE_PREFIXES for index in range(count)
|
|
73
|
+
}
|
|
74
|
+
if set(declared) != expected:
|
|
75
|
+
raise SessionError("unsupported Git context")
|
|
76
|
+
for index in range(count):
|
|
77
|
+
key = os.environ[f"GIT_CONFIG_KEY_{index}"].strip().lower()
|
|
78
|
+
if key in _RELEVANT_CONFIG or key.startswith(_RELEVANT_CONFIG_PREFIXES):
|
|
79
|
+
raise SessionError("unsupported Git context")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read(path: Path) -> bytes:
|
|
83
|
+
"""Missing files are empty; redirected, shared and special files are unavailable."""
|
|
84
|
+
if path == Path(os.devnull):
|
|
85
|
+
return b""
|
|
86
|
+
with open_directory(Path(path.absolute().anchor)) as root:
|
|
87
|
+
try:
|
|
88
|
+
with ExitStack() as stack:
|
|
89
|
+
parent = root
|
|
90
|
+
for part in path.absolute().parts[1:-1]:
|
|
91
|
+
parent = stack.enter_context(
|
|
92
|
+
open_entry(parent, part, os.O_RDONLY | os.O_DIRECTORY)
|
|
93
|
+
)
|
|
94
|
+
descriptor = stack.enter_context(open_entry(parent, path.name))
|
|
95
|
+
info = os.fstat(descriptor)
|
|
96
|
+
if (
|
|
97
|
+
not stat.S_ISREG(info.st_mode)
|
|
98
|
+
or info.st_nlink != 1
|
|
99
|
+
or info.st_size > 1024 * 1024
|
|
100
|
+
):
|
|
101
|
+
raise SessionError("unsafe exclusion data")
|
|
102
|
+
with os.fdopen(os.dup(descriptor), "rb") as stream:
|
|
103
|
+
data = stream.read(1024 * 1024 + 1)
|
|
104
|
+
after = os.fstat(descriptor)
|
|
105
|
+
if len(data) > 1024 * 1024 or (
|
|
106
|
+
info.st_size,
|
|
107
|
+
info.st_mtime_ns,
|
|
108
|
+
info.st_ctime_ns,
|
|
109
|
+
) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
|
|
110
|
+
raise SessionError("unsafe exclusion data")
|
|
111
|
+
return data
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
return b""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve(
|
|
117
|
+
workspace: Path, *, source: Path | None = None, inputs: tuple[Path, ...] = ()
|
|
118
|
+
) -> IgnoreRules | None:
|
|
119
|
+
"""Optional boundary: unsupported applicability never means an empty rule set."""
|
|
120
|
+
try:
|
|
121
|
+
return _resolve(workspace, source=source, inputs=inputs)
|
|
122
|
+
except (Exception, KeyboardInterrupt):
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve(
|
|
127
|
+
workspace: Path, *, source: Path | None = None, inputs: tuple[Path, ...] = ()
|
|
128
|
+
) -> IgnoreRules:
|
|
129
|
+
# Overrides that change repository discovery or inject command configuration cannot
|
|
130
|
+
# be reproduced by this bounded resolver. In particular, never silently drop them.
|
|
131
|
+
_check_environment()
|
|
132
|
+
executable = resolve_executable(
|
|
133
|
+
workspace,
|
|
134
|
+
workspace.parent / ".session",
|
|
135
|
+
*inputs,
|
|
136
|
+
*((source,) if source is not None else ()),
|
|
137
|
+
)
|
|
138
|
+
# Only ordinary standalone repositories are supported. Gitfiles, linked worktrees,
|
|
139
|
+
# missing metadata and alternate worktree roots require unavailable history.
|
|
140
|
+
for folder in (workspace / ".git", workspace / ".git/objects", workspace / ".git/refs"):
|
|
141
|
+
with open_directory(folder):
|
|
142
|
+
pass
|
|
143
|
+
if _read(workspace / ".git/commondir") or _read(workspace / ".git/config.worktree"):
|
|
144
|
+
raise SessionError("unsupported worktree context")
|
|
145
|
+
local = _read(workspace / ".git/config")
|
|
146
|
+
if not local or not _read(workspace / ".git/HEAD"):
|
|
147
|
+
raise SessionError("Git context unavailable")
|
|
148
|
+
home = Path(os.environ.get("HOME", ""))
|
|
149
|
+
xdg = Path(os.environ.get("XDG_CONFIG_HOME", str(home / ".config")))
|
|
150
|
+
if not home.is_absolute() or not xdg.is_absolute():
|
|
151
|
+
raise SessionError("unsupported configuration path")
|
|
152
|
+
with tempfile.TemporaryDirectory(prefix="patchground-ignore-config-") as temporary:
|
|
153
|
+
private = Path(temporary)
|
|
154
|
+
env = {
|
|
155
|
+
"PATH": os.defpath,
|
|
156
|
+
"HOME": str(home),
|
|
157
|
+
"XDG_CONFIG_HOME": str(xdg),
|
|
158
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
159
|
+
"GIT_CONFIG_SYSTEM": os.devnull,
|
|
160
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
161
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
162
|
+
"GIT_ALLOW_PROTOCOL": "",
|
|
163
|
+
"LC_ALL": "C",
|
|
164
|
+
}
|
|
165
|
+
repository = private / "repository"
|
|
166
|
+
repository.mkdir()
|
|
167
|
+
(repository / "objects").mkdir()
|
|
168
|
+
(repository / "refs").mkdir()
|
|
169
|
+
(repository / "HEAD").write_bytes(b"ref: refs/heads/unused\n")
|
|
170
|
+
(repository / "config").write_bytes(b"[core]\n bare = true\n")
|
|
171
|
+
command = [
|
|
172
|
+
executable,
|
|
173
|
+
"--git-dir=" + str(repository),
|
|
174
|
+
"--no-pager",
|
|
175
|
+
"--no-optional-locks",
|
|
176
|
+
"-c",
|
|
177
|
+
"core.hooksPath=" + os.devnull,
|
|
178
|
+
"-c",
|
|
179
|
+
"core.fsmonitor=false",
|
|
180
|
+
"-c",
|
|
181
|
+
"core.attributesFile=" + os.devnull,
|
|
182
|
+
"-c",
|
|
183
|
+
"protocol.allow=never",
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
def run(args: list[str], *, environment: dict[str, str] = env, data: bytes = b"") -> bytes:
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
[*command, *args],
|
|
189
|
+
cwd=private,
|
|
190
|
+
env=environment,
|
|
191
|
+
input=data,
|
|
192
|
+
capture_output=True,
|
|
193
|
+
timeout=10,
|
|
194
|
+
check=True,
|
|
195
|
+
)
|
|
196
|
+
return result.stdout
|
|
197
|
+
|
|
198
|
+
configs: list[bytes] = []
|
|
199
|
+
no_system = os.environ.get("GIT_CONFIG_NOSYSTEM", "0").lower()
|
|
200
|
+
if no_system not in ("1", "true", "yes", "on", "0", "false", "no", "off"):
|
|
201
|
+
raise SessionError("unsupported system configuration override")
|
|
202
|
+
if no_system in ("0", "false", "no", "off"):
|
|
203
|
+
system = os.environ.get("GIT_CONFIG_SYSTEM")
|
|
204
|
+
if system is None:
|
|
205
|
+
# Git exposes its compiled-in path. This read-only data query may read
|
|
206
|
+
# system configuration, but cannot invoke hooks, filters or tools.
|
|
207
|
+
discovery = dict(env)
|
|
208
|
+
discovery.pop("GIT_CONFIG_SYSTEM")
|
|
209
|
+
discovery.pop("GIT_CONFIG_NOSYSTEM")
|
|
210
|
+
system = os.fsdecode(
|
|
211
|
+
run(["var", "GIT_CONFIG_SYSTEM"], environment=discovery)
|
|
212
|
+
).removesuffix("\n")
|
|
213
|
+
path = Path(system)
|
|
214
|
+
if not path.is_absolute():
|
|
215
|
+
raise SessionError("unsupported system configuration path")
|
|
216
|
+
configs.append(_read(path))
|
|
217
|
+
if "GIT_CONFIG_GLOBAL" in os.environ:
|
|
218
|
+
path = Path(os.environ["GIT_CONFIG_GLOBAL"])
|
|
219
|
+
if not path.is_absolute():
|
|
220
|
+
raise SessionError("unsupported global configuration path")
|
|
221
|
+
configs.append(_read(path))
|
|
222
|
+
else:
|
|
223
|
+
configs.extend((_read(xdg / "git/config"), _read(home / ".gitconfig")))
|
|
224
|
+
configs.append(local)
|
|
225
|
+
excludes: bytes | None = None
|
|
226
|
+
ignore_case = False
|
|
227
|
+
for data in configs:
|
|
228
|
+
pairs = run(["config", "--no-includes", "--file", "-", "--null", "--list"], data=data)
|
|
229
|
+
for pair in pairs.split(b"\0"):
|
|
230
|
+
if not pair:
|
|
231
|
+
continue
|
|
232
|
+
key = pair.partition(b"\n")[0]
|
|
233
|
+
if (
|
|
234
|
+
key.startswith((b"include.", b"includeif.", b"extensions."))
|
|
235
|
+
or key == b"core.worktree"
|
|
236
|
+
):
|
|
237
|
+
raise SessionError("unsupported configuration applicability")
|
|
238
|
+
if key == b"core.excludesfile":
|
|
239
|
+
# Ask Git to expand its path syntax, then copy only validated data.
|
|
240
|
+
expanded = run(
|
|
241
|
+
[
|
|
242
|
+
"config",
|
|
243
|
+
"--no-includes",
|
|
244
|
+
"--file",
|
|
245
|
+
"-",
|
|
246
|
+
"--path",
|
|
247
|
+
"--null",
|
|
248
|
+
"--get",
|
|
249
|
+
"core.excludesFile",
|
|
250
|
+
],
|
|
251
|
+
data=data,
|
|
252
|
+
).removesuffix(b"\0")
|
|
253
|
+
if expanded:
|
|
254
|
+
path = Path(os.fsdecode(expanded))
|
|
255
|
+
if not path.is_absolute():
|
|
256
|
+
raise SessionError("relative exclusion path unavailable")
|
|
257
|
+
excludes = _read(path)
|
|
258
|
+
else:
|
|
259
|
+
excludes = b""
|
|
260
|
+
elif key == b"core.ignorecase":
|
|
261
|
+
parsed = run(
|
|
262
|
+
[
|
|
263
|
+
"config",
|
|
264
|
+
"--no-includes",
|
|
265
|
+
"--file",
|
|
266
|
+
"-",
|
|
267
|
+
"--bool",
|
|
268
|
+
"--get",
|
|
269
|
+
"core.ignoreCase",
|
|
270
|
+
],
|
|
271
|
+
data=data,
|
|
272
|
+
)
|
|
273
|
+
ignore_case = parsed.strip() == b"true"
|
|
274
|
+
return IgnoreRules(
|
|
275
|
+
executable=executable,
|
|
276
|
+
info=_read(workspace / ".git/info/exclude"),
|
|
277
|
+
global_rules=_read(xdg / "git/ignore") if excludes is None else excludes,
|
|
278
|
+
ignore_case=ignore_case,
|
|
279
|
+
)
|