raidlock 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 (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,35 @@
1
+ import json
2
+ import shlex
3
+
4
+ from ..core import Usage
5
+ from ..core.errors import Precondition
6
+ from .base import Adapter
7
+
8
+ KINDS = ("message", "command", "usage", "result", "error", "model", "files")
9
+
10
+
11
+ class Shell(Adapter):
12
+ """Any local command. Prompt on stdin, cwd is the clone. Stdout lines that are JSON objects with a
13
+ `kind` from KINDS are events; any other line, JSON or not, is an agent message."""
14
+
15
+ name = "shell"
16
+ runtime = "shell"
17
+ provider = "local"
18
+ binary = ""
19
+
20
+ def command(self, run):
21
+ if not run.cmd:
22
+ raise Precondition(
23
+ '--to shell needs --cmd "<command>"', hint='raidlock delegate <card> --to shell --cmd "./agent.sh"'
24
+ )
25
+ return shlex.split(run.cmd)
26
+
27
+ def parse(self, obj):
28
+ if obj.get("kind") == "usage":
29
+ fields = {k: int(obj.get(k, 0) or 0) for k in ("input", "cached_input", "cache_write", "output")}
30
+ return [{"kind": "usage", "usage": Usage(**fields), "usd": obj.get("usd")}]
31
+ if obj.get("kind") in KINDS:
32
+ return [dict(obj)]
33
+ if obj.get("type") == "raw":
34
+ return [{"kind": "message", "text": obj.get("text", "")}]
35
+ return [{"kind": "message", "text": json.dumps(obj, ensure_ascii=False)}]
@@ -0,0 +1,93 @@
1
+ """Cached Git object stores with a unique disposable worktree for every verification."""
2
+
3
+ import os
4
+ import tempfile
5
+ from contextlib import contextmanager
6
+
7
+ from ..core.errors import Precondition, Timeout
8
+ from ..core.lock import FileLock
9
+ from ..core.portable import remove_tree
10
+ from . import workspace
11
+
12
+ KEEP = 8
13
+ SHA = frozenset("0123456789abcdef")
14
+
15
+
16
+ def _remove(path):
17
+ if os.path.islink(path) or os.path.isfile(path):
18
+ os.unlink(path)
19
+ elif os.path.isdir(path):
20
+ remove_tree(path)
21
+
22
+
23
+ def _git(cwd, *args):
24
+ return workspace.git(cwd, *args, timeout=30)
25
+
26
+
27
+ def _usable(entry):
28
+ gitdir = os.path.join(entry, "git")
29
+ return all(os.path.isdir(path) and not os.path.islink(path) for path in (entry, gitdir)) and not os.path.lexists(
30
+ os.path.join(entry, "checkout")
31
+ )
32
+
33
+
34
+ def _fresh(src, sha, entry):
35
+ _remove(entry)
36
+ os.makedirs(entry, mode=0o700)
37
+ gitdir = os.path.join(entry, "git")
38
+ with workspace.object_view(src) as source:
39
+ _git(
40
+ entry, "clone", "--quiet", "--bare", "--no-local", "--no-hardlinks", source or os.path.realpath(src), gitdir
41
+ )
42
+ for remote in _git(gitdir, "remote").split():
43
+ _git(gitdir, "remote", "remove", remote)
44
+
45
+
46
+ def _prune(root, keep, current):
47
+ try:
48
+ names = [n for n in os.listdir(root) if set(n) <= SHA and n != current]
49
+ except OSError:
50
+ return
51
+ entries = sorted(
52
+ (os.path.join(root, n) for n in names if os.path.isdir(os.path.join(root, n))),
53
+ key=os.path.getmtime,
54
+ reverse=True,
55
+ )
56
+ for entry in entries[max(0, keep - 1) :]:
57
+ try:
58
+ with FileLock(entry + ".lock", timeout=0):
59
+ _remove(entry)
60
+ except (Timeout, OSError):
61
+ continue
62
+
63
+
64
+ @contextmanager
65
+ def checkout(src, sha, root, timeout=600.0, keep=KEEP, stats=None):
66
+ """Yield a fresh worktree and its cached Git directory while holding the entry lock."""
67
+ sha = (sha or "").strip().lower()
68
+ if len(sha) < 40 or not set(sha) <= SHA:
69
+ raise Precondition(f"verify needs a full commit SHA, got {sha[:80]!r}")
70
+ os.makedirs(root, mode=0o700, exist_ok=True)
71
+ entry = os.path.join(root, sha)
72
+ gitdir = os.path.join(entry, "git")
73
+ with FileLock(entry + ".lock", timeout=timeout):
74
+ reused = False
75
+ if _usable(entry):
76
+ try:
77
+ _git(gitdir, "cat-file", "-e", sha + "^{commit}")
78
+ reused = True
79
+ except (Precondition, OSError):
80
+ pass
81
+ if not reused:
82
+ _fresh(src, sha, entry)
83
+ if stats is not None:
84
+ stats["reused"] = reused
85
+ os.utime(entry)
86
+ worktree = tempfile.mkdtemp(prefix="raidlock-checkout-")
87
+ try:
88
+ _git(gitdir, "worktree", "add", "--quiet", "--detach", worktree, sha)
89
+ yield worktree, gitdir
90
+ finally:
91
+ _remove(worktree)
92
+ _git(gitdir, "worktree", "prune", "--expire=now")
93
+ _prune(root, keep, sha)
@@ -0,0 +1,376 @@
1
+ import os
2
+ import re
3
+ import shutil
4
+ import stat
5
+ import subprocess
6
+ import tempfile
7
+ import uuid
8
+ import zlib
9
+ from contextlib import ExitStack, contextmanager
10
+
11
+ from ..core import exe
12
+ from ..core.errors import Precondition
13
+ from ..core.portable import open_nofollow, remove_tree
14
+
15
+ METADATA = ("config", "hooks", "info", "objects", "objects/info", "refs")
16
+ PLANTED = (
17
+ "hooks",
18
+ "info/attributes",
19
+ "info/grafts",
20
+ "info/exclude",
21
+ "refs/replace",
22
+ "objects/info/alternates",
23
+ "objects/info/http-alternates",
24
+ )
25
+ IDENT = ("-c", "user.name=raidlock", "-c", "user.email=raidlock@localhost")
26
+
27
+
28
+ def git_env(extra=None):
29
+ env = {
30
+ k: os.environ[k]
31
+ for k in ("PATH", "HOME", "LANG", "LC_ALL", "SYSTEMROOT", "TMPDIR", "TEMP", "TMP")
32
+ if k in os.environ
33
+ }
34
+ context_keys = {
35
+ "GIT_DIR",
36
+ "GIT_WORK_TREE",
37
+ "GIT_INDEX_FILE",
38
+ "GIT_OBJECT_DIRECTORY",
39
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
40
+ "GIT_AUTHOR_NAME",
41
+ "GIT_AUTHOR_EMAIL",
42
+ "GIT_AUTHOR_DATE",
43
+ "GIT_COMMITTER_NAME",
44
+ "GIT_COMMITTER_EMAIL",
45
+ "GIT_COMMITTER_DATE",
46
+ }
47
+ env.update({k: v for k, v in (extra or {}).items() if not k.startswith("GIT_") or k in context_keys})
48
+ env.update(
49
+ GIT_CONFIG_NOSYSTEM="1",
50
+ GIT_CONFIG_GLOBAL=os.devnull,
51
+ GIT_TERMINAL_PROMPT="0",
52
+ GIT_OPTIONAL_LOCKS="0",
53
+ GIT_NO_REPLACE_OBJECTS="1",
54
+ GIT_ATTR_NOSYSTEM="1",
55
+ GIT_NO_LAZY_FETCH="1",
56
+ GIT_PAGER="cat",
57
+ GIT_SSH_COMMAND="false",
58
+ GIT_ASKPASS="false",
59
+ SSH_ASKPASS="false",
60
+ )
61
+ return env
62
+
63
+
64
+ SAFE_CONFIG = (
65
+ "core.hooksPath=" + os.devnull,
66
+ "core.fsmonitor=false",
67
+ "core.attributesFile=" + os.devnull,
68
+ "core.pager=cat",
69
+ "diff.external=",
70
+ "core.sshCommand=false",
71
+ "credential.helper=",
72
+ "gpg.program=false",
73
+ "gpg.ssh.program=false",
74
+ "gpg.x509.program=false",
75
+ "commit.gpgsign=false",
76
+ "core.askPass=false",
77
+ "core.untrackedCache=false",
78
+ "core.splitIndex=false",
79
+ )
80
+ READ_COMMANDS = {
81
+ "status",
82
+ "diff",
83
+ "diff-tree",
84
+ "rev-parse",
85
+ "rev-list",
86
+ "ls-tree",
87
+ "ls-files",
88
+ "cat-file",
89
+ "merge-base",
90
+ "log",
91
+ "show",
92
+ "show-ref",
93
+ "ls-remote",
94
+ "for-each-ref",
95
+ "merge-tree",
96
+ }
97
+
98
+ WRITE_COMMANDS = {
99
+ "add",
100
+ "commit",
101
+ "checkout",
102
+ "reset",
103
+ "apply",
104
+ "read-tree",
105
+ "write-tree",
106
+ "commit-tree",
107
+ "update-index",
108
+ }
109
+
110
+
111
+ def repo_location(path):
112
+ from ..core import repos
113
+
114
+ path = os.path.realpath(path)
115
+ if os.path.isfile(os.path.join(path, "HEAD")) and os.path.isdir(os.path.join(path, "objects")):
116
+ return path, None
117
+ root = repos.find_root(path)
118
+ return (repos.git_dir(root), root) if root else (None, None)
119
+
120
+
121
+ @contextmanager
122
+ def object_view(repo):
123
+ """Expose refs, objects and a copied index without reading local config or attributes."""
124
+ from ..core import repos
125
+
126
+ gdir, root = repo_location(repo)
127
+ if not gdir:
128
+ yield None
129
+ return
130
+ common = repos.common_dir(gdir)
131
+ with tempfile.TemporaryDirectory(prefix="raidlock-git-") as temp:
132
+ for name in ("HEAD", "index"):
133
+ source = os.path.join(gdir, name)
134
+ if os.path.isfile(source):
135
+ shutil.copyfile(source, os.path.join(temp, name))
136
+ os.symlink(os.path.abspath(os.path.join(common, "refs")), os.path.join(temp, "refs"))
137
+ objects = os.path.join(temp, "objects")
138
+ os.makedirs(os.path.join(objects, "info"))
139
+ with open(os.path.join(objects, "info", "alternates"), "w", newline="\n") as stream:
140
+ stream.write(os.path.abspath(os.path.join(common, "objects")) + "\n")
141
+ os.mkdir(os.path.join(objects, "4b"))
142
+ with open(os.path.join(objects, "4b", "825dc642cb6eb9a060e54bf8d69288fbee4904"), "wb") as stream:
143
+ stream.write(zlib.compress(b"tree 0\0"))
144
+ exclude = os.path.join(common, "info", "exclude")
145
+ if os.path.isfile(exclude):
146
+ os.mkdir(os.path.join(temp, "info"))
147
+ shutil.copyfile(exclude, os.path.join(temp, "info", "exclude"))
148
+ packed = os.path.join(common, "packed-refs")
149
+ if os.path.isfile(packed):
150
+ shutil.copyfile(packed, os.path.join(temp, "packed-refs"))
151
+ with open(os.path.join(temp, "config"), "w", newline="\n") as stream:
152
+ stream.write("[core]\nrepositoryformatversion = 0\nbare = " + ("false" if root else "true") + "\n")
153
+ for remote in repos._read_remotes(os.path.join(common, "config")):
154
+ if re.fullmatch(r"[A-Za-z0-9_.-]+", remote):
155
+ stream.write(f'[remote "{remote}"]\nfetch = +refs/heads/*:refs/remotes/{remote}/*\n')
156
+ yield temp
157
+
158
+
159
+ def git_run(cwd, *args, binary=False, env=None, timeout=None, input=None):
160
+ command = next((a for i, a in enumerate(args) if not a.startswith("-") and (i == 0 or args[i - 1] != "-c")), "")
161
+ full = git_env(env)
162
+ options = [item for value in SAFE_CONFIG for item in ("-c", value)]
163
+ if command in {"diff", "diff-tree", "log", "show"}:
164
+ pos = args.index(command) + 1
165
+ args = (*args[:pos], "--no-ext-diff", "--no-textconv", *args[pos:])
166
+ if command == "apply" and not ({"--index", "--cached"} & set(args)):
167
+ pos = args.index(command) + 1
168
+ args = (*args[:pos], "--index", *args[pos:])
169
+ with ExitStack() as stack:
170
+ read_only = command in READ_COMMANDS or (command == "config" and "--file" in args)
171
+ view = (
172
+ stack.enter_context(object_view(cwd))
173
+ if read_only or command in WRITE_COMMANDS or full.get("GIT_INDEX_FILE")
174
+ else None
175
+ )
176
+ persist = bool(
177
+ view and command in WRITE_COMMANDS and not full.get("GIT_DIR") and not full.get("GIT_INDEX_FILE")
178
+ )
179
+ clone_origin = None
180
+ if command in {"clone", "fetch"}:
181
+ replaced = list(args)
182
+ for i, arg in enumerate(args):
183
+ if os.path.isabs(arg) and os.path.isdir(arg) and (command != "clone" or i == len(args) - 2):
184
+ source = stack.enter_context(object_view(arg))
185
+ if source:
186
+ replaced[i] = source
187
+ if command == "clone":
188
+ clone_origin = arg
189
+ args = tuple(replaced)
190
+ if command == "clone":
191
+ args = (*args[: args.index(command) + 1], "--no-local", *args[args.index(command) + 1 :])
192
+ if view and not full.get("GIT_DIR"):
193
+ full.update(
194
+ GIT_DIR=view,
195
+ GIT_ATTR_SOURCE="4b825dc642cb6eb9a060e54bf8d69288fbee4904",
196
+ )
197
+ _, worktree = repo_location(cwd)
198
+ if worktree:
199
+ full["GIT_WORK_TREE"] = worktree
200
+ if persist:
201
+ from ..core.repos import common_dir
202
+
203
+ full["GIT_OBJECT_DIRECTORY"] = os.path.join(common_dir(repo_location(cwd)[0]), "objects")
204
+ if view and full.get("GIT_OBJECT_DIRECTORY"):
205
+ full["GIT_ALTERNATE_OBJECT_DIRECTORIES"] = os.pathsep.join(
206
+ filter(None, (full.get("GIT_ALTERNATE_OBJECT_DIRECTORIES"), os.path.join(view, "objects")))
207
+ )
208
+ command_at = args.index(command) if command else 0
209
+ proc = subprocess.run(
210
+ [exe.find("git"), *args[:command_at], "--no-pager", *options, *args[command_at:]],
211
+ cwd=cwd,
212
+ env=full,
213
+ capture_output=True,
214
+ input=input,
215
+ timeout=timeout,
216
+ text=not binary,
217
+ **({} if binary else {"encoding": "utf-8", "errors": "replace"}),
218
+ )
219
+
220
+ if persist and proc.returncode == 0:
221
+ gdir = repo_location(cwd)[0]
222
+ for name in ("HEAD", "index"):
223
+ path = os.path.join(view, name)
224
+ if os.path.isfile(path):
225
+ _replace_file(os.path.join(gdir, name), _read_nofollow(path))
226
+ if clone_origin and proc.returncode == 0:
227
+ destination = os.path.abspath(os.path.join(cwd, args[-1]))
228
+ git(destination, "config", "remote.origin.url", clone_origin)
229
+ if view and command == "rev-parse":
230
+ before, after = view, os.path.abspath(repo_location(cwd)[0])
231
+ proc.stdout = (
232
+ proc.stdout.replace(before.encode(), after.encode()) if binary else proc.stdout.replace(before, after)
233
+ )
234
+ return proc
235
+
236
+
237
+ def git(cwd, *args, check=True, binary=False, env=None, timeout=None, input=None):
238
+ proc = git_run(cwd, *args, binary=binary, env=env, timeout=timeout, input=input)
239
+ if check and proc.returncode != 0:
240
+ err = proc.stderr.decode("utf-8", "replace") if binary else proc.stderr
241
+ raise Precondition(f"git {' '.join(args[:2])} failed: {err.strip()[:300]}", cwd=cwd)
242
+ return proc.stdout
243
+
244
+
245
+ def head(repo):
246
+ return git(repo, "rev-parse", "HEAD").strip()
247
+
248
+
249
+ def is_ancestor(repo, older, newer):
250
+ if not re.fullmatch(r"[0-9a-f]{7,64}", older or "") or not re.fullmatch(r"[0-9a-f]{7,64}", newer or ""):
251
+ return False
252
+ return git_run(repo, "merge-base", "--is-ancestor", older, newer, timeout=30).returncode == 0
253
+
254
+
255
+ def dirty(repo):
256
+ return bool(git(repo, "status", "--porcelain", "--untracked-files=no", check=False).strip())
257
+
258
+
259
+ def clone(src, dst, ref=None):
260
+ """Clone the committed state of `src` into `dst` through a pack transfer, drop its remotes, return
261
+ (BASE, branch)."""
262
+ if os.path.exists(dst):
263
+ remove_tree(dst)
264
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
265
+ git(os.path.dirname(dst), "clone", "--quiet", "--no-local", "--no-hardlinks", os.path.realpath(src), dst)
266
+ if ref:
267
+ commit = git(src, "rev-parse", "--verify", "--end-of-options", ref + "^{commit}").strip()
268
+ git(dst, "checkout", "--quiet", "--detach", commit)
269
+ for remote in git(dst, "remote").split():
270
+ git(dst, "remote", "remove", remote)
271
+ base = head(dst)
272
+ branch = git(dst, "rev-parse", "--abbrev-ref", "HEAD", check=False).strip()
273
+ return base, branch
274
+
275
+
276
+ def check_metadata(repo):
277
+ """Refuse a clone whose git metadata is a symlink or a gitfile: the runner would write through it."""
278
+ if os.path.islink(repo):
279
+ raise Precondition("symlinked git metadata: clone root is a symlink", cwd=repo, path=".")
280
+ gdir = os.path.join(repo, ".git")
281
+ try:
282
+ st = os.lstat(gdir)
283
+ except FileNotFoundError:
284
+ raise Precondition("the clone has no .git directory any more", cwd=repo) from None
285
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
286
+ raise Precondition("symlinked git metadata: .git is not a plain directory", cwd=repo, path=".git")
287
+ for rel in METADATA:
288
+ path = os.path.join(gdir, rel)
289
+ if os.path.islink(path):
290
+ raise Precondition(f"symlinked git metadata: .git/{rel} is a symlink", cwd=repo, path=".git/" + rel)
291
+ return gdir
292
+
293
+
294
+ def snapshot_config(repo):
295
+ gdir = check_metadata(repo)
296
+ return _read_nofollow(os.path.join(gdir, "config"))
297
+
298
+
299
+ def _read_nofollow(path):
300
+ fd = open_nofollow(path, os.O_RDONLY)
301
+ with os.fdopen(fd, "rb") as f:
302
+ return f.read()
303
+
304
+
305
+ def _replace_file(path, data):
306
+ folder = os.path.dirname(path)
307
+ tmp = os.path.join(folder, f".raidlock-{uuid.uuid4().hex}.tmp")
308
+ fd = open_nofollow(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
309
+ try:
310
+ with os.fdopen(fd, "wb") as f:
311
+ f.write(data)
312
+ os.replace(tmp, path)
313
+ except BaseException:
314
+ if os.path.lexists(tmp):
315
+ os.unlink(tmp)
316
+ raise
317
+
318
+
319
+ def _remove(path):
320
+ if os.path.islink(path) or os.path.isfile(path):
321
+ os.unlink(path)
322
+ elif os.path.isdir(path):
323
+ remove_tree(path)
324
+
325
+
326
+ def restore(repo, config):
327
+ """Undo what an agent could plant in .git before anyone runs git there again: config, hooks, attributes,
328
+ replace refs, grafts and alternates. Refuses symlinked metadata instead of writing through it."""
329
+ gdir = check_metadata(repo)
330
+ _replace_file(os.path.join(gdir, "config"), config)
331
+ for rel in PLANTED:
332
+ _remove(os.path.join(gdir, rel))
333
+
334
+
335
+ def audit(source, base, worktree, store):
336
+ """Hash the worker's files into a lead-owned object store that holds BASE fetched from `source`, and return
337
+ (changed paths, tree). The worker's .git (refs, index, objects, config) is never read."""
338
+ if os.path.exists(store):
339
+ remove_tree(store)
340
+ git(os.path.dirname(store), "init", "--quiet", "--bare", store)
341
+ git(
342
+ store,
343
+ "-c",
344
+ "uploadpack.allowAnySHA1InWant=true",
345
+ "fetch",
346
+ "--quiet",
347
+ "--no-tags",
348
+ "--depth=1",
349
+ os.path.realpath(source),
350
+ base,
351
+ )
352
+ env = {
353
+ "GIT_DIR": store,
354
+ "GIT_WORK_TREE": os.path.realpath(worktree),
355
+ "GIT_INDEX_FILE": os.path.join(store, "audit-index"),
356
+ }
357
+ git(store, "-c", "core.bare=false", "read-tree", base, env=env)
358
+ git(store, "-c", "core.bare=false", "-c", "core.symlinks=true", "add", "-A", env=env)
359
+ tree = git(store, "-c", "core.bare=false", "write-tree", env=env).strip()
360
+ paths = git(store, "diff", "--name-only", "--no-renames", "-z", base, tree).split("\0")
361
+ return sorted({p for p in paths if p}), tree
362
+
363
+
364
+ def write_patch(store, base, tree, out):
365
+ """Write a binary patch from BASE to the audited tree to `out`; return its size."""
366
+ data = git(store, "diff", "--binary", "--no-renames", base, tree, binary=True)
367
+ with open(out, "wb") as f:
368
+ f.write(data)
369
+ return len(data)
370
+
371
+
372
+ def apply_and_commit(repo, patch_file):
373
+ git(repo, "apply", "--whitespace=nowarn", patch_file)
374
+ git(repo, "add", "-A")
375
+ git(repo, *IDENT, "commit", "--quiet", "--no-verify", "-m", "changes under review")
376
+ return head(repo)