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,145 @@
|
|
|
1
|
+
"""Read-only readiness probes for `doctor`: Git availability and session-disk space.
|
|
2
|
+
|
|
3
|
+
Never touches an existing workspace or runs a challenge; Git uses a temporary clean directory.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from patchground.sessions.models import SessionError
|
|
18
|
+
from patchground.sessions.store import SESSIONS_ENV
|
|
19
|
+
from patchground.sessions.workflow import _root
|
|
20
|
+
|
|
21
|
+
MINIMUM_FREE_BYTES = 1024**3
|
|
22
|
+
_GIT_TIMEOUT = 10.0
|
|
23
|
+
_VERSION = re.compile(r"git version (\d+(?:\.\d+){1,3})")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GitProblem(StrEnum):
|
|
27
|
+
MISSING = "missing" # no git on PATH
|
|
28
|
+
UNUSABLE = "unusable" # found, but failed, timed out or printed no recognisable version
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class GitProbe:
|
|
33
|
+
available: bool
|
|
34
|
+
problem: GitProblem | None = None
|
|
35
|
+
version: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DiskProblem(StrEnum):
|
|
39
|
+
LOW_SPACE = "low_space"
|
|
40
|
+
NOT_WRITABLE = "not_writable"
|
|
41
|
+
UNREADABLE = "unreadable" # no existing ancestor could be inspected
|
|
42
|
+
INVALID_SETTING = "invalid_setting" # PATCHGROUND_SESSIONS_DIR is not an absolute path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class DiskProbe:
|
|
47
|
+
"""Space where sessions are created. ``location`` is display-safe (home shown as ``~``)."""
|
|
48
|
+
|
|
49
|
+
ok: bool
|
|
50
|
+
location: str
|
|
51
|
+
problem: DiskProblem | None = None
|
|
52
|
+
free_bytes: int | None = None
|
|
53
|
+
minimum_bytes: int = MINIMUM_FREE_BYTES
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def probe_git() -> GitProbe:
|
|
57
|
+
"""Run ``git --version`` with no user/system configuration and a bounded wait."""
|
|
58
|
+
executable = shutil.which("git")
|
|
59
|
+
if executable is None:
|
|
60
|
+
return GitProbe(available=False, problem=GitProblem.MISSING)
|
|
61
|
+
try:
|
|
62
|
+
resolved = str(Path(executable).resolve(strict=True))
|
|
63
|
+
with tempfile.TemporaryDirectory(prefix="patchground-doctor-") as home:
|
|
64
|
+
env = {
|
|
65
|
+
"PATH": os.defpath,
|
|
66
|
+
"HOME": home,
|
|
67
|
+
"XDG_CONFIG_HOME": home,
|
|
68
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
69
|
+
"GIT_CONFIG_SYSTEM": os.devnull,
|
|
70
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
71
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
72
|
+
"LC_ALL": "C",
|
|
73
|
+
}
|
|
74
|
+
completed = subprocess.run(
|
|
75
|
+
[resolved, "--version"],
|
|
76
|
+
cwd=home,
|
|
77
|
+
env=env,
|
|
78
|
+
check=True,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
timeout=_GIT_TIMEOUT,
|
|
81
|
+
)
|
|
82
|
+
except (OSError, subprocess.SubprocessError):
|
|
83
|
+
return GitProbe(available=False, problem=GitProblem.UNUSABLE)
|
|
84
|
+
match = _VERSION.search(completed.stdout.decode("ascii", errors="replace"))
|
|
85
|
+
if match is None:
|
|
86
|
+
return GitProbe(available=False, problem=GitProblem.UNUSABLE)
|
|
87
|
+
return GitProbe(available=True, version=match.group(1))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def probe_disk(root: Path | None = None, *, minimum_bytes: int = MINIMUM_FREE_BYTES) -> DiskProbe:
|
|
91
|
+
"""Check free space and writability at the sessions root, or its nearest existing ancestor."""
|
|
92
|
+
try:
|
|
93
|
+
target = _root(root)
|
|
94
|
+
except SessionError:
|
|
95
|
+
return DiskProbe(ok=False, location=SESSIONS_ENV, problem=DiskProblem.INVALID_SETTING)
|
|
96
|
+
location = display_path(target)
|
|
97
|
+
existing = _nearest_existing(target)
|
|
98
|
+
if existing is None:
|
|
99
|
+
return DiskProbe(ok=False, location=location, problem=DiskProblem.UNREADABLE)
|
|
100
|
+
if not existing.is_dir(): # a file where a directory is needed
|
|
101
|
+
return DiskProbe(ok=False, location=location, problem=DiskProblem.NOT_WRITABLE)
|
|
102
|
+
try:
|
|
103
|
+
free = shutil.disk_usage(existing).free
|
|
104
|
+
except OSError:
|
|
105
|
+
return DiskProbe(ok=False, location=location, problem=DiskProblem.UNREADABLE)
|
|
106
|
+
if not os.access(existing, os.W_OK | os.X_OK):
|
|
107
|
+
return DiskProbe(
|
|
108
|
+
ok=False,
|
|
109
|
+
location=location,
|
|
110
|
+
problem=DiskProblem.NOT_WRITABLE,
|
|
111
|
+
free_bytes=free,
|
|
112
|
+
minimum_bytes=minimum_bytes,
|
|
113
|
+
)
|
|
114
|
+
if free < minimum_bytes:
|
|
115
|
+
return DiskProbe(
|
|
116
|
+
ok=False,
|
|
117
|
+
location=location,
|
|
118
|
+
problem=DiskProblem.LOW_SPACE,
|
|
119
|
+
free_bytes=free,
|
|
120
|
+
minimum_bytes=minimum_bytes,
|
|
121
|
+
)
|
|
122
|
+
return DiskProbe(ok=True, location=location, free_bytes=free, minimum_bytes=minimum_bytes)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _nearest_existing(path: Path) -> Path | None:
|
|
126
|
+
for candidate in (path, *path.parents):
|
|
127
|
+
try:
|
|
128
|
+
if candidate.exists():
|
|
129
|
+
return candidate
|
|
130
|
+
except OSError:
|
|
131
|
+
return None
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def display_path(path: Path) -> str:
|
|
136
|
+
"""Show paths under the user's home as ``~/...`` so output never reveals the home path."""
|
|
137
|
+
try:
|
|
138
|
+
home = Path.home().absolute()
|
|
139
|
+
except (RuntimeError, KeyError):
|
|
140
|
+
return str(path)
|
|
141
|
+
try:
|
|
142
|
+
relative = path.relative_to(home)
|
|
143
|
+
except ValueError:
|
|
144
|
+
return str(path)
|
|
145
|
+
return "~" if relative == Path() else str(Path("~") / relative)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Bounded copies: no links, special files, Git metadata or credential files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
from contextlib import AbstractContextManager, suppress
|
|
9
|
+
from fnmatch import fnmatch
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from patchground import safeio
|
|
13
|
+
from patchground.sessions.models import SessionError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def excluded(name: str) -> bool:
|
|
17
|
+
return name.lower() == ".git" or any(
|
|
18
|
+
fnmatch(name.lower(), pattern) for pattern in (".env*", "*.pem", "*.key", "id_*")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def no_links(path: Path) -> None:
|
|
23
|
+
safeio.no_links(path, error=SessionError, label="session")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def safe_path(path: Path, *, directory: bool) -> None:
|
|
27
|
+
safeio.safe_path(path, directory=directory, error=SessionError, label="session")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def open_directory(path: Path) -> AbstractContextManager[int]:
|
|
31
|
+
return safeio.open_directory(path, error=SessionError, label="session")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def open_entry(parent: int, name: str, flags: int = os.O_RDONLY) -> AbstractContextManager[int]:
|
|
35
|
+
return safeio.open_entry(parent, name, flags, error=SessionError, label="session")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tree(path: Path, *, skip: bool = False) -> list[Path]:
|
|
39
|
+
entries: list[Path] = []
|
|
40
|
+
|
|
41
|
+
def visit(parent: int, base: Path) -> None:
|
|
42
|
+
for name in sorted(os.listdir(parent)):
|
|
43
|
+
with open_entry(parent, name) as descriptor:
|
|
44
|
+
if skip and excluded(name):
|
|
45
|
+
continue
|
|
46
|
+
entry = base / name
|
|
47
|
+
entries.append(entry)
|
|
48
|
+
if stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
|
49
|
+
visit(descriptor, entry)
|
|
50
|
+
|
|
51
|
+
with open_directory(path) as descriptor:
|
|
52
|
+
visit(descriptor, path)
|
|
53
|
+
return entries
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def copy_tree(source: Path, destination: Path) -> None:
|
|
57
|
+
def copy(parent: int, target: int) -> None:
|
|
58
|
+
for name in sorted(os.listdir(parent)):
|
|
59
|
+
with open_entry(parent, name) as descriptor:
|
|
60
|
+
info = os.fstat(descriptor)
|
|
61
|
+
if excluded(name):
|
|
62
|
+
continue
|
|
63
|
+
if stat.S_ISDIR(info.st_mode):
|
|
64
|
+
with suppress(FileExistsError):
|
|
65
|
+
os.mkdir(name, mode=0o700, dir_fd=target)
|
|
66
|
+
# Layers may merge directories, but never follow destination links.
|
|
67
|
+
with open_entry(target, name) as folder:
|
|
68
|
+
if not stat.S_ISDIR(os.fstat(folder).st_mode):
|
|
69
|
+
raise SessionError("check layer collision")
|
|
70
|
+
copy(descriptor, folder)
|
|
71
|
+
else:
|
|
72
|
+
try:
|
|
73
|
+
output = os.open(
|
|
74
|
+
name,
|
|
75
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
76
|
+
0o600,
|
|
77
|
+
dir_fd=target,
|
|
78
|
+
)
|
|
79
|
+
except FileExistsError as exc:
|
|
80
|
+
raise SessionError("check layer collision") from exc
|
|
81
|
+
with os.fdopen(output, "wb") as writer:
|
|
82
|
+
# dup owns only the stream lifetime; no pathname is reopened.
|
|
83
|
+
with os.fdopen(os.dup(descriptor), "rb") as reader:
|
|
84
|
+
shutil.copyfileobj(reader, writer)
|
|
85
|
+
os.fchmod(writer.fileno(), stat.S_IMODE(info.st_mode) & 0o777)
|
|
86
|
+
|
|
87
|
+
with open_directory(source) as source_fd, open_directory(destination) as destination_fd:
|
|
88
|
+
copy(source_fd, destination_fd)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def readonly(path: Path) -> None:
|
|
92
|
+
def visit(parent: int) -> None:
|
|
93
|
+
for name in os.listdir(parent):
|
|
94
|
+
with open_entry(parent, name) as descriptor:
|
|
95
|
+
mode = os.fstat(descriptor).st_mode
|
|
96
|
+
if stat.S_ISDIR(mode):
|
|
97
|
+
visit(descriptor)
|
|
98
|
+
os.fchmod(descriptor, 0o500 if stat.S_ISDIR(mode) or mode & 0o111 else 0o400)
|
|
99
|
+
|
|
100
|
+
with open_directory(path) as descriptor:
|
|
101
|
+
visit(descriptor)
|
|
102
|
+
os.fchmod(descriptor, 0o500)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def remove_partial(path: Path) -> None:
|
|
106
|
+
# Only called for a newly reserved directory, never an existing attempt.
|
|
107
|
+
for base, dirs, _files in os.walk(path, followlinks=False):
|
|
108
|
+
Path(base).chmod(0o700)
|
|
109
|
+
for name in dirs:
|
|
110
|
+
entry = Path(base) / name
|
|
111
|
+
if not entry.is_symlink():
|
|
112
|
+
entry.chmod(0o700)
|
|
113
|
+
shutil.rmtree(path)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Initialize fresh workspace Git before handoff; private checkpoints live in history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from patchground import safeio
|
|
11
|
+
from patchground.sessions.models import SessionError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_executable(*inputs: Path) -> str:
|
|
15
|
+
return safeio.resolve_git(*inputs, error=SessionError)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def initialize(
|
|
19
|
+
workspace: Path, *, source: Path | None = None, inputs: tuple[Path, ...] = ()
|
|
20
|
+
) -> None:
|
|
21
|
+
if (workspace / ".git").exists():
|
|
22
|
+
raise SessionError("refusing to reuse an existing repository")
|
|
23
|
+
executable = resolve_executable(workspace, *inputs, *((source,) if source is not None else ()))
|
|
24
|
+
with tempfile.TemporaryDirectory(prefix="patchground-git-") as home:
|
|
25
|
+
# Allowlist, not a GIT_* denylist: config, index, object stores, hooks and
|
|
26
|
+
# process injection settings must not be inherited from the caller.
|
|
27
|
+
env = {
|
|
28
|
+
"PATH": os.defpath,
|
|
29
|
+
"HOME": home,
|
|
30
|
+
"XDG_CONFIG_HOME": home,
|
|
31
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
32
|
+
"GIT_CONFIG_SYSTEM": os.devnull,
|
|
33
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
34
|
+
"GIT_ATTR_NOSYSTEM": "1",
|
|
35
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
36
|
+
"LC_ALL": "C",
|
|
37
|
+
}
|
|
38
|
+
command = [
|
|
39
|
+
executable,
|
|
40
|
+
"-c",
|
|
41
|
+
"core.hooksPath=" + os.devnull,
|
|
42
|
+
"-c",
|
|
43
|
+
"core.fsmonitor=false",
|
|
44
|
+
"-c",
|
|
45
|
+
"core.attributesFile=" + os.devnull,
|
|
46
|
+
"-c",
|
|
47
|
+
"core.autocrlf=false",
|
|
48
|
+
"-c",
|
|
49
|
+
"commit.gpgSign=false",
|
|
50
|
+
"-c",
|
|
51
|
+
"user.name=Patchground",
|
|
52
|
+
"-c",
|
|
53
|
+
"user.email=0+patchground@users.noreply.github.com",
|
|
54
|
+
]
|
|
55
|
+
for args in (
|
|
56
|
+
["init", "--template=", "--initial-branch=main"],
|
|
57
|
+
["add", "--all", "--", "."],
|
|
58
|
+
["commit", "--allow-empty", "-m", "Initial challenge workspace"],
|
|
59
|
+
):
|
|
60
|
+
try:
|
|
61
|
+
subprocess.run(
|
|
62
|
+
[*command, *args],
|
|
63
|
+
cwd=workspace,
|
|
64
|
+
env=env,
|
|
65
|
+
check=True,
|
|
66
|
+
capture_output=True,
|
|
67
|
+
timeout=60,
|
|
68
|
+
)
|
|
69
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
70
|
+
raise SessionError("could not initialize the fresh Git baseline") from exc
|