dev-bubble 0.7.17__tar.gz → 0.7.18__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.18}/.gitignore +1 -0
- {dev_bubble-0.7.17/dev_bubble.egg-info → dev_bubble-0.7.18}/PKG-INFO +1 -1
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/__init__.py +1 -1
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/finalization.py +4 -2
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/hooks/__init__.py +8 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/hooks/lean.py +143 -7
- {dev_bubble-0.7.17 → dev_bubble-0.7.18/dev_bubble.egg-info}/PKG-INFO +1 -1
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_hooks.py +148 -1
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/.claude/CLAUDE.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/.github/workflows/ci.yml +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/.github/workflows/publish.yml +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/CHANGELOG.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/LICENSE +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/README.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/SPEC.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/__main__.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/ai.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/auth_proxy.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/automation.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/clean.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/cli.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/clone.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/cloud.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/cloud_types.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/__init__.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/cloud_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/completion.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/doctor.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/images.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/infrastructure.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/internal.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/lifecycle.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/list_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/relay_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/remote_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/security_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/settings.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/status_cmd.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/config.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/container_helpers.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/data/skill.md +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/default_repos.json +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/git_store.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/github_token.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/graphql_validator.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/hooks/python.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/image_management.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/__init__.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/builder.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/base.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/cloud-init.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/lean-toolchain.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/lean.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/python.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/claude.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/codex.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/elan.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/emacs.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/gh.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/neovim.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/pins.json +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/uv.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/vscode.sh +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/lean.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/lifecycle.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/naming.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/network.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/notices.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/output.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/provisioning.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/relay.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/remote.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/repo_registry.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/runtime/__init__.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/runtime/base.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/runtime/colima.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/runtime/incus.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/security.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/setup.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/skill.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/spinner.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/target.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/token_store.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/tools.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/tunnel.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/vscode.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/config/com.bubble.git-update.plist +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/config/com.bubble.image-refresh.plist +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/config/com.bubble.relay-daemon.plist +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/conftest.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/SOURCES.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/dependency_links.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/entry_points.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/requires.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/top_level.txt +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/pyproject.toml +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/setup.cfg +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/conftest.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_ai.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_auth_proxy.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_authorized_keys.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_branch_no_target.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_build_lock.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_claude_projects_symlink.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_cloud.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_colima.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_completion.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_config.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_customize.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_editor.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_ephemeral.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_git_store.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_github_security_override.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_github_token.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_graphql_validator.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_integration.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_internal.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_lifecycle.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_list_columns.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_list_remote.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_mounts.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_multi_target.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_naming.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_network.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_notices.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_reattach_network.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_relay.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_remote.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_repo_registry.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_security.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_skill.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_spinner.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_status.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_systemd_path.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_target.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_token_no_argv_leak.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_tools.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_tunnel.py +0 -0
- {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_vscode.py +0 -0
|
@@ -38,7 +38,9 @@ def finalize_bubble(
|
|
|
38
38
|
before any clone/fetch operations.
|
|
39
39
|
"""
|
|
40
40
|
q_short = shlex.quote(short)
|
|
41
|
-
|
|
41
|
+
repo_dir = f"/home/user/{short}"
|
|
42
|
+
subdir = hook.project_subdir() if hook else ""
|
|
43
|
+
project_dir = f"{repo_dir}/{subdir}" if subdir else repo_dir
|
|
42
44
|
if hook:
|
|
43
45
|
hook.post_clone(runtime, name, project_dir)
|
|
44
46
|
|
|
@@ -50,7 +52,7 @@ def finalize_bubble(
|
|
|
50
52
|
# letting gh discover the host without needing to actually use the remote.
|
|
51
53
|
if t.owner and t.repo:
|
|
52
54
|
q_repo = shlex.quote(f"git@github.com:{t.owner}/{t.repo}.git")
|
|
53
|
-
q_dir = shlex.quote(
|
|
55
|
+
q_dir = shlex.quote(repo_dir)
|
|
54
56
|
add_cmd = f"cd {q_dir} && git remote add github {q_repo} 2>/dev/null || true"
|
|
55
57
|
try:
|
|
56
58
|
runtime.exec(name, ["su", "-", "user", "-c", add_cmd])
|
|
@@ -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 project_subdir(self) -> str:
|
|
73
|
+
"""Subdirectory within the repo where the project lives.
|
|
74
|
+
|
|
75
|
+
Returns "" if the project is at the repo root (the common case).
|
|
76
|
+
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."""
|
|
@@ -17,11 +17,50 @@ from . import GitDependency, Hook
|
|
|
17
17
|
_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def
|
|
21
|
-
"""
|
|
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
|
+
"""Confirm a sibling lakefile exists next to a discovered lean-toolchain.
|
|
38
|
+
|
|
39
|
+
Filters out vendored/example/doc directories that happen to ship a
|
|
40
|
+
lean-toolchain without being the project root.
|
|
41
|
+
"""
|
|
42
|
+
for name in ("lakefile.toml", "lakefile.lean"):
|
|
43
|
+
try:
|
|
44
|
+
subprocess.run(
|
|
45
|
+
["git", "-C", str(bare_repo_path), "cat-file", "-e", f"{ref}:{subdir}/{name}"],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
check=True,
|
|
48
|
+
)
|
|
49
|
+
return True
|
|
50
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
51
|
+
continue
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_lean_toolchain(bare_repo_path: Path, ref: str, subdir: str = "") -> str | None:
|
|
56
|
+
"""Read the lean-toolchain file content from a bare repo at a given ref.
|
|
57
|
+
|
|
58
|
+
``subdir`` is "" for the repo root, or a relative path like "foo" or "foo/bar".
|
|
59
|
+
"""
|
|
60
|
+
path = f"{subdir}/lean-toolchain" if subdir else "lean-toolchain"
|
|
22
61
|
try:
|
|
23
62
|
result = subprocess.run(
|
|
24
|
-
["git", "-C", str(bare_repo_path), "show", f"{ref}:
|
|
63
|
+
["git", "-C", str(bare_repo_path), "show", f"{ref}:{path}"],
|
|
25
64
|
capture_output=True,
|
|
26
65
|
text=True,
|
|
27
66
|
check=True,
|
|
@@ -31,6 +70,81 @@ def _read_lean_toolchain(bare_repo_path: Path, ref: str) -> str | None:
|
|
|
31
70
|
return None
|
|
32
71
|
|
|
33
72
|
|
|
73
|
+
def _find_lean_toolchain_subdir(bare_repo_path: Path, ref: str) -> str | None:
|
|
74
|
+
"""Search for a non-root `lean-toolchain` file in the bare repo at `ref`.
|
|
75
|
+
|
|
76
|
+
Returns the relative directory containing the file, or None if there are
|
|
77
|
+
zero or multiple matches, or if the unique match doesn't sit next to a
|
|
78
|
+
lakefile. The root is handled separately by the caller; this only fires
|
|
79
|
+
on the slow path when root has no lean-toolchain.
|
|
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.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
proc = subprocess.Popen(
|
|
88
|
+
["git", "-C", str(bare_repo_path), "ls-tree", "-rz", "--name-only", ref],
|
|
89
|
+
stdout=subprocess.PIPE,
|
|
90
|
+
stderr=subprocess.DEVNULL,
|
|
91
|
+
)
|
|
92
|
+
except (OSError, FileNotFoundError):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
matches: list[str] = []
|
|
96
|
+
try:
|
|
97
|
+
assert proc.stdout is not None
|
|
98
|
+
buf = b""
|
|
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
|
|
119
|
+
finally:
|
|
120
|
+
try:
|
|
121
|
+
proc.stdout.close()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
try:
|
|
125
|
+
proc.terminate()
|
|
126
|
+
proc.wait(timeout=2)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
if not matches:
|
|
131
|
+
return None
|
|
132
|
+
if len(matches) >= 2:
|
|
133
|
+
dirs = sorted(m[: -len("/lean-toolchain")] for m in matches)
|
|
134
|
+
click.echo(
|
|
135
|
+
f"Multiple lean-toolchain files found ({', '.join(dirs)}); "
|
|
136
|
+
"cannot auto-detect Lean project subdirectory.",
|
|
137
|
+
err=True,
|
|
138
|
+
)
|
|
139
|
+
return None
|
|
140
|
+
subdir = matches[0][: -len("/lean-toolchain")]
|
|
141
|
+
if not _is_safe_subdir(subdir):
|
|
142
|
+
return None
|
|
143
|
+
if not _has_lakefile(bare_repo_path, ref, subdir):
|
|
144
|
+
return None
|
|
145
|
+
return subdir
|
|
146
|
+
|
|
147
|
+
|
|
34
148
|
def _parse_lean_version(toolchain_str: str) -> str | None:
|
|
35
149
|
"""Extract the version tag from a lean-toolchain string.
|
|
36
150
|
|
|
@@ -52,11 +166,14 @@ def _parse_lean_version(toolchain_str: str) -> str | None:
|
|
|
52
166
|
return None
|
|
53
167
|
|
|
54
168
|
|
|
55
|
-
def _parse_git_dependencies(
|
|
169
|
+
def _parse_git_dependencies(
|
|
170
|
+
bare_repo_path: Path, ref: str, subdir: str = ""
|
|
171
|
+
) -> list[GitDependency]:
|
|
56
172
|
"""Parse git dependencies from lake-manifest.json in the bare repo."""
|
|
173
|
+
path = f"{subdir}/lake-manifest.json" if subdir else "lake-manifest.json"
|
|
57
174
|
try:
|
|
58
175
|
result = subprocess.run(
|
|
59
|
-
["git", "-C", str(bare_repo_path), "show", f"{ref}:
|
|
176
|
+
["git", "-C", str(bare_repo_path), "show", f"{ref}:{path}"],
|
|
60
177
|
capture_output=True,
|
|
61
178
|
text=True,
|
|
62
179
|
check=True,
|
|
@@ -100,31 +217,50 @@ class LeanHook(Hook):
|
|
|
100
217
|
self._needs_cache: bool = False
|
|
101
218
|
self._is_lean4: bool = False
|
|
102
219
|
self._git_deps: list[GitDependency] = []
|
|
220
|
+
self._subdir: str = ""
|
|
103
221
|
|
|
104
222
|
def name(self) -> str:
|
|
105
223
|
return "Lean 4"
|
|
106
224
|
|
|
107
225
|
def detect(self, bare_repo_path: Path, ref: str) -> bool:
|
|
108
|
-
"""Check for lean-toolchain file at the given ref in the bare repo.
|
|
226
|
+
"""Check for lean-toolchain file at the given ref in the bare repo.
|
|
227
|
+
|
|
228
|
+
Tries the repo root first (the common case). If that fails, scans the
|
|
229
|
+
tree for a single non-root `lean-toolchain` file and uses its
|
|
230
|
+
directory. Bails out (returns False) when there are multiple matches.
|
|
231
|
+
"""
|
|
109
232
|
content = _read_lean_toolchain(bare_repo_path, ref)
|
|
233
|
+
subdir = ""
|
|
234
|
+
if content is None:
|
|
235
|
+
found = _find_lean_toolchain_subdir(bare_repo_path, ref)
|
|
236
|
+
if found is not None:
|
|
237
|
+
content = _read_lean_toolchain(bare_repo_path, ref, found)
|
|
238
|
+
if content is not None:
|
|
239
|
+
subdir = found
|
|
240
|
+
|
|
110
241
|
if content is not None:
|
|
111
242
|
self._toolchain = content
|
|
243
|
+
self._subdir = subdir
|
|
112
244
|
self._is_lean4 = bare_repo_path.name == "lean4.git"
|
|
113
245
|
if self._is_lean4:
|
|
114
246
|
self._git_deps = []
|
|
115
247
|
self._needs_cache = False
|
|
116
248
|
else:
|
|
117
|
-
self._git_deps = _parse_git_dependencies(bare_repo_path, ref)
|
|
249
|
+
self._git_deps = _parse_git_dependencies(bare_repo_path, ref, subdir)
|
|
118
250
|
self._needs_cache = bare_repo_path.name == "mathlib4.git" or any(
|
|
119
251
|
d.name == "mathlib" for d in self._git_deps
|
|
120
252
|
)
|
|
121
253
|
return True
|
|
122
254
|
self._toolchain = None
|
|
255
|
+
self._subdir = ""
|
|
123
256
|
self._needs_cache = False
|
|
124
257
|
self._is_lean4 = False
|
|
125
258
|
self._git_deps = []
|
|
126
259
|
return False
|
|
127
260
|
|
|
261
|
+
def project_subdir(self) -> str:
|
|
262
|
+
return self._subdir
|
|
263
|
+
|
|
128
264
|
def image_name(self) -> str:
|
|
129
265
|
"""Return the image name based on the lean-toolchain version.
|
|
130
266
|
|
|
@@ -28,7 +28,9 @@ def _make_hook_repo(tmp_path, work_name, files):
|
|
|
28
28
|
work = tmp_path / work_name
|
|
29
29
|
subprocess.run([GIT, "clone", str(repo), str(work)], capture_output=True, check=True)
|
|
30
30
|
for name, content in files.items():
|
|
31
|
-
|
|
31
|
+
path = work / name
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path.write_text(content)
|
|
32
34
|
subprocess.run([GIT, "-C", str(work), "add", "."], capture_output=True, check=True)
|
|
33
35
|
subprocess.run(
|
|
34
36
|
[GIT, "-C", str(work), "commit", "-m", "init"],
|
|
@@ -223,6 +225,151 @@ class TestLean4Detection:
|
|
|
223
225
|
assert hook.workspace_file("/home/user/mathlib4") is None
|
|
224
226
|
|
|
225
227
|
|
|
228
|
+
@pytest.fixture
|
|
229
|
+
def subdir_lean_repo(tmp_path):
|
|
230
|
+
"""Create a bare git repo with lean-toolchain in a subdirectory."""
|
|
231
|
+
return _make_hook_repo(
|
|
232
|
+
tmp_path,
|
|
233
|
+
"subdir-work",
|
|
234
|
+
{
|
|
235
|
+
"README.md": "# Root\n",
|
|
236
|
+
"myproj/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
237
|
+
"myproj/lakefile.toml": "name = 'myproj'\n",
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.fixture
|
|
243
|
+
def nested_subdir_lean_repo(tmp_path):
|
|
244
|
+
"""Create a bare git repo with lean-toolchain in a nested subdirectory."""
|
|
245
|
+
return _make_hook_repo(
|
|
246
|
+
tmp_path,
|
|
247
|
+
"nested-work",
|
|
248
|
+
{
|
|
249
|
+
"docs/README.md": "# Docs\n",
|
|
250
|
+
"src/lean/lean-toolchain": "leanprover/lean4:v4.21.0\n",
|
|
251
|
+
"src/lean/lakefile.lean": "package myproj\n",
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.fixture
|
|
257
|
+
def subdir_no_lakefile_repo(tmp_path):
|
|
258
|
+
"""Subdirectory has lean-toolchain but no sibling lakefile (vendored/doc dir)."""
|
|
259
|
+
return _make_hook_repo(
|
|
260
|
+
tmp_path,
|
|
261
|
+
"no-lakefile",
|
|
262
|
+
{
|
|
263
|
+
"README.md": "# Root\n",
|
|
264
|
+
"vendor/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.fixture
|
|
270
|
+
def ambiguous_lean_repo(tmp_path):
|
|
271
|
+
"""Create a bare git repo with multiple non-root lean-toolchain files."""
|
|
272
|
+
return _make_hook_repo(
|
|
273
|
+
tmp_path,
|
|
274
|
+
"ambig-work",
|
|
275
|
+
{
|
|
276
|
+
"a/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
277
|
+
"b/lean-toolchain": "leanprover/lean4:v4.20.0\n",
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@pytest.fixture
|
|
283
|
+
def root_and_subdir_lean_repo(tmp_path):
|
|
284
|
+
"""Repo with lean-toolchain at root AND in a subdirectory.
|
|
285
|
+
|
|
286
|
+
The root file should win — we don't enter the slow scan path when root
|
|
287
|
+
has lean-toolchain, even if other copies exist deeper in the tree.
|
|
288
|
+
"""
|
|
289
|
+
return _make_hook_repo(
|
|
290
|
+
tmp_path,
|
|
291
|
+
"root-and-sub",
|
|
292
|
+
{
|
|
293
|
+
"lean-toolchain": "leanprover/lean4:v4.19.0\n",
|
|
294
|
+
"vendor/lean-toolchain": "leanprover/lean4:v4.10.0\n",
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class TestSubdirDetection:
|
|
300
|
+
def test_detect_subdir_lean_repo(self, subdir_lean_repo):
|
|
301
|
+
hook = LeanHook()
|
|
302
|
+
assert hook.detect(subdir_lean_repo, "HEAD") is True
|
|
303
|
+
assert hook.project_subdir() == "myproj"
|
|
304
|
+
assert hook.image_name() == "lean-v4.20.0"
|
|
305
|
+
|
|
306
|
+
def test_detect_nested_subdir(self, nested_subdir_lean_repo):
|
|
307
|
+
hook = LeanHook()
|
|
308
|
+
assert hook.detect(nested_subdir_lean_repo, "HEAD") is True
|
|
309
|
+
assert hook.project_subdir() == "src/lean"
|
|
310
|
+
assert hook.image_name() == "lean-v4.21.0"
|
|
311
|
+
|
|
312
|
+
def test_ambiguous_repo_not_detected(self, ambiguous_lean_repo):
|
|
313
|
+
hook = LeanHook()
|
|
314
|
+
assert hook.detect(ambiguous_lean_repo, "HEAD") is False
|
|
315
|
+
assert hook.project_subdir() == ""
|
|
316
|
+
|
|
317
|
+
def test_root_wins_over_subdir(self, root_and_subdir_lean_repo):
|
|
318
|
+
hook = LeanHook()
|
|
319
|
+
assert hook.detect(root_and_subdir_lean_repo, "HEAD") is True
|
|
320
|
+
assert hook.project_subdir() == ""
|
|
321
|
+
assert hook.image_name() == "lean-v4.19.0"
|
|
322
|
+
|
|
323
|
+
def test_root_repo_has_empty_subdir(self, lean_repo):
|
|
324
|
+
hook = LeanHook()
|
|
325
|
+
hook.detect(lean_repo, "HEAD")
|
|
326
|
+
assert hook.project_subdir() == ""
|
|
327
|
+
|
|
328
|
+
def test_subdir_cleared_on_redetect_failure(self, subdir_lean_repo, non_lean_repo):
|
|
329
|
+
"""Re-running detect() on a non-Lean repo must clear stale subdir state."""
|
|
330
|
+
hook = LeanHook()
|
|
331
|
+
hook.detect(subdir_lean_repo, "HEAD")
|
|
332
|
+
assert hook.project_subdir() == "myproj"
|
|
333
|
+
assert hook.detect(non_lean_repo, "HEAD") is False
|
|
334
|
+
assert hook.project_subdir() == ""
|
|
335
|
+
|
|
336
|
+
def test_subdir_without_lakefile_rejected(self, subdir_no_lakefile_repo):
|
|
337
|
+
"""A lean-toolchain in a subdir with no sibling lakefile is ignored."""
|
|
338
|
+
hook = LeanHook()
|
|
339
|
+
assert hook.detect(subdir_no_lakefile_repo, "HEAD") is False
|
|
340
|
+
assert hook.project_subdir() == ""
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class TestSafeSubdir:
|
|
344
|
+
def test_safe_names(self):
|
|
345
|
+
from bubble.hooks.lean import _is_safe_subdir
|
|
346
|
+
|
|
347
|
+
assert _is_safe_subdir("myproj")
|
|
348
|
+
assert _is_safe_subdir("src/lean")
|
|
349
|
+
assert _is_safe_subdir("a.b-c_d/e1")
|
|
350
|
+
|
|
351
|
+
def test_rejects_traversal(self):
|
|
352
|
+
from bubble.hooks.lean import _is_safe_subdir
|
|
353
|
+
|
|
354
|
+
assert not _is_safe_subdir("..")
|
|
355
|
+
assert not _is_safe_subdir("../etc")
|
|
356
|
+
assert not _is_safe_subdir("foo/../bar")
|
|
357
|
+
assert not _is_safe_subdir(".")
|
|
358
|
+
assert not _is_safe_subdir("./foo")
|
|
359
|
+
|
|
360
|
+
def test_rejects_absolute_and_edge_cases(self):
|
|
361
|
+
from bubble.hooks.lean import _is_safe_subdir
|
|
362
|
+
|
|
363
|
+
assert not _is_safe_subdir("")
|
|
364
|
+
assert not _is_safe_subdir("/abs")
|
|
365
|
+
assert not _is_safe_subdir("trailing/")
|
|
366
|
+
assert not _is_safe_subdir("foo//bar")
|
|
367
|
+
assert not _is_safe_subdir("foo bar")
|
|
368
|
+
assert not _is_safe_subdir("foo;bar")
|
|
369
|
+
assert not _is_safe_subdir("foo\nbar")
|
|
370
|
+
assert not _is_safe_subdir("café")
|
|
371
|
+
|
|
372
|
+
|
|
226
373
|
@pytest.fixture
|
|
227
374
|
def python_repo(tmp_path):
|
|
228
375
|
"""Create a bare git repo with a pyproject.toml file."""
|
|
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
|