Skip to content

Commit 25f3567

Browse files
authored
Fix status reporting, unify dry-run JSON, support non-git VCS (#514)
Breaking: sync --dry-run --json now emits a flat array of plan entries instead of a nested workspace-grouped object, matching the schema of sync --json and status --json. Fixes: - Status: repos without upstream showed as "up to date" (ahead/behind was 0 instead of None) - Status: non-git directories were counted as dirty - Sync: duplicate repos when multiple patterns match the same path - Sync: non-git repos were BLOCKED in dry-run instead of UPDATE - Sync: update_repo return type was GitSync, now includes HgSync and SvnSync - Formatter: missing option flags (-d, -x, -V, --fetch, --offline, --verbose, --max-concurrent, --name, --url) New: - _HelpTheme protocol for CPython 3.14+ argparse colorization - SVN and Mercurial tests for update_repo - Non-git repos excluded from clean/dirty status counts Docs: - README and quickstart sync examples updated to use --all
2 parents bf42d26 + 4b32b3d commit 25f3567

14 files changed

Lines changed: 528 additions & 112 deletions

CHANGES

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,78 @@ $ uvx --from 'vcspull' --prerelease allow vcspull
3333
_Notes on upcoming releases will be added here_
3434
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
3535

36+
### Breaking changes
37+
38+
#### cli(sync): Route dry-run JSON through OutputFormatter (#514)
39+
40+
JSON dry-run output bypassed `OutputFormatter`, producing a nested
41+
workspace schema instead of the flat array used by other commands.
42+
Dry-run JSON and NDJSON now use the same `formatter.emit()` path.
43+
44+
- Dry-run `--json` output is now a flat array of plan entries, matching
45+
the schema of `sync --json` and `status --json`
46+
47+
### What's new
48+
49+
#### cli(_formatter): Add `_HelpTheme` protocol for argparse colorization (#514)
50+
51+
Add a typed protocol documenting the CPython 3.14+ argparse theme
52+
attributes consumed by `VcspullHelpFormatter`. The formatter hooks into
53+
`_theme` to colorize example command blocks in help text, applying
54+
distinct colors to program names, subcommands, and options.
55+
56+
### Bug fixes
57+
58+
#### cli(status): Fix false "up to date" and phantom dirty counts (#514)
59+
60+
`check_repo_status` initialized `ahead`/`behind` to `0` instead of
61+
`None` when no upstream is tracked, causing `_determine_plan_action`
62+
to report repos as "up to date" when the remote state was actually
63+
unknown. Additionally, non-git directories (where `clean` is `None`)
64+
were counted as dirty in the status summary.
65+
66+
- Initialize `ahead`/`behind` as `None` so missing upstream propagates
67+
correctly
68+
- Use identity checks (`is True` / `is False`) instead of truthiness
69+
for `clean`, excluding non-git repos from clean/dirty counts
70+
71+
#### cli(sync): Deduplicate repos matched by multiple patterns (#514)
72+
73+
When multiple patterns matched the same repository (e.g. `"myrepo"`
74+
and `"*"`), the repo was synced multiple times. Repos are now
75+
deduplicated by path after pattern matching.
76+
77+
#### cli(sync): Report non-git repos as UPDATE in dry-run plan (#514)
78+
79+
`_determine_plan_action` blocked non-git repos in dry-run output even
80+
though `update_repo` supports SVN and Mercurial via `create_project()`.
81+
Non-git repos now show `UPDATE` with a "non-git VCS" detail instead of
82+
`BLOCKED`.
83+
84+
#### cli(sync): Widen `update_repo` return type (#514)
85+
86+
Return type was annotated as `GitSync` but `create_project()` returns
87+
`GitSync | HgSync | SvnSync`. The annotation now matches the actual
88+
return type.
89+
90+
#### cli(_formatter): Complete option classification for help colorization (#514)
91+
92+
The help formatter's option sets were missing several flags (`-d`,
93+
`-x`, `-V`, `--fetch`, `--offline`, `--verbose`, etc.) and
94+
value-expecting options (`--max-concurrent`, `--name`, `--url`),
95+
causing tokens following those options to be misclassified in colorized
96+
help output.
97+
98+
### Documentation
99+
100+
- Update sync examples in README and quickstart to use `--all`, matching
101+
the v1.53.0 CLI change (#514)
102+
103+
### Tests
104+
105+
- Add SVN and Mercurial tests for `update_repo` to verify non-git VCS
106+
paths (#514)
107+
36108
## vcspull v1.53.0 (2026-02-08)
37109

38110
### What's new

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ duplicate roots reported, not rewritten.
174174
## Sync your repos
175175

176176
```console
177-
$ vcspull sync
177+
$ vcspull sync --all
178178
```
179179

180180
Preview planned work with Terraform-style plan output or emit structured data

docs/quickstart.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ Now run the command, to pull all the repositories in your
139139
`.vcspull.yaml` / `.vcspull.json`.
140140

141141
```console
142-
$ vcspull sync
142+
$ vcspull sync --all
143143
```
144144

145145
Also, you can sync arbitrary projects, lets assume you have a mercurial
@@ -154,7 +154,7 @@ be any name):
154154
Use `-f/--file` to specify a config.
155155

156156
```console
157-
$ vcspull sync -f .deps.yaml
157+
$ vcspull sync -f .deps.yaml --all
158158
```
159159

160160
You can also use [fnmatch] to pull repositories from your config in

src/vcspull/cli/_formatter.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
"--path",
1717
"--color",
1818
"--field",
19+
"--max-concurrent",
20+
"--name",
21+
"--url",
1922
}
2023

2124
OPTIONS_FLAG_ONLY = {
@@ -34,6 +37,7 @@
3437
"--ndjson",
3538
"--tree",
3639
"--detailed",
40+
"-d",
3741
"-i",
3842
"--ignore-case",
3943
"-S",
@@ -44,14 +48,57 @@
4448
"-v",
4549
"--invert-match",
4650
"--any",
51+
"--exit-on-error",
52+
"-x",
53+
"--fetch",
54+
"--long",
55+
"--offline",
56+
"--relative-paths",
57+
"--show-unchanged",
58+
"--summary-only",
59+
"--version",
60+
"-V",
61+
"--no-concurrent",
62+
"--sequential",
63+
"--no-merge",
64+
"--verbose",
4765
}
4866

4967

68+
class _HelpTheme(t.Protocol):
69+
"""Protocol describing the argparse color theme.
70+
71+
Python 3.14+ sets ``self._theme`` on ``HelpFormatter`` instances via
72+
``_colorize.get_theme().argparse``. This protocol documents the
73+
attributes consumed by :meth:`VcspullHelpFormatter._fill_text` and
74+
:meth:`VcspullHelpFormatter._colorize_example_line`.
75+
"""
76+
77+
heading: str
78+
reset: str
79+
label: str
80+
long_option: str
81+
short_option: str
82+
prog: str
83+
action: str
84+
85+
5086
class VcspullHelpFormatter(argparse.RawDescriptionHelpFormatter):
51-
"""Render description blocks while colorizing example sections when possible."""
87+
"""Extend argparse help colorization to example command sections.
88+
89+
Python 3.14+ natively colorizes usage and option groups via
90+
``_set_color()`` / ``_colorize.get_theme().argparse``. This
91+
formatter hooks into the same ``_theme`` attribute to additionally
92+
colorize the "examples:" blocks in description text, applying
93+
distinct colors to program names, subcommands, long/short options,
94+
and option values.
95+
96+
When ``_theme`` is ``None`` (older Python or ``NO_COLOR`` set),
97+
``_fill_text`` falls through to the base class unchanged.
98+
"""
5299

53100
def _fill_text(self, text: str, width: int, indent: str) -> str:
54-
theme = getattr(self, "_theme", None)
101+
theme = t.cast("_HelpTheme | None", getattr(self, "_theme", None))
55102
if not text or theme is None:
56103
return super()._fill_text(text, width, indent)
57104

@@ -105,7 +152,7 @@ def _colorize_example_line(
105152
self,
106153
content: str,
107154
*,
108-
theme: t.Any,
155+
theme: _HelpTheme,
109156
expect_value: bool,
110157
) -> _ColorizedLine:
111158
parts: list[str] = []

src/vcspull/cli/_output.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -132,29 +132,6 @@ class PlanResult:
132132
entries: list[PlanEntry]
133133
summary: PlanSummary
134134

135-
def to_workspace_mapping(self) -> dict[str, list[PlanEntry]]:
136-
"""Group plan entries by workspace root."""
137-
grouped: dict[str, list[PlanEntry]] = {}
138-
for entry in self.entries:
139-
grouped.setdefault(entry.workspace_root, []).append(entry)
140-
return grouped
141-
142-
def to_json_object(self) -> dict[str, t.Any]:
143-
"""Return the JSON structure for ``--json`` output."""
144-
workspaces: list[dict[str, t.Any]] = []
145-
for workspace_root, entries in self.to_workspace_mapping().items():
146-
workspaces.append(
147-
{
148-
"path": workspace_root,
149-
"operations": [entry.to_payload() for entry in entries],
150-
},
151-
)
152-
return {
153-
"format_version": "1",
154-
"workspaces": workspaces,
155-
"summary": self.summary.to_payload(),
156-
}
157-
158135

159136
class OutputFormatter:
160137
"""Manages output formatting for different modes (human, JSON, NDJSON)."""

src/vcspull/cli/discover.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ def discover_repos(
393393
cwd=cwd,
394394
)
395395

396+
# TODO(#discover-non-git): Also scan for .hg and .svn repositories
396397
if recursive:
397398
for root, dirs, _ in os.walk(scan_dir):
398399
if ".git" in dirs:

src/vcspull/cli/status.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,8 @@ def check_repo_status(repo: ConfigDict, detailed: bool = False) -> dict[str, t.A
297297
if branch_result is not None:
298298
status["branch"] = branch_result.stdout.strip()
299299

300-
ahead = 0
301-
behind = 0
300+
ahead: int | None = None
301+
behind: int | None = None
302302
upstream_available = _run_git_command(
303303
repo_path,
304304
"rev-parse",
@@ -443,9 +443,9 @@ def status_repos(
443443

444444
if status["exists"]:
445445
summary["exists"] += 1
446-
if status["clean"]:
446+
if status["clean"] is True:
447447
summary["clean"] += 1
448-
else:
448+
elif status["clean"] is False:
449449
summary["dirty"] += 1
450450
else:
451451
summary["missing"] += 1

src/vcspull/cli/sync.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import argparse
66
import asyncio
77
import contextlib
8-
import json
98
import logging
109
import os
1110
import pathlib
@@ -23,6 +22,8 @@
2322
from libvcs._internal.shortcuts import create_project
2423
from libvcs._internal.types import VCSLiteral
2524
from libvcs.sync.git import GitSync
25+
from libvcs.sync.hg import HgSync
26+
from libvcs.sync.svn import SvnSync
2627
from libvcs.url import registry as url_tools
2728

2829
from vcspull import exc
@@ -196,7 +197,7 @@ def _determine_plan_action(
196197
return PlanAction.CLONE, "missing"
197198

198199
if not status.get("is_git"):
199-
return PlanAction.BLOCKED, "not a git repository"
200+
return PlanAction.UPDATE, "non-git VCS (detailed plan not available)"
200201

201202
clean_state = status.get("clean")
202203
if clean_state is False:
@@ -491,14 +492,9 @@ def _emit_plan_output(
491492
show_unchanged=render_options.show_unchanged,
492493
)
493494

494-
if formatter.mode == OutputMode.NDJSON:
495-
for entry in display_entries:
496-
formatter.emit(entry)
497-
formatter.emit(plan.summary)
498-
return
499-
structured = PlanResult(entries=display_entries, summary=plan.summary)
500-
sys.stdout.write(json.dumps(structured.to_json_object(), indent=2) + "\n")
501-
sys.stdout.flush()
495+
for entry in display_entries:
496+
formatter.emit(entry)
497+
formatter.emit(plan.summary)
502498

503499

504500
def create_sync_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
@@ -696,6 +692,16 @@ def sync(
696692
unmatched_count += 1
697693
found_repos.extend(found)
698694

695+
# Deduplicate repos matched by multiple patterns
696+
seen_paths: set[str] = set()
697+
deduped: list[ConfigDict] = []
698+
for repo in found_repos:
699+
key = str(repo.get("path", ""))
700+
if key not in seen_paths:
701+
seen_paths.add(key)
702+
deduped.append(repo)
703+
found_repos = deduped
704+
699705
if workspace_root:
700706
found_repos = filter_by_workspace(found_repos, workspace_root)
701707

@@ -924,7 +930,7 @@ def update_repo(
924930
repo_dict: t.Any,
925931
progress_callback: ProgressCallback | None = None,
926932
# repo_dict: Dict[str, Union[str, Dict[str, GitRemote], pathlib.Path]]
927-
) -> GitSync:
933+
) -> GitSync | HgSync | SvnSync:
928934
"""Synchronize a single repository."""
929935
repo_dict = deepcopy(repo_dict)
930936
if "pip_url" not in repo_dict:
@@ -941,7 +947,7 @@ def update_repo(
941947

942948
repo_dict["vcs"] = vcs
943949

944-
r = create_project(**repo_dict) # Creates the repo object
950+
r: GitSync | HgSync | SvnSync = create_project(**repo_dict)
945951
if repo_dict.get("vcs") == "git":
946952
result = r.update_repo(set_remotes=True)
947953
else:
@@ -952,5 +958,4 @@ def update_repo(
952958
repo_name = str(repo_dict.get("name", repo_dict.get("url", "unknown")))
953959
raise SyncFailedError(repo_name=repo_name, errors=error_messages)
954960

955-
# TODO: Fix this
956-
return r # type:ignore
961+
return r

0 commit comments

Comments
 (0)