Skip to content

Commit 2ae81a8

Browse files
committed
cli/status(refactor[typing]): Add StatusResult TypedDict
why: Make status payload shape explicit and reuse it in sync plan logic/tests. what: - Define StatusResult and update status helpers to return it - Accept StatusResult in sync plan action selection - Update status/sync plan tests to build typed status inputs
1 parent 311dafc commit 2ae81a8

4 files changed

Lines changed: 78 additions & 18 deletions

File tree

src/vcspull/cli/status.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@
2828
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
2929

3030

31+
class StatusResult(t.TypedDict):
32+
"""Typed status payload for a single repository."""
33+
34+
name: str
35+
path: str
36+
workspace_root: str
37+
exists: bool
38+
is_git: bool
39+
clean: bool | None
40+
branch: str | None
41+
ahead: int | None
42+
behind: int | None
43+
44+
3145
@dataclass
3246
class StatusCheckConfig:
3347
"""Configuration options for status checking."""
@@ -174,7 +188,7 @@ async def _check_repos_status_async(
174188
*,
175189
config: StatusCheckConfig,
176190
progress: StatusProgressPrinter | None,
177-
) -> list[dict[str, t.Any]]:
191+
) -> list[StatusResult]:
178192
"""Check repository status concurrently using asyncio.
179193
180194
Parameters
@@ -188,18 +202,18 @@ async def _check_repos_status_async(
188202
189203
Returns
190204
-------
191-
list[dict[str, t.Any]]
205+
list[StatusResult]
192206
List of status dictionaries in completion order
193207
"""
194208
if not repos:
195209
return []
196210

197211
semaphore = asyncio.Semaphore(min(config.max_concurrent, len(repos)))
198-
results: list[dict[str, t.Any]] = []
212+
results: list[StatusResult] = []
199213
exists_count = 0
200214
missing_count = 0
201215

202-
async def check_with_limit(repo: ConfigDict) -> dict[str, t.Any]:
216+
async def check_with_limit(repo: ConfigDict) -> StatusResult:
203217
async with semaphore:
204218
return await asyncio.to_thread(
205219
check_repo_status,
@@ -214,7 +228,7 @@ async def check_with_limit(repo: ConfigDict) -> dict[str, t.Any]:
214228
results.append(status)
215229

216230
# Update counts for progress
217-
if status.get("exists"):
231+
if status["exists"]:
218232
exists_count += 1
219233
else:
220234
missing_count += 1
@@ -242,7 +256,7 @@ def _run_git_command(
242256
return None
243257

244258

245-
def check_repo_status(repo: ConfigDict, detailed: bool = False) -> dict[str, t.Any]:
259+
def check_repo_status(repo: ConfigDict, detailed: bool = False) -> StatusResult:
246260
"""Check the status of a single repository.
247261
248262
Parameters
@@ -261,7 +275,7 @@ def check_repo_status(repo: ConfigDict, detailed: bool = False) -> dict[str, t.A
261275
repo_name = repo.get("name", "unknown")
262276
workspace_root = repo.get("workspace_root", "")
263277

264-
status: dict[str, t.Any] = {
278+
status: StatusResult = {
265279
"name": repo_name,
266280
"path": str(PrivatePath(repo_path)),
267281
"workspace_root": workspace_root,
@@ -485,7 +499,7 @@ def status_repos(
485499

486500

487501
def _format_status_line(
488-
status: dict[str, t.Any],
502+
status: StatusResult,
489503
formatter: OutputFormatter,
490504
colors: Colors,
491505
detailed: bool,

src/vcspull/cli/sync.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
get_output_mode,
4444
)
4545
from ._workspaces import filter_by_workspace
46-
from .status import check_repo_status
46+
from .status import StatusResult, check_repo_status
4747

4848
log = logging.getLogger(__name__)
4949

@@ -188,7 +188,7 @@ def _maybe_fetch(
188188

189189

190190
def _determine_plan_action(
191-
status: dict[str, t.Any],
191+
status: StatusResult,
192192
*,
193193
config: SyncPlanConfig,
194194
) -> tuple[PlanAction, str | None]:

tests/cli/test_status.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from vcspull.cli.status import (
1313
StatusCheckConfig,
14+
StatusResult,
1415
_check_repos_status_async,
1516
check_repo_status,
1617
status_repos,
@@ -223,7 +224,16 @@ def test_check_repo_status(
223224
else:
224225
repo_path.mkdir(parents=True)
225226

226-
repo_dict: t.Any = {"name": "test-repo", "path": str(repo_path)}
227+
repo_dict = t.cast(
228+
"ConfigDict",
229+
{
230+
"vcs": None,
231+
"name": "test-repo",
232+
"path": repo_path,
233+
"url": str(repo_path),
234+
"workspace_root": str(tmp_path),
235+
},
236+
)
227237

228238
status = check_repo_status(repo_dict, detailed=False)
229239

@@ -630,21 +640,32 @@ async def test_check_repos_status_async_concurrency_limit(
630640
) -> None:
631641
"""Test that semaphore limits concurrent operations."""
632642
# Create multiple repos
633-
repos_list = []
643+
repos_list: list[ConfigDict] = []
634644
for i in range(10):
635645
repo_path = tmp_path / f"repo{i}"
636646
init_git_repo(repo_path)
637-
repos_list.append({"name": f"repo{i}", "path": str(repo_path)})
647+
repos_list.append(
648+
t.cast(
649+
"ConfigDict",
650+
{
651+
"vcs": None,
652+
"name": f"repo{i}",
653+
"path": repo_path,
654+
"url": str(repo_path),
655+
"workspace_root": str(tmp_path),
656+
},
657+
),
658+
)
638659

639-
repos = t.cast("list[ConfigDict]", repos_list)
660+
repos = repos_list
640661

641662
# Track concurrent calls
642663
concurrent_calls = []
643664
max_concurrent_seen = 0
644665

645666
original_check = check_repo_status
646667

647-
def tracked_check(repo: t.Any, detailed: bool = False) -> dict[str, t.Any]:
668+
def tracked_check(repo: ConfigDict, detailed: bool = False) -> StatusResult:
648669
concurrent_calls.append(1)
649670
nonlocal max_concurrent_seen
650671
current = len(concurrent_calls)

tests/cli/test_sync_plan_helpers.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,28 @@
1313
if t.TYPE_CHECKING:
1414
import pathlib
1515

16+
from vcspull.cli.status import StatusResult
17+
18+
19+
StatusOverride: t.TypeAlias = dict[str, bool | int | None]
20+
21+
22+
def _build_status(overrides: StatusOverride) -> StatusResult:
23+
"""Build a StatusResult with defaults for required keys."""
24+
base: dict[str, object] = {
25+
"name": "repo",
26+
"path": "/tmp/repo",
27+
"workspace_root": "/tmp",
28+
"exists": False,
29+
"is_git": False,
30+
"clean": None,
31+
"branch": None,
32+
"ahead": None,
33+
"behind": None,
34+
}
35+
base.update(overrides)
36+
return t.cast("StatusResult", base)
37+
1638

1739
class MaybeFetchFixture(t.NamedTuple):
1840
"""Fixture for _maybe_fetch behaviours."""
@@ -147,7 +169,7 @@ class DeterminePlanActionFixture(t.NamedTuple):
147169
"""Fixture for _determine_plan_action outcomes."""
148170

149171
test_id: str
150-
status: dict[str, t.Any]
172+
status: StatusOverride
151173
config: SyncPlanConfig
152174
expected_action: PlanAction
153175
expected_detail: str
@@ -239,12 +261,15 @@ class DeterminePlanActionFixture(t.NamedTuple):
239261
)
240262
def test_determine_plan_action(
241263
test_id: str,
242-
status: dict[str, t.Any],
264+
status: StatusOverride,
243265
config: SyncPlanConfig,
244266
expected_action: PlanAction,
245267
expected_detail: str,
246268
) -> None:
247269
"""Verify _determine_plan_action handles edge cases."""
248-
action, detail = _determine_plan_action(status, config=config)
270+
action, detail = _determine_plan_action(
271+
_build_status(status),
272+
config=config,
273+
)
249274
assert action is expected_action
250275
assert detail == expected_detail

0 commit comments

Comments
 (0)