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.
Files changed (139) hide show
  1. {dev_bubble-0.7.18/dev_bubble.egg-info → dev_bubble-0.7.19}/PKG-INFO +1 -1
  2. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/__init__.py +1 -1
  3. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/finalization.py +4 -4
  4. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/hooks/__init__.py +5 -5
  5. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/hooks/lean.py +138 -90
  6. {dev_bubble-0.7.18 → dev_bubble-0.7.19/dev_bubble.egg-info}/PKG-INFO +1 -1
  7. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_hooks.py +118 -27
  8. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/.claude/CLAUDE.md +0 -0
  9. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/.github/workflows/ci.yml +0 -0
  10. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/.github/workflows/publish.yml +0 -0
  11. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/.gitignore +0 -0
  12. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/CHANGELOG.md +0 -0
  13. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/LICENSE +0 -0
  14. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/README.md +0 -0
  15. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/SPEC.md +0 -0
  16. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/__main__.py +0 -0
  17. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/ai.py +0 -0
  18. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/auth_proxy.py +0 -0
  19. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/automation.py +0 -0
  20. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/clean.py +0 -0
  21. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/cli.py +0 -0
  22. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/clone.py +0 -0
  23. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/cloud.py +0 -0
  24. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/cloud_types.py +0 -0
  25. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/__init__.py +0 -0
  26. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/cloud_cmd.py +0 -0
  27. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/completion.py +0 -0
  28. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/doctor.py +0 -0
  29. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/images.py +0 -0
  30. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/infrastructure.py +0 -0
  31. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/internal.py +0 -0
  32. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/lifecycle.py +0 -0
  33. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/list_cmd.py +0 -0
  34. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/relay_cmd.py +0 -0
  35. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/remote_cmd.py +0 -0
  36. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/security_cmd.py +0 -0
  37. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/settings.py +0 -0
  38. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/commands/status_cmd.py +0 -0
  39. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/config.py +0 -0
  40. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/container_helpers.py +0 -0
  41. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/data/skill.md +0 -0
  42. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/default_repos.json +0 -0
  43. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/git_store.py +0 -0
  44. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/github_token.py +0 -0
  45. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/graphql_validator.py +0 -0
  46. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/hooks/python.py +0 -0
  47. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/image_management.py +0 -0
  48. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/__init__.py +0 -0
  49. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/builder.py +0 -0
  50. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/base.sh +0 -0
  51. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/cloud-init.sh +0 -0
  52. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/lean-toolchain.sh +0 -0
  53. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/lean.sh +0 -0
  54. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/python.sh +0 -0
  55. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/claude.sh +0 -0
  56. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/codex.sh +0 -0
  57. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/elan.sh +0 -0
  58. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/emacs.sh +0 -0
  59. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/gh.sh +0 -0
  60. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/neovim.sh +0 -0
  61. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/pins.json +0 -0
  62. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/uv.sh +0 -0
  63. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/images/scripts/tools/vscode.sh +0 -0
  64. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/lean.py +0 -0
  65. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/lifecycle.py +0 -0
  66. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/naming.py +0 -0
  67. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/network.py +0 -0
  68. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/notices.py +0 -0
  69. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/output.py +0 -0
  70. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/provisioning.py +0 -0
  71. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/relay.py +0 -0
  72. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/remote.py +0 -0
  73. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/repo_registry.py +0 -0
  74. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/runtime/__init__.py +0 -0
  75. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/runtime/base.py +0 -0
  76. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/runtime/colima.py +0 -0
  77. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/runtime/incus.py +0 -0
  78. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/security.py +0 -0
  79. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/setup.py +0 -0
  80. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/skill.py +0 -0
  81. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/spinner.py +0 -0
  82. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/target.py +0 -0
  83. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/token_store.py +0 -0
  84. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/tools.py +0 -0
  85. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/tunnel.py +0 -0
  86. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/bubble/vscode.py +0 -0
  87. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/config/com.bubble.git-update.plist +0 -0
  88. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/config/com.bubble.image-refresh.plist +0 -0
  89. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/config/com.bubble.relay-daemon.plist +0 -0
  90. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/conftest.py +0 -0
  91. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/SOURCES.txt +0 -0
  92. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/dependency_links.txt +0 -0
  93. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/entry_points.txt +0 -0
  94. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/requires.txt +0 -0
  95. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/dev_bubble.egg-info/top_level.txt +0 -0
  96. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/pyproject.toml +0 -0
  97. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/setup.cfg +0 -0
  98. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/conftest.py +0 -0
  99. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_ai.py +0 -0
  100. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_auth_proxy.py +0 -0
  101. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_authorized_keys.py +0 -0
  102. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_branch_no_target.py +0 -0
  103. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_build_lock.py +0 -0
  104. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_claude_projects_symlink.py +0 -0
  105. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_cloud.py +0 -0
  106. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_colima.py +0 -0
  107. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_completion.py +0 -0
  108. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_config.py +0 -0
  109. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_customize.py +0 -0
  110. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_editor.py +0 -0
  111. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_ephemeral.py +0 -0
  112. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_git_store.py +0 -0
  113. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_github_security_override.py +0 -0
  114. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_github_token.py +0 -0
  115. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_graphql_validator.py +0 -0
  116. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_integration.py +0 -0
  117. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_internal.py +0 -0
  118. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_lifecycle.py +0 -0
  119. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_list_columns.py +0 -0
  120. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_list_remote.py +0 -0
  121. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_mounts.py +0 -0
  122. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_multi_target.py +0 -0
  123. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_naming.py +0 -0
  124. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_network.py +0 -0
  125. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_notices.py +0 -0
  126. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_reattach_network.py +0 -0
  127. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_relay.py +0 -0
  128. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_remote.py +0 -0
  129. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_repo_registry.py +0 -0
  130. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_security.py +0 -0
  131. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_skill.py +0 -0
  132. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_spinner.py +0 -0
  133. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_status.py +0 -0
  134. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_systemd_path.py +0 -0
  135. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_target.py +0 -0
  136. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_token_no_argv_leak.py +0 -0
  137. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_tools.py +0 -0
  138. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_tunnel.py +0 -0
  139. {dev_bubble-0.7.18 → dev_bubble-0.7.19}/tests/test_vscode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.18
3
+ Version: 0.7.19
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """bubble: Containerized development environments."""
2
2
 
3
- __version__ = "0.7.18"
3
+ __version__ = "0.7.19"
@@ -38,11 +38,11 @@ def finalize_bubble(
38
38
  before any clone/fetch operations.
39
39
  """
40
40
  q_short = shlex.quote(short)
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
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(repo_dir)
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 project_subdir(self) -> str:
73
- """Subdirectory within the repo where the project lives.
72
+ def notices(self) -> list[str]:
73
+ """One-shot human-readable notes to print after detection.
74
74
 
75
- Returns "" if the project is at the repo root (the common case).
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
- """Confirm a sibling lakefile exists next to a discovered lean-toolchain.
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 the project root.
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}:{subdir}/{name}"],
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 _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
+ 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
- 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.
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 None
91
+ return []
94
92
 
95
- matches: list[str] = []
93
+ subdirs: list[str] = []
94
+ suffix = b"/lean-toolchain"
96
95
  try:
97
96
  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
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
- 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
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 detect(self, bare_repo_path: Path, ref: str) -> bool:
226
- """Check for lean-toolchain file at the given ref in the bare repo.
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
- 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.
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
- 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
-
241
- if content is not None:
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._is_lean4 = bare_repo_path.name == "lean4.git"
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
- self._toolchain = None
255
- self._subdir = ""
256
- self._needs_cache = False
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
- return False
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 project_subdir(self) -> str:
262
- return self._subdir
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, project_dir)
344
+ self._populate_lake_packages(runtime, container, build_dir)
297
345
 
298
- q_dir = shlex.quote(project_dir)
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."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.18
3
+ Version: 0.7.19
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0
@@ -254,11 +254,16 @@ def nested_subdir_lean_repo(tmp_path):
254
254
 
255
255
 
256
256
  @pytest.fixture
257
- def subdir_no_lakefile_repo(tmp_path):
258
- """Subdirectory has lean-toolchain but no sibling lakefile (vendored/doc dir)."""
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
- "no-lakefile",
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 ambiguous_lean_repo(tmp_path):
271
- """Create a bare git repo with multiple non-root lean-toolchain files."""
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
- "ambig-work",
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 root_and_subdir_lean_repo(tmp_path):
284
- """Repo with lean-toolchain at root AND in a subdirectory.
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
- 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
- """
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.project_subdir() == "myproj"
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.project_subdir() == "src/lean"
329
+ assert hook._subdir == "src/lean"
310
330
  assert hook.image_name() == "lean-v4.21.0"
311
331
 
312
- def test_ambiguous_repo_not_detected(self, ambiguous_lean_repo):
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(ambiguous_lean_repo, "HEAD") is False
315
- assert hook.project_subdir() == ""
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.project_subdir() == ""
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 test_root_repo_has_empty_subdir(self, lean_repo):
372
+ def test_root_repo_subdir_empty(self, lean_repo):
324
373
  hook = LeanHook()
325
374
  hook.detect(lean_repo, "HEAD")
326
- assert hook.project_subdir() == ""
375
+ assert hook._subdir == ""
327
376
 
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."""
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.project_subdir() == "myproj"
380
+ assert hook._subdir == "myproj"
333
381
  assert hook.detect(non_lean_repo, "HEAD") is False
334
- assert hook.project_subdir() == ""
382
+ assert hook._subdir == ""
383
+ assert hook._multi_project is False
384
+ assert hook.notices() == []
385
+
335
386
 
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."""
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(subdir_no_lakefile_repo, "HEAD") is False
340
- assert hook.project_subdir() == ""
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