Skip to content

Commit a995816

Browse files
committed
cli(sync): quiet stderr/stdout capture for structured output
1 parent 937d5da commit a995816

2 files changed

Lines changed: 57 additions & 2 deletions

File tree

src/vcspull/cli/sync.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from __future__ import annotations
44

5+
import contextlib
56
import logging
67
import sys
78
import typing as t
89
from collections.abc import Callable
910
from copy import deepcopy
1011
from datetime import datetime
12+
from io import StringIO
1113

1214
from libvcs._internal.shortcuts import create_project
1315
from libvcs.url import registry as url_tools
@@ -173,7 +175,7 @@ def sync(
173175
progress_callback = progress_cb
174176
else:
175177

176-
def silent_progress(_output: str, _timestamp: datetime) -> None:
178+
def silent_progress(output: str, timestamp: datetime) -> None:
177179
"""Suppress progress for machine-readable output."""
178180
return None
179181

@@ -205,12 +207,29 @@ def silent_progress(_output: str, _timestamp: datetime) -> None:
205207
)
206208
continue
207209

210+
buffer: StringIO | None = None
211+
captured_output: str | None = None
208212
try:
209-
update_repo(repo, progress_callback=progress_callback)
213+
if is_human:
214+
update_repo(repo, progress_callback=progress_callback)
215+
else:
216+
buffer = StringIO()
217+
with (
218+
contextlib.redirect_stdout(buffer),
219+
contextlib.redirect_stderr(
220+
buffer,
221+
),
222+
):
223+
update_repo(repo, progress_callback=progress_callback)
224+
captured_output = buffer.getvalue()
210225
except Exception as e:
211226
summary["failed"] += 1
212227
event["status"] = "error"
213228
event["error"] = str(e)
229+
if not is_human and buffer is not None and not captured_output:
230+
captured_output = buffer.getvalue()
231+
if captured_output:
232+
event["details"] = captured_output.strip()
214233
formatter.emit(event)
215234
if is_human:
216235
log.info(

tests/test_cli.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,3 +663,39 @@ def test_sync_ndjson_machine_output(
663663
event.get("status") for event in events if event["reason"] == "sync"
664664
}
665665
assert preview_statuses == {"preview"}
666+
667+
668+
def test_sync_json_machine_output(
669+
tmp_path: pathlib.Path,
670+
capsys: pytest.CaptureFixture[str],
671+
monkeypatch: pytest.MonkeyPatch,
672+
user_path: pathlib.Path,
673+
config_path: pathlib.Path,
674+
git_repo: GitSync,
675+
) -> None:
676+
"""JSON mode should emit a single array without progress chatter."""
677+
config = {
678+
"~/github_projects/": {
679+
"my_git_repo": {
680+
"url": f"git+file://{git_repo.path}",
681+
"remotes": {"origin": f"git+file://{git_repo.path}"},
682+
},
683+
},
684+
}
685+
yaml_config = config_path / ".vcspull.yaml"
686+
yaml_config.write_text(
687+
yaml.dump(config, default_flow_style=False), encoding="utf-8"
688+
)
689+
690+
monkeypatch.chdir(tmp_path)
691+
692+
with contextlib.suppress(SystemExit):
693+
cli(["sync", "--json", "--dry-run", "my_git_repo"])
694+
695+
captured = capsys.readouterr()
696+
payload = captured.out.strip()
697+
assert payload.startswith("[") and payload.endswith("]"), payload
698+
events = json.loads(payload)
699+
assert isinstance(events, list)
700+
reasons = {event["reason"] for event in events}
701+
assert reasons >= {"sync", "summary"}

0 commit comments

Comments
 (0)