Skip to content

Commit 937d5da

Browse files
committed
cli(sync): suppress progress noise in machine-readable output
1 parent 779b067 commit 937d5da

2 files changed

Lines changed: 69 additions & 9 deletions

File tree

src/vcspull/cli/sync.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import logging
66
import sys
77
import typing as t
8+
from collections.abc import Callable
89
from copy import deepcopy
10+
from datetime import datetime
911

1012
from libvcs._internal.shortcuts import create_project
1113
from libvcs.url import registry as url_tools
@@ -21,13 +23,14 @@
2123
if t.TYPE_CHECKING:
2224
import argparse
2325
import pathlib
24-
from datetime import datetime
2526

2627
from libvcs._internal.types import VCSLiteral
2728
from libvcs.sync.git import GitSync
2829

2930
log = logging.getLogger(__name__)
3031

32+
ProgressCallback = Callable[[str, datetime], None]
33+
3134

3235
def clamp(n: int, _min: int, _max: int) -> int:
3336
"""Clamp a number between a min and max value."""
@@ -124,6 +127,8 @@ def sync(
124127
formatter = OutputFormatter(output_mode)
125128
colors = Colors(get_color_mode(color))
126129

130+
is_human = formatter.mode == OutputMode.HUMAN
131+
127132
if config:
128133
configs = load_configs([config])
129134
else:
@@ -140,7 +145,7 @@ def sync(
140145
name = repo_pattern
141146

142147
found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name)
143-
if not found:
148+
if not found and is_human:
144149
log.info(NO_REPOS_FOR_TERM_MSG.format(name=name))
145150
found_repos.extend(found)
146151

@@ -163,6 +168,17 @@ def sync(
163168

164169
summary = {"total": 0, "synced": 0, "previewed": 0, "failed": 0}
165170

171+
progress_callback: ProgressCallback
172+
if is_human:
173+
progress_callback = progress_cb
174+
else:
175+
176+
def silent_progress(_output: str, _timestamp: datetime) -> None:
177+
"""Suppress progress for machine-readable output."""
178+
return None
179+
180+
progress_callback = silent_progress
181+
166182
for repo in found_repos:
167183
repo_name = repo.get("name", "unknown")
168184
repo_path = repo.get("path", "unknown")
@@ -181,23 +197,25 @@ def sync(
181197
summary["previewed"] += 1
182198
event["status"] = "preview"
183199
formatter.emit(event)
184-
log.info(f"Would sync {repo_name} at {repo_path}")
200+
if is_human:
201+
log.info(f"Would sync {repo_name} at {repo_path}")
185202
formatter.emit_text(
186203
f"{colors.warning('→')} Would sync {colors.info(repo_name)} "
187204
f"{colors.muted('→')} {repo_path}",
188205
)
189206
continue
190207

191208
try:
192-
update_repo(repo)
209+
update_repo(repo, progress_callback=progress_callback)
193210
except Exception as e:
194211
summary["failed"] += 1
195212
event["status"] = "error"
196213
event["error"] = str(e)
197214
formatter.emit(event)
198-
log.info(
199-
f"Failed syncing {repo_name}",
200-
)
215+
if is_human:
216+
log.info(
217+
f"Failed syncing {repo_name}",
218+
)
201219
if log.isEnabledFor(logging.DEBUG):
202220
import traceback
203221

@@ -275,6 +293,7 @@ def __init__(self, repo_url: str, *args: object, **kwargs: object) -> None:
275293

276294
def update_repo(
277295
repo_dict: t.Any,
296+
progress_callback: ProgressCallback | None = None,
278297
# repo_dict: Dict[str, Union[str, Dict[str, GitRemote], pathlib.Path]]
279298
) -> GitSync:
280299
"""Synchronize a single repository."""
@@ -283,7 +302,8 @@ def update_repo(
283302
repo_dict["pip_url"] = repo_dict.pop("url")
284303
if "url" not in repo_dict:
285304
repo_dict["url"] = repo_dict.pop("pip_url")
286-
repo_dict["progress_callback"] = progress_cb
305+
306+
repo_dict["progress_callback"] = progress_callback or progress_cb
287307

288308
if repo_dict.get("vcs") is None:
289309
vcs = guess_vcs(url=repo_dict["url"])

tests/test_cli.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ class SyncNewBehaviourFixture(t.NamedTuple):
226226
cli_args=["sync", "--dry-run", "--json", "my_git_repo"],
227227
expect_json=True,
228228
expected_stdout_contains=[],
229-
expected_log_contains=["Would sync my_git_repo"],
229+
expected_log_contains=[],
230230
expected_summary={"total": 1, "previewed": 1, "synced": 0, "failed": 0},
231231
),
232232
SyncNewBehaviourFixture(
@@ -623,3 +623,43 @@ def _missing_git(
623623

624624
if expected_stdout_fragment is not None:
625625
assert expected_stdout_fragment in captured.out
626+
627+
628+
def test_sync_ndjson_machine_output(
629+
tmp_path: pathlib.Path,
630+
capsys: pytest.CaptureFixture[str],
631+
monkeypatch: pytest.MonkeyPatch,
632+
user_path: pathlib.Path,
633+
config_path: pathlib.Path,
634+
git_repo: GitSync,
635+
) -> None:
636+
"""NDJSON mode should emit pure JSON lines without progress noise."""
637+
config = {
638+
"~/github_projects/": {
639+
"my_git_repo": {
640+
"url": f"git+file://{git_repo.path}",
641+
"remotes": {"origin": f"git+file://{git_repo.path}"},
642+
},
643+
},
644+
}
645+
yaml_config = config_path / ".vcspull.yaml"
646+
yaml_config.write_text(
647+
yaml.dump(config, default_flow_style=False), encoding="utf-8"
648+
)
649+
650+
monkeypatch.chdir(tmp_path)
651+
652+
with contextlib.suppress(SystemExit):
653+
cli(["sync", "--ndjson", "--dry-run", "my_git_repo"])
654+
655+
captured = capsys.readouterr()
656+
ndjson_lines = [line for line in captured.out.splitlines() if line.strip()]
657+
assert ndjson_lines, "Expected NDJSON payload on stdout"
658+
659+
events = [json.loads(line) for line in ndjson_lines]
660+
reasons = {event["reason"] for event in events}
661+
assert reasons >= {"sync", "summary"}
662+
preview_statuses = {
663+
event.get("status") for event in events if event["reason"] == "sync"
664+
}
665+
assert preview_statuses == {"preview"}

0 commit comments

Comments
 (0)