devs-cli 2.0.6__tar.gz → 2.0.7__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.
- {devs_cli-2.0.6/devs_cli.egg-info → devs_cli-2.0.7}/PKG-INFO +1 -1
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/cli.py +73 -18
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/config.py +7 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7/devs_cli.egg-info}/PKG-INFO +1 -1
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/SOURCES.txt +1 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/pyproject.toml +1 -1
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_misc.py +40 -11
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_container_manager.py +71 -0
- devs_cli-2.0.7/tests/test_repo_cache.py +198 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/LICENSE +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/README.md +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/__init__.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/core/__init__.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/core/integration.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/exceptions.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/utils/__init__.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/dependency_links.txt +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/entry_points.txt +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/requires.txt +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/top_level.txt +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/setup.cfg +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_clean.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_start.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_stop.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_vscode.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_e2e.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_error_parsing.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_integration.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_live_mode.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_project.py +0 -0
- {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_workspace_manager.py +0 -0
|
@@ -15,6 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from .core import Project, ContainerManager, WorkspaceManager
|
|
16
16
|
from .core.integration import VSCodeIntegration, ExternalToolIntegration
|
|
17
17
|
from devs_common.devs_config import DevsConfigLoader
|
|
18
|
+
from devs_common.utils.repo_cache import RepoCache
|
|
18
19
|
from .exceptions import (
|
|
19
20
|
DevsError,
|
|
20
21
|
ProjectNotFoundError,
|
|
@@ -87,7 +88,12 @@ def debug_option(f):
|
|
|
87
88
|
|
|
88
89
|
def check_dependencies() -> None:
|
|
89
90
|
"""Check and report on dependencies."""
|
|
90
|
-
|
|
91
|
+
try:
|
|
92
|
+
project = Project()
|
|
93
|
+
except Exception:
|
|
94
|
+
# Outside a git repo (e.g. using --repo), use a dummy project
|
|
95
|
+
project = None
|
|
96
|
+
integration = ExternalToolIntegration(project)
|
|
91
97
|
missing = integration.get_missing_dependencies()
|
|
92
98
|
|
|
93
99
|
if missing:
|
|
@@ -104,13 +110,29 @@ def check_dependencies() -> None:
|
|
|
104
110
|
|
|
105
111
|
|
|
106
112
|
def get_project() -> Project:
|
|
107
|
-
"""Get project instance with error handling.
|
|
113
|
+
"""Get project instance with error handling.
|
|
114
|
+
|
|
115
|
+
If the CLI was invoked with --repo, the repo is cloned/updated
|
|
116
|
+
into the local cache and the Project is created from that path.
|
|
117
|
+
Otherwise, the current working directory is used.
|
|
118
|
+
"""
|
|
119
|
+
# Check for --repo from the CLI group context
|
|
120
|
+
repo = None
|
|
108
121
|
try:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
ctx = click.get_current_context()
|
|
123
|
+
repo = ctx.obj.get('REPO') if ctx.obj else None
|
|
124
|
+
except RuntimeError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
if repo:
|
|
129
|
+
repo_cache = RepoCache(cache_dir=config.repo_cache_dir)
|
|
130
|
+
repo_path = repo_cache.ensure_repo(repo)
|
|
131
|
+
project = Project(project_dir=repo_path)
|
|
132
|
+
else:
|
|
133
|
+
project = Project()
|
|
112
134
|
return project
|
|
113
|
-
except ProjectNotFoundError as e:
|
|
135
|
+
except (ProjectNotFoundError, DevsError) as e:
|
|
114
136
|
console.print(f"❌ {e}")
|
|
115
137
|
sys.exit(1)
|
|
116
138
|
|
|
@@ -132,14 +154,16 @@ def _get_version(ctx: click.Context, param: click.Parameter, value: bool) -> Non
|
|
|
132
154
|
@click.group()
|
|
133
155
|
@click.option('--version', is_flag=True, callback=_get_version, expose_value=False, is_eager=True, help='Show version and exit.')
|
|
134
156
|
@click.option('--debug', is_flag=True, help='Show debug tracebacks on error')
|
|
157
|
+
@click.option('--repo', default=None, help='GitHub org/repo (e.g. "ideonate/devs") to clone into cache instead of using CWD')
|
|
135
158
|
@click.pass_context
|
|
136
|
-
def cli(ctx, debug: bool) -> None:
|
|
159
|
+
def cli(ctx, debug: bool, repo: str) -> None:
|
|
137
160
|
"""DevContainer Management Tool
|
|
138
161
|
|
|
139
162
|
Manage multiple named devcontainers for any project.
|
|
140
163
|
"""
|
|
141
164
|
ctx.ensure_object(dict)
|
|
142
165
|
ctx.obj['DEBUG'] = debug
|
|
166
|
+
ctx.obj['REPO'] = repo
|
|
143
167
|
|
|
144
168
|
|
|
145
169
|
@cli.command()
|
|
@@ -900,25 +924,56 @@ def list(all_projects: bool) -> None:
|
|
|
900
924
|
|
|
901
925
|
if all_projects:
|
|
902
926
|
console.print("📋 All devcontainers:")
|
|
903
|
-
|
|
904
|
-
|
|
927
|
+
console.print("")
|
|
928
|
+
|
|
929
|
+
try:
|
|
930
|
+
containers = ContainerManager.list_all_containers()
|
|
931
|
+
|
|
932
|
+
if not containers:
|
|
933
|
+
console.print(" No active devcontainers found")
|
|
934
|
+
return
|
|
935
|
+
|
|
936
|
+
table = Table()
|
|
937
|
+
table.add_column("Project", style="magenta")
|
|
938
|
+
table.add_column("Name", style="cyan")
|
|
939
|
+
table.add_column("Mode", style="yellow")
|
|
940
|
+
table.add_column("Status", style="green")
|
|
941
|
+
table.add_column("Container", style="dim")
|
|
942
|
+
table.add_column("Created", style="dim")
|
|
943
|
+
|
|
944
|
+
for container in containers:
|
|
945
|
+
created_str = container.created.strftime("%Y-%m-%d %H:%M") if container.created else "unknown"
|
|
946
|
+
mode = "live" if container.labels.get('devs.live') == 'true' else "copy"
|
|
947
|
+
table.add_row(
|
|
948
|
+
container.project_name,
|
|
949
|
+
container.dev_name,
|
|
950
|
+
mode,
|
|
951
|
+
container.status,
|
|
952
|
+
container.name,
|
|
953
|
+
created_str
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
console.print(table)
|
|
957
|
+
|
|
958
|
+
except ContainerError as e:
|
|
959
|
+
console.print(f"❌ Error listing containers: {e}")
|
|
905
960
|
return
|
|
906
|
-
|
|
961
|
+
|
|
907
962
|
project = get_project()
|
|
908
963
|
container_manager = ContainerManager(project, config)
|
|
909
|
-
|
|
964
|
+
|
|
910
965
|
console.print(f"📋 Active devcontainers for project: {project.info.name}")
|
|
911
966
|
console.print("")
|
|
912
|
-
|
|
967
|
+
|
|
913
968
|
try:
|
|
914
969
|
containers = container_manager.list_containers()
|
|
915
|
-
|
|
970
|
+
|
|
916
971
|
if not containers:
|
|
917
972
|
console.print(" No active devcontainers found")
|
|
918
973
|
console.print("")
|
|
919
974
|
console.print("💡 Start some with: devs start <dev-name>")
|
|
920
975
|
return
|
|
921
|
-
|
|
976
|
+
|
|
922
977
|
# Create a table
|
|
923
978
|
table = Table()
|
|
924
979
|
table.add_column("Name", style="cyan")
|
|
@@ -926,7 +981,7 @@ def list(all_projects: bool) -> None:
|
|
|
926
981
|
table.add_column("Status", style="green")
|
|
927
982
|
table.add_column("Container", style="dim")
|
|
928
983
|
table.add_column("Created", style="dim")
|
|
929
|
-
|
|
984
|
+
|
|
930
985
|
for container in containers:
|
|
931
986
|
created_str = container.created.strftime("%Y-%m-%d %H:%M") if container.created else "unknown"
|
|
932
987
|
mode = "live" if container.labels.get('devs.live') == 'true' else "copy"
|
|
@@ -937,13 +992,13 @@ def list(all_projects: bool) -> None:
|
|
|
937
992
|
container.name,
|
|
938
993
|
created_str
|
|
939
994
|
)
|
|
940
|
-
|
|
995
|
+
|
|
941
996
|
console.print(table)
|
|
942
997
|
console.print("")
|
|
943
998
|
console.print("💡 Open with: devs vscode <dev-name>")
|
|
944
999
|
console.print("💡 Shell into: devs shell <dev-name>")
|
|
945
1000
|
console.print("💡 Stop with: devs stop <dev-name>")
|
|
946
|
-
|
|
1001
|
+
|
|
947
1002
|
except ContainerError as e:
|
|
948
1003
|
console.print(f"❌ Error listing containers: {e}")
|
|
949
1004
|
|
|
@@ -952,7 +1007,7 @@ def list(all_projects: bool) -> None:
|
|
|
952
1007
|
def status() -> None:
|
|
953
1008
|
"""Show project and dependency status."""
|
|
954
1009
|
try:
|
|
955
|
-
project =
|
|
1010
|
+
project = get_project()
|
|
956
1011
|
|
|
957
1012
|
console.print(f"📁 Project: {project.info.name}")
|
|
958
1013
|
console.print(f" Directory: {project.info.directory}")
|
|
@@ -17,6 +17,7 @@ class Config(BaseConfig):
|
|
|
17
17
|
CLAUDE_CONFIG_DIR = Path.home() / ".devs" / "claudeconfig"
|
|
18
18
|
CODEX_CONFIG_DIR = Path.home() / ".devs" / "codexconfig"
|
|
19
19
|
VSCODE_CLI_DIR = Path.home() / ".devs" / "vscode-cli"
|
|
20
|
+
REPO_CACHE_DIR = Path.home() / ".devs" / "repocache"
|
|
20
21
|
|
|
21
22
|
@property
|
|
22
23
|
def container_labels(self) -> Dict[str, str]:
|
|
@@ -47,6 +48,12 @@ class Config(BaseConfig):
|
|
|
47
48
|
self.vscode_cli_dir = Path(vscode_cli_env)
|
|
48
49
|
else:
|
|
49
50
|
self.vscode_cli_dir = self.VSCODE_CLI_DIR
|
|
51
|
+
|
|
52
|
+
repo_cache_env = os.getenv("DEVS_REPO_CACHE_DIR")
|
|
53
|
+
if repo_cache_env:
|
|
54
|
+
self.repo_cache_dir = Path(repo_cache_env)
|
|
55
|
+
else:
|
|
56
|
+
self.repo_cache_dir = self.REPO_CACHE_DIR
|
|
50
57
|
|
|
51
58
|
def get_default_workspaces_dir(self) -> Path:
|
|
52
59
|
"""Get default workspaces directory for CLI package."""
|
|
@@ -69,21 +69,50 @@ class TestListCommand:
|
|
|
69
69
|
assert result.exit_code == 0
|
|
70
70
|
assert "No active devcontainers found" in result.output
|
|
71
71
|
|
|
72
|
-
@patch('devs.cli.
|
|
73
|
-
def
|
|
74
|
-
"""Test listing
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
@patch('devs.cli.ContainerManager')
|
|
73
|
+
def test_list_all_projects(self, mock_container_manager_class, cli_runner, temp_project):
|
|
74
|
+
"""Test listing containers across all projects."""
|
|
75
|
+
mock_container_manager_class.list_all_containers.return_value = [
|
|
76
|
+
MockContainer("/dev-org-a-repo-a-alice", "running", {
|
|
77
|
+
"devs.project": "org-a-repo-a",
|
|
78
|
+
"devs.name": "alice",
|
|
79
|
+
"devs.managed": "true"
|
|
80
|
+
}),
|
|
81
|
+
MockContainer("/dev-org-a-repo-a-bob", "exited", {
|
|
82
|
+
"devs.project": "org-a-repo-a",
|
|
83
|
+
"devs.name": "bob",
|
|
84
|
+
"devs.managed": "true"
|
|
85
|
+
}),
|
|
86
|
+
MockContainer("/dev-org-b-repo-b-charlie", "running", {
|
|
87
|
+
"devs.project": "org-b-repo-b",
|
|
88
|
+
"devs.name": "charlie",
|
|
89
|
+
"devs.managed": "true"
|
|
90
|
+
}),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
result = cli_runner.invoke(cli, ['list', '--all-projects'])
|
|
94
|
+
|
|
95
|
+
assert result.exit_code == 0
|
|
96
|
+
assert "All devcontainers" in result.output
|
|
97
|
+
assert "org-a-repo-a" in result.output
|
|
98
|
+
assert "org-b-repo-b" in result.output
|
|
99
|
+
assert "alice" in result.output
|
|
100
|
+
assert "bob" in result.output
|
|
101
|
+
assert "charlie" in result.output
|
|
102
|
+
assert "running" in result.output
|
|
103
|
+
assert "exited" in result.output
|
|
104
|
+
# Should include Project column
|
|
105
|
+
mock_container_manager_class.list_all_containers.assert_called_once()
|
|
106
|
+
|
|
107
|
+
@patch('devs.cli.ContainerManager')
|
|
108
|
+
def test_list_all_projects_no_containers(self, mock_container_manager_class, cli_runner, temp_project):
|
|
109
|
+
"""Test --all-projects when no containers exist."""
|
|
110
|
+
mock_container_manager_class.list_all_containers.return_value = []
|
|
80
111
|
|
|
81
|
-
# Run command with --all-projects (not --all)
|
|
82
112
|
result = cli_runner.invoke(cli, ['list', '--all-projects'])
|
|
83
113
|
|
|
84
|
-
# --all-projects is not implemented yet, verify the output message
|
|
85
114
|
assert result.exit_code == 0
|
|
86
|
-
assert "
|
|
115
|
+
assert "No active devcontainers found" in result.output
|
|
87
116
|
|
|
88
117
|
|
|
89
118
|
class TestStatusCommand:
|
|
@@ -125,6 +125,77 @@ class TestContainerManager:
|
|
|
125
125
|
assert all(isinstance(c, ContainerInfo) for c in containers)
|
|
126
126
|
assert {c.dev_name for c in containers} == {"alice", "bob"}
|
|
127
127
|
|
|
128
|
+
def test_list_all_containers(self):
|
|
129
|
+
"""Test listing all devs-managed containers across all projects."""
|
|
130
|
+
with patch('devs_common.core.container.DockerClient') as mock_docker_client_class:
|
|
131
|
+
mock_docker_instance = MagicMock()
|
|
132
|
+
mock_docker_client_class.return_value = mock_docker_instance
|
|
133
|
+
|
|
134
|
+
mock_docker_instance.find_containers_by_labels.return_value = [
|
|
135
|
+
{
|
|
136
|
+
'name': 'dev-org-a-repo-a-alice',
|
|
137
|
+
'id': 'abc123',
|
|
138
|
+
'status': 'running',
|
|
139
|
+
'labels': {
|
|
140
|
+
'devs.managed': 'true',
|
|
141
|
+
'devs.project': 'org-a-repo-a',
|
|
142
|
+
'devs.dev': 'alice'
|
|
143
|
+
},
|
|
144
|
+
'created': '2025-01-01T00:00:00.000000Z'
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
'name': 'dev-org-b-repo-b-bob',
|
|
148
|
+
'id': 'def456',
|
|
149
|
+
'status': 'exited',
|
|
150
|
+
'labels': {
|
|
151
|
+
'devs.managed': 'true',
|
|
152
|
+
'devs.project': 'org-b-repo-b',
|
|
153
|
+
'devs.dev': 'bob'
|
|
154
|
+
},
|
|
155
|
+
'created': '2025-01-02T00:00:00.000000Z'
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
'name': 'dev-org-a-repo-a-charlie',
|
|
159
|
+
'id': 'ghi789',
|
|
160
|
+
'status': 'running',
|
|
161
|
+
'labels': {
|
|
162
|
+
'devs.managed': 'true',
|
|
163
|
+
'devs.project': 'org-a-repo-a',
|
|
164
|
+
'devs.dev': 'charlie'
|
|
165
|
+
},
|
|
166
|
+
'created': '2025-01-03T00:00:00.000000Z'
|
|
167
|
+
},
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
containers = ContainerManager.list_all_containers()
|
|
171
|
+
|
|
172
|
+
# Verify it searched with devs.managed label
|
|
173
|
+
mock_docker_instance.find_containers_by_labels.assert_called_once_with(
|
|
174
|
+
{"devs.managed": "true"}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
assert len(containers) == 3
|
|
178
|
+
assert all(isinstance(c, ContainerInfo) for c in containers)
|
|
179
|
+
|
|
180
|
+
# Verify sorted by project name then dev name
|
|
181
|
+
assert containers[0].project_name == "org-a-repo-a"
|
|
182
|
+
assert containers[0].dev_name == "alice"
|
|
183
|
+
assert containers[1].project_name == "org-a-repo-a"
|
|
184
|
+
assert containers[1].dev_name == "charlie"
|
|
185
|
+
assert containers[2].project_name == "org-b-repo-b"
|
|
186
|
+
assert containers[2].dev_name == "bob"
|
|
187
|
+
|
|
188
|
+
def test_list_all_containers_empty(self):
|
|
189
|
+
"""Test listing all containers when none exist."""
|
|
190
|
+
with patch('devs_common.core.container.DockerClient') as mock_docker_client_class:
|
|
191
|
+
mock_docker_instance = MagicMock()
|
|
192
|
+
mock_docker_client_class.return_value = mock_docker_instance
|
|
193
|
+
mock_docker_instance.find_containers_by_labels.return_value = []
|
|
194
|
+
|
|
195
|
+
containers = ContainerManager.list_all_containers()
|
|
196
|
+
|
|
197
|
+
assert len(containers) == 0
|
|
198
|
+
|
|
128
199
|
def test_stop_container_success(self, mock_project):
|
|
129
200
|
"""Test successful container stop."""
|
|
130
201
|
with patch('devs_common.utils.docker_client.docker') as mock_docker:
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Tests for RepoCache utility and --repo CLI option."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import MagicMock, Mock, patch, call
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from click.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from devs_common.utils.repo_cache import RepoCache
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestRepoCache:
|
|
14
|
+
"""Unit tests for the RepoCache class."""
|
|
15
|
+
|
|
16
|
+
def test_repo_name_to_dir_name(self):
|
|
17
|
+
cache = RepoCache(cache_dir=Path("/tmp/cache"))
|
|
18
|
+
assert cache._repo_name_to_dir_name("ideonate/devs") == "ideonate-devs"
|
|
19
|
+
assert cache._repo_name_to_dir_name("Org/Repo") == "org-repo"
|
|
20
|
+
|
|
21
|
+
def test_build_clone_url_no_token(self, monkeypatch):
|
|
22
|
+
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
23
|
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
24
|
+
cache = RepoCache()
|
|
25
|
+
assert cache._build_clone_url("org/repo") == "https://github.com/org/repo.git"
|
|
26
|
+
|
|
27
|
+
def test_build_clone_url_with_gh_token(self, monkeypatch):
|
|
28
|
+
monkeypatch.setenv("GH_TOKEN", "tok123")
|
|
29
|
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
30
|
+
cache = RepoCache()
|
|
31
|
+
assert cache._build_clone_url("org/repo") == "https://x-access-token:tok123@github.com/org/repo.git"
|
|
32
|
+
|
|
33
|
+
def test_build_clone_url_with_github_token(self, monkeypatch):
|
|
34
|
+
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
35
|
+
monkeypatch.setenv("GITHUB_TOKEN", "tok456")
|
|
36
|
+
cache = RepoCache()
|
|
37
|
+
assert cache._build_clone_url("org/repo") == "https://x-access-token:tok456@github.com/org/repo.git"
|
|
38
|
+
|
|
39
|
+
def test_build_clone_url_with_explicit_token(self, monkeypatch):
|
|
40
|
+
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
41
|
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
42
|
+
cache = RepoCache(token="mytoken")
|
|
43
|
+
assert cache._build_clone_url("org/repo") == "https://x-access-token:mytoken@github.com/org/repo.git"
|
|
44
|
+
|
|
45
|
+
def test_explicit_token_takes_priority(self, monkeypatch):
|
|
46
|
+
monkeypatch.setenv("GH_TOKEN", "envtoken")
|
|
47
|
+
cache = RepoCache(token="explicit")
|
|
48
|
+
assert cache._build_clone_url("org/repo") == "https://x-access-token:explicit@github.com/org/repo.git"
|
|
49
|
+
|
|
50
|
+
@patch("devs_common.utils.repo_cache.subprocess.run")
|
|
51
|
+
def test_ensure_repo_clones_when_missing(self, mock_run, tmp_path):
|
|
52
|
+
cache = RepoCache(cache_dir=tmp_path)
|
|
53
|
+
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
|
54
|
+
|
|
55
|
+
path = cache.ensure_repo("ideonate/devs")
|
|
56
|
+
|
|
57
|
+
assert path == tmp_path / "ideonate-devs"
|
|
58
|
+
# First call should be git clone
|
|
59
|
+
clone_call = mock_run.call_args_list[0]
|
|
60
|
+
assert clone_call[0][0][0:2] == ["git", "clone"]
|
|
61
|
+
|
|
62
|
+
@patch("devs_common.utils.repo_cache.subprocess.run")
|
|
63
|
+
def test_ensure_repo_updates_when_exists(self, mock_run, tmp_path):
|
|
64
|
+
# Pre-create repo directory with .git
|
|
65
|
+
repo_dir = tmp_path / "ideonate-devs"
|
|
66
|
+
repo_dir.mkdir()
|
|
67
|
+
(repo_dir / ".git").mkdir()
|
|
68
|
+
|
|
69
|
+
mock_run.return_value = Mock(returncode=0, stdout="refs/remotes/origin/main", stderr="")
|
|
70
|
+
cache = RepoCache(cache_dir=tmp_path)
|
|
71
|
+
path = cache.ensure_repo("ideonate/devs")
|
|
72
|
+
|
|
73
|
+
assert path == repo_dir
|
|
74
|
+
# Should call set-url then fetch, not clone
|
|
75
|
+
cmds = [c[0][0][:2] for c in mock_run.call_args_list]
|
|
76
|
+
assert ["git", "remote"] in cmds
|
|
77
|
+
assert ["git", "fetch"] in cmds
|
|
78
|
+
|
|
79
|
+
@patch("devs_common.utils.repo_cache.subprocess.run")
|
|
80
|
+
def test_ensure_repo_with_branch(self, mock_run, tmp_path):
|
|
81
|
+
cache = RepoCache(cache_dir=tmp_path)
|
|
82
|
+
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
|
83
|
+
|
|
84
|
+
cache.ensure_repo("org/repo", branch="develop")
|
|
85
|
+
|
|
86
|
+
# Should have a checkout call with "develop"
|
|
87
|
+
checkout_calls = [
|
|
88
|
+
c for c in mock_run.call_args_list
|
|
89
|
+
if c[0][0][:2] == ["git", "checkout"]
|
|
90
|
+
]
|
|
91
|
+
assert len(checkout_calls) >= 1
|
|
92
|
+
assert "develop" in checkout_calls[0][0][0]
|
|
93
|
+
|
|
94
|
+
@patch("devs_common.utils.repo_cache.subprocess.run")
|
|
95
|
+
def test_clone_failure_raises(self, mock_run, tmp_path):
|
|
96
|
+
from devs_common.exceptions import DevsError
|
|
97
|
+
|
|
98
|
+
mock_run.return_value = Mock(returncode=1, stdout="", stderr="fatal: repo not found")
|
|
99
|
+
cache = RepoCache(cache_dir=tmp_path)
|
|
100
|
+
|
|
101
|
+
with pytest.raises(DevsError, match="Failed to clone"):
|
|
102
|
+
cache.ensure_repo("bad/repo")
|
|
103
|
+
|
|
104
|
+
def test_get_repo_path(self, tmp_path):
|
|
105
|
+
cache = RepoCache(cache_dir=tmp_path)
|
|
106
|
+
assert cache.get_repo_path("ideonate/devs") == tmp_path / "ideonate-devs"
|
|
107
|
+
|
|
108
|
+
@patch("devs_common.utils.repo_cache.subprocess.run")
|
|
109
|
+
def test_default_branches_preference(self, mock_run, tmp_path):
|
|
110
|
+
"""When default_branches is set, those branches are tried in order."""
|
|
111
|
+
cache = RepoCache(cache_dir=tmp_path, default_branches=["dev", "main"])
|
|
112
|
+
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
|
113
|
+
|
|
114
|
+
cache.ensure_repo("org/repo")
|
|
115
|
+
|
|
116
|
+
# After clone, should try checkout of first preferred branch (dev)
|
|
117
|
+
checkout_calls = [
|
|
118
|
+
c for c in mock_run.call_args_list
|
|
119
|
+
if len(c[0][0]) >= 2 and c[0][0][:2] == ["git", "checkout"]
|
|
120
|
+
]
|
|
121
|
+
assert len(checkout_calls) >= 1
|
|
122
|
+
assert "dev" in checkout_calls[0][0][0]
|
|
123
|
+
|
|
124
|
+
@patch("devs_common.utils.repo_cache.subprocess.run")
|
|
125
|
+
def test_clean_runs_git_clean(self, mock_run, tmp_path):
|
|
126
|
+
"""When clean=True, git clean -fd is run after checkout."""
|
|
127
|
+
cache = RepoCache(cache_dir=tmp_path, clean=True, default_branches=["main"])
|
|
128
|
+
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
|
129
|
+
|
|
130
|
+
cache.ensure_repo("org/repo")
|
|
131
|
+
|
|
132
|
+
clean_calls = [
|
|
133
|
+
c for c in mock_run.call_args_list
|
|
134
|
+
if len(c[0][0]) >= 2 and c[0][0][:2] == ["git", "clean"]
|
|
135
|
+
]
|
|
136
|
+
assert len(clean_calls) >= 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestRepoCliOption:
|
|
140
|
+
"""Test the --repo CLI option integration."""
|
|
141
|
+
|
|
142
|
+
@patch("devs.cli.RepoCache")
|
|
143
|
+
@patch("devs.cli.ContainerManager")
|
|
144
|
+
@patch("devs.cli.WorkspaceManager")
|
|
145
|
+
@patch("devs.cli.Project")
|
|
146
|
+
def test_start_with_repo_flag(self, mock_project_cls, mock_wm, mock_cm, mock_rc):
|
|
147
|
+
from devs.cli import cli
|
|
148
|
+
|
|
149
|
+
# Set up mocks
|
|
150
|
+
mock_cache_instance = MagicMock()
|
|
151
|
+
mock_cache_instance.ensure_repo.return_value = Path("/tmp/cache/org-repo")
|
|
152
|
+
mock_rc.return_value = mock_cache_instance
|
|
153
|
+
|
|
154
|
+
mock_project = MagicMock()
|
|
155
|
+
mock_project.info.name = "org-repo"
|
|
156
|
+
mock_project_cls.return_value = mock_project
|
|
157
|
+
|
|
158
|
+
mock_cm_instance = MagicMock()
|
|
159
|
+
mock_cm_instance.ensure_container_running.return_value = True
|
|
160
|
+
mock_cm.return_value = mock_cm_instance
|
|
161
|
+
|
|
162
|
+
mock_wm_instance = MagicMock()
|
|
163
|
+
mock_wm_instance.create_workspace.return_value = Path("/tmp/ws/org-repo-dev1")
|
|
164
|
+
mock_wm.return_value = mock_wm_instance
|
|
165
|
+
|
|
166
|
+
runner = CliRunner()
|
|
167
|
+
result = runner.invoke(cli, ["--repo", "org/repo", "start", "dev1"])
|
|
168
|
+
|
|
169
|
+
# RepoCache should have been created and called
|
|
170
|
+
mock_rc.assert_called_once()
|
|
171
|
+
mock_cache_instance.ensure_repo.assert_called_once_with("org/repo")
|
|
172
|
+
# Project should have been created with the cached repo path
|
|
173
|
+
mock_project_cls.assert_called_with(project_dir=Path("/tmp/cache/org-repo"))
|
|
174
|
+
|
|
175
|
+
@patch("devs.cli.Project")
|
|
176
|
+
def test_start_without_repo_flag_uses_cwd(self, mock_project_cls):
|
|
177
|
+
"""Without --repo, Project() is called with no args (uses CWD)."""
|
|
178
|
+
from devs.cli import cli
|
|
179
|
+
|
|
180
|
+
mock_project = MagicMock()
|
|
181
|
+
mock_project.info.name = "test-project"
|
|
182
|
+
mock_project_cls.return_value = mock_project
|
|
183
|
+
|
|
184
|
+
runner = CliRunner()
|
|
185
|
+
# Will fail because of missing ContainerManager etc, but we check Project was called correctly
|
|
186
|
+
with patch("devs.cli.ContainerManager") as mock_cm, \
|
|
187
|
+
patch("devs.cli.WorkspaceManager") as mock_wm:
|
|
188
|
+
mock_cm_instance = MagicMock()
|
|
189
|
+
mock_cm_instance.ensure_container_running.return_value = True
|
|
190
|
+
mock_cm.return_value = mock_cm_instance
|
|
191
|
+
mock_wm_instance = MagicMock()
|
|
192
|
+
mock_wm_instance.create_workspace.return_value = Path("/tmp/ws")
|
|
193
|
+
mock_wm.return_value = mock_wm_instance
|
|
194
|
+
|
|
195
|
+
result = runner.invoke(cli, ["start", "dev1"])
|
|
196
|
+
|
|
197
|
+
# Project should be called without project_dir (defaults to CWD)
|
|
198
|
+
mock_project_cls.assert_called_with()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|