devs-cli 2.0.5__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.5/devs_cli.egg-info → devs_cli-2.0.7}/PKG-INFO +1 -1
  2. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs/cli.py +89 -89
  3. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs/config.py +7 -0
  4. {devs_cli-2.0.5 → devs_cli-2.0.7/devs_cli.egg-info}/PKG-INFO +1 -1
  5. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs_cli.egg-info/SOURCES.txt +1 -0
  6. {devs_cli-2.0.5 → devs_cli-2.0.7}/pyproject.toml +1 -1
  7. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_cli_misc.py +40 -11
  8. {devs_cli-2.0.5 → 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.5 → devs_cli-2.0.7}/LICENSE +0 -0
  11. {devs_cli-2.0.5 → devs_cli-2.0.7}/README.md +0 -0
  12. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs/__init__.py +0 -0
  13. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs/core/__init__.py +0 -0
  14. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs/core/integration.py +0 -0
  15. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs/exceptions.py +0 -0
  16. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs/utils/__init__.py +0 -0
  17. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs_cli.egg-info/dependency_links.txt +0 -0
  18. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs_cli.egg-info/entry_points.txt +0 -0
  19. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs_cli.egg-info/requires.txt +0 -0
  20. {devs_cli-2.0.5 → devs_cli-2.0.7}/devs_cli.egg-info/top_level.txt +0 -0
  21. {devs_cli-2.0.5 → devs_cli-2.0.7}/setup.cfg +0 -0
  22. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_cli.py +0 -0
  23. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_cli_clean.py +0 -0
  24. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_cli_start.py +0 -0
  25. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_cli_stop.py +0 -0
  26. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_cli_vscode.py +0 -0
  27. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_e2e.py +0 -0
  28. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_error_parsing.py +0 -0
  29. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_integration.py +0 -0
  30. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_live_mode.py +0 -0
  31. {devs_cli-2.0.5 → devs_cli-2.0.7}/tests/test_project.py +0 -0
  32. {devs_cli-2.0.5 → 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.5
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()
@@ -794,7 +818,7 @@ def runtests(dev_name: str, reset_workspace: bool, live: bool, env: tuple, debug
794
818
 
795
819
 
796
820
  @cli.command()
797
- @click.argument('dev_name', required=False)
821
+ @click.argument('dev_name')
798
822
  @click.option('--auth', is_flag=True, help='Set up tunnel authentication (interactive, one-time setup)')
799
823
  @click.option('--status', is_flag=True, help='Check tunnel status instead of starting')
800
824
  @click.option('--kill', 'kill_tunnel', is_flag=True, help='Kill running tunnel')
@@ -809,26 +833,18 @@ def tunnel(dev_name: str, auth: bool, status: bool, kill_tunnel: bool, live: boo
809
833
  tunnel service, and your local VS Code connects through that.
810
834
 
811
835
  First-time setup requires authentication:
812
- devs tunnel --auth
836
+ devs tunnel <name> --auth
813
837
 
814
- After that, tunnels run in the background automatically.
838
+ Auth is stored in a bind-mounted directory so it persists across
839
+ all containers on this machine.
815
840
 
816
841
  DEV_NAME: Development environment name
817
842
 
818
- Example: devs tunnel --auth # One-time auth setup (no project needed)
843
+ Example: devs tunnel sally --auth # One-time auth setup
819
844
  Example: devs tunnel sally # Start tunnel (background)
820
845
  Example: devs tunnel sally --status # Check tunnel status
821
846
  Example: devs tunnel sally --kill # Stop running tunnel
822
847
  """
823
- # Handle authentication mode - no project or dev_name needed
824
- if auth:
825
- _handle_tunnel_auth(debug=debug)
826
- return
827
-
828
- # All other modes require dev_name
829
- if not dev_name:
830
- raise click.UsageError("DEV_NAME is required unless using --auth")
831
-
832
848
  check_dependencies()
833
849
  project = get_project()
834
850
 
@@ -847,7 +863,17 @@ def tunnel(dev_name: str, auth: bool, status: bool, kill_tunnel: bool, live: boo
847
863
  # Ensure workspace exists (handles live mode internally)
848
864
  workspace_dir = workspace_manager.create_workspace(dev_name, live=live)
849
865
 
850
- if status:
866
+ if auth:
867
+ # Interactive authentication inside the container
868
+ container_manager.tunnel_auth(
869
+ dev_name=dev_name,
870
+ workspace_dir=workspace_dir,
871
+ debug=debug,
872
+ live=live,
873
+ extra_env=extra_env
874
+ )
875
+
876
+ elif status:
851
877
  # Check tunnel status
852
878
  is_running, status_msg = container_manager.get_tunnel_status(
853
879
  dev_name=dev_name,
@@ -889,63 +915,6 @@ def tunnel(dev_name: str, auth: bool, status: bool, kill_tunnel: bool, live: boo
889
915
  sys.exit(1)
890
916
 
891
917
 
892
- def _handle_tunnel_auth(debug: bool) -> None:
893
- """Handle VS Code tunnel authentication.
894
-
895
- Runs the VS Code CLI on the host to authenticate. Auth is stored
896
- in ~/.devs/vscode-cli/ and bind-mounted into containers.
897
- """
898
- try:
899
- config.ensure_directories()
900
-
901
- console.print("🔐 Setting up VS Code tunnel authentication...")
902
- console.print(f" Configuration will be saved to: {config.vscode_cli_dir}")
903
- console.print("")
904
- console.print(" Starting interactive authentication...")
905
- console.print(" Follow the prompts to authenticate with GitHub")
906
- console.print("")
907
-
908
- env = os.environ.copy()
909
- env['VSCODE_CLI_DATA_DIR'] = str(config.vscode_cli_dir)
910
-
911
- cmd = ['code', 'tunnel', 'user', 'login', '--provider', 'github']
912
-
913
- if debug:
914
- console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
915
- console.print(f"[dim]VSCODE_CLI_DATA_DIR: {config.vscode_cli_dir}[/dim]")
916
-
917
- result = subprocess.run(cmd, env=env, check=False)
918
-
919
- if result.returncode == 0:
920
- console.print("")
921
- console.print("✅ Tunnel authentication configured successfully!")
922
- console.print(f" Configuration saved to: {config.vscode_cli_dir}")
923
- console.print(" This authentication will be shared across all devcontainers")
924
- console.print("")
925
- console.print("💡 You can now start tunnels from any project:")
926
- console.print(" devs tunnel <name>")
927
- else:
928
- console.print("")
929
- console.print("[yellow]Authentication was cancelled or failed.[/yellow]")
930
-
931
- except FileNotFoundError:
932
- console.print("❌ VS Code CLI not found on host machine")
933
- console.print("")
934
- console.print("Please install VS Code CLI first:")
935
- console.print(" https://code.visualstudio.com/docs/editor/command-line")
936
- console.print("")
937
- console.print("Or install the standalone CLI:")
938
- console.print(" curl -fSL 'https://update.code.visualstudio.com/latest/cli-linux-x64/stable' -o /tmp/vscode-cli.tar.gz")
939
- console.print(" sudo tar -xf /tmp/vscode-cli.tar.gz -C /usr/local/bin")
940
- sys.exit(1)
941
-
942
- except Exception as e:
943
- console.print(f"❌ Failed to configure tunnel authentication: {e}")
944
- if debug:
945
- import traceback
946
- console.print(traceback.format_exc())
947
- sys.exit(1)
948
-
949
918
 
950
919
  @cli.command()
951
920
  @click.option('--all-projects', is_flag=True, help='List containers for all projects')
@@ -955,25 +924,56 @@ def list(all_projects: bool) -> None:
955
924
 
956
925
  if all_projects:
957
926
  console.print("📋 All devcontainers:")
958
- # This would require a more complex implementation
959
- 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}")
960
960
  return
961
-
961
+
962
962
  project = get_project()
963
963
  container_manager = ContainerManager(project, config)
964
-
964
+
965
965
  console.print(f"📋 Active devcontainers for project: {project.info.name}")
966
966
  console.print("")
967
-
967
+
968
968
  try:
969
969
  containers = container_manager.list_containers()
970
-
970
+
971
971
  if not containers:
972
972
  console.print(" No active devcontainers found")
973
973
  console.print("")
974
974
  console.print("💡 Start some with: devs start <dev-name>")
975
975
  return
976
-
976
+
977
977
  # Create a table
978
978
  table = Table()
979
979
  table.add_column("Name", style="cyan")
@@ -981,7 +981,7 @@ def list(all_projects: bool) -> None:
981
981
  table.add_column("Status", style="green")
982
982
  table.add_column("Container", style="dim")
983
983
  table.add_column("Created", style="dim")
984
-
984
+
985
985
  for container in containers:
986
986
  created_str = container.created.strftime("%Y-%m-%d %H:%M") if container.created else "unknown"
987
987
  mode = "live" if container.labels.get('devs.live') == 'true' else "copy"
@@ -992,13 +992,13 @@ def list(all_projects: bool) -> None:
992
992
  container.name,
993
993
  created_str
994
994
  )
995
-
995
+
996
996
  console.print(table)
997
997
  console.print("")
998
998
  console.print("💡 Open with: devs vscode <dev-name>")
999
999
  console.print("💡 Shell into: devs shell <dev-name>")
1000
1000
  console.print("💡 Stop with: devs stop <dev-name>")
1001
-
1001
+
1002
1002
  except ContainerError as e:
1003
1003
  console.print(f"❌ Error listing containers: {e}")
1004
1004
 
@@ -1007,7 +1007,7 @@ def list(all_projects: bool) -> None:
1007
1007
  def status() -> None:
1008
1008
  """Show project and dependency status."""
1009
1009
  try:
1010
- project = Project()
1010
+ project = get_project()
1011
1011
 
1012
1012
  console.print(f"📁 Project: {project.info.name}")
1013
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.5
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.5"
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