Skip to content

Commit 43b6771

Browse files
committed
cli/status(feat[detailed]): report branch divergence and update docs
1 parent 0b857cd commit 43b6771

5 files changed

Lines changed: 358 additions & 35 deletions

File tree

docs/cli/list.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,23 @@ $ vcspull list -f ~/projects/.vcspull.yaml
130130

131131
## Workspace filtering
132132

133-
Filter repositories by workspace root (planned feature):
133+
Filter repositories by workspace root with `-w/--workspace/--workspace-root`:
134134

135135
```console
136136
$ vcspull list -w ~/code/
137+
• flask → /home/d/code/flask
138+
• requests → /home/d/code/requests
139+
```
140+
141+
Globbing is supported, so you can target multiple related workspaces:
142+
143+
```console
144+
$ vcspull list --workspace '*/work/*'
137145
```
138146

147+
The workspace filter combines with pattern filters and structured output flags,
148+
allowing you to export subsets of your configuration quickly.
149+
139150
## Color output
140151

141152
Control colored output with `--color`:

docs/cli/status.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ Check the status of all configured repositories:
2323

2424
```console
2525
$ vcspull status
26-
tiktoken → /home/d/study/ai/tiktoken (missing)
27-
flask → /home/d/code/flask (exists, clean)
28-
django → /home/d/code/django (exists, clean)
26+
tiktoken: missing
27+
flask: up to date
28+
django: up to date
2929

3030
Summary: 3 repositories, 2 exist, 1 missing
3131
```
@@ -58,12 +58,16 @@ Show additional information with `--detailed` or `-d`:
5858

5959
```console
6060
$ vcspull status --detailed
61-
flask → /home/d/code/flask
61+
flask: up to date
6262
Path: /home/d/code/flask
63-
Status: exists, git repository, clean
63+
Branch: main
64+
Ahead/Behind: 0/0
6465
```
6566

66-
This mode shows the full path and expanded status information.
67+
This mode shows the full path, active branch, and divergence counters (`ahead`
68+
and `behind`) relative to the tracked upstream. If the working tree has
69+
uncommitted changes the headline reports `dirty` and the JSON payloads set
70+
`clean` to `false`.
6771

6872
## JSON output
6973

@@ -84,7 +88,10 @@ Output format:
8488
"workspace_root": "~/study/ai/",
8589
"exists": false,
8690
"is_git": false,
87-
"clean": null
91+
"clean": null,
92+
"branch": null,
93+
"ahead": null,
94+
"behind": null
8895
},
8996
{
9097
"reason": "status",
@@ -93,7 +100,10 @@ Output format:
93100
"workspace_root": "~/code/",
94101
"exists": true,
95102
"is_git": true,
96-
"clean": true
103+
"clean": true,
104+
"branch": "main",
105+
"ahead": 0,
106+
"behind": 0
97107
},
98108
{
99109
"reason": "summary",
@@ -113,7 +123,9 @@ Each status entry includes:
113123
- `workspace_root`: Configuration section this repo belongs to
114124
- `exists`: Whether the directory exists
115125
- `is_git`: Whether it's a Git repository
116-
- `clean`: Git working tree status (null if not a Git repo or doesn't exist)
126+
- `clean`: Git working tree status (`null` if not a git repo or missing)
127+
- `branch`: Current branch (when detailed information is available)
128+
- `ahead`, `behind`: Divergence counts relative to the upstream branch
117129

118130
Filter with [jq]:
119131

docs/cli/sync.md

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,42 @@ Use `--dry-run` or `-n` to:
4141
Export sync operations as JSON for automation:
4242

4343
```console
44-
$ vcspull sync --json '*'
45-
```
46-
47-
This is a planned feature that will output structured sync results including:
48-
- Repository names and paths
49-
- Sync status (cloned, updated, skipped, error)
50-
- Commit information
51-
- Error messages
44+
$ vcspull sync --dry-run --json '*'
45+
[
46+
{
47+
"reason": "sync",
48+
"name": "flask",
49+
"path": "/home/d/code/flask",
50+
"workspace_root": "~/code/",
51+
"status": "preview"
52+
},
53+
{
54+
"reason": "summary",
55+
"total": 3,
56+
"synced": 0,
57+
"previewed": 3,
58+
"failed": 0
59+
}
60+
]
61+
```
62+
63+
Each event emitted during the run includes:
64+
65+
- `reason`: `"sync"` for repository events, `"summary"` for the final summary
66+
- `name`, `path`, `workspace_root`: Repository metadata from your config
67+
- `status`: `"synced"`, `"preview"`, or `"error"` (with an `error` field)
68+
69+
Use `--json` without `--dry-run` to capture actual sync executions—successful
70+
and failed repositories are emitted with their final state.
5271

5372
## NDJSON output
5473

5574
Stream sync events line-by-line with `--ndjson`:
5675

5776
```console
58-
$ vcspull sync --ndjson '*'
77+
$ vcspull sync --dry-run --ndjson '*'
78+
{"reason":"sync","name":"flask","path":"/home/d/code/flask","workspace_root":"~/code/","status":"preview"}
79+
{"reason":"summary","total":3,"synced":0,"previewed":3,"failed":0}
5980
```
6081

6182
Each line is a JSON object representing a sync event, ideal for:

src/vcspull/cli/status.py

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import argparse
66
import logging
77
import pathlib
8+
import subprocess
89
import typing as t
910

1011
from vcspull.config import filter_repos, find_config_files, load_configs
@@ -74,6 +75,23 @@ def create_status_subparser(parser: argparse.ArgumentParser) -> None:
7475
)
7576

7677

78+
def _run_git_command(
79+
repo_path: pathlib.Path,
80+
*args: str,
81+
) -> subprocess.CompletedProcess[str] | None:
82+
"""Execute a git command and return the completed process."""
83+
try:
84+
return subprocess.run(
85+
["git", *args],
86+
cwd=repo_path,
87+
capture_output=True,
88+
text=True,
89+
check=True,
90+
)
91+
except (subprocess.CalledProcessError, FileNotFoundError):
92+
return None
93+
94+
7795
def check_repo_status(repo: ConfigDict, detailed: bool = False) -> dict[str, t.Any]:
7896
"""Check the status of a single repository.
7997
@@ -100,6 +118,9 @@ def check_repo_status(repo: ConfigDict, detailed: bool = False) -> dict[str, t.A
100118
"exists": False,
101119
"is_git": False,
102120
"clean": None,
121+
"branch": None,
122+
"ahead": None,
123+
"behind": None,
103124
}
104125

105126
# Check if repository exists
@@ -110,12 +131,52 @@ def check_repo_status(repo: ConfigDict, detailed: bool = False) -> dict[str, t.A
110131
if (repo_path / ".git").exists():
111132
status["is_git"] = True
112133

113-
# TODO: Add more detailed status checks when detailed=True
114-
# - Check if clean/dirty
115-
# - Check if ahead/behind remote
116-
# - Check current branch
117-
# For now, just mark as clean if .git exists
118-
status["clean"] = True
134+
porcelain_result = _run_git_command(repo_path, "status", "--porcelain")
135+
if porcelain_result is not None:
136+
status["clean"] = porcelain_result.stdout.strip() == ""
137+
else:
138+
status["clean"] = True
139+
140+
if detailed:
141+
branch_result = _run_git_command(
142+
repo_path,
143+
"rev-parse",
144+
"--abbrev-ref",
145+
"HEAD",
146+
)
147+
if branch_result is not None:
148+
status["branch"] = branch_result.stdout.strip()
149+
150+
ahead = 0
151+
behind = 0
152+
upstream_available = _run_git_command(
153+
repo_path,
154+
"rev-parse",
155+
"--abbrev-ref",
156+
"@{upstream}",
157+
)
158+
if upstream_available is not None:
159+
counts = _run_git_command(
160+
repo_path,
161+
"rev-list",
162+
"--left-right",
163+
"--count",
164+
"@{upstream}...HEAD",
165+
)
166+
if counts is not None:
167+
parts = counts.stdout.strip().split()
168+
if len(parts) == 2:
169+
behind, ahead = (int(parts[0]), int(parts[1]))
170+
status["ahead"] = ahead
171+
status["behind"] = behind
172+
173+
# Maintain clean flag if porcelain failed
174+
if status["clean"] is None:
175+
status["clean"] = True
176+
else:
177+
status["branch"] = None
178+
status["ahead"] = None
179+
status["behind"] = None
119180

120181
return status
121182

@@ -249,10 +310,32 @@ def _format_status_line(
249310
status_color = colors.error(message)
250311
elif status["is_git"]:
251312
symbol = colors.success("✓")
252-
message = "up to date" if status["clean"] else "dirty"
253-
status_color = (
254-
colors.success(message) if status["clean"] else colors.warning(message)
255-
)
313+
clean_state = status["clean"]
314+
ahead = status.get("ahead")
315+
behind = status.get("behind")
316+
if clean_state is False:
317+
message = "dirty"
318+
status_color = colors.warning(message)
319+
elif isinstance(ahead, int) and isinstance(behind, int):
320+
if ahead > 0 and behind > 0:
321+
message = f"diverged (ahead {ahead}, behind {behind})"
322+
status_color = colors.warning(message)
323+
elif ahead > 0:
324+
message = f"ahead by {ahead}"
325+
status_color = colors.info(message)
326+
elif behind > 0:
327+
message = f"behind by {behind}"
328+
status_color = colors.warning(message)
329+
else:
330+
message = "up to date"
331+
status_color = colors.success(message)
332+
else:
333+
message = "up to date" if clean_state else "dirty"
334+
status_color = (
335+
colors.success(message)
336+
if clean_state in {True, None}
337+
else colors.warning(message)
338+
)
256339
else:
257340
symbol = colors.warning("⚠")
258341
message = "not a git repo"
@@ -262,3 +345,10 @@ def _format_status_line(
262345

263346
if detailed:
264347
formatter.emit_text(f" {colors.muted('Path:')} {status['path']}")
348+
branch = status.get("branch")
349+
if branch:
350+
formatter.emit_text(f" {colors.muted('Branch:')} {branch}")
351+
ahead = status.get("ahead")
352+
behind = status.get("behind")
353+
if isinstance(ahead, int) and isinstance(behind, int):
354+
formatter.emit_text(f" {colors.muted('Ahead/Behind:')} {ahead}/{behind}")

0 commit comments

Comments
 (0)