git-ssh-sync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_ssh_sync/__init__.py +3 -0
- git_ssh_sync/branch.py +205 -0
- git_ssh_sync/cli.py +228 -0
- git_ssh_sync/clone.py +92 -0
- git_ssh_sync/config.py +212 -0
- git_ssh_sync/console.py +5 -0
- git_ssh_sync/doctor.py +588 -0
- git_ssh_sync/errors.py +37 -0
- git_ssh_sync/git.py +186 -0
- git_ssh_sync/ssh.py +55 -0
- git_ssh_sync/status.py +228 -0
- git_ssh_sync/sync.py +415 -0
- git_ssh_sync-0.1.0.dist-info/METADATA +294 -0
- git_ssh_sync-0.1.0.dist-info/RECORD +16 -0
- git_ssh_sync-0.1.0.dist-info/WHEEL +4 -0
- git_ssh_sync-0.1.0.dist-info/entry_points.txt +3 -0
git_ssh_sync/config.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Configuration file management for git-ssh-sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(Exception):
|
|
15
|
+
"""Base class for configuration errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProjectAlreadyExistsError(ConfigError):
|
|
19
|
+
"""Raised when a project already exists and overwrite was not requested."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProjectNotFoundError(ConfigError):
|
|
23
|
+
"""Raised when a project is not registered."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _expand_path(value: str) -> str:
|
|
27
|
+
return str(Path(value).expanduser())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LocalConfig(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
repo_path: str
|
|
34
|
+
|
|
35
|
+
_expand_repo_path = field_validator("repo_path")(_expand_path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DevConfig(BaseModel):
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
40
|
+
|
|
41
|
+
host: str = Field(min_length=1)
|
|
42
|
+
user: str = Field(min_length=1)
|
|
43
|
+
work_path: str = Field(min_length=1)
|
|
44
|
+
cache_path: str = Field(min_length=1)
|
|
45
|
+
|
|
46
|
+
_expand_work_path = field_validator("work_path")(_expand_path)
|
|
47
|
+
_expand_cache_path = field_validator("cache_path")(_expand_path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OptionsConfig(BaseModel):
|
|
51
|
+
model_config = ConfigDict(extra="forbid")
|
|
52
|
+
|
|
53
|
+
sync_tags: bool = True
|
|
54
|
+
lfs: bool = False
|
|
55
|
+
submodules: bool = False
|
|
56
|
+
ff_only: bool = True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProjectConfig(BaseModel):
|
|
60
|
+
model_config = ConfigDict(extra="forbid")
|
|
61
|
+
|
|
62
|
+
origin: str = Field(min_length=1)
|
|
63
|
+
local: LocalConfig
|
|
64
|
+
dev: DevConfig
|
|
65
|
+
options: OptionsConfig = Field(default_factory=OptionsConfig)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AppConfig(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid")
|
|
70
|
+
|
|
71
|
+
version: int = 1
|
|
72
|
+
projects: dict[str, ProjectConfig] = Field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_config_path() -> Path:
|
|
76
|
+
"""Return the default config path for the current platform."""
|
|
77
|
+
if sys.platform == "win32":
|
|
78
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
79
|
+
else:
|
|
80
|
+
base = Path.home() / ".config"
|
|
81
|
+
return base / "git-ssh-sync" / "config.yaml"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def format_validation_error(error: ValidationError) -> str:
|
|
85
|
+
"""Format validation errors with project and field context."""
|
|
86
|
+
messages: list[str] = []
|
|
87
|
+
for item in error.errors():
|
|
88
|
+
loc = item.get("loc", ())
|
|
89
|
+
field = ".".join(str(part) for part in loc)
|
|
90
|
+
if len(loc) >= 3 and loc[0] == "projects":
|
|
91
|
+
project = loc[1]
|
|
92
|
+
project_field = ".".join(str(part) for part in loc[2:])
|
|
93
|
+
messages.append(
|
|
94
|
+
f"project '{project}' field '{project_field}': {item['msg']}"
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
messages.append(f"field '{field}': {item['msg']}")
|
|
98
|
+
return "Invalid configuration: " + "; ".join(messages)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
102
|
+
"""Load config.yaml, returning an empty config when it does not exist."""
|
|
103
|
+
config_path = path or default_config_path()
|
|
104
|
+
if not config_path.exists():
|
|
105
|
+
return AppConfig()
|
|
106
|
+
|
|
107
|
+
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
108
|
+
try:
|
|
109
|
+
return AppConfig.model_validate(data)
|
|
110
|
+
except ValidationError as error:
|
|
111
|
+
raise ConfigError(format_validation_error(error)) from error
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_config(config: AppConfig, path: Path | None = None) -> None:
|
|
115
|
+
"""Save config.yaml."""
|
|
116
|
+
config_path = path or default_config_path()
|
|
117
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
data = config.model_dump(mode="json")
|
|
119
|
+
config_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_project(config: AppConfig, project: str) -> ProjectConfig:
|
|
123
|
+
"""Return a project config or raise a descriptive error."""
|
|
124
|
+
try:
|
|
125
|
+
return config.projects[project]
|
|
126
|
+
except KeyError as error:
|
|
127
|
+
raise ProjectNotFoundError(f"Project '{project}' is not configured.") from error
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_project_config(
|
|
131
|
+
project: str,
|
|
132
|
+
*,
|
|
133
|
+
origin: str | None,
|
|
134
|
+
dev_host: str | None,
|
|
135
|
+
dev_user: str | None,
|
|
136
|
+
dev_work_path: str | None,
|
|
137
|
+
local_repo_path: str | None = None,
|
|
138
|
+
dev_cache_path: str | None = None,
|
|
139
|
+
options: OptionsConfig | None = None,
|
|
140
|
+
) -> ProjectConfig:
|
|
141
|
+
"""Build and validate a project config, applying init defaults."""
|
|
142
|
+
raw: dict[str, Any] = {
|
|
143
|
+
"origin": origin,
|
|
144
|
+
"local": {
|
|
145
|
+
"repo_path": local_repo_path or f"~/.git-ssh-sync/repos/{project}",
|
|
146
|
+
},
|
|
147
|
+
"dev": {
|
|
148
|
+
"host": dev_host,
|
|
149
|
+
"user": dev_user,
|
|
150
|
+
"work_path": dev_work_path,
|
|
151
|
+
"cache_path": dev_cache_path
|
|
152
|
+
or f"/home/{dev_user}/.git-ssh-sync/cache/{project}.git",
|
|
153
|
+
},
|
|
154
|
+
"options": (options or OptionsConfig()).model_dump(mode="json"),
|
|
155
|
+
}
|
|
156
|
+
try:
|
|
157
|
+
return ProjectConfig.model_validate(raw)
|
|
158
|
+
except ValidationError as error:
|
|
159
|
+
raise ConfigError(
|
|
160
|
+
format_validation_error_for_project(project, error)
|
|
161
|
+
) from error
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def format_validation_error_for_project(project: str, error: ValidationError) -> str:
|
|
165
|
+
"""Format project creation validation errors."""
|
|
166
|
+
messages = []
|
|
167
|
+
for item in error.errors():
|
|
168
|
+
field = ".".join(str(part) for part in item["loc"])
|
|
169
|
+
messages.append(f"project '{project}' field '{field}': {item['msg']}")
|
|
170
|
+
return "Invalid configuration: " + "; ".join(messages)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def register_project(
|
|
174
|
+
config: AppConfig,
|
|
175
|
+
project: str,
|
|
176
|
+
project_config: ProjectConfig,
|
|
177
|
+
*,
|
|
178
|
+
force: bool = False,
|
|
179
|
+
) -> AppConfig:
|
|
180
|
+
"""Register or update a project in an app config."""
|
|
181
|
+
if project in config.projects and not force:
|
|
182
|
+
raise ProjectAlreadyExistsError(
|
|
183
|
+
f"Project '{project}' already exists. Use --force to overwrite it."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
projects = dict(config.projects)
|
|
187
|
+
projects[project] = project_config
|
|
188
|
+
return config.model_copy(update={"projects": projects})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def init_project(
|
|
192
|
+
project: str,
|
|
193
|
+
*,
|
|
194
|
+
origin: str | None,
|
|
195
|
+
dev_host: str | None,
|
|
196
|
+
dev_user: str | None,
|
|
197
|
+
dev_work_path: str | None,
|
|
198
|
+
force: bool = False,
|
|
199
|
+
config_path: Path | None = None,
|
|
200
|
+
) -> ProjectConfig:
|
|
201
|
+
"""Create or update a project in config.yaml."""
|
|
202
|
+
config = load_config(config_path)
|
|
203
|
+
project_config = build_project_config(
|
|
204
|
+
project,
|
|
205
|
+
origin=origin,
|
|
206
|
+
dev_host=dev_host,
|
|
207
|
+
dev_user=dev_user,
|
|
208
|
+
dev_work_path=dev_work_path,
|
|
209
|
+
)
|
|
210
|
+
updated = register_project(config, project, project_config, force=force)
|
|
211
|
+
save_config(updated, config_path)
|
|
212
|
+
return project_config
|