Skip to content

Commit ea6af2d

Browse files
committed
sync(feat): Apply checkout policy and recovery
why: Configured targets and dirty policies must reach the native backend, and retained recovery details must survive CLI reporting. what: - Forward immutable target and policy values without changing input - Require --yes before any configured discard accesses a repository - Preserve recovery tokens, states, conflicts, and ordered failures - Keep target-drift warnings visible in interactive output - Verify native target selection, dirty policy, and machine output
1 parent 4ac5a23 commit ea6af2d

11 files changed

Lines changed: 541 additions & 132 deletions

File tree

‎CHANGES‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ legacy options stop migration before it writes the configuration file.
8080
they rewrite and preserve entries pinned for that command.
8181
See {ref}`migration` for an upgrade example.
8282

83+
#### Apply main checkout policy and retain recovery details
84+
85+
Sync forwards the configured selector kind, remote, and drift/dirty policy
86+
to libvcs. Git preservation retains a recovery token after restoration or
87+
conflict. Human output shows retained material; JSON and NDJSON preserve its
88+
exact identity and operation outcomes. Configured discard requires `--yes`
89+
before repository access. See {ref}`cli-sync` and {ref}`migration`.
90+
8391
#### Validate shared checkout targets
8492

8593
`working_copy` and `worktrees` accept the same `branch`, `tag`, `commit`, or

‎MIGRATION‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ well.
2323

2424
<!-- Maintainers, insert changes / features for the next release here -->
2525

26+
### Explicit checkout policy and sync results
27+
28+
The main checkout passes its selector kind, remote, and `sync` policy to
29+
libvcs. Git defaults to aborting on local changes; choose `dirty: preserve`
30+
to retain and restore them. Configuring `dirty: discard` also requires
31+
`vcspull sync --yes` on each invocation.
32+
33+
Python callers of `vcspull.cli.sync.update_repo` receive `SyncExecution`:
34+
use `.project` for the backend instance and `.result` for its complete
35+
`SyncResult`. Failures still raise `SyncFailedError`, whose `.result` now
36+
retains recovery tokens, conflicts, and operation outcomes. Programmatic
37+
discard requires `yes=True`.
38+
2639
### Exact numeric values
2740

2841
Integer configuration fields accept values up to `9007199254740991`

‎docs/cli/sync.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,39 @@ Use `--dry-run` or `-n` to:
151151
- Test pattern filters
152152
- Preview operations in CI/CD
153153

154+
## Checkout policy
155+
156+
Configure the main checkout independently of clone options:
157+
158+
```yaml
159+
~/code/:
160+
project:
161+
repo: git+https://example.com/project.git
162+
working_copy:
163+
branch: main
164+
sync:
165+
drift: follow
166+
dirty: preserve
167+
```
168+
169+
`follow` resolves the configured target; `keep` and `warn` leave an existing
170+
checkout in place. The default dirty policy is `abort`. Git preservation
171+
retains staged, unstaged, and untracked changes in an owned stash and restores
172+
them after updating. Conflicts retain a recovery token instead of discarding
173+
local state. Keep other editors and VCS writers out of the checkout during sync.
174+
175+
A configured `dirty: discard` requires explicit authorization on each call:
176+
177+
```console
178+
$ vcspull sync project --yes
179+
```
180+
181+
`--yes` authorizes an existing discard policy; it does not enable discard for
182+
other entries. Successful preservation and failures print the retained recovery
183+
location. JSON and NDJSON events include `update_state`, `preservation_state`,
184+
`recovery`, `conflicts`, and ordered `errors`. A recovery token stays available
185+
until explicitly released through libvcs; a failed operation never deletes it.
186+
154187
## JSON output
155188

156189
Export sync operations as JSON for automation:

‎src/vcspull/_internal/sync.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Shared checkout settings and sync outcomes for CLI commands."""
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
import typing as t
7+
from collections.abc import Mapping
8+
9+
from libvcs import BaseSync, SyncPolicy, SyncResult, SyncTarget
10+
11+
from vcspull.validator import validate_working_copy
12+
13+
14+
@dataclasses.dataclass(frozen=True)
15+
class SyncExecution:
16+
"""Keep the backend instance and its complete synchronization result."""
17+
18+
project: BaseSync
19+
result: SyncResult
20+
21+
22+
def checkout_settings(
23+
config: Mapping[str, t.Any] | None, *, worktree: bool = False
24+
) -> tuple[SyncTarget | None, SyncPolicy]:
25+
"""Translate a validated checkout without collapsing its selector kind.
26+
27+
>>> target, policy = checkout_settings({"tag": "v1", "sync": {"drift": "keep"}})
28+
>>> target.tag, policy.drift
29+
('v1', 'keep')
30+
>>> checkout_settings(None)
31+
(None, SyncPolicy(drift='follow', dirty='abort'))
32+
"""
33+
if config is None:
34+
return None, SyncPolicy()
35+
values = dict(config)
36+
validate_working_copy(values, worktree=worktree)
37+
target = SyncTarget(
38+
**{
39+
key: values[key]
40+
for key in ("branch", "tag", "commit", "rev", "remote")
41+
if key in values
42+
}
43+
)
44+
return target, SyncPolicy(**values.get("sync", {}))
45+
46+
47+
def sync_result_data(result: SyncResult) -> dict[str, t.Any]:
48+
"""Serialize recovery identity and outcomes without exception objects.
49+
50+
>>> sync_result_data(SyncResult())["preservation_state"]
51+
'not-needed'
52+
"""
53+
return {
54+
"update_state": result.update_state,
55+
"preservation_state": result.preservation_state,
56+
"recovery": dataclasses.asdict(result.recovery) if result.recovery else None,
57+
"conflicts": [dataclasses.asdict(conflict) for conflict in result.conflicts],
58+
"errors": [
59+
{"step": error.step, "message": error.message} for error in result.errors
60+
],
61+
}

‎src/vcspull/cli/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ def cli(_args: list[str] | None = None) -> None:
532532
log_file=getattr(args, "log_file", None),
533533
no_log_file=getattr(args, "no_log_file", False),
534534
panel_lines=getattr(args, "panel_lines", None),
535+
yes=getattr(args, "yes", False),
535536
)
536537
elif args.subparser_name == "list":
537538
list_repos(

0 commit comments

Comments
 (0)