dev-bubble 0.7.18__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.18/dev_bubble.egg-info → dev_bubble-0.7.19}/PKG-INFO +1 -1
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/__init__.py +1 -1
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/finalization.py +4 -4
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/hooks/__init__.py +5 -5
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/hooks/lean.py +138 -90
- {dev_bubble-0.7.18 → dev_bubble-0.7.19/dev_bubble.egg-info}/PKG-INFO +1 -1
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_hooks.py +118 -27
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/.claude/CLAUDE.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/.github/workflows/ci.yml +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/.github/workflows/publish.yml +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/.gitignore +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/CHANGELOG.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/LICENSE +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/README.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/SPEC.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/__main__.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/ai.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/auth_proxy.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/automation.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/clean.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/cli.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/clone.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/cloud.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/cloud_types.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/__init__.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/cloud_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/completion.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/doctor.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/images.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/infrastructure.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/internal.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/lifecycle.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/list_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/relay_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/remote_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/security_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/settings.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/status_cmd.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/config.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/container_helpers.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/data/skill.md +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/default_repos.json +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/git_store.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/github_token.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/graphql_validator.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/hooks/python.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/image_management.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/__init__.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/builder.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/base.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/cloud-init.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/lean-toolchain.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/lean.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/python.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/claude.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/codex.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/elan.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/emacs.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/gh.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/neovim.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/pins.json +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/uv.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/vscode.sh +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/lean.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/lifecycle.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/naming.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/network.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/notices.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/output.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/provisioning.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/relay.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/remote.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/repo_registry.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/runtime/__init__.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/runtime/base.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/runtime/colima.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/runtime/incus.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/security.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/setup.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/skill.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/spinner.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/target.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/token_store.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/tools.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/tunnel.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/vscode.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/config/com.bubble.git-update.plist +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/config/com.bubble.image-refresh.plist +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/config/com.bubble.relay-daemon.plist +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/conftest.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/SOURCES.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/dependency_links.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/entry_points.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/requires.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/top_level.txt +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/pyproject.toml +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/setup.cfg +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/conftest.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_ai.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_auth_proxy.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_authorized_keys.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_branch_no_target.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_build_lock.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_claude_projects_symlink.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_cloud.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_colima.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_completion.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_config.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_customize.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_editor.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_ephemeral.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_git_store.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_github_security_override.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_github_token.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_graphql_validator.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_integration.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_internal.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_lifecycle.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_list_columns.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_list_remote.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_mounts.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_multi_target.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_naming.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_network.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_notices.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_reattach_network.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_relay.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_remote.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_repo_registry.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_security.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_skill.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_spinner.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_status.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_systemd_path.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_target.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_token_no_argv_leak.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_tools.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_tunnel.py +0 -0
- {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_vscode.py +0 -0
|
@@ -38,11 +38,11 @@ def finalize_bubble(
|
|
|
38
38
|
before any clone/fetch operations.
|
|
39
39
|
"""
|
|
40
40
|
q_short = shlex.quote(short)
|
|
41
|
-
|
|
42
|
-
subdir = hook.project_subdir() if hook else ""
|
|
43
|
-
project_dir = f"{repo_dir}/{subdir}" if subdir else repo_dir
|
|
41
|
+
project_dir = f"/home/user/{short}"
|
|
44
42
|
if hook:
|
|
45
43
|
hook.post_clone(runtime, name, project_dir)
|
|
44
|
+
for note in hook.notices():
|
|
45
|
+
click.echo(note, err=True)
|
|
46
46
|
|
|
47
47
|
# Add a "github" remote with SSH-format URL for gh CLI host discovery.
|
|
48
48
|
# The global url.insteadOf rewrites HTTPS github.com URLs to the proxy,
|
|
@@ -52,7 +52,7 @@ def finalize_bubble(
|
|
|
52
52
|
# letting gh discover the host without needing to actually use the remote.
|
|
53
53
|
if t.owner and t.repo:
|
|
54
54
|
q_repo = shlex.quote(f"git@github.com:{t.owner}/{t.repo}.git")
|
|
55
|
-
q_dir = shlex.quote(
|
|
55
|
+
q_dir = shlex.quote(project_dir)
|
|
56
56
|
add_cmd = f"cd {q_dir} && git remote add github {q_repo} 2>/dev/null || true"
|
|
57
57
|
try:
|
|
58
58
|
runtime.exec(name, ["su", "-", "user", "-c", add_cmd])
|
|
@@ -69,13 +69,13 @@ 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
|
|
73
|
-
"""
|
|
72
|
+
def notices(self) -> list[str]:
|
|
73
|
+
"""One-shot human-readable notes to print after detection.
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
Only populated after detect() returns True.
|
|
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
77
|
"""
|
|
78
|
-
return
|
|
78
|
+
return []
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def discover_hooks() -> list[Hook]:
|
|
@@ -34,15 +34,17 @@ def _is_safe_subdir(subdir: str) -> bool:
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def _has_lakefile(bare_repo_path: Path, ref: str, subdir: str) -> bool:
|
|
37
|
-
"""
|
|
37
|
+
"""Check for a sibling lakefile alongside a discovered lean-toolchain.
|
|
38
38
|
|
|
39
39
|
Filters out vendored/example/doc directories that happen to ship a
|
|
40
|
-
lean-toolchain without being
|
|
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.
|
|
41
42
|
"""
|
|
43
|
+
prefix = f"{subdir}/" if subdir else ""
|
|
42
44
|
for name in ("lakefile.toml", "lakefile.lean"):
|
|
43
45
|
try:
|
|
44
46
|
subprocess.run(
|
|
45
|
-
["git", "-C", str(bare_repo_path), "cat-file", "-e", f"{ref}:{
|
|
47
|
+
["git", "-C", str(bare_repo_path), "cat-file", "-e", f"{ref}:{prefix}{name}"],
|
|
46
48
|
capture_output=True,
|
|
47
49
|
check=True,
|
|
48
50
|
)
|
|
@@ -70,18 +72,14 @@ def _read_lean_toolchain(bare_repo_path: Path, ref: str, subdir: str = "") -> st
|
|
|
70
72
|
return None
|
|
71
73
|
|
|
72
74
|
|
|
73
|
-
def
|
|
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`.
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Uses `git ls-tree -z` (NUL-delimited) and streams output, stopping after
|
|
82
|
-
the second match so a huge tree with no Lean project doesn't pay for the
|
|
83
|
-
full listing. NUL-delimited mode also avoids git's quotePath escaping for
|
|
84
|
-
paths with unusual bytes.
|
|
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.
|
|
85
83
|
"""
|
|
86
84
|
try:
|
|
87
85
|
proc = subprocess.Popen(
|
|
@@ -90,59 +88,37 @@ def _find_lean_toolchain_subdir(bare_repo_path: Path, ref: str) -> str | None:
|
|
|
90
88
|
stderr=subprocess.DEVNULL,
|
|
91
89
|
)
|
|
92
90
|
except (OSError, FileNotFoundError):
|
|
93
|
-
return
|
|
91
|
+
return []
|
|
94
92
|
|
|
95
|
-
|
|
93
|
+
subdirs: list[str] = []
|
|
94
|
+
suffix = b"/lean-toolchain"
|
|
96
95
|
try:
|
|
97
96
|
assert proc.stdout is not None
|
|
98
|
-
|
|
99
|
-
suffix = b"/lean-toolchain"
|
|
100
|
-
while True:
|
|
101
|
-
chunk = proc.stdout.read(65536)
|
|
102
|
-
if not chunk:
|
|
103
|
-
buf += b""
|
|
104
|
-
# Flush whatever's left.
|
|
105
|
-
for entry in buf.split(b"\0"):
|
|
106
|
-
if entry.endswith(suffix):
|
|
107
|
-
matches.append(entry.decode("utf-8", "replace"))
|
|
108
|
-
if len(matches) >= 2:
|
|
109
|
-
break
|
|
110
|
-
break
|
|
111
|
-
buf += chunk
|
|
112
|
-
parts = buf.split(b"\0")
|
|
113
|
-
buf = parts[-1] # last fragment may be incomplete
|
|
114
|
-
for entry in parts[:-1]:
|
|
115
|
-
if entry.endswith(suffix):
|
|
116
|
-
matches.append(entry.decode("utf-8", "replace"))
|
|
117
|
-
if len(matches) >= 2:
|
|
118
|
-
break
|
|
97
|
+
data = proc.stdout.read()
|
|
119
98
|
finally:
|
|
120
99
|
try:
|
|
121
|
-
proc.stdout.close()
|
|
100
|
+
proc.stdout.close() # type: ignore[union-attr]
|
|
122
101
|
except Exception:
|
|
123
102
|
pass
|
|
124
103
|
try:
|
|
125
|
-
proc.terminate()
|
|
126
104
|
proc.wait(timeout=2)
|
|
127
105
|
except Exception:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return None
|
|
145
|
-
return subdir
|
|
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
|
|
146
122
|
|
|
147
123
|
|
|
148
124
|
def _parse_lean_version(toolchain_str: str) -> str | None:
|
|
@@ -218,48 +194,110 @@ class LeanHook(Hook):
|
|
|
218
194
|
self._is_lean4: bool = False
|
|
219
195
|
self._git_deps: list[GitDependency] = []
|
|
220
196
|
self._subdir: str = ""
|
|
197
|
+
self._multi_project: bool = False
|
|
198
|
+
self._notices: list[str] = []
|
|
221
199
|
|
|
222
200
|
def name(self) -> str:
|
|
223
201
|
return "Lean 4"
|
|
224
202
|
|
|
225
|
-
def
|
|
226
|
-
|
|
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 = []
|
|
227
211
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
231
222
|
"""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
242
253
|
self._toolchain = content
|
|
243
254
|
self._subdir = subdir
|
|
244
|
-
self.
|
|
245
|
-
if self._is_lean4:
|
|
246
|
-
self._git_deps = []
|
|
247
|
-
self._needs_cache = False
|
|
248
|
-
else:
|
|
249
|
-
self._git_deps = _parse_git_dependencies(bare_repo_path, ref, subdir)
|
|
250
|
-
self._needs_cache = bare_repo_path.name == "mathlib4.git" or any(
|
|
251
|
-
d.name == "mathlib" for d in self._git_deps
|
|
252
|
-
)
|
|
255
|
+
self._configure_for_single_project(bare_repo_path, ref, subdir)
|
|
253
256
|
return True
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
self.
|
|
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
|
+
)
|
|
257
282
|
self._is_lean4 = False
|
|
258
283
|
self._git_deps = []
|
|
259
|
-
|
|
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
|
+
)
|
|
260
298
|
|
|
261
|
-
def
|
|
262
|
-
return self.
|
|
299
|
+
def notices(self) -> list[str]:
|
|
300
|
+
return list(self._notices)
|
|
263
301
|
|
|
264
302
|
def image_name(self) -> str:
|
|
265
303
|
"""Return the image name based on the lean-toolchain version.
|
|
@@ -287,15 +325,25 @@ class LeanHook(Hook):
|
|
|
287
325
|
return None
|
|
288
326
|
|
|
289
327
|
def post_clone(self, runtime: ContainerRuntime, container: str, project_dir: str):
|
|
290
|
-
"""Pre-populate Lake dependencies, then set up auto build command.
|
|
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
|
+
|
|
291
337
|
if self._is_lean4:
|
|
292
338
|
self._setup_lean4_build(runtime, container, project_dir)
|
|
293
339
|
return
|
|
294
340
|
|
|
341
|
+
build_dir = f"{project_dir}/{self._subdir}" if self._subdir else project_dir
|
|
342
|
+
|
|
295
343
|
if self._git_deps:
|
|
296
|
-
self._populate_lake_packages(runtime, container,
|
|
344
|
+
self._populate_lake_packages(runtime, container, build_dir)
|
|
297
345
|
|
|
298
|
-
q_dir = shlex.quote(
|
|
346
|
+
q_dir = shlex.quote(build_dir)
|
|
299
347
|
if self._needs_cache:
|
|
300
348
|
cmd = f"cd {q_dir} && lake exe cache get && lake build"
|
|
301
349
|
msg = "Mathlib cache download and build will start automatically."
|
|
@@ -254,11 +254,16 @@ def nested_subdir_lean_repo(tmp_path):
|
|
|
254
254
|
|
|
255
255
|
|
|
256
256
|
@pytest.fixture
|
|
257
|
-
def
|
|
258
|
-
"""
|
|
257
|
+
def vendor_only_lean_repo(tmp_path):
|
|
258
|
+
"""Repo whose only lean-toolchain lives in a directory with no lakefile.
|
|
259
|
+
|
|
260
|
+
Detection should NOT fire — this is the false-positive guard: a Python
|
|
261
|
+
repo could plausibly vendor a lean-toolchain file, and we don't want
|
|
262
|
+
that to inject the Lean image and a failing `lake build`.
|
|
263
|
+
"""
|
|
259
264
|
return _make_hook_repo(
|
|
260
265
|
tmp_path,
|
|
261
|
-
"
|
|
266
|
+
"vendor-only",
|
|
262
267
|
{
|
|
263
268
|
"README.md": "# Root\n",
|
|
264
269
|
"vendor/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
@@ -267,25 +272,38 @@ def subdir_no_lakefile_repo(tmp_path):
|
|
|
267
272
|
|
|
268
273
|
|
|
269
274
|
@pytest.fixture
|
|
270
|
-
def
|
|
271
|
-
"""
|
|
275
|
+
def multi_identical_lean_repo(tmp_path):
|
|
276
|
+
"""Multiple lean-toolchain files in subdirs, all with identical content."""
|
|
272
277
|
return _make_hook_repo(
|
|
273
278
|
tmp_path,
|
|
274
|
-
"
|
|
279
|
+
"multi-same",
|
|
275
280
|
{
|
|
276
281
|
"a/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
282
|
+
"a/lakefile.toml": "name = 'a'\n",
|
|
277
283
|
"b/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
284
|
+
"b/lakefile.toml": "name = 'b'\n",
|
|
278
285
|
},
|
|
279
286
|
)
|
|
280
287
|
|
|
281
288
|
|
|
282
289
|
@pytest.fixture
|
|
283
|
-
def
|
|
284
|
-
"""
|
|
290
|
+
def multi_versions_lean_repo(tmp_path):
|
|
291
|
+
"""Multiple lean-toolchain files in subdirs with different versions."""
|
|
292
|
+
return _make_hook_repo(
|
|
293
|
+
tmp_path,
|
|
294
|
+
"multi-versions",
|
|
295
|
+
{
|
|
296
|
+
"a/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
297
|
+
"a/lakefile.toml": "name = 'a'\n",
|
|
298
|
+
"b/lean-toolchain": "leanprover/lean4:v4.21.0\n",
|
|
299
|
+
"b/lakefile.toml": "name = 'b'\n",
|
|
300
|
+
},
|
|
301
|
+
)
|
|
285
302
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
303
|
+
|
|
304
|
+
@pytest.fixture
|
|
305
|
+
def root_and_subdir_lean_repo(tmp_path):
|
|
306
|
+
"""Root lean-toolchain plus extra copies in subdirs — root wins."""
|
|
289
307
|
return _make_hook_repo(
|
|
290
308
|
tmp_path,
|
|
291
309
|
"root-and-sub",
|
|
@@ -300,44 +318,117 @@ class TestSubdirDetection:
|
|
|
300
318
|
def test_detect_subdir_lean_repo(self, subdir_lean_repo):
|
|
301
319
|
hook = LeanHook()
|
|
302
320
|
assert hook.detect(subdir_lean_repo, "HEAD") is True
|
|
303
|
-
assert hook.
|
|
321
|
+
assert hook._subdir == "myproj"
|
|
304
322
|
assert hook.image_name() == "lean-v4.20.0"
|
|
323
|
+
assert hook._multi_project is False
|
|
324
|
+
assert hook.notices() == []
|
|
305
325
|
|
|
306
326
|
def test_detect_nested_subdir(self, nested_subdir_lean_repo):
|
|
307
327
|
hook = LeanHook()
|
|
308
328
|
assert hook.detect(nested_subdir_lean_repo, "HEAD") is True
|
|
309
|
-
assert hook.
|
|
329
|
+
assert hook._subdir == "src/lean"
|
|
310
330
|
assert hook.image_name() == "lean-v4.21.0"
|
|
311
331
|
|
|
312
|
-
def
|
|
332
|
+
def test_vendor_only_not_detected(self, vendor_only_lean_repo):
|
|
333
|
+
"""A non-root lean-toolchain without a sibling lakefile is ignored.
|
|
334
|
+
|
|
335
|
+
Guards against false positives like a Python repo with a vendored
|
|
336
|
+
lean-toolchain ending up on the Lean image with a failing auto-build.
|
|
337
|
+
"""
|
|
313
338
|
hook = LeanHook()
|
|
314
|
-
assert hook.detect(
|
|
315
|
-
assert hook.
|
|
339
|
+
assert hook.detect(vendor_only_lean_repo, "HEAD") is False
|
|
340
|
+
assert hook._subdir == ""
|
|
316
341
|
|
|
317
342
|
def test_root_wins_over_subdir(self, root_and_subdir_lean_repo):
|
|
318
343
|
hook = LeanHook()
|
|
319
344
|
assert hook.detect(root_and_subdir_lean_repo, "HEAD") is True
|
|
320
|
-
assert hook.
|
|
345
|
+
assert hook._subdir == ""
|
|
346
|
+
assert hook._multi_project is False
|
|
347
|
+
assert hook.image_name() == "lean-v4.19.0"
|
|
348
|
+
|
|
349
|
+
def test_root_wins_even_with_buildable_subdir(self, tmp_path):
|
|
350
|
+
"""Root lean-toolchain takes precedence even if a buildable subdir exists.
|
|
351
|
+
|
|
352
|
+
Subdir lean-toolchain files in real repos (e.g. lean4 itself) are
|
|
353
|
+
almost always test/vendor fixtures, so root is canonical.
|
|
354
|
+
"""
|
|
355
|
+
repo = _make_hook_repo(
|
|
356
|
+
tmp_path,
|
|
357
|
+
"root-plus-buildable-sub",
|
|
358
|
+
{
|
|
359
|
+
"lean-toolchain": "leanprover/lean4:v4.19.0\n",
|
|
360
|
+
"lakefile.toml": "name = 'main'\n",
|
|
361
|
+
"sub/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
362
|
+
"sub/lakefile.toml": "name = 'sub'\n",
|
|
363
|
+
},
|
|
364
|
+
)
|
|
365
|
+
hook = LeanHook()
|
|
366
|
+
assert hook.detect(repo, "HEAD") is True
|
|
367
|
+
assert hook._subdir == ""
|
|
368
|
+
assert hook._multi_project is False
|
|
321
369
|
assert hook.image_name() == "lean-v4.19.0"
|
|
370
|
+
assert hook.notices() == []
|
|
322
371
|
|
|
323
|
-
def
|
|
372
|
+
def test_root_repo_subdir_empty(self, lean_repo):
|
|
324
373
|
hook = LeanHook()
|
|
325
374
|
hook.detect(lean_repo, "HEAD")
|
|
326
|
-
assert hook.
|
|
375
|
+
assert hook._subdir == ""
|
|
327
376
|
|
|
328
|
-
def
|
|
329
|
-
"""Re-running detect() on a non-Lean repo must clear stale subdir state."""
|
|
377
|
+
def test_state_cleared_on_redetect_failure(self, subdir_lean_repo, non_lean_repo):
|
|
330
378
|
hook = LeanHook()
|
|
331
379
|
hook.detect(subdir_lean_repo, "HEAD")
|
|
332
|
-
assert hook.
|
|
380
|
+
assert hook._subdir == "myproj"
|
|
333
381
|
assert hook.detect(non_lean_repo, "HEAD") is False
|
|
334
|
-
assert hook.
|
|
382
|
+
assert hook._subdir == ""
|
|
383
|
+
assert hook._multi_project is False
|
|
384
|
+
assert hook.notices() == []
|
|
385
|
+
|
|
335
386
|
|
|
336
|
-
|
|
337
|
-
|
|
387
|
+
class TestMultiProject:
|
|
388
|
+
def test_identical_toolchains_pick_image_skip_build(
|
|
389
|
+
self, multi_identical_lean_repo, mock_runtime
|
|
390
|
+
):
|
|
338
391
|
hook = LeanHook()
|
|
339
|
-
assert hook.detect(
|
|
340
|
-
assert hook.
|
|
392
|
+
assert hook.detect(multi_identical_lean_repo, "HEAD") is True
|
|
393
|
+
assert hook._multi_project is True
|
|
394
|
+
assert hook.image_name() == "lean-v4.20.0"
|
|
395
|
+
assert hook._subdir == ""
|
|
396
|
+
notes = hook.notices()
|
|
397
|
+
assert len(notes) == 1
|
|
398
|
+
assert "Multiple Lean projects" in notes[0]
|
|
399
|
+
assert "a" in notes[0] and "b" in notes[0]
|
|
400
|
+
|
|
401
|
+
hook.post_clone(mock_runtime, "c", "/home/user/repo")
|
|
402
|
+
marker_calls = [
|
|
403
|
+
c
|
|
404
|
+
for c in mock_runtime.calls
|
|
405
|
+
if c[0] == "exec" and ".bubble-fetch-cache" in " ".join(c[2])
|
|
406
|
+
]
|
|
407
|
+
assert marker_calls == []
|
|
408
|
+
|
|
409
|
+
def test_differing_toolchains_use_plain_lean(self, multi_versions_lean_repo):
|
|
410
|
+
hook = LeanHook()
|
|
411
|
+
assert hook.detect(multi_versions_lean_repo, "HEAD") is True
|
|
412
|
+
assert hook._multi_project is True
|
|
413
|
+
assert hook.image_name() == "lean"
|
|
414
|
+
notes = hook.notices()
|
|
415
|
+
assert len(notes) == 1
|
|
416
|
+
assert "Multiple Lean toolchains" in notes[0]
|
|
417
|
+
assert "v4.20.0" in notes[0] and "v4.21.0" in notes[0]
|
|
418
|
+
assert "elan will install" in notes[0]
|
|
419
|
+
|
|
420
|
+
def test_subdir_build_dir(self, subdir_lean_repo, mock_runtime):
|
|
421
|
+
"""Single-subdir auto-build runs in the subdir, not the repo root."""
|
|
422
|
+
hook = LeanHook()
|
|
423
|
+
hook.detect(subdir_lean_repo, "HEAD")
|
|
424
|
+
hook.post_clone(mock_runtime, "c", "/home/user/repo")
|
|
425
|
+
marker_calls = [
|
|
426
|
+
c
|
|
427
|
+
for c in mock_runtime.calls
|
|
428
|
+
if c[0] == "exec" and ".bubble-fetch-cache" in " ".join(c[2])
|
|
429
|
+
]
|
|
430
|
+
assert len(marker_calls) == 1
|
|
431
|
+
assert "/home/user/repo/myproj" in " ".join(marker_calls[0][2])
|
|
341
432
|
|
|
342
433
|
|
|
343
434
|
class TestSafeSubdir:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|