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.
Files changed (32) hide show
  1. {devs_cli-2.0.6/devs_cli.egg-info → devs_cli-2.0.7}/PKG-INFO +1 -1
  2. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/cli.py +73 -18
  3. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/config.py +7 -0
  4. {devs_cli-2.0.6 → devs_cli-2.0.7/devs_cli.egg-info}/PKG-INFO +1 -1
  5. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/SOURCES.txt +1 -0
  6. {devs_cli-2.0.6 → devs_cli-2.0.7}/pyproject.toml +1 -1
  7. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_misc.py +40 -11
  8. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_container_manager.py +71 -0
  9. devs_cli-2.0.7/tests/test_repo_cache.py +198 -0
  10. {devs_cli-2.0.6 → devs_cli-2.0.7}/LICENSE +0 -0
  11. {devs_cli-2.0.6 → devs_cli-2.0.7}/README.md +0 -0
  12. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/__init__.py +0 -0
  13. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/core/__init__.py +0 -0
  14. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/core/integration.py +0 -0
  15. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/exceptions.py +0 -0
  16. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs/utils/__init__.py +0 -0
  17. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/dependency_links.txt +0 -0
  18. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/entry_points.txt +0 -0
  19. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/requires.txt +0 -0
  20. {devs_cli-2.0.6 → devs_cli-2.0.7}/devs_cli.egg-info/top_level.txt +0 -0
  21. {devs_cli-2.0.6 → devs_cli-2.0.7}/setup.cfg +0 -0
  22. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli.py +0 -0
  23. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_clean.py +0 -0
  24. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_start.py +0 -0
  25. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_stop.py +0 -0
  26. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_cli_vscode.py +0 -0
  27. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_e2e.py +0 -0
  28. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_error_parsing.py +0 -0
  29. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_integration.py +0 -0
  30. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_live_mode.py +0 -0
  31. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_project.py +0 -0
  32. {devs_cli-2.0.6 → devs_cli-2.0.7}/tests/test_workspace_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-cli
3
- Version: 2.0.6
3
+ Version: 2.0.7
4
4
  Summary: DevContainer Management Tool - Manage multiple named devcontainers for any project
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -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
- integration = ExternalToolIntegration(Project())
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
- project = Project()
110
- # No longer require devcontainer config upfront -
111
- # WorkspaceManager will provide default template if needed
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
- # This would require a more complex implementation
904
- console.print(" --all-projects not implemented yet")
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 = 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."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-cli
3
- Version: 2.0.6
3
+ Version: 2.0.7
4
4
  Summary: DevContainer Management Tool - Manage multiple named devcontainers for any project
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -26,4 +26,5 @@ tests/test_error_parsing.py
26
26
  tests/test_integration.py
27
27
  tests/test_live_mode.py
28
28
  tests/test_project.py
29
+ tests/test_repo_cache.py
29
30
  tests/test_workspace_manager.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devs-cli"
7
- version = "2.0.6"
7
+ version = "2.0.7"
8
8
  description = "DevContainer Management Tool - Manage multiple named devcontainers for any project"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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.Project')
73
- def test_list_all_containers(self, mock_project_class, cli_runner, temp_project):
74
- """Test listing all devs containers - --all-projects not yet implemented."""
75
- # Setup mocks
76
- mock_project = Mock()
77
- mock_project.path = temp_project
78
- mock_project.info.name = "test-org-test-repo"
79
- mock_project_class.return_value = mock_project
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 "--all-projects not implemented yet" in result.output
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