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.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- 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)
|