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.
Files changed (139) hide show
  1. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/.gitignore +1 -0
  2. {dev_bubble-0.7.17/dev_bubble.egg-info → dev_bubble-0.7.18}/PKG-INFO +1 -1
  3. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/__init__.py +1 -1
  4. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/finalization.py +4 -2
  5. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/hooks/__init__.py +8 -0
  6. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/hooks/lean.py +143 -7
  7. {dev_bubble-0.7.17 → dev_bubble-0.7.18/dev_bubble.egg-info}/PKG-INFO +1 -1
  8. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_hooks.py +148 -1
  9. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/.claude/CLAUDE.md +0 -0
  10. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/.github/workflows/ci.yml +0 -0
  11. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/.github/workflows/publish.yml +0 -0
  12. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/CHANGELOG.md +0 -0
  13. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/LICENSE +0 -0
  14. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/README.md +0 -0
  15. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/SPEC.md +0 -0
  16. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/__main__.py +0 -0
  17. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/ai.py +0 -0
  18. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/auth_proxy.py +0 -0
  19. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/automation.py +0 -0
  20. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/clean.py +0 -0
  21. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/cli.py +0 -0
  22. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/clone.py +0 -0
  23. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/cloud.py +0 -0
  24. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/cloud_types.py +0 -0
  25. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/__init__.py +0 -0
  26. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/cloud_cmd.py +0 -0
  27. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/completion.py +0 -0
  28. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/doctor.py +0 -0
  29. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/images.py +0 -0
  30. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/infrastructure.py +0 -0
  31. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/internal.py +0 -0
  32. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/lifecycle.py +0 -0
  33. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/list_cmd.py +0 -0
  34. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/relay_cmd.py +0 -0
  35. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/remote_cmd.py +0 -0
  36. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/security_cmd.py +0 -0
  37. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/settings.py +0 -0
  38. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/commands/status_cmd.py +0 -0
  39. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/config.py +0 -0
  40. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/container_helpers.py +0 -0
  41. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/data/skill.md +0 -0
  42. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/default_repos.json +0 -0
  43. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/git_store.py +0 -0
  44. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/github_token.py +0 -0
  45. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/graphql_validator.py +0 -0
  46. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/hooks/python.py +0 -0
  47. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/image_management.py +0 -0
  48. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/__init__.py +0 -0
  49. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/builder.py +0 -0
  50. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/base.sh +0 -0
  51. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/cloud-init.sh +0 -0
  52. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/lean-toolchain.sh +0 -0
  53. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/lean.sh +0 -0
  54. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/python.sh +0 -0
  55. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/claude.sh +0 -0
  56. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/codex.sh +0 -0
  57. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/elan.sh +0 -0
  58. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/emacs.sh +0 -0
  59. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/gh.sh +0 -0
  60. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/neovim.sh +0 -0
  61. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/pins.json +0 -0
  62. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/uv.sh +0 -0
  63. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/images/scripts/tools/vscode.sh +0 -0
  64. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/lean.py +0 -0
  65. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/lifecycle.py +0 -0
  66. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/naming.py +0 -0
  67. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/network.py +0 -0
  68. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/notices.py +0 -0
  69. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/output.py +0 -0
  70. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/provisioning.py +0 -0
  71. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/relay.py +0 -0
  72. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/remote.py +0 -0
  73. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/repo_registry.py +0 -0
  74. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/runtime/__init__.py +0 -0
  75. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/runtime/base.py +0 -0
  76. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/runtime/colima.py +0 -0
  77. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/runtime/incus.py +0 -0
  78. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/security.py +0 -0
  79. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/setup.py +0 -0
  80. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/skill.py +0 -0
  81. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/spinner.py +0 -0
  82. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/target.py +0 -0
  83. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/token_store.py +0 -0
  84. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/tools.py +0 -0
  85. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/tunnel.py +0 -0
  86. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/bubble/vscode.py +0 -0
  87. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/config/com.bubble.git-update.plist +0 -0
  88. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/config/com.bubble.image-refresh.plist +0 -0
  89. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/config/com.bubble.relay-daemon.plist +0 -0
  90. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/conftest.py +0 -0
  91. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/SOURCES.txt +0 -0
  92. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/dependency_links.txt +0 -0
  93. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/entry_points.txt +0 -0
  94. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/requires.txt +0 -0
  95. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/dev_bubble.egg-info/top_level.txt +0 -0
  96. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/pyproject.toml +0 -0
  97. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/setup.cfg +0 -0
  98. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/conftest.py +0 -0
  99. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_ai.py +0 -0
  100. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_auth_proxy.py +0 -0
  101. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_authorized_keys.py +0 -0
  102. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_branch_no_target.py +0 -0
  103. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_build_lock.py +0 -0
  104. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_claude_projects_symlink.py +0 -0
  105. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_cloud.py +0 -0
  106. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_colima.py +0 -0
  107. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_completion.py +0 -0
  108. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_config.py +0 -0
  109. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_customize.py +0 -0
  110. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_editor.py +0 -0
  111. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_ephemeral.py +0 -0
  112. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_git_store.py +0 -0
  113. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_github_security_override.py +0 -0
  114. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_github_token.py +0 -0
  115. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_graphql_validator.py +0 -0
  116. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_integration.py +0 -0
  117. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_internal.py +0 -0
  118. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_lifecycle.py +0 -0
  119. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_list_columns.py +0 -0
  120. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_list_remote.py +0 -0
  121. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_mounts.py +0 -0
  122. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_multi_target.py +0 -0
  123. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_naming.py +0 -0
  124. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_network.py +0 -0
  125. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_notices.py +0 -0
  126. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_reattach_network.py +0 -0
  127. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_relay.py +0 -0
  128. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_remote.py +0 -0
  129. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_repo_registry.py +0 -0
  130. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_security.py +0 -0
  131. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_skill.py +0 -0
  132. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_spinner.py +0 -0
  133. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_status.py +0 -0
  134. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_systemd_path.py +0 -0
  135. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_target.py +0 -0
  136. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_token_no_argv_leak.py +0 -0
  137. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_tools.py +0 -0
  138. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_tunnel.py +0 -0
  139. {dev_bubble-0.7.17 → dev_bubble-0.7.18}/tests/test_vscode.py +0 -0
@@ -11,3 +11,4 @@ uv.lock
11
11
  .vscode/claude-prompt.txt
12
12
  .vscode/tasks.json
13
13
  .vscode/settings.json
14
+ .claude/scheduled_tasks.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.17
3
+ Version: 0.7.18
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.17"
3
+ __version__ = "0.7.18"
@@ -38,7 +38,9 @@ def finalize_bubble(
38
38
  before any clone/fetch operations.
39
39
  """
40
40
  q_short = shlex.quote(short)
41
- project_dir = f"/home/user/{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
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(project_dir)
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 _read_lean_toolchain(bare_repo_path: Path, ref: str) -> str | None:
21
- """Read the lean-toolchain file content from a bare repo at a given ref."""
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}:lean-toolchain"],
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(bare_repo_path: Path, ref: str) -> list[GitDependency]:
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}:lake-manifest.json"],
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.17
3
+ Version: 0.7.18
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
@@ -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
- (work / name).write_text(content)
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