dev-bubble 0.7.17__tar.gz → 0.7.19__tar.gz
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.
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/.gitignore +1 -0
- {dev_bubble-0.7.17/dev_bubble.egg-info → dev_bubble-0.7.19}/PKG-INFO +1 -1
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/__init__.py +1 -1
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/finalization.py +2 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/hooks/__init__.py +8 -0
- dev_bubble-0.7.19/bubble/hooks/lean.py +459 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19/dev_bubble.egg-info}/PKG-INFO +1 -1
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_hooks.py +239 -1
- dev_bubble-0.7.17/bubble/hooks/lean.py +0 -275
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/.claude/CLAUDE.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/.github/workflows/ci.yml +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/.github/workflows/publish.yml +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/CHANGELOG.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/LICENSE +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/README.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/SPEC.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/__main__.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/ai.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/auth_proxy.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/automation.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/clean.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/cli.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/clone.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/cloud.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/cloud_types.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/__init__.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/cloud_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/completion.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/doctor.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/images.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/infrastructure.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/internal.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/lifecycle.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/list_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/relay_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/remote_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/security_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/settings.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/commands/status_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/config.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/container_helpers.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/data/skill.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/default_repos.json +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/git_store.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/github_token.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/graphql_validator.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/hooks/python.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/image_management.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/__init__.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/builder.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/base.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/cloud-init.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/lean-toolchain.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/lean.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/python.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/claude.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/codex.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/elan.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/emacs.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/gh.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/neovim.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/pins.json +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/uv.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/images/scripts/tools/vscode.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/lean.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/lifecycle.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/naming.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/network.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/notices.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/output.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/provisioning.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/relay.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/remote.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/repo_registry.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/runtime/__init__.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/runtime/base.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/runtime/colima.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/runtime/incus.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/security.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/setup.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/skill.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/spinner.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/target.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/token_store.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/tools.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/tunnel.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/bubble/vscode.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/config/com.bubble.git-update.plist +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/config/com.bubble.image-refresh.plist +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/config/com.bubble.relay-daemon.plist +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/conftest.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/SOURCES.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/dependency_links.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/entry_points.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/requires.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/dev_bubble.egg-info/top_level.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/pyproject.toml +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/setup.cfg +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/conftest.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_ai.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_auth_proxy.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_authorized_keys.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_branch_no_target.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_build_lock.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_claude_projects_symlink.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_cloud.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_colima.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_completion.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_config.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_customize.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_editor.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_ephemeral.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_git_store.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_github_security_override.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_github_token.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_graphql_validator.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_integration.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_internal.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_lifecycle.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_list_columns.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_list_remote.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_mounts.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_multi_target.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_naming.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_network.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_notices.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_reattach_network.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_relay.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_remote.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_repo_registry.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_security.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_skill.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_spinner.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_status.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_systemd_path.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_target.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_token_no_argv_leak.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_tools.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_tunnel.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.19}/tests/test_vscode.py +0 -0
|
@@ -41,6 +41,8 @@ def finalize_bubble(
|
|
|
41
41
|
project_dir = f"/home/user/{short}"
|
|
42
42
|
if hook:
|
|
43
43
|
hook.post_clone(runtime, name, project_dir)
|
|
44
|
+
for note in hook.notices():
|
|
45
|
+
click.echo(note, err=True)
|
|
44
46
|
|
|
45
47
|
# Add a "github" remote with SSH-format URL for gh CLI host discovery.
|
|
46
48
|
# The global url.insteadOf rewrites HTTPS github.com URLs to the proxy,
|
|
@@ -69,6 +69,14 @@ class Hook(ABC):
|
|
|
69
69
|
"""Return absolute path to a .code-workspace file to open, or None."""
|
|
70
70
|
return None
|
|
71
71
|
|
|
72
|
+
def notices(self) -> list[str]:
|
|
73
|
+
"""One-shot human-readable notes to print after detection.
|
|
74
|
+
|
|
75
|
+
Used for warnings the user should see when a bubble is created (but
|
|
76
|
+
not on every reattach). Only populated after detect() returns True.
|
|
77
|
+
"""
|
|
78
|
+
return []
|
|
79
|
+
|
|
72
80
|
|
|
73
81
|
def discover_hooks() -> list[Hook]:
|
|
74
82
|
"""Return all registered hooks in priority order."""
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Lean 4 language hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..git_store import parse_github_url
|
|
12
|
+
from ..lean import LEAN_VERSION_RE
|
|
13
|
+
from ..runtime.base import ContainerRuntime
|
|
14
|
+
from . import GitDependency, Hook
|
|
15
|
+
|
|
16
|
+
# Allowlist for Lake package names and repo names (prevents path traversal)
|
|
17
|
+
_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_safe_subdir(subdir: str) -> bool:
|
|
21
|
+
"""Validate a detected project subdirectory.
|
|
22
|
+
|
|
23
|
+
The string flows into shell commands and into paths under /home/user/, so
|
|
24
|
+
each path component must match the same conservative policy used for Lake
|
|
25
|
+
package names. Reject absolute paths, leading/trailing slashes, empty
|
|
26
|
+
components, and any '.' / '..' segments.
|
|
27
|
+
"""
|
|
28
|
+
if not subdir or subdir.startswith("/") or subdir.endswith("/"):
|
|
29
|
+
return False
|
|
30
|
+
for part in subdir.split("/"):
|
|
31
|
+
if not part or part in (".", "..") or not _SAFE_NAME_RE.match(part):
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _has_lakefile(bare_repo_path: Path, ref: str, subdir: str) -> bool:
|
|
37
|
+
"""Check for a sibling lakefile alongside a discovered lean-toolchain.
|
|
38
|
+
|
|
39
|
+
Filters out vendored/example/doc directories that happen to ship a
|
|
40
|
+
`lean-toolchain` without being a buildable Lean project — e.g. a Python
|
|
41
|
+
repo with `vendor/lean-toolchain` shouldn't be mistaken for a Lean repo.
|
|
42
|
+
"""
|
|
43
|
+
prefix = f"{subdir}/" if subdir else ""
|
|
44
|
+
for name in ("lakefile.toml", "lakefile.lean"):
|
|
45
|
+
try:
|
|
46
|
+
subprocess.run(
|
|
47
|
+
["git", "-C", str(bare_repo_path), "cat-file", "-e", f"{ref}:{prefix}{name}"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
check=True,
|
|
50
|
+
)
|
|
51
|
+
return True
|
|
52
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
53
|
+
continue
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_lean_toolchain(bare_repo_path: Path, ref: str, subdir: str = "") -> str | None:
|
|
58
|
+
"""Read the lean-toolchain file content from a bare repo at a given ref.
|
|
59
|
+
|
|
60
|
+
``subdir`` is "" for the repo root, or a relative path like "foo" or "foo/bar".
|
|
61
|
+
"""
|
|
62
|
+
path = f"{subdir}/lean-toolchain" if subdir else "lean-toolchain"
|
|
63
|
+
try:
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["git", "-C", str(bare_repo_path), "show", f"{ref}:{path}"],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
check=True,
|
|
69
|
+
)
|
|
70
|
+
return result.stdout.strip()
|
|
71
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _find_lean_toolchain_subdirs(bare_repo_path: Path, ref: str) -> list[str]:
|
|
76
|
+
"""Return every directory containing a `lean-toolchain` file at `ref`.
|
|
77
|
+
|
|
78
|
+
Empty string represents the repo root. Uses `git ls-tree -rz` so paths
|
|
79
|
+
with unusual bytes aren't quoted, and streams output through a single
|
|
80
|
+
decode/split pass. Unsafe path components (traversal, control chars,
|
|
81
|
+
non-ASCII) are silently dropped — they'd be unsafe to plumb into shell
|
|
82
|
+
commands later.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
proc = subprocess.Popen(
|
|
86
|
+
["git", "-C", str(bare_repo_path), "ls-tree", "-rz", "--name-only", ref],
|
|
87
|
+
stdout=subprocess.PIPE,
|
|
88
|
+
stderr=subprocess.DEVNULL,
|
|
89
|
+
)
|
|
90
|
+
except (OSError, FileNotFoundError):
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
subdirs: list[str] = []
|
|
94
|
+
suffix = b"/lean-toolchain"
|
|
95
|
+
try:
|
|
96
|
+
assert proc.stdout is not None
|
|
97
|
+
data = proc.stdout.read()
|
|
98
|
+
finally:
|
|
99
|
+
try:
|
|
100
|
+
proc.stdout.close() # type: ignore[union-attr]
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
try:
|
|
104
|
+
proc.wait(timeout=2)
|
|
105
|
+
except Exception:
|
|
106
|
+
try:
|
|
107
|
+
proc.terminate()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
for entry in data.split(b"\0"):
|
|
112
|
+
if entry == b"lean-toolchain":
|
|
113
|
+
subdirs.append("")
|
|
114
|
+
elif entry.endswith(suffix):
|
|
115
|
+
try:
|
|
116
|
+
subdir = entry[: -len(suffix)].decode("utf-8")
|
|
117
|
+
except UnicodeDecodeError:
|
|
118
|
+
continue
|
|
119
|
+
if _is_safe_subdir(subdir):
|
|
120
|
+
subdirs.append(subdir)
|
|
121
|
+
return subdirs
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_lean_version(toolchain_str: str) -> str | None:
|
|
125
|
+
"""Extract the version tag from a lean-toolchain string.
|
|
126
|
+
|
|
127
|
+
Handles formats like:
|
|
128
|
+
leanprover/lean4:v4.16.0
|
|
129
|
+
leanprover/lean4:v4.16.0-rc2
|
|
130
|
+
leanprover/lean4:nightly-2024-01-01 (returns None)
|
|
131
|
+
|
|
132
|
+
Returns the version (e.g. 'v4.16.0') if it's a stable or RC release, else None.
|
|
133
|
+
"""
|
|
134
|
+
# Strip the repository prefix if present
|
|
135
|
+
if ":" in toolchain_str:
|
|
136
|
+
version = toolchain_str.split(":", 1)[1]
|
|
137
|
+
else:
|
|
138
|
+
version = toolchain_str
|
|
139
|
+
|
|
140
|
+
if LEAN_VERSION_RE.fullmatch(version):
|
|
141
|
+
return version
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_git_dependencies(
|
|
146
|
+
bare_repo_path: Path, ref: str, subdir: str = ""
|
|
147
|
+
) -> list[GitDependency]:
|
|
148
|
+
"""Parse git dependencies from lake-manifest.json in the bare repo."""
|
|
149
|
+
path = f"{subdir}/lake-manifest.json" if subdir else "lake-manifest.json"
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
["git", "-C", str(bare_repo_path), "show", f"{ref}:{path}"],
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
check=True,
|
|
156
|
+
)
|
|
157
|
+
manifest = json.loads(result.stdout)
|
|
158
|
+
except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
deps = []
|
|
162
|
+
for pkg in manifest.get("packages", []):
|
|
163
|
+
if pkg.get("type") != "git":
|
|
164
|
+
continue
|
|
165
|
+
url = pkg.get("url", "")
|
|
166
|
+
name = pkg.get("name", "")
|
|
167
|
+
rev = pkg.get("rev", "")
|
|
168
|
+
org_repo = parse_github_url(url)
|
|
169
|
+
if not org_repo:
|
|
170
|
+
continue # Skip non-GitHub deps
|
|
171
|
+
# Validate name and rev to prevent path traversal and option injection
|
|
172
|
+
if not name or not _SAFE_NAME_RE.match(name):
|
|
173
|
+
continue
|
|
174
|
+
if not rev or not re.match(r"^[0-9a-f]{40}$", rev):
|
|
175
|
+
continue
|
|
176
|
+
deps.append(
|
|
177
|
+
GitDependency(
|
|
178
|
+
name=name,
|
|
179
|
+
url=url,
|
|
180
|
+
rev=rev,
|
|
181
|
+
sub_dir=pkg.get("subDir"),
|
|
182
|
+
org_repo=org_repo,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return deps
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class LeanHook(Hook):
|
|
189
|
+
"""Hook for Lean 4 projects (detected by lean-toolchain file)."""
|
|
190
|
+
|
|
191
|
+
def __init__(self):
|
|
192
|
+
self._toolchain: str | None = None
|
|
193
|
+
self._needs_cache: bool = False
|
|
194
|
+
self._is_lean4: bool = False
|
|
195
|
+
self._git_deps: list[GitDependency] = []
|
|
196
|
+
self._subdir: str = ""
|
|
197
|
+
self._multi_project: bool = False
|
|
198
|
+
self._notices: list[str] = []
|
|
199
|
+
|
|
200
|
+
def name(self) -> str:
|
|
201
|
+
return "Lean 4"
|
|
202
|
+
|
|
203
|
+
def _reset_state(self) -> None:
|
|
204
|
+
self._toolchain = None
|
|
205
|
+
self._subdir = ""
|
|
206
|
+
self._needs_cache = False
|
|
207
|
+
self._is_lean4 = False
|
|
208
|
+
self._git_deps = []
|
|
209
|
+
self._multi_project = False
|
|
210
|
+
self._notices = []
|
|
211
|
+
|
|
212
|
+
def detect(self, bare_repo_path: Path, ref: str) -> bool:
|
|
213
|
+
"""Fire if there is any `lean-toolchain` file at the given ref.
|
|
214
|
+
|
|
215
|
+
The repo root is preferred when present. For repos with one or more
|
|
216
|
+
`lean-toolchain` files in subdirectories, the hook still fires (so
|
|
217
|
+
elan + the VS Code extension end up in the bubble), but VS Code
|
|
218
|
+
always opens at the repo root. When multiple files exist the
|
|
219
|
+
auto-build is skipped and a notice is emitted; when their contents
|
|
220
|
+
disagree the plain `lean` image is used and elan installs toolchains
|
|
221
|
+
on demand.
|
|
222
|
+
"""
|
|
223
|
+
self._reset_state()
|
|
224
|
+
root_content = _read_lean_toolchain(bare_repo_path, ref)
|
|
225
|
+
|
|
226
|
+
if root_content is not None:
|
|
227
|
+
# Root lean-toolchain wins — treat the repo as a single-root
|
|
228
|
+
# project even if additional lean-toolchain files exist deeper
|
|
229
|
+
# in the tree. Subdir copies in real repos are almost always
|
|
230
|
+
# vendored/test fixtures (e.g. lean4 itself ships them under
|
|
231
|
+
# tests/), so demoting to multi-project would be wrong.
|
|
232
|
+
self._toolchain = root_content
|
|
233
|
+
self._subdir = ""
|
|
234
|
+
self._configure_for_single_project(bare_repo_path, ref, "")
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
# Only count subdirs that look like real Lean projects (sibling
|
|
238
|
+
# lakefile). This filters out e.g. a Python repo with a vendored
|
|
239
|
+
# `lean-toolchain` that has no lakefile next to it.
|
|
240
|
+
candidates = [
|
|
241
|
+
s
|
|
242
|
+
for s in _find_lean_toolchain_subdirs(bare_repo_path, ref)
|
|
243
|
+
if _has_lakefile(bare_repo_path, ref, s)
|
|
244
|
+
]
|
|
245
|
+
if not candidates:
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
if len(candidates) == 1:
|
|
249
|
+
subdir = candidates[0]
|
|
250
|
+
content = _read_lean_toolchain(bare_repo_path, ref, subdir)
|
|
251
|
+
if content is None:
|
|
252
|
+
return False
|
|
253
|
+
self._toolchain = content
|
|
254
|
+
self._subdir = subdir
|
|
255
|
+
self._configure_for_single_project(bare_repo_path, ref, subdir)
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
# Multiple buildable Lean projects across subdirectories.
|
|
259
|
+
self._multi_project = True
|
|
260
|
+
self._subdir = "" # we don't pick one for the auto-build
|
|
261
|
+
contents: list[str] = []
|
|
262
|
+
for s in candidates:
|
|
263
|
+
c = _read_lean_toolchain(bare_repo_path, ref, s)
|
|
264
|
+
if c is not None:
|
|
265
|
+
contents.append(c)
|
|
266
|
+
unique = sorted(set(contents))
|
|
267
|
+
dirs_label = ", ".join(sorted(candidates))
|
|
268
|
+
if len(unique) == 1:
|
|
269
|
+
self._toolchain = contents[0]
|
|
270
|
+
self._notices.append(
|
|
271
|
+
f"Multiple Lean projects detected ({dirs_label}); skipping auto-build."
|
|
272
|
+
" Run `lake build` in your project's subdirectory."
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
self._toolchain = None # forces image_name() to plain `lean`
|
|
276
|
+
versions_label = ", ".join(unique)
|
|
277
|
+
self._notices.append(
|
|
278
|
+
f"Multiple Lean toolchains detected across {dirs_label}: {versions_label}."
|
|
279
|
+
" Using the base `lean` image; elan will install each toolchain"
|
|
280
|
+
" on demand the first time you run `lake build` in a subdirectory."
|
|
281
|
+
)
|
|
282
|
+
self._is_lean4 = False
|
|
283
|
+
self._git_deps = []
|
|
284
|
+
self._needs_cache = False
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
def _configure_for_single_project(self, bare_repo_path: Path, ref: str, subdir: str) -> None:
|
|
288
|
+
"""Populate is_lean4 / git_deps / needs_cache for a single-project repo."""
|
|
289
|
+
self._is_lean4 = bare_repo_path.name == "lean4.git"
|
|
290
|
+
if self._is_lean4:
|
|
291
|
+
self._git_deps = []
|
|
292
|
+
self._needs_cache = False
|
|
293
|
+
else:
|
|
294
|
+
self._git_deps = _parse_git_dependencies(bare_repo_path, ref, subdir)
|
|
295
|
+
self._needs_cache = bare_repo_path.name == "mathlib4.git" or any(
|
|
296
|
+
d.name == "mathlib" for d in self._git_deps
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def notices(self) -> list[str]:
|
|
300
|
+
return list(self._notices)
|
|
301
|
+
|
|
302
|
+
def image_name(self) -> str:
|
|
303
|
+
"""Return the image name based on the lean-toolchain version.
|
|
304
|
+
|
|
305
|
+
For stable/RC versions (v4.X.Y, v4.X.Y-rcK): returns 'lean-v4.X.Y' or 'lean-v4.X.Y-rcK'.
|
|
306
|
+
For nightlies or unrecognized: returns 'lean' (base image with elan only).
|
|
307
|
+
"""
|
|
308
|
+
if self._toolchain:
|
|
309
|
+
version = _parse_lean_version(self._toolchain)
|
|
310
|
+
if version:
|
|
311
|
+
return f"lean-{version}"
|
|
312
|
+
return "lean"
|
|
313
|
+
|
|
314
|
+
def shared_mounts(self) -> list[tuple[str, str, str]]:
|
|
315
|
+
if self._needs_cache:
|
|
316
|
+
return [("mathlib-cache", "/shared/mathlib-cache", "MATHLIB_CACHE_DIR")]
|
|
317
|
+
return []
|
|
318
|
+
|
|
319
|
+
def git_dependencies(self) -> list[GitDependency]:
|
|
320
|
+
return self._git_deps
|
|
321
|
+
|
|
322
|
+
def workspace_file(self, project_dir: str) -> str | None:
|
|
323
|
+
if self._is_lean4:
|
|
324
|
+
return f"{project_dir}/lean.code-workspace"
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
def post_clone(self, runtime: ContainerRuntime, container: str, project_dir: str):
|
|
328
|
+
"""Pre-populate Lake dependencies, then set up auto build command.
|
|
329
|
+
|
|
330
|
+
``project_dir`` is always the repo root. The hook's stored ``_subdir``
|
|
331
|
+
controls where ``lake build`` actually runs. For multi-project repos
|
|
332
|
+
the auto-build is skipped entirely.
|
|
333
|
+
"""
|
|
334
|
+
if self._multi_project:
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
if self._is_lean4:
|
|
338
|
+
self._setup_lean4_build(runtime, container, project_dir)
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
build_dir = f"{project_dir}/{self._subdir}" if self._subdir else project_dir
|
|
342
|
+
|
|
343
|
+
if self._git_deps:
|
|
344
|
+
self._populate_lake_packages(runtime, container, build_dir)
|
|
345
|
+
|
|
346
|
+
q_dir = shlex.quote(build_dir)
|
|
347
|
+
if self._needs_cache:
|
|
348
|
+
cmd = f"cd {q_dir} && lake exe cache get && lake build"
|
|
349
|
+
msg = "Mathlib cache download and build will start automatically."
|
|
350
|
+
else:
|
|
351
|
+
cmd = f"cd {q_dir} && lake build"
|
|
352
|
+
msg = "Build will start automatically."
|
|
353
|
+
# Write command for the VS Code extension or shell login hook to pick up
|
|
354
|
+
runtime.exec(
|
|
355
|
+
container,
|
|
356
|
+
[
|
|
357
|
+
"su",
|
|
358
|
+
"-",
|
|
359
|
+
"user",
|
|
360
|
+
"-c",
|
|
361
|
+
f"printf '%s' {shlex.quote(cmd)} > ~/.bubble-fetch-cache",
|
|
362
|
+
],
|
|
363
|
+
)
|
|
364
|
+
click.echo(msg)
|
|
365
|
+
|
|
366
|
+
def _setup_lean4_build(self, runtime: ContainerRuntime, container: str, project_dir: str):
|
|
367
|
+
"""Set up auto-build for the lean4 repo itself (cmake is in base image)."""
|
|
368
|
+
q_dir = shlex.quote(project_dir)
|
|
369
|
+
cmd = f"cd {q_dir} && cmake --preset release && make -C build/release -j$(nproc)"
|
|
370
|
+
runtime.exec(
|
|
371
|
+
container,
|
|
372
|
+
[
|
|
373
|
+
"su",
|
|
374
|
+
"-",
|
|
375
|
+
"user",
|
|
376
|
+
"-c",
|
|
377
|
+
f"printf '%s' {shlex.quote(cmd)} > ~/.bubble-fetch-cache",
|
|
378
|
+
],
|
|
379
|
+
)
|
|
380
|
+
click.echo("Lean 4 build will start automatically.")
|
|
381
|
+
|
|
382
|
+
def _populate_lake_packages(self, runtime: ContainerRuntime, container: str, project_dir: str):
|
|
383
|
+
"""Clone each dependency into .lake/packages/<name>/ using alternates."""
|
|
384
|
+
q_dir = shlex.quote(project_dir)
|
|
385
|
+
|
|
386
|
+
# Create .lake/packages/ directory
|
|
387
|
+
runtime.exec(
|
|
388
|
+
container,
|
|
389
|
+
["su", "-", "user", "-c", f"mkdir -p {q_dir}/.lake/packages"],
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
populated = 0
|
|
393
|
+
for dep in self._git_deps:
|
|
394
|
+
repo_name = dep.org_repo.split("/")[-1]
|
|
395
|
+
# All values are validated (_SAFE_NAME_RE, hex SHA, parse_github_url)
|
|
396
|
+
# but quote everything for defense in depth
|
|
397
|
+
q_bare = shlex.quote(f"/shared/git/{repo_name}.git")
|
|
398
|
+
q_url = shlex.quote(dep.url)
|
|
399
|
+
q_rev = shlex.quote(dep.rev)
|
|
400
|
+
q_pkg = shlex.quote(f"{project_dir}/.lake/packages/{dep.name}")
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
# Clone from mounted bare repo with --reference for alternates.
|
|
404
|
+
# Since source and reference are the same, zero objects are copied.
|
|
405
|
+
# Use -c safe.directory to allow reading the root-owned mounted bare repo
|
|
406
|
+
# (scoped to this command only, not persisted in global gitconfig).
|
|
407
|
+
runtime.exec(
|
|
408
|
+
container,
|
|
409
|
+
[
|
|
410
|
+
"su",
|
|
411
|
+
"-",
|
|
412
|
+
"user",
|
|
413
|
+
"-c",
|
|
414
|
+
f"git -c safe.directory={q_bare} clone"
|
|
415
|
+
f" --reference {q_bare} file://{q_bare} {q_pkg}",
|
|
416
|
+
],
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Fix remote URL to match what the manifest expects
|
|
420
|
+
runtime.exec(
|
|
421
|
+
container,
|
|
422
|
+
[
|
|
423
|
+
"su",
|
|
424
|
+
"-",
|
|
425
|
+
"user",
|
|
426
|
+
"-c",
|
|
427
|
+
f"git -C {q_pkg} remote set-url origin {q_url}",
|
|
428
|
+
],
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Checkout the exact revision from the manifest
|
|
432
|
+
# rev is validated as a 40-char hex SHA, so no option injection risk
|
|
433
|
+
runtime.exec(
|
|
434
|
+
container,
|
|
435
|
+
[
|
|
436
|
+
"su",
|
|
437
|
+
"-",
|
|
438
|
+
"user",
|
|
439
|
+
"-c",
|
|
440
|
+
f"git -C {q_pkg} checkout {q_rev}",
|
|
441
|
+
],
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
populated += 1
|
|
445
|
+
except RuntimeError as e:
|
|
446
|
+
# Non-fatal: Lake will clone this dep normally when needed
|
|
447
|
+
click.echo(f" Warning: could not pre-populate {dep.name}: {e}")
|
|
448
|
+
|
|
449
|
+
if populated:
|
|
450
|
+
click.echo(f" Pre-populated {populated}/{len(self._git_deps)} Lake dependencies.")
|
|
451
|
+
|
|
452
|
+
def network_domains(self) -> list[str]:
|
|
453
|
+
return [
|
|
454
|
+
"releases.lean-lang.org",
|
|
455
|
+
"reservoir.lean-lang.org",
|
|
456
|
+
"reservoir.lean-cache.cloud",
|
|
457
|
+
"mathlib4.lean-cache.cloud",
|
|
458
|
+
"lakecache.blob.core.windows.net",
|
|
459
|
+
]
|