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.
Files changed (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. 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