gpt-5.2 ChatGPT: refactor tools code into cli.tools.vscode and add unit tests
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled

* Move VS Code workspace logic (incl. guards) from cli.commands.tools into cli.tools.vscode
* Extract shared repo path resolution into cli.tools.paths and reuse for explore/terminal
* Simplify cli.commands.tools to pure orchestration via open_vscode_workspace
* Update existing tools command unit test to assert delegation instead of patching removed internals
* Add new unit tests for cli.tools.paths and cli.tools.vscode (workspace creation, reuse, guard errors)

https://chatgpt.com/share/69419a6a-c9e4-800f-9538-b6652b2da6b3
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-16 18:43:56 +01:00
parent be70dd4239
commit ffa9d9660a
7 changed files with 331 additions and 174 deletions

View File

@@ -1,64 +1,27 @@
from __future__ import annotations from __future__ import annotations
import json
import os
from typing import Any, Dict, List from typing import Any, Dict, List
from pkgmgr .cli .context import CLIContext from pkgmgr.cli.context import CLIContext
from pkgmgr .core .command .run import run_command from pkgmgr.cli.tools import open_vscode_workspace
from pkgmgr .core .repository .identifier import get_repo_identifier from pkgmgr.cli.tools.paths import resolve_repository_path
from pkgmgr .core .repository .dir import get_repo_dir from pkgmgr.core.command.run import run_command
Repository = Dict[str, Any] Repository = Dict[str, Any]
def _resolve_repository_path(repository: Repository, ctx: CLIContext) -> str:
"""
Resolve the filesystem path for a repository.
Priority:
1. Use explicit keys if present (directory / path / workspace / workspace_dir).
2. Fallback to get_repo_dir(...) using the repositories base directory
from the CLI context.
"""
# 1) Explicit path-like keys on the repository object
for key in ("directory", "path", "workspace", "workspace_dir"):
value = repository.get(key)
if value:
return value
# 2) Fallback: compute from base dir + repository metadata
base_dir = (
getattr(ctx, "repositories_base_dir", None)
or getattr(ctx, "repositories_dir", None)
)
if not base_dir:
raise RuntimeError(
"Cannot resolve repositories base directory from context; "
"expected ctx.repositories_base_dir or ctx.repositories_dir."
)
return get_repo_dir(base_dir, repository)
def handle_tools_command( def handle_tools_command(
args, args,
ctx: CLIContext, ctx: CLIContext,
selected: List[Repository], selected: List[Repository],
) -> None: ) -> None:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# nautilus "explore" command # nautilus "explore" command
# ------------------------------------------------------------------ # ------------------------------------------------------------------
if args.command == "explore": if args.command == "explore":
for repository in selected: for repository in selected:
repo_path = _resolve_repository_path(repository, ctx) repo_path = resolve_repository_path(repository, ctx)
run_command( run_command(f'nautilus "{repo_path}" & disown')
f'nautilus "{repo_path}" & disown'
)
return return
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -66,50 +29,13 @@ def handle_tools_command(
# ------------------------------------------------------------------ # ------------------------------------------------------------------
if args.command == "terminal": if args.command == "terminal":
for repository in selected: for repository in selected:
repo_path = _resolve_repository_path(repository, ctx) repo_path = resolve_repository_path(repository, ctx)
run_command( run_command(f'gnome-terminal --tab --working-directory="{repo_path}"')
f'gnome-terminal --tab --working-directory="{repo_path}"'
)
return return
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# VS Code workspace command # VS Code workspace command
# ------------------------------------------------------------------ # ------------------------------------------------------------------
if args.command == "code": if args.command == "code":
if not selected: open_vscode_workspace(ctx, selected)
print("No repositories selected.")
return
identifiers = [
get_repo_identifier(repo, ctx.all_repositories)
for repo in selected
]
sorted_identifiers = sorted(identifiers)
workspace_name = "_".join(sorted_identifiers) + ".code-workspace"
directories_cfg = ctx.config_merged.get("directories") or {}
workspaces_dir = os.path.expanduser(
directories_cfg.get("workspaces", "~/Workspaces")
)
os.makedirs(workspaces_dir, exist_ok=True)
workspace_file = os.path.join(workspaces_dir, workspace_name)
folders = [
{"path": _resolve_repository_path(repository, ctx)}
for repository in selected
]
workspace_data = {
"folders": folders,
"settings": {},
}
if not os.path.exists(workspace_file):
with open(workspace_file, "w", encoding="utf-8") as f:
json.dump(workspace_data, f, indent=4)
print(f"Created workspace file: {workspace_file}")
else:
print(f"Using existing workspace file: {workspace_file}")
run_command(f'code "{workspace_file}"')
return return

View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from .vscode import open_vscode_workspace
__all__ = ["open_vscode_workspace"]

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any, Dict
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.repository.dir import get_repo_dir
Repository = Dict[str, Any]
def resolve_repository_path(repository: Repository, ctx: CLIContext) -> str:
"""
Resolve the filesystem path for a repository.
Priority:
1. Use explicit keys if present (directory / path / workspace / workspace_dir).
2. Fallback to get_repo_dir(...) using the repositories base directory
from the CLI context.
"""
for key in ("directory", "path", "workspace", "workspace_dir"):
value = repository.get(key)
if value:
return value
base_dir = (
getattr(ctx, "repositories_base_dir", None)
or getattr(ctx, "repositories_dir", None)
)
if not base_dir:
raise RuntimeError(
"Cannot resolve repositories base directory from context; "
"expected ctx.repositories_base_dir or ctx.repositories_dir."
)
return get_repo_dir(base_dir, repository)

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import json
import os
import shutil
from typing import Any, Dict, List
from pkgmgr.cli.context import CLIContext
from pkgmgr.cli.tools.paths import resolve_repository_path
from pkgmgr.core.command.run import run_command
from pkgmgr.core.repository.identifier import get_repo_identifier
Repository = Dict[str, Any]
def _ensure_vscode_cli_available() -> None:
"""
Ensure that the VS Code CLI ('code') is available in PATH.
"""
if shutil.which("code") is None:
raise RuntimeError(
"VS Code CLI ('code') not found in PATH.\n\n"
"Hint:\n"
" Install Visual Studio Code and ensure the 'code' command is available.\n"
" VS Code → Command Palette → 'Shell Command: Install code command in PATH'\n"
)
def _ensure_identifiers_are_filename_safe(identifiers: List[str]) -> None:
"""
Ensure identifiers can be used in a filename.
If an identifier contains '/', it likely means the repository has not yet
been explicitly identified (no short identifier configured).
"""
invalid = [i for i in identifiers if "/" in i or os.sep in i]
if invalid:
raise RuntimeError(
"Cannot create VS Code workspace.\n\n"
"The following repositories are not yet identified "
"(identifier contains '/'): \n"
+ "\n".join(f" - {i}" for i in invalid)
+ "\n\n"
"Hint:\n"
" The repository has no short identifier yet.\n"
" Add an explicit identifier in your configuration before using `pkgmgr tools code`.\n"
)
def _resolve_workspaces_dir(ctx: CLIContext) -> str:
directories_cfg = ctx.config_merged.get("directories") or {}
return os.path.expanduser(directories_cfg.get("workspaces", "~/Workspaces"))
def _build_workspace_filename(identifiers: List[str]) -> str:
sorted_identifiers = sorted(identifiers)
return "_".join(sorted_identifiers) + ".code-workspace"
def _build_workspace_data(selected: List[Repository], ctx: CLIContext) -> Dict[str, Any]:
folders = [{"path": resolve_repository_path(repo, ctx)} for repo in selected]
return {
"folders": folders,
"settings": {},
}
def open_vscode_workspace(ctx: CLIContext, selected: List[Repository]) -> None:
"""
Create (if missing) and open a VS Code workspace for the selected repositories.
Policy:
- Fail with a clear error if VS Code CLI is missing.
- Fail with a clear error if any repository identifier contains '/', because that
indicates the repo has not been explicitly identified (no short identifier).
- Do NOT auto-sanitize identifiers and do NOT create subfolders under workspaces.
"""
if not selected:
print("No repositories selected.")
return
_ensure_vscode_cli_available()
identifiers = [get_repo_identifier(repo, ctx.all_repositories) for repo in selected]
_ensure_identifiers_are_filename_safe(identifiers)
workspaces_dir = _resolve_workspaces_dir(ctx)
os.makedirs(workspaces_dir, exist_ok=True)
workspace_name = _build_workspace_filename(identifiers)
workspace_file = os.path.join(workspaces_dir, workspace_name)
workspace_data = _build_workspace_data(selected, ctx)
if not os.path.exists(workspace_file):
with open(workspace_file, "w", encoding="utf-8") as f:
json.dump(workspace_data, f, indent=4)
print(f"Created workspace file: {workspace_file}")
else:
print(f"Using existing workspace file: {workspace_file}")
run_command(f'code "{workspace_file}"')

View File

@@ -1,11 +1,9 @@
from __future__ import annotations from __future__ import annotations
import json
import os
import tempfile
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, Dict, List from typing import Any, Dict, List
from unittest.mock import call, patch
from pkgmgr.cli.commands.tools import handle_tools_command from pkgmgr.cli.commands.tools import handle_tools_command
@@ -26,36 +24,23 @@ class TestHandleToolsCommand(unittest.TestCase):
Unit tests for pkgmgr.cli.commands.tools.handle_tools_command. Unit tests for pkgmgr.cli.commands.tools.handle_tools_command.
We focus on: We focus on:
- Correct path resolution for repositories that have a 'directory' key. - Correct path resolution and shell commands for 'explore' and 'terminal'.
- Correct shell commands for 'explore' and 'terminal'. - For 'code': delegation to pkgmgr.cli.tools.open_vscode_workspace.
- Proper workspace creation and invocation of 'code' for the 'code' command.
""" """
def setUp(self) -> None: def setUp(self) -> None:
# Two fake repositories with explicit 'directory' entries so that
# _resolve_repository_path() does not need to call get_repo_dir().
self.repos: List[Repository] = [ self.repos: List[Repository] = [
{"alias": "repo1", "directory": "/tmp/repo1"}, {"alias": "repo1", "directory": "/tmp/repo1"},
{"alias": "repo2", "directory": "/tmp/repo2"}, {"alias": "repo2", "directory": "/tmp/repo2"},
] ]
# Minimal CLI context; only attributes used in tools.py are provided.
self.ctx = SimpleNamespace( self.ctx = SimpleNamespace(
config_merged={"directories": {"workspaces": "~/Workspaces"}}, config_merged={"directories": {"workspaces": "~/Workspaces"}},
all_repositories=self.repos, all_repositories=self.repos,
repositories_base_dir="/base/dir", repositories_base_dir="/base/dir",
) )
# ------------------------------------------------------------------ #
# Helper
# ------------------------------------------------------------------ #
def _patch_run_command(self): def _patch_run_command(self):
"""
Convenience context manager for patching run_command in tools module.
"""
from unittest.mock import patch
return patch("pkgmgr.cli.commands.tools.run_command") return patch("pkgmgr.cli.commands.tools.run_command")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -63,12 +48,6 @@ class TestHandleToolsCommand(unittest.TestCase):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_explore_uses_directory_paths(self) -> None: def test_explore_uses_directory_paths(self) -> None:
"""
The 'explore' command should call Nautilus with the resolved
repository paths and use '& disown' as in the implementation.
"""
from unittest.mock import call
args = _Args(command="explore") args = _Args(command="explore")
with self._patch_run_command() as mock_run_command: with self._patch_run_command() as mock_run_command:
@@ -85,12 +64,6 @@ class TestHandleToolsCommand(unittest.TestCase):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_terminal_uses_directory_paths(self) -> None: def test_terminal_uses_directory_paths(self) -> None:
"""
The 'terminal' command should open a GNOME Terminal tab with the
repository as its working directory.
"""
from unittest.mock import call
args = _Args(command="terminal") args = _Args(command="terminal")
with self._patch_run_command() as mock_run_command: with self._patch_run_command() as mock_run_command:
@@ -106,63 +79,10 @@ class TestHandleToolsCommand(unittest.TestCase):
# Tests for 'code' # Tests for 'code'
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_code_creates_workspace_and_calls_code(self) -> None: def test_code_delegates_to_open_vscode_workspace(self) -> None:
"""
The 'code' command should:
- Build a workspace file name from sorted repository identifiers.
- Resolve the repository paths into VS Code 'folders'.
- Create the workspace file if it does not exist.
- Call 'code "<workspace_file>"' via run_command.
"""
from unittest.mock import patch
args = _Args(command="code") args = _Args(command="code")
with tempfile.TemporaryDirectory() as tmpdir: with patch("pkgmgr.cli.commands.tools.open_vscode_workspace") as m:
# Patch expanduser so that the configured '~/Workspaces' handle_tools_command(args, self.ctx, self.repos)
# resolves into our temporary directory.
with patch(
"pkgmgr.cli.commands.tools.os.path.expanduser"
) as mock_expanduser:
mock_expanduser.return_value = tmpdir
# Patch get_repo_identifier so the resulting workspace file m.assert_called_once_with(self.ctx, self.repos)
# name is deterministic and easy to assert.
with patch(
"pkgmgr.cli.commands.tools.get_repo_identifier"
) as mock_get_identifier:
mock_get_identifier.side_effect = ["repo-b", "repo-a"]
with self._patch_run_command() as mock_run_command:
handle_tools_command(args, self.ctx, self.repos)
# The identifiers are ['repo-b', 'repo-a'], which are
# sorted to ['repo-a', 'repo-b'] and joined with '_'.
expected_workspace_name = "repo-a_repo-b.code-workspace"
expected_workspace_file = os.path.join(
tmpdir, expected_workspace_name
)
# Workspace file should have been created.
self.assertTrue(
os.path.exists(expected_workspace_file),
"Workspace file was not created.",
)
# The content of the workspace must be valid JSON with
# the expected folder paths.
with open(expected_workspace_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.assertIn("folders", data)
folder_paths = {f["path"] for f in data["folders"]}
self.assertEqual(
folder_paths,
{"/tmp/repo1", "/tmp/repo2"},
)
# And VS Code must have been invoked with that workspace.
mock_run_command.assert_called_once_with(
f'code "{expected_workspace_file}"'
)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
class TestResolveRepositoryPath(unittest.TestCase):
def test_explicit_directory_key_wins(self) -> None:
from pkgmgr.cli.tools.paths import resolve_repository_path
ctx = SimpleNamespace(repositories_base_dir="/base", repositories_dir="/base2")
repo = {"directory": "/explicit/repo"}
self.assertEqual(resolve_repository_path(repo, ctx), "/explicit/repo")
def test_fallback_uses_get_repo_dir_with_repositories_base_dir(self) -> None:
from pkgmgr.cli.tools.paths import resolve_repository_path
ctx = SimpleNamespace(repositories_base_dir="/base", repositories_dir="/base2")
repo = {"provider": "github.com", "account": "acme", "repository": "demo"}
with patch("pkgmgr.cli.tools.paths.get_repo_dir", return_value="/computed/repo") as m:
out = resolve_repository_path(repo, ctx)
self.assertEqual(out, "/computed/repo")
m.assert_called_once_with("/base", repo)
def test_raises_if_no_base_dir_in_context(self) -> None:
from pkgmgr.cli.tools.paths import resolve_repository_path
ctx = SimpleNamespace(repositories_base_dir=None, repositories_dir=None)
repo = {"provider": "github.com", "account": "acme", "repository": "demo"}
with self.assertRaises(RuntimeError) as cm:
resolve_repository_path(repo, ctx)
self.assertIn("Cannot resolve repositories base directory", str(cm.exception))

View File

@@ -0,0 +1,131 @@
from __future__ import annotations
import json
import os
import tempfile
import unittest
from types import SimpleNamespace
from typing import Any, Dict, List
from unittest.mock import patch
Repository = Dict[str, Any]
class TestOpenVSCodeWorkspace(unittest.TestCase):
def test_no_selected_repos_prints_message_and_returns(self) -> None:
from pkgmgr.cli.tools.vscode import open_vscode_workspace
ctx = SimpleNamespace(config_merged={}, all_repositories=[])
with patch("builtins.print") as p:
open_vscode_workspace(ctx, [])
p.assert_called_once()
self.assertIn("No repositories selected.", str(p.call_args[0][0]))
def test_raises_if_code_cli_missing(self) -> None:
from pkgmgr.cli.tools.vscode import open_vscode_workspace
ctx = SimpleNamespace(config_merged={}, all_repositories=[])
selected: List[Repository] = [{"provider": "github.com", "account": "x", "repository": "y"}]
with patch("pkgmgr.cli.tools.vscode.shutil.which", return_value=None):
with self.assertRaises(RuntimeError) as cm:
open_vscode_workspace(ctx, selected)
self.assertIn("VS Code CLI ('code') not found", str(cm.exception))
def test_raises_if_identifier_contains_slash(self) -> None:
from pkgmgr.cli.tools.vscode import open_vscode_workspace
ctx = SimpleNamespace(
config_merged={"directories": {"workspaces": "~/Workspaces"}},
all_repositories=[],
)
selected: List[Repository] = [{"provider": "github.com", "account": "x", "repository": "y"}]
with patch("pkgmgr.cli.tools.vscode.shutil.which", return_value="/usr/bin/code"), patch(
"pkgmgr.cli.tools.vscode.get_repo_identifier",
return_value="github.com/x/y",
):
with self.assertRaises(RuntimeError) as cm:
open_vscode_workspace(ctx, selected)
msg = str(cm.exception)
self.assertIn("not yet identified", msg)
self.assertIn("identifier contains '/'", msg)
def test_creates_workspace_file_and_calls_code(self) -> None:
from pkgmgr.cli.tools.vscode import open_vscode_workspace
with tempfile.TemporaryDirectory() as tmp:
workspaces_dir = os.path.join(tmp, "Workspaces")
repo_path = os.path.join(tmp, "Repos", "dotlinker")
ctx = SimpleNamespace(
config_merged={"directories": {"workspaces": workspaces_dir}},
all_repositories=[],
repositories_base_dir=os.path.join(tmp, "Repos"),
)
selected: List[Repository] = [
{"provider": "github.com", "account": "kevin", "repository": "dotlinker"}
]
with patch("pkgmgr.cli.tools.vscode.shutil.which", return_value="/usr/bin/code"), patch(
"pkgmgr.cli.tools.vscode.get_repo_identifier",
return_value="dotlinker",
), patch(
"pkgmgr.cli.tools.vscode.resolve_repository_path",
return_value=repo_path,
), patch(
"pkgmgr.cli.tools.vscode.run_command"
) as run_cmd:
open_vscode_workspace(ctx, selected)
workspace_file = os.path.join(workspaces_dir, "dotlinker.code-workspace")
self.assertTrue(os.path.exists(workspace_file))
with open(workspace_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.assertEqual(data["folders"], [{"path": repo_path}])
self.assertEqual(data["settings"], {})
run_cmd.assert_called_once_with(f'code "{workspace_file}"')
def test_uses_existing_workspace_file_without_overwriting(self) -> None:
from pkgmgr.cli.tools.vscode import open_vscode_workspace
with tempfile.TemporaryDirectory() as tmp:
workspaces_dir = os.path.join(tmp, "Workspaces")
os.makedirs(workspaces_dir, exist_ok=True)
workspace_file = os.path.join(workspaces_dir, "dotlinker.code-workspace")
original = {"folders": [{"path": "/original"}], "settings": {"x": 1}}
with open(workspace_file, "w", encoding="utf-8") as f:
json.dump(original, f)
ctx = SimpleNamespace(
config_merged={"directories": {"workspaces": workspaces_dir}},
all_repositories=[],
)
selected: List[Repository] = [
{"provider": "github.com", "account": "kevin", "repository": "dotlinker"}
]
with patch("pkgmgr.cli.tools.vscode.shutil.which", return_value="/usr/bin/code"), patch(
"pkgmgr.cli.tools.vscode.get_repo_identifier",
return_value="dotlinker",
), patch(
"pkgmgr.cli.tools.vscode.resolve_repository_path",
return_value="/new/path",
), patch(
"pkgmgr.cli.tools.vscode.run_command"
) as run_cmd:
open_vscode_workspace(ctx, selected)
with open(workspace_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.assertEqual(data, original)
run_cmd.assert_called_once_with(f'code "{workspace_file}"')