h2h-dev-tools 0.2.1__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.
- h2h_dev_tools/__init__.py +1 -0
- h2h_dev_tools/__main__.py +43 -0
- h2h_dev_tools/atomic_files.py +32 -0
- h2h_dev_tools/bundle.py +107 -0
- h2h_dev_tools/bundle_verify.py +255 -0
- h2h_dev_tools/change_scope.py +167 -0
- h2h_dev_tools/ci_dependencies.py +70 -0
- h2h_dev_tools/dependency_audit.py +220 -0
- h2h_dev_tools/dependency_manifest.py +23 -0
- h2h_dev_tools/distribution_check.py +70 -0
- h2h_dev_tools/documentation.py +175 -0
- h2h_dev_tools/formal.py +209 -0
- h2h_dev_tools/git-flow-merge.sh +263 -0
- h2h_dev_tools/install-git-hooks.sh +34 -0
- h2h_dev_tools/migrate_v1.py +61 -0
- h2h_dev_tools/offline_wheel.py +138 -0
- h2h_dev_tools/primary-branch.sh +52 -0
- h2h_dev_tools/process.py +873 -0
- h2h_dev_tools/publish_version.py +106 -0
- h2h_dev_tools/rebuild-env.sh +80 -0
- h2h_dev_tools/version_policy.py +391 -0
- h2h_dev_tools-0.2.1.dist-info/METADATA +221 -0
- h2h_dev_tools-0.2.1.dist-info/RECORD +26 -0
- h2h_dev_tools-0.2.1.dist-info/WHEEL +4 -0
- h2h_dev_tools-0.2.1.dist-info/entry_points.txt +2 -0
- h2h_dev_tools-0.2.1.dist-info/licenses/LICENSE +674 -0
h2h_dev_tools/formal.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Checksum-pinned TLC acquisition and offline host/container execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import tomllib
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
TlaRuntime = Literal["auto", "host", "docker"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class TlcLock:
|
|
23
|
+
filename: str
|
|
24
|
+
url: str
|
|
25
|
+
sha256: str
|
|
26
|
+
runtime_image: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_tlc_lock(path: Path, *, expected_version: str) -> TlcLock:
|
|
30
|
+
with path.open("rb") as stream:
|
|
31
|
+
document = tomllib.load(stream)
|
|
32
|
+
value = document.get("tla_plus", {}).get("tlc")
|
|
33
|
+
if not isinstance(value, dict):
|
|
34
|
+
raise RuntimeError(f"Missing [tla_plus.tlc] in {path}")
|
|
35
|
+
if value.get("version") != expected_version:
|
|
36
|
+
raise RuntimeError(f"Unsupported tla_plus.tlc.version in {path}")
|
|
37
|
+
for field in ("filename", "url", "sha256", "runtime_image"):
|
|
38
|
+
if not isinstance(value.get(field), str) or not value[field]:
|
|
39
|
+
raise RuntimeError(f"Invalid tla_plus.tlc.{field} in {path}")
|
|
40
|
+
filename, url, digest, image = (
|
|
41
|
+
str(value[field]) for field in ("filename", "url", "sha256", "runtime_image")
|
|
42
|
+
)
|
|
43
|
+
if Path(filename).name != filename or filename in {".", ".."}:
|
|
44
|
+
raise RuntimeError(f"tla_plus.tlc.filename in {path} must be a basename")
|
|
45
|
+
if not url.startswith("https://"):
|
|
46
|
+
raise RuntimeError(f"tla_plus.tlc.url in {path} must use HTTPS")
|
|
47
|
+
if re.fullmatch(r"[0-9a-fA-F]{64}", digest) is None:
|
|
48
|
+
raise RuntimeError(f"tla_plus.tlc.sha256 in {path} is invalid")
|
|
49
|
+
if re.fullmatch(r"[^@]+@sha256:[0-9a-f]{64}", image) is None:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f"tla_plus.tlc.runtime_image in {path} must be digest-pinned"
|
|
52
|
+
)
|
|
53
|
+
return TlcLock(filename, url, digest.lower(), image)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sha256(path: Path) -> str:
|
|
57
|
+
digest = hashlib.sha256()
|
|
58
|
+
with path.open("rb") as stream:
|
|
59
|
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
60
|
+
digest.update(block)
|
|
61
|
+
return digest.hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify_jar(path: Path, expected: str) -> None:
|
|
65
|
+
actual = sha256(path)
|
|
66
|
+
if actual != expected:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
f"TLC checksum mismatch: expected={expected} actual={actual}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fetch_tlc(lock: TlcLock, output_directory: Path) -> Path:
|
|
73
|
+
"""Explicit online acquisition; verification never calls this function."""
|
|
74
|
+
output_directory.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
destination = output_directory / lock.filename
|
|
76
|
+
if destination.is_file() and sha256(destination) == lock.sha256:
|
|
77
|
+
return destination
|
|
78
|
+
with tempfile.NamedTemporaryFile(
|
|
79
|
+
dir=output_directory, prefix=".tlc-", delete=False
|
|
80
|
+
) as temporary:
|
|
81
|
+
temporary_path = Path(temporary.name)
|
|
82
|
+
try:
|
|
83
|
+
with urllib.request.urlopen(lock.url, timeout=60) as response:
|
|
84
|
+
shutil.copyfileobj(response, temporary)
|
|
85
|
+
except BaseException:
|
|
86
|
+
temporary_path.unlink(missing_ok=True)
|
|
87
|
+
raise
|
|
88
|
+
try:
|
|
89
|
+
verify_jar(temporary_path, lock.sha256)
|
|
90
|
+
os.replace(temporary_path, destination)
|
|
91
|
+
finally:
|
|
92
|
+
temporary_path.unlink(missing_ok=True)
|
|
93
|
+
return destination
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fetch_main(repository_root: Path, *, expected_version: str) -> None:
|
|
97
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--output-directory", type=Path, default=repository_root / ".formal-tools"
|
|
100
|
+
)
|
|
101
|
+
arguments = parser.parse_args()
|
|
102
|
+
lock = load_tlc_lock(
|
|
103
|
+
repository_root / "verification/tools.lock.toml",
|
|
104
|
+
expected_version=expected_version,
|
|
105
|
+
)
|
|
106
|
+
print(fetch_tlc(lock, arguments.output_directory))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def host_java_available() -> bool:
|
|
110
|
+
java = shutil.which("java")
|
|
111
|
+
if java is None:
|
|
112
|
+
return False
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
[java, "-version"],
|
|
115
|
+
check=False,
|
|
116
|
+
stdout=subprocess.DEVNULL,
|
|
117
|
+
stderr=subprocess.DEVNULL,
|
|
118
|
+
)
|
|
119
|
+
return result.returncode == 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run(command: list[str], *, cwd: Path) -> None:
|
|
123
|
+
print("+", " ".join(command), flush=True)
|
|
124
|
+
subprocess.run(command, cwd=cwd, check=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def verify_tla(
|
|
128
|
+
repository_root: Path,
|
|
129
|
+
tla_jar: Path,
|
|
130
|
+
*,
|
|
131
|
+
profile: str,
|
|
132
|
+
runtime: TlaRuntime,
|
|
133
|
+
expected_version: str,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Execute the explicitly selected finite model profile, without downloading."""
|
|
136
|
+
repository_root = repository_root.resolve()
|
|
137
|
+
if not tla_jar.is_file():
|
|
138
|
+
raise FileNotFoundError(f"TLC JAR does not exist: {tla_jar}")
|
|
139
|
+
lock = load_tlc_lock(
|
|
140
|
+
repository_root / "verification/tools.lock.toml",
|
|
141
|
+
expected_version=expected_version,
|
|
142
|
+
)
|
|
143
|
+
verify_jar(tla_jar, lock.sha256)
|
|
144
|
+
configs = sorted((repository_root / "verification/tla").glob(f"*{profile}.cfg"))
|
|
145
|
+
if not configs:
|
|
146
|
+
raise RuntimeError("No TLA+ model configurations were found")
|
|
147
|
+
use_host_java = runtime == "host" or (runtime == "auto" and host_java_available())
|
|
148
|
+
if runtime == "host" and not host_java_available():
|
|
149
|
+
raise RuntimeError("The requested host Java runtime is not available")
|
|
150
|
+
if not use_host_java and shutil.which("docker") is None:
|
|
151
|
+
raise RuntimeError("Neither a working Java runtime nor Docker is available")
|
|
152
|
+
for config in configs:
|
|
153
|
+
module_stem = config.name.removesuffix(f"{profile}.cfg")
|
|
154
|
+
if not module_stem:
|
|
155
|
+
raise RuntimeError(f"Cannot infer a TLA+ module from {config.name}")
|
|
156
|
+
module = config.with_name(f"{module_stem}.tla")
|
|
157
|
+
if not module.is_file():
|
|
158
|
+
raise RuntimeError(f"Missing TLA+ module for {config.name}")
|
|
159
|
+
with tempfile.TemporaryDirectory(prefix="h2h-tlc-") as state_directory:
|
|
160
|
+
if use_host_java:
|
|
161
|
+
command = [
|
|
162
|
+
"java",
|
|
163
|
+
"-XX:+UseParallelGC",
|
|
164
|
+
"-Xmx4g",
|
|
165
|
+
"-cp",
|
|
166
|
+
str(tla_jar.resolve()),
|
|
167
|
+
"tlc2.TLC",
|
|
168
|
+
"-workers",
|
|
169
|
+
"auto",
|
|
170
|
+
"-metadir",
|
|
171
|
+
str(Path(state_directory) / config.stem),
|
|
172
|
+
"-config",
|
|
173
|
+
config.name,
|
|
174
|
+
module.name,
|
|
175
|
+
]
|
|
176
|
+
_run(command, cwd=config.parent)
|
|
177
|
+
else:
|
|
178
|
+
command = [
|
|
179
|
+
"docker",
|
|
180
|
+
"run",
|
|
181
|
+
"--rm",
|
|
182
|
+
"--pull=never",
|
|
183
|
+
"--network",
|
|
184
|
+
"none",
|
|
185
|
+
"--read-only",
|
|
186
|
+
"--tmpfs",
|
|
187
|
+
"/tmp:rw,nosuid,nodev,size=512m",
|
|
188
|
+
"--mount",
|
|
189
|
+
f"type=bind,src={repository_root},dst=/workspace,readonly",
|
|
190
|
+
"--mount",
|
|
191
|
+
f"type=bind,src={tla_jar.resolve()},dst=/tools/tla2tools.jar,readonly",
|
|
192
|
+
"--workdir",
|
|
193
|
+
"/workspace/verification/tla",
|
|
194
|
+
lock.runtime_image,
|
|
195
|
+
"java",
|
|
196
|
+
"-XX:+UseParallelGC",
|
|
197
|
+
"-Xmx4g",
|
|
198
|
+
"-cp",
|
|
199
|
+
"/tools/tla2tools.jar",
|
|
200
|
+
"tlc2.TLC",
|
|
201
|
+
"-workers",
|
|
202
|
+
"auto",
|
|
203
|
+
"-metadir",
|
|
204
|
+
f"/tmp/tlc/{config.stem}",
|
|
205
|
+
"-config",
|
|
206
|
+
config.name,
|
|
207
|
+
module.name,
|
|
208
|
+
]
|
|
209
|
+
_run(command, cwd=repository_root)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
fail() {
|
|
6
|
+
printf 'git-flow-merge: %s\n' "$1" >&2
|
|
7
|
+
exit 1
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
usage() {
|
|
11
|
+
printf 'usage: scripts/git-flow-merge.sh [--cleanup-only]\n' >&2
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
assert_clean() {
|
|
15
|
+
local status_output worktree=$1
|
|
16
|
+
|
|
17
|
+
status_output="$(
|
|
18
|
+
git -C "$worktree" status --porcelain=v1 --untracked-files=all
|
|
19
|
+
)" || fail "could not inspect worktree status: $worktree"
|
|
20
|
+
[[ -z "$status_output" ]] \
|
|
21
|
+
|| fail "worktree is not clean: $worktree"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
assert_no_operation() {
|
|
25
|
+
local git_path name worktree=$1
|
|
26
|
+
|
|
27
|
+
for name in \
|
|
28
|
+
MERGE_HEAD \
|
|
29
|
+
CHERRY_PICK_HEAD \
|
|
30
|
+
REVERT_HEAD \
|
|
31
|
+
REBASE_HEAD \
|
|
32
|
+
rebase-apply \
|
|
33
|
+
rebase-merge \
|
|
34
|
+
sequencer \
|
|
35
|
+
BISECT_START; do
|
|
36
|
+
git_path="$(
|
|
37
|
+
git -C "$worktree" \
|
|
38
|
+
rev-parse --path-format=absolute --git-path "$name"
|
|
39
|
+
)" || fail "could not inspect Git operation state: $worktree"
|
|
40
|
+
[[ ! -e "$git_path" ]] || fail "Git operation is active: $name"
|
|
41
|
+
done
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
assert_ref_equals() {
|
|
45
|
+
local actual expected=$3 ref=$2 worktree=$1
|
|
46
|
+
|
|
47
|
+
actual="$(
|
|
48
|
+
git -C "$worktree" rev-parse --verify "$ref^{commit}" 2>/dev/null
|
|
49
|
+
)" || fail "Git ref is no longer available: $ref"
|
|
50
|
+
[[ "$actual" == "$expected" ]] \
|
|
51
|
+
|| fail "Git ref changed during cleanup: $ref"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
is_ancestor() {
|
|
55
|
+
local ancestor=$2 descendant=$3 status worktree=$1
|
|
56
|
+
|
|
57
|
+
if git -C "$worktree" merge-base --is-ancestor "$ancestor" "$descendant"; then
|
|
58
|
+
return 0
|
|
59
|
+
else
|
|
60
|
+
status=$?
|
|
61
|
+
fi
|
|
62
|
+
[[ "$status" -eq 1 ]] \
|
|
63
|
+
|| fail "could not verify commit ancestry: $ancestor $descendant"
|
|
64
|
+
return 1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
abort_merge_if_needed() {
|
|
68
|
+
local worktree=$1 merge_head
|
|
69
|
+
|
|
70
|
+
merge_head="$(
|
|
71
|
+
git -C "$worktree" \
|
|
72
|
+
rev-parse --path-format=absolute --git-path MERGE_HEAD
|
|
73
|
+
)" || fail "could not inspect merge state: $worktree"
|
|
74
|
+
if [[ -e "$merge_head" ]]; then
|
|
75
|
+
git -C "$worktree" merge --abort || return 1
|
|
76
|
+
fi
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
restore_after_failure() {
|
|
80
|
+
local status=$?
|
|
81
|
+
|
|
82
|
+
trap - EXIT HUP INT TERM
|
|
83
|
+
if (( status != 0 )); then
|
|
84
|
+
if [[ -n "$reviewer_pid" ]]; then
|
|
85
|
+
# wait is interruptible; stop review before discarding its candidate.
|
|
86
|
+
kill -TERM "$reviewer_pid" 2>/dev/null || true
|
|
87
|
+
wait "$reviewer_pid" || true
|
|
88
|
+
fi
|
|
89
|
+
if abort_merge_if_needed "$merge_worktree"; then
|
|
90
|
+
# A signal or cleanup failure after the commit must not undo success.
|
|
91
|
+
if [[ "$merge_worktree" == "$repository_root" ]] \
|
|
92
|
+
&& [[ "$(git -C "$merge_worktree" rev-parse HEAD)" == "$primary_oid" ]]; then
|
|
93
|
+
git -C "$repository_root" switch "$task_branch" || {
|
|
94
|
+
printf 'git-flow-merge: task branch retained; could not switch to %s\n' \
|
|
95
|
+
"$task_branch" >&2
|
|
96
|
+
}
|
|
97
|
+
fi
|
|
98
|
+
fi
|
|
99
|
+
fi
|
|
100
|
+
exit "$status"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
repository_root=${1:?repository root is required}
|
|
104
|
+
strategy=${2:?merge strategy is required}
|
|
105
|
+
review_command=${3-}
|
|
106
|
+
shift 3
|
|
107
|
+
[[ "$strategy" == direct || "$strategy" == candidate ]] || fail 'invalid merge strategy'
|
|
108
|
+
|
|
109
|
+
mode='finish'
|
|
110
|
+
if [[ $# -gt 1 ]]; then
|
|
111
|
+
usage
|
|
112
|
+
fail 'too many arguments'
|
|
113
|
+
fi
|
|
114
|
+
if [[ $# -eq 1 ]]; then
|
|
115
|
+
[[ "$1" == '--cleanup-only' ]] || {
|
|
116
|
+
usage
|
|
117
|
+
fail "unsupported argument: $1"
|
|
118
|
+
}
|
|
119
|
+
mode='cleanup-only'
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
cd "$repository_root"
|
|
123
|
+
|
|
124
|
+
primary="$(scripts/detect-primary-branch.sh)"
|
|
125
|
+
task_branch="$(git branch --show-current)"
|
|
126
|
+
[[ -n "$task_branch" ]] || fail 'detached HEAD is not supported'
|
|
127
|
+
[[ "$task_branch" != "$primary" ]] \
|
|
128
|
+
|| fail 'run this script from the task branch'
|
|
129
|
+
|
|
130
|
+
assert_clean "$repository_root"
|
|
131
|
+
assert_no_operation "$repository_root"
|
|
132
|
+
|
|
133
|
+
primary_ref="refs/heads/$primary"
|
|
134
|
+
task_ref="refs/heads/$task_branch"
|
|
135
|
+
primary_oid="$(git rev-parse --verify "$primary_ref^{commit}")" \
|
|
136
|
+
|| fail "primary branch is not a commit: $primary"
|
|
137
|
+
task_oid="$(git rev-parse --verify "$task_ref^{commit}")" \
|
|
138
|
+
|| fail "task branch is not a commit: $task_branch"
|
|
139
|
+
git merge-base "$primary_oid" "$task_oid" >/dev/null \
|
|
140
|
+
|| fail "task branch and primary have no common ancestor: $primary"
|
|
141
|
+
|
|
142
|
+
primary_worktree=''
|
|
143
|
+
candidate_worktree=''
|
|
144
|
+
candidate_branch=''
|
|
145
|
+
worktree_listing="$(git worktree list --porcelain)" \
|
|
146
|
+
|| fail 'could not inspect repository worktrees'
|
|
147
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
148
|
+
case "$line" in
|
|
149
|
+
'worktree '*)
|
|
150
|
+
candidate_worktree="${line#worktree }"
|
|
151
|
+
candidate_branch=''
|
|
152
|
+
;;
|
|
153
|
+
'branch refs/heads/'*)
|
|
154
|
+
candidate_branch="${line#branch refs/heads/}"
|
|
155
|
+
if [[ "$candidate_branch" == "$primary" ]]; then
|
|
156
|
+
primary_worktree="$candidate_worktree"
|
|
157
|
+
fi
|
|
158
|
+
;;
|
|
159
|
+
esac
|
|
160
|
+
done <<< "$worktree_listing"
|
|
161
|
+
|
|
162
|
+
if is_ancestor "$repository_root" "$task_oid" "$primary_oid"; then
|
|
163
|
+
if [[ -n "$primary_worktree" ]]; then
|
|
164
|
+
assert_clean "$primary_worktree"
|
|
165
|
+
assert_no_operation "$primary_worktree"
|
|
166
|
+
cleanup_worktree="$primary_worktree"
|
|
167
|
+
else
|
|
168
|
+
cleanup_worktree="$repository_root"
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
assert_ref_equals "$repository_root" "$task_ref" "$task_oid"
|
|
172
|
+
assert_ref_equals "$repository_root" "$primary_ref" "$primary_oid"
|
|
173
|
+
is_ancestor "$repository_root" "$task_oid" "$primary_oid" \
|
|
174
|
+
|| fail 'task is no longer contained in primary; branch was retained'
|
|
175
|
+
|
|
176
|
+
if [[ "$cleanup_worktree" == "$repository_root" ]]; then
|
|
177
|
+
git switch "$primary" \
|
|
178
|
+
|| fail "could not switch to primary; task branch was retained: $primary"
|
|
179
|
+
else
|
|
180
|
+
cd "$cleanup_worktree"
|
|
181
|
+
git -C "$cleanup_worktree" worktree remove "$repository_root" \
|
|
182
|
+
|| fail "no-op task worktree remains: $repository_root"
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
assert_clean "$cleanup_worktree"
|
|
186
|
+
assert_no_operation "$cleanup_worktree"
|
|
187
|
+
assert_ref_equals "$cleanup_worktree" "$task_ref" "$task_oid"
|
|
188
|
+
assert_ref_equals "$cleanup_worktree" "$primary_ref" "$primary_oid"
|
|
189
|
+
is_ancestor "$cleanup_worktree" "$task_oid" "$primary_oid" \
|
|
190
|
+
|| fail 'task is no longer contained in primary; branch was retained'
|
|
191
|
+
git -C "$cleanup_worktree" branch -d "$task_branch" \
|
|
192
|
+
|| fail "no-op task branch remains: $task_branch"
|
|
193
|
+
|
|
194
|
+
printf 'Cleaned up no-op task %s already contained in %s at %s\n' \
|
|
195
|
+
"$task_branch" "$primary" "$task_oid"
|
|
196
|
+
exit 0
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
[[ "$mode" != 'cleanup-only' ]] \
|
|
200
|
+
|| fail 'task contains commits not present in primary; branch was retained'
|
|
201
|
+
|
|
202
|
+
hooks_path="$(git config --local --get core.hooksPath || true)"
|
|
203
|
+
[[ "$hooks_path" == '.githooks' ]] \
|
|
204
|
+
|| fail 'repository hooks are not installed; run scripts/install-git-hooks.sh'
|
|
205
|
+
[[ -x .githooks/pre-merge-commit ]] \
|
|
206
|
+
|| fail '.githooks/pre-merge-commit is missing or not executable'
|
|
207
|
+
if [[ "$strategy" == candidate ]]; then
|
|
208
|
+
[[ -x .githooks/pre-commit ]] || fail '.githooks/pre-commit is missing or not executable'
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
if [[ -n "$primary_worktree" ]]; then
|
|
212
|
+
assert_clean "$primary_worktree"
|
|
213
|
+
assert_no_operation "$primary_worktree"
|
|
214
|
+
merge_worktree="$primary_worktree"
|
|
215
|
+
else
|
|
216
|
+
merge_worktree="$repository_root"
|
|
217
|
+
fi
|
|
218
|
+
|
|
219
|
+
reviewer_pid=''
|
|
220
|
+
trap restore_after_failure EXIT
|
|
221
|
+
trap 'exit 129' HUP
|
|
222
|
+
trap 'exit 130' INT
|
|
223
|
+
trap 'exit 143' TERM
|
|
224
|
+
if [[ -z "$primary_worktree" ]]; then
|
|
225
|
+
git switch "$primary"
|
|
226
|
+
fi
|
|
227
|
+
assert_ref_equals "$merge_worktree" "$primary_ref" "$primary_oid"
|
|
228
|
+
assert_ref_equals "$merge_worktree" "$task_ref" "$task_oid"
|
|
229
|
+
export WORKFLOW_MERGE_TASK_REF="$task_branch"
|
|
230
|
+
if [[ "$strategy" == direct ]]; then
|
|
231
|
+
# Git invokes pre-merge-commit for the exact candidate in these repositories.
|
|
232
|
+
git -C "$merge_worktree" merge --no-ff --no-edit "$task_branch" \
|
|
233
|
+
|| fail 'merge or merge gate failed; task branch was retained'
|
|
234
|
+
else
|
|
235
|
+
# These repositories' pre-commit hooks delegate pending merges to their gate.
|
|
236
|
+
git -C "$merge_worktree" merge --no-ff --no-commit "$task_branch" \
|
|
237
|
+
|| fail 'merge failed; task branch was retained'
|
|
238
|
+
if [[ -n "$review_command" ]]; then
|
|
239
|
+
(cd "$merge_worktree" && exec bash "$review_command") &
|
|
240
|
+
reviewer_pid=$!
|
|
241
|
+
if ! wait "$reviewer_pid"; then
|
|
242
|
+
reviewer_pid=''
|
|
243
|
+
fail 'code review failed; task branch was retained'
|
|
244
|
+
fi
|
|
245
|
+
reviewer_pid=''
|
|
246
|
+
fi
|
|
247
|
+
git -C "$merge_worktree" commit --no-edit \
|
|
248
|
+
|| fail 'merge commit or release gate failed; task branch was retained'
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
cd "$merge_worktree"
|
|
252
|
+
if [[ "$repository_root" != "$merge_worktree" ]]; then
|
|
253
|
+
assert_clean "$repository_root"
|
|
254
|
+
git -C "$merge_worktree" worktree remove "$repository_root" \
|
|
255
|
+
|| fail "merge succeeded, but task worktree remains: $repository_root"
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
git -C "$merge_worktree" branch -d "$task_branch" \
|
|
259
|
+
|| fail "merge succeeded, but task branch remains: $task_branch"
|
|
260
|
+
|
|
261
|
+
merge_commit="$(git -C "$merge_worktree" rev-parse HEAD)"
|
|
262
|
+
printf 'Merged %s into %s at %s\n' \
|
|
263
|
+
"$task_branch" "$primary" "$merge_commit"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
repo_root=${1:?repository root is required}
|
|
5
|
+
resource_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
cd "$repo_root"
|
|
7
|
+
|
|
8
|
+
existing_hooks_path="$(git config --get core.hooksPath || true)"
|
|
9
|
+
if [[ -n "$existing_hooks_path" && "$existing_hooks_path" != ".githooks" ]]; then
|
|
10
|
+
echo "Refusing to replace existing core.hooksPath: $existing_hooks_path" >&2
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
if [[ -z "$existing_hooks_path" ]]; then
|
|
15
|
+
default_hooks_directory="$(git rev-parse --git-path hooks)"
|
|
16
|
+
shopt -s nullglob
|
|
17
|
+
for existing_hook in "$default_hooks_directory"/*; do
|
|
18
|
+
if [[ -f "$existing_hook" \
|
|
19
|
+
&& -x "$existing_hook" \
|
|
20
|
+
&& "$existing_hook" != *.sample ]]; then
|
|
21
|
+
echo "Refusing to disable existing Git hook: $existing_hook" >&2
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
done
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
git config --local core.hooksPath .githooks
|
|
28
|
+
primary="$(bash "$resource_dir/primary-branch.sh" "$repo_root")"
|
|
29
|
+
git config --local "branch.$primary.mergeOptions" --no-ff
|
|
30
|
+
git config --local "branch.$primary.rebase" false
|
|
31
|
+
git config --local pull.rebase false
|
|
32
|
+
git config --local pull.ff only
|
|
33
|
+
|
|
34
|
+
printf 'Installed repository Git hooks; primary branch: %s\n' "$primary"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""One-time conversion of verified toolkit 0.1 source bundles to components.
|
|
2
|
+
|
|
3
|
+
Normal verification and synchronization accept only the current schema. This
|
|
4
|
+
explicit converter verifies all legacy hashes before replacing generated files;
|
|
5
|
+
it never executes the old payload or adopts local edits.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .bundle import replace_bundle
|
|
14
|
+
from .bundle_verify import (
|
|
15
|
+
MANIFEST_NAME,
|
|
16
|
+
BundleError,
|
|
17
|
+
BundleManifest,
|
|
18
|
+
_reject_duplicate_keys,
|
|
19
|
+
bundle_directory,
|
|
20
|
+
regular_bytes,
|
|
21
|
+
validate_manifest,
|
|
22
|
+
verify_payload,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _legacy_manifest(repository: Path) -> BundleManifest:
|
|
27
|
+
try:
|
|
28
|
+
document = json.loads(
|
|
29
|
+
regular_bytes(bundle_directory(repository) / MANIFEST_NAME),
|
|
30
|
+
object_pairs_hook=_reject_duplicate_keys,
|
|
31
|
+
)
|
|
32
|
+
except (UnicodeError, json.JSONDecodeError) as error:
|
|
33
|
+
raise BundleError(f"Invalid legacy manifest: {error}") from error
|
|
34
|
+
if (
|
|
35
|
+
not isinstance(document, dict)
|
|
36
|
+
or set(document)
|
|
37
|
+
!= {
|
|
38
|
+
"schema_version",
|
|
39
|
+
"toolkit_name",
|
|
40
|
+
"toolkit_version",
|
|
41
|
+
"payload_sha256",
|
|
42
|
+
"files",
|
|
43
|
+
}
|
|
44
|
+
or type(document["schema_version"]) is not int
|
|
45
|
+
or document["schema_version"] != 1
|
|
46
|
+
):
|
|
47
|
+
raise BundleError("migrate-v1 requires an unmodified schema-1 bundle")
|
|
48
|
+
document["schema_version"] = 2
|
|
49
|
+
document["components"] = [
|
|
50
|
+
"change-scope",
|
|
51
|
+
"dependencies",
|
|
52
|
+
"documentation",
|
|
53
|
+
"publish-version",
|
|
54
|
+
]
|
|
55
|
+
return verify_payload(repository, validate_manifest(document))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def migrate_v1(repository: Path) -> bool:
|
|
59
|
+
"""Convert once using explicit current pyproject component configuration."""
|
|
60
|
+
_legacy_manifest(repository)
|
|
61
|
+
return replace_bundle(repository, lambda: _legacy_manifest(repository))
|