dev-bubble 0.6.2__tar.gz → 0.6.3__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.6.2 → dev_bubble-0.6.3}/CHANGELOG.md +6 -0
- {dev_bubble-0.6.2/dev_bubble.egg-info → dev_bubble-0.6.3}/PKG-INFO +1 -1
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/__init__.py +1 -1
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/cli.py +2 -2
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/settings.py +16 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/config.py +60 -19
- {dev_bubble-0.6.2 → dev_bubble-0.6.3/dev_bubble.egg-info}/PKG-INFO +1 -1
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_claude_projects_symlink.py +137 -46
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.claude/CLAUDE.md +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.github/workflows/ci.yml +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.github/workflows/publish.yml +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.gitignore +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.vscode/claude-prompt.txt +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.vscode/settings.json +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.vscode/tasks.json +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/LICENSE +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/README.md +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/__main__.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/auth_proxy.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/automation.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/claude.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/clean.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/clone.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/cloud.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/cloud_types.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/__init__.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/cloud_cmd.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/doctor.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/images.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/infrastructure.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/lifecycle.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/list_cmd.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/relay_cmd.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/remote_cmd.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/container_helpers.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/data/skill.md +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/default_repos.json +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/finalization.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/git_store.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/github_token.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/hooks/__init__.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/hooks/lean.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/image_management.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/__init__.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/builder.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/base.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/cloud-init.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/lean-toolchain.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/lean.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/claude.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/codex.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/elan.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/emacs.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/gh.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/neovim.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/pins.json +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/vscode.sh +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/lifecycle.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/naming.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/native.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/network.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/provisioning.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/relay.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/remote.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/repo_registry.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/runtime/__init__.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/runtime/base.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/runtime/colima.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/runtime/incus.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/security.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/setup.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/skill.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/target.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/tools.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/tunnel.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/vscode.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/config/com.bubble.git-update.plist +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/config/com.bubble.image-refresh.plist +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/config/com.bubble.relay-daemon.plist +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/SOURCES.txt +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/dependency_links.txt +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/entry_points.txt +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/requires.txt +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/top_level.txt +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/pyproject.toml +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/setup.cfg +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/conftest.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_auth_proxy.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_build_lock.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_claude.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_cloud.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_config.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_customize.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_editor.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_git_store.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_github_token.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_hooks.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_integration.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_lifecycle.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_list_remote.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_mounts.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_naming.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_native.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_network.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_relay.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_remote.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_repo_registry.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_security.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_skill.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_target.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_tools.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_tunnel.py +0 -0
- {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_vscode.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3 — 2026-03-12
|
|
4
|
+
- Don't prompt interactively during `bubble open` (#88)
|
|
5
|
+
- `maybe_symlink_claude_projects()` now prints an informational message instead of blocking on a `[y/N]` prompt
|
|
6
|
+
- New `bubble config symlink-claude-projects` command to perform the symlink manually
|
|
7
|
+
- Suppress the hint with `claude_projects_symlink = "no"` in `~/.bubble/config.toml`
|
|
8
|
+
|
|
3
9
|
## 0.6.2 — 2026-03-12
|
|
4
10
|
- SSH tunnel auth proxy for remote/cloud bubbles (#81)
|
|
5
11
|
- Remote and cloud bubbles now use the same repo-scoped auth proxy as local bubbles
|
|
@@ -659,9 +659,9 @@ def open_cmd(
|
|
|
659
659
|
# Suppress auto mounts that overlap with user mounts (exact or ancestry)
|
|
660
660
|
user_targets = {Path(m.target) for m in mount_specs}
|
|
661
661
|
cc_mounts = [m for m in cc_mounts if not mount_overlaps(Path(m.target), user_targets)]
|
|
662
|
-
#
|
|
662
|
+
# Hint about symlinking ~/.bubble/claude-projects/ to ~/.claude/projects/
|
|
663
663
|
if not machine_readable:
|
|
664
|
-
maybe_symlink_claude_projects()
|
|
664
|
+
maybe_symlink_claude_projects(config)
|
|
665
665
|
|
|
666
666
|
# Editor config mounts (emacs/neovim only — suppress if user mounts overlap)
|
|
667
667
|
ec_mounts = editor_config_mounts(editor)
|
|
@@ -396,3 +396,19 @@ def register_settings_commands(main):
|
|
|
396
396
|
click.echo(f"Accepted {len(changed)} risk(s). On-by-default warnings silenced.")
|
|
397
397
|
else:
|
|
398
398
|
click.echo("No auto-defaulting-to-on settings to accept.")
|
|
399
|
+
|
|
400
|
+
@config_group.command("symlink-claude-projects")
|
|
401
|
+
def config_symlink_claude_projects():
|
|
402
|
+
"""Replace ~/.bubble/claude-projects/ with a symlink to ~/.claude/projects/.
|
|
403
|
+
|
|
404
|
+
If ~/.claude/projects/ is inside a git repo, this command merges any
|
|
405
|
+
existing session data from ~/.bubble/claude-projects/ into
|
|
406
|
+
~/.claude/projects/ and replaces the directory with a symlink.
|
|
407
|
+
|
|
408
|
+
This lets bubble session state live inside the git-tracked directory
|
|
409
|
+
and get synced across machines automatically.
|
|
410
|
+
"""
|
|
411
|
+
from ..config import do_symlink_claude_projects
|
|
412
|
+
|
|
413
|
+
if not do_symlink_claude_projects():
|
|
414
|
+
raise SystemExit(1)
|
|
@@ -420,15 +420,18 @@ def _is_inside_git_repo(path: Path) -> bool:
|
|
|
420
420
|
return False
|
|
421
421
|
|
|
422
422
|
|
|
423
|
-
def maybe_symlink_claude_projects() -> None:
|
|
424
|
-
"""
|
|
423
|
+
def maybe_symlink_claude_projects(config: dict | None = None) -> None:
|
|
424
|
+
"""Print an informational message if claude-projects could be symlinked.
|
|
425
425
|
|
|
426
426
|
If ~/.claude/projects/ is inside a git repo and ~/.bubble/claude-projects/ is a real
|
|
427
|
-
directory (not already a symlink),
|
|
428
|
-
|
|
429
|
-
|
|
427
|
+
directory (not already a symlink), print a one-time hint about the
|
|
428
|
+
``bubble config symlink-claude-projects`` command. Never prompts interactively.
|
|
429
|
+
|
|
430
|
+
Suppressed when ``claude_projects_symlink = "no"`` is set in config.
|
|
430
431
|
"""
|
|
431
|
-
|
|
432
|
+
# Respect opt-out config
|
|
433
|
+
if config and config.get("claude_projects_symlink") == "no":
|
|
434
|
+
return
|
|
432
435
|
|
|
433
436
|
claude_projects = CLAUDE_CONFIG_DIR / "projects"
|
|
434
437
|
bubble_projects = CLAUDE_PROJECTS_DIR
|
|
@@ -447,29 +450,65 @@ def maybe_symlink_claude_projects() -> None:
|
|
|
447
450
|
bubble_projects.symlink_to(claude_projects)
|
|
448
451
|
return
|
|
449
452
|
|
|
450
|
-
#
|
|
451
|
-
|
|
452
|
-
|
|
453
|
+
# It's a real directory — print informational message (no prompt)
|
|
454
|
+
import click
|
|
455
|
+
|
|
456
|
+
click.echo(
|
|
457
|
+
"~/.claude/projects is git-tracked. Claude sessions within bubbles are stored\n"
|
|
458
|
+
"in ~/.bubble/claude-projects. To replace that directory with a symlink (so\n"
|
|
459
|
+
"session state is tracked in git), run:\n"
|
|
460
|
+
"\n"
|
|
461
|
+
" bubble config symlink-claude-projects\n"
|
|
462
|
+
"\n"
|
|
463
|
+
'To suppress this message, set claude_projects_symlink = "no" in '
|
|
464
|
+
"~/.bubble/config.toml.",
|
|
465
|
+
err=True,
|
|
466
|
+
)
|
|
467
|
+
|
|
453
468
|
|
|
454
|
-
|
|
469
|
+
def do_symlink_claude_projects() -> bool:
|
|
470
|
+
"""Replace ~/.bubble/claude-projects/ with a symlink to ~/.claude/projects/.
|
|
471
|
+
|
|
472
|
+
Merges existing contents from bubble-projects into claude-projects before
|
|
473
|
+
replacing. Returns True if the symlink was created, False otherwise.
|
|
474
|
+
"""
|
|
455
475
|
import click
|
|
456
476
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
return
|
|
477
|
+
claude_projects = CLAUDE_CONFIG_DIR / "projects"
|
|
478
|
+
bubble_projects = CLAUDE_PROJECTS_DIR
|
|
479
|
+
|
|
480
|
+
if not claude_projects.is_dir():
|
|
481
|
+
click.echo(f"{claude_projects} does not exist.", err=True)
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
if not _is_inside_git_repo(claude_projects):
|
|
485
|
+
click.echo(f"{claude_projects} is not inside a git repository.", err=True)
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
if bubble_projects.is_symlink():
|
|
489
|
+
target = bubble_projects.resolve()
|
|
490
|
+
if target == claude_projects.resolve():
|
|
491
|
+
click.echo(f"{bubble_projects} is already a symlink to {claude_projects}.")
|
|
492
|
+
return True
|
|
493
|
+
click.echo(f"{bubble_projects} is a symlink to {target}, not {claude_projects}.", err=True)
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
if not bubble_projects.exists():
|
|
497
|
+
bubble_projects.parent.mkdir(parents=True, exist_ok=True)
|
|
498
|
+
bubble_projects.symlink_to(claude_projects)
|
|
499
|
+
click.echo(f"Created symlink: {bubble_projects} -> {claude_projects}")
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
if not bubble_projects.is_dir():
|
|
503
|
+
click.echo(f"{bubble_projects} exists but is not a directory.", err=True)
|
|
504
|
+
return False
|
|
463
505
|
|
|
464
506
|
# Merge existing contents into ~/.claude/projects/
|
|
465
|
-
# Move unique items; for conflicts, copy bubble-only files into the
|
|
466
|
-
# destination so nothing is silently lost.
|
|
467
507
|
for child in bubble_projects.iterdir():
|
|
468
508
|
dest = claude_projects / child.name
|
|
469
509
|
if not dest.exists():
|
|
470
510
|
shutil.move(str(child), str(dest))
|
|
471
511
|
elif child.is_dir() and dest.is_dir():
|
|
472
|
-
# Recursively merge directory contents that only exist in bubble
|
|
473
512
|
_merge_dir(child, dest)
|
|
474
513
|
else:
|
|
475
514
|
click.echo(f" Skipping {child.name} (already exists in {claude_projects})")
|
|
@@ -477,6 +516,8 @@ def maybe_symlink_claude_projects() -> None:
|
|
|
477
516
|
# Replace with symlink — use rmtree since conflicts may leave remnants
|
|
478
517
|
shutil.rmtree(str(bubble_projects))
|
|
479
518
|
bubble_projects.symlink_to(claude_projects)
|
|
519
|
+
click.echo(f"Created symlink: {bubble_projects} -> {claude_projects}")
|
|
520
|
+
return True
|
|
480
521
|
|
|
481
522
|
|
|
482
523
|
def _merge_dir(src: Path, dest: Path) -> None:
|
|
@@ -4,7 +4,7 @@ from unittest.mock import patch
|
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
|
-
from bubble.config import maybe_symlink_claude_projects
|
|
7
|
+
from bubble.config import do_symlink_claude_projects, maybe_symlink_claude_projects
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@pytest.fixture
|
|
@@ -82,8 +82,54 @@ class TestMaybeSymlinkClaudeProjects:
|
|
|
82
82
|
assert bubble_projects.is_symlink()
|
|
83
83
|
assert bubble_projects.resolve() == claude_projects.resolve()
|
|
84
84
|
|
|
85
|
-
def
|
|
86
|
-
"""
|
|
85
|
+
def test_prints_hint_when_real_dir_exists(self, setup_dirs, capsys):
|
|
86
|
+
"""Prints informational message when ~/.bubble/claude-projects/ is a real dir."""
|
|
87
|
+
claude_projects, bubble_projects = setup_dirs
|
|
88
|
+
bubble_projects.mkdir()
|
|
89
|
+
|
|
90
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
91
|
+
maybe_symlink_claude_projects()
|
|
92
|
+
|
|
93
|
+
# Directory is NOT replaced (no prompt, no auto-replace)
|
|
94
|
+
assert bubble_projects.is_dir()
|
|
95
|
+
assert not bubble_projects.is_symlink()
|
|
96
|
+
|
|
97
|
+
# Message was printed to stderr
|
|
98
|
+
captured = capsys.readouterr()
|
|
99
|
+
assert "symlink-claude-projects" in captured.err
|
|
100
|
+
assert "claude_projects_symlink" in captured.err
|
|
101
|
+
|
|
102
|
+
def test_suppressed_by_config(self, setup_dirs, capsys):
|
|
103
|
+
"""No message when claude_projects_symlink = 'no' in config."""
|
|
104
|
+
claude_projects, bubble_projects = setup_dirs
|
|
105
|
+
bubble_projects.mkdir()
|
|
106
|
+
|
|
107
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
108
|
+
maybe_symlink_claude_projects(config={"claude_projects_symlink": "no"})
|
|
109
|
+
|
|
110
|
+
assert bubble_projects.is_dir()
|
|
111
|
+
assert not bubble_projects.is_symlink()
|
|
112
|
+
|
|
113
|
+
captured = capsys.readouterr()
|
|
114
|
+
assert captured.err == ""
|
|
115
|
+
|
|
116
|
+
def test_handles_empty_bubble_projects_dir(self, setup_dirs, capsys):
|
|
117
|
+
"""Prints hint when ~/.bubble/claude-projects/ exists but is empty."""
|
|
118
|
+
claude_projects, bubble_projects = setup_dirs
|
|
119
|
+
bubble_projects.mkdir()
|
|
120
|
+
|
|
121
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
122
|
+
maybe_symlink_claude_projects()
|
|
123
|
+
|
|
124
|
+
# Not replaced — just a hint
|
|
125
|
+
assert bubble_projects.is_dir()
|
|
126
|
+
assert not bubble_projects.is_symlink()
|
|
127
|
+
assert "symlink-claude-projects" in capsys.readouterr().err
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestDoSymlinkClaudeProjects:
|
|
131
|
+
def test_replaces_dir_with_symlink(self, setup_dirs):
|
|
132
|
+
"""Replaces ~/.bubble/claude-projects/ with a symlink and merges contents."""
|
|
87
133
|
claude_projects, bubble_projects = setup_dirs
|
|
88
134
|
bubble_projects.mkdir()
|
|
89
135
|
|
|
@@ -91,48 +137,46 @@ class TestMaybeSymlinkClaudeProjects:
|
|
|
91
137
|
(bubble_projects / "session-a").mkdir()
|
|
92
138
|
(bubble_projects / "session-a" / "data.jsonl").write_text("data")
|
|
93
139
|
|
|
94
|
-
with (
|
|
95
|
-
|
|
96
|
-
patch("sys.stdin", **{"isatty.return_value": True}),
|
|
97
|
-
patch("click.confirm", return_value=True),
|
|
98
|
-
):
|
|
99
|
-
maybe_symlink_claude_projects()
|
|
140
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
141
|
+
result = do_symlink_claude_projects()
|
|
100
142
|
|
|
143
|
+
assert result is True
|
|
101
144
|
assert bubble_projects.is_symlink()
|
|
102
145
|
assert bubble_projects.resolve() == claude_projects.resolve()
|
|
103
146
|
# Content was moved
|
|
104
147
|
assert (claude_projects / "session-a" / "data.jsonl").read_text() == "data"
|
|
105
148
|
|
|
106
|
-
def
|
|
107
|
-
"""
|
|
149
|
+
def test_creates_symlink_when_bubble_dir_missing(self, setup_dirs):
|
|
150
|
+
"""Creates symlink directly when ~/.bubble/claude-projects/ doesn't exist."""
|
|
108
151
|
claude_projects, bubble_projects = setup_dirs
|
|
109
|
-
bubble_projects.mkdir()
|
|
110
|
-
(bubble_projects / "keep-me").write_text("data")
|
|
111
152
|
|
|
112
|
-
with (
|
|
113
|
-
|
|
114
|
-
patch("sys.stdin", **{"isatty.return_value": True}),
|
|
115
|
-
patch("click.confirm", return_value=False),
|
|
116
|
-
):
|
|
117
|
-
maybe_symlink_claude_projects()
|
|
153
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
154
|
+
result = do_symlink_claude_projects()
|
|
118
155
|
|
|
119
|
-
assert
|
|
120
|
-
assert
|
|
121
|
-
assert
|
|
156
|
+
assert result is True
|
|
157
|
+
assert bubble_projects.is_symlink()
|
|
158
|
+
assert bubble_projects.resolve() == claude_projects.resolve()
|
|
122
159
|
|
|
123
|
-
def
|
|
124
|
-
"""
|
|
160
|
+
def test_noop_when_already_symlink(self, setup_dirs):
|
|
161
|
+
"""Returns True when already a symlink."""
|
|
162
|
+
claude_projects, bubble_projects = setup_dirs
|
|
163
|
+
bubble_projects.symlink_to(claude_projects)
|
|
164
|
+
|
|
165
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
166
|
+
result = do_symlink_claude_projects()
|
|
167
|
+
|
|
168
|
+
assert result is True
|
|
169
|
+
assert bubble_projects.is_symlink()
|
|
170
|
+
|
|
171
|
+
def test_fails_when_not_git_tracked(self, setup_dirs):
|
|
172
|
+
"""Returns False when ~/.claude/projects/ is not in a git repo."""
|
|
125
173
|
claude_projects, bubble_projects = setup_dirs
|
|
126
174
|
bubble_projects.mkdir()
|
|
127
|
-
(bubble_projects / "keep-me").write_text("data")
|
|
128
175
|
|
|
129
|
-
with (
|
|
130
|
-
|
|
131
|
-
patch("sys.stdin", **{"isatty.return_value": False}),
|
|
132
|
-
):
|
|
133
|
-
maybe_symlink_claude_projects()
|
|
176
|
+
with patch("bubble.config._is_inside_git_repo", return_value=False):
|
|
177
|
+
result = do_symlink_claude_projects()
|
|
134
178
|
|
|
135
|
-
assert
|
|
179
|
+
assert result is False
|
|
136
180
|
assert not bubble_projects.is_symlink()
|
|
137
181
|
|
|
138
182
|
def test_merges_without_overwriting_conflicts(self, setup_dirs):
|
|
@@ -150,12 +194,8 @@ class TestMaybeSymlinkClaudeProjects:
|
|
|
150
194
|
(bubble_projects / "unique").mkdir()
|
|
151
195
|
(bubble_projects / "unique" / "data.txt").write_text("unique-data")
|
|
152
196
|
|
|
153
|
-
with (
|
|
154
|
-
|
|
155
|
-
patch("sys.stdin", **{"isatty.return_value": True}),
|
|
156
|
-
patch("click.confirm", return_value=True),
|
|
157
|
-
):
|
|
158
|
-
maybe_symlink_claude_projects()
|
|
197
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
198
|
+
do_symlink_claude_projects()
|
|
159
199
|
|
|
160
200
|
assert bubble_projects.is_symlink()
|
|
161
201
|
# Original was preserved (not overwritten)
|
|
@@ -165,20 +205,71 @@ class TestMaybeSymlinkClaudeProjects:
|
|
|
165
205
|
# Unique content was moved
|
|
166
206
|
assert (claude_projects / "unique" / "data.txt").read_text() == "unique-data"
|
|
167
207
|
|
|
168
|
-
def
|
|
169
|
-
"""
|
|
208
|
+
def test_fails_when_bubble_projects_is_file(self, setup_dirs):
|
|
209
|
+
"""Returns False when ~/.bubble/claude-projects is a file, not a dir."""
|
|
210
|
+
claude_projects, bubble_projects = setup_dirs
|
|
211
|
+
bubble_projects.write_text("not a directory")
|
|
212
|
+
|
|
213
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
214
|
+
result = do_symlink_claude_projects()
|
|
215
|
+
|
|
216
|
+
assert result is False
|
|
217
|
+
assert not bubble_projects.is_symlink()
|
|
218
|
+
|
|
219
|
+
def test_fails_when_symlink_points_elsewhere(self, setup_dirs):
|
|
220
|
+
"""Returns False when existing symlink points to wrong target."""
|
|
221
|
+
claude_projects, bubble_projects = setup_dirs
|
|
222
|
+
other_dir = bubble_projects.parent / "other"
|
|
223
|
+
other_dir.mkdir()
|
|
224
|
+
bubble_projects.symlink_to(other_dir)
|
|
225
|
+
|
|
226
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
227
|
+
result = do_symlink_claude_projects()
|
|
228
|
+
|
|
229
|
+
assert result is False
|
|
230
|
+
|
|
231
|
+
def test_succeeds_when_symlink_points_to_claude_projects(self, setup_dirs):
|
|
232
|
+
"""Returns True when existing symlink already points to correct target."""
|
|
233
|
+
claude_projects, bubble_projects = setup_dirs
|
|
234
|
+
bubble_projects.symlink_to(claude_projects)
|
|
235
|
+
|
|
236
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
237
|
+
result = do_symlink_claude_projects()
|
|
238
|
+
|
|
239
|
+
assert result is True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TestSymlinkClaudeProjectsCLI:
|
|
243
|
+
def test_exit_code_on_failure(self, setup_dirs):
|
|
244
|
+
"""CLI command exits with code 1 on failure."""
|
|
245
|
+
from click.testing import CliRunner
|
|
246
|
+
|
|
247
|
+
from bubble.cli import main
|
|
248
|
+
|
|
170
249
|
claude_projects, bubble_projects = setup_dirs
|
|
171
250
|
bubble_projects.mkdir()
|
|
172
251
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
252
|
+
runner = CliRunner()
|
|
253
|
+
with patch("bubble.config._is_inside_git_repo", return_value=False):
|
|
254
|
+
result = runner.invoke(main, ["config", "symlink-claude-projects"])
|
|
255
|
+
|
|
256
|
+
assert result.exit_code != 0
|
|
257
|
+
|
|
258
|
+
def test_exit_code_on_success(self, setup_dirs):
|
|
259
|
+
"""CLI command exits with code 0 on success."""
|
|
260
|
+
from click.testing import CliRunner
|
|
261
|
+
|
|
262
|
+
from bubble.cli import main
|
|
179
263
|
|
|
264
|
+
claude_projects, bubble_projects = setup_dirs
|
|
265
|
+
# Don't create bubble_projects — simplest success path
|
|
266
|
+
|
|
267
|
+
runner = CliRunner()
|
|
268
|
+
with patch("bubble.config._is_inside_git_repo", return_value=True):
|
|
269
|
+
result = runner.invoke(main, ["config", "symlink-claude-projects"])
|
|
270
|
+
|
|
271
|
+
assert result.exit_code == 0
|
|
180
272
|
assert bubble_projects.is_symlink()
|
|
181
|
-
assert bubble_projects.resolve() == claude_projects.resolve()
|
|
182
273
|
|
|
183
274
|
|
|
184
275
|
class TestIsInsideGitRepo:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|