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/doctor.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""Environment diagnosis workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from git_ssh_sync import git, ssh
|
|
14
|
+
from git_ssh_sync.config import ProjectConfig, get_project, load_config
|
|
15
|
+
from git_ssh_sync.console import console
|
|
16
|
+
from git_ssh_sync.errors import CommandExecutionError
|
|
17
|
+
from git_ssh_sync.status import _ssh_repo_url, _uses_lfs, _uses_submodules
|
|
18
|
+
|
|
19
|
+
CheckStatus = Literal["ok", "warning", "error"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DoctorError(RuntimeError):
|
|
23
|
+
"""Raised when doctor finds one or more failed checks."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class DoctorCheck:
|
|
28
|
+
"""Single diagnostic check result."""
|
|
29
|
+
|
|
30
|
+
section: str
|
|
31
|
+
name: str
|
|
32
|
+
status: CheckStatus
|
|
33
|
+
message: str
|
|
34
|
+
environment: str
|
|
35
|
+
next_action: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class DoctorReport:
|
|
40
|
+
"""Collected diagnostic results for a configured project."""
|
|
41
|
+
|
|
42
|
+
project: str
|
|
43
|
+
branch: str
|
|
44
|
+
origin_url: str
|
|
45
|
+
dev_host: str
|
|
46
|
+
dev_work_path: str
|
|
47
|
+
checks: tuple[DoctorCheck, ...]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def has_errors(self) -> bool:
|
|
51
|
+
return any(check.status == "error" for check in self.checks)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ok(section: str, name: str, message: str, *, environment: str) -> DoctorCheck:
|
|
55
|
+
return DoctorCheck(section, name, "ok", message, environment)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _warning(
|
|
59
|
+
section: str,
|
|
60
|
+
name: str,
|
|
61
|
+
message: str,
|
|
62
|
+
*,
|
|
63
|
+
environment: str,
|
|
64
|
+
next_action: str | None = None,
|
|
65
|
+
) -> DoctorCheck:
|
|
66
|
+
return DoctorCheck(section, name, "warning", message, environment, next_action)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _error(
|
|
70
|
+
section: str,
|
|
71
|
+
name: str,
|
|
72
|
+
message: str,
|
|
73
|
+
*,
|
|
74
|
+
environment: str,
|
|
75
|
+
next_action: str,
|
|
76
|
+
) -> DoctorCheck:
|
|
77
|
+
return DoctorCheck(section, name, "error", message, environment, next_action)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _command_error_message(error: CommandExecutionError) -> str:
|
|
81
|
+
detail = error.stderr.strip() or error.stdout.strip()
|
|
82
|
+
if detail:
|
|
83
|
+
return detail
|
|
84
|
+
return f"exit code {error.returncode}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_local_commands() -> list[DoctorCheck]:
|
|
88
|
+
checks: list[DoctorCheck] = []
|
|
89
|
+
for command in ("git", "ssh"):
|
|
90
|
+
path = shutil.which(command)
|
|
91
|
+
if path:
|
|
92
|
+
checks.append(
|
|
93
|
+
_ok(
|
|
94
|
+
"Local",
|
|
95
|
+
f"{command} command",
|
|
96
|
+
f"{command} found at {path}",
|
|
97
|
+
environment="local",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
checks.append(
|
|
102
|
+
_error(
|
|
103
|
+
"Local",
|
|
104
|
+
f"{command} command",
|
|
105
|
+
f"{command} command was not found.",
|
|
106
|
+
environment="local",
|
|
107
|
+
next_action=f"Install {command} and make sure it is available on PATH.",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return checks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_gateway_repo(local_path: Path) -> list[DoctorCheck]:
|
|
114
|
+
if not local_path.exists():
|
|
115
|
+
return [
|
|
116
|
+
_error(
|
|
117
|
+
"Local",
|
|
118
|
+
"gateway repo",
|
|
119
|
+
f"Gateway repository does not exist: {local_path}",
|
|
120
|
+
environment="local",
|
|
121
|
+
next_action="Run git-ssh-sync clone for this project.",
|
|
122
|
+
)
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
result = git.run_git(["rev-parse", "--git-dir"], cwd=local_path, check=False)
|
|
126
|
+
if result.returncode == 0:
|
|
127
|
+
return [
|
|
128
|
+
_ok(
|
|
129
|
+
"Local",
|
|
130
|
+
"gateway repo",
|
|
131
|
+
"Gateway repository exists and is readable.",
|
|
132
|
+
environment="local",
|
|
133
|
+
)
|
|
134
|
+
]
|
|
135
|
+
return [
|
|
136
|
+
_error(
|
|
137
|
+
"Local",
|
|
138
|
+
"gateway repo",
|
|
139
|
+
f"Gateway repository is not healthy: {_command_error_message(_as_command_error(result))}",
|
|
140
|
+
environment="local",
|
|
141
|
+
next_action=f"Inspect or recreate the gateway repository at {local_path}.",
|
|
142
|
+
)
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _as_command_error(result: git.CommandResult) -> CommandExecutionError:
|
|
147
|
+
return CommandExecutionError(
|
|
148
|
+
environment=result.environment,
|
|
149
|
+
command=result.command,
|
|
150
|
+
returncode=result.returncode,
|
|
151
|
+
cwd=result.cwd,
|
|
152
|
+
stdout=result.stdout,
|
|
153
|
+
stderr=result.stderr,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _check_origin(local_path: Path, branch: str) -> list[DoctorCheck]:
|
|
158
|
+
checks: list[DoctorCheck] = []
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
git.fetch("origin", cwd=local_path)
|
|
162
|
+
except CommandExecutionError as error:
|
|
163
|
+
return [
|
|
164
|
+
_error(
|
|
165
|
+
"Local",
|
|
166
|
+
"origin fetch",
|
|
167
|
+
f"Could not fetch origin: {_command_error_message(error)}",
|
|
168
|
+
environment=error.environment,
|
|
169
|
+
next_action="Check origin URL, network access, and SSH credentials on the local machine.",
|
|
170
|
+
)
|
|
171
|
+
]
|
|
172
|
+
checks.append(
|
|
173
|
+
_ok("Local", "origin fetch", "origin fetch succeeded.", environment="local")
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
origin_ref = f"refs/remotes/origin/{branch}"
|
|
177
|
+
branch_result = git.run_git(
|
|
178
|
+
["show-ref", "--verify", "--quiet", origin_ref], cwd=local_path, check=False
|
|
179
|
+
)
|
|
180
|
+
if branch_result.returncode == 0:
|
|
181
|
+
checks.append(
|
|
182
|
+
_ok(
|
|
183
|
+
"Repository",
|
|
184
|
+
"current branch",
|
|
185
|
+
f"origin/{branch} exists.",
|
|
186
|
+
environment="local",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
checks.append(
|
|
191
|
+
_error(
|
|
192
|
+
"Repository",
|
|
193
|
+
"current branch",
|
|
194
|
+
f"origin/{branch} does not exist.",
|
|
195
|
+
environment="local",
|
|
196
|
+
next_action=f"Create {branch} on origin or switch to an existing branch.",
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return checks
|
|
200
|
+
|
|
201
|
+
dry_run = git.run_git(
|
|
202
|
+
["push", "--dry-run", "origin", f"{origin_ref}:refs/heads/{branch}"],
|
|
203
|
+
cwd=local_path,
|
|
204
|
+
check=False,
|
|
205
|
+
)
|
|
206
|
+
if dry_run.returncode == 0:
|
|
207
|
+
checks.append(
|
|
208
|
+
_ok(
|
|
209
|
+
"Local",
|
|
210
|
+
"origin push dry-run",
|
|
211
|
+
"origin push dry-run succeeded.",
|
|
212
|
+
environment="local",
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
checks.append(
|
|
217
|
+
_error(
|
|
218
|
+
"Local",
|
|
219
|
+
"origin push dry-run",
|
|
220
|
+
f"origin push dry-run failed: {_command_error_message(_as_command_error(dry_run))}",
|
|
221
|
+
environment="local",
|
|
222
|
+
next_action="Check write permission to origin and branch protection rules.",
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
return checks
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _check_remote_path(
|
|
229
|
+
*, host: str, user: str, path: str, label: str, next_action: str
|
|
230
|
+
) -> DoctorCheck:
|
|
231
|
+
result = ssh.run_ssh(host, ["test", "-d", path], user=user, check=False)
|
|
232
|
+
if result.returncode == 0:
|
|
233
|
+
return _ok(
|
|
234
|
+
"Development", label, f"{path} exists.", environment=result.environment
|
|
235
|
+
)
|
|
236
|
+
if result.returncode == 1:
|
|
237
|
+
return _error(
|
|
238
|
+
"Development",
|
|
239
|
+
label,
|
|
240
|
+
f"{path} does not exist.",
|
|
241
|
+
environment=result.environment,
|
|
242
|
+
next_action=next_action,
|
|
243
|
+
)
|
|
244
|
+
return _error(
|
|
245
|
+
"Development",
|
|
246
|
+
label,
|
|
247
|
+
f"Could not inspect {path}: {_command_error_message(_as_command_error(result))}",
|
|
248
|
+
environment=result.environment,
|
|
249
|
+
next_action="Check SSH access and path permissions on the development environment.",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_development(project_config: ProjectConfig) -> list[DoctorCheck]:
|
|
254
|
+
checks: list[DoctorCheck] = []
|
|
255
|
+
host = project_config.dev.host
|
|
256
|
+
user = project_config.dev.user
|
|
257
|
+
work_path = project_config.dev.work_path
|
|
258
|
+
cache_path = project_config.dev.cache_path
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
ssh.run_ssh(host, ["true"], user=user)
|
|
262
|
+
except CommandExecutionError as error:
|
|
263
|
+
return [
|
|
264
|
+
_error(
|
|
265
|
+
"Development",
|
|
266
|
+
"SSH connection",
|
|
267
|
+
f"SSH connection failed: {_command_error_message(error)}",
|
|
268
|
+
environment=error.environment,
|
|
269
|
+
next_action="Fix SSH host, user, keys, or network access for the development environment.",
|
|
270
|
+
)
|
|
271
|
+
]
|
|
272
|
+
checks.append(
|
|
273
|
+
_ok(
|
|
274
|
+
"Development",
|
|
275
|
+
"SSH connection",
|
|
276
|
+
"SSH connection succeeded.",
|
|
277
|
+
environment=f"ssh:{user}@{host}",
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
git_result = ssh.run_ssh(
|
|
282
|
+
host, ["sh", "-lc", "command -v git"], user=user, check=False
|
|
283
|
+
)
|
|
284
|
+
if git_result.returncode == 0:
|
|
285
|
+
checks.append(
|
|
286
|
+
_ok(
|
|
287
|
+
"Development",
|
|
288
|
+
"git command",
|
|
289
|
+
f"git found at {git_result.stdout.strip() or 'git'}",
|
|
290
|
+
environment=git_result.environment,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
checks.append(
|
|
295
|
+
_error(
|
|
296
|
+
"Development",
|
|
297
|
+
"git command",
|
|
298
|
+
"git command was not found on the development environment.",
|
|
299
|
+
environment=git_result.environment,
|
|
300
|
+
next_action="Install git on the development environment and make it available on PATH.",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
checks.append(
|
|
305
|
+
_check_remote_path(
|
|
306
|
+
host=host,
|
|
307
|
+
user=user,
|
|
308
|
+
path=cache_path,
|
|
309
|
+
label="bare cache repo",
|
|
310
|
+
next_action="Run git-ssh-sync clone for this project to create the cache repository.",
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
checks.append(
|
|
314
|
+
_check_remote_path(
|
|
315
|
+
host=host,
|
|
316
|
+
user=user,
|
|
317
|
+
path=work_path,
|
|
318
|
+
label="work repo",
|
|
319
|
+
next_action="Run git-ssh-sync clone for this project to create the work repository.",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
branch = ssh.run_remote_git(
|
|
324
|
+
host, work_path, ["branch", "--show-current"], user=user, check=False
|
|
325
|
+
)
|
|
326
|
+
if branch.returncode == 0:
|
|
327
|
+
checks.append(
|
|
328
|
+
_ok(
|
|
329
|
+
"Development",
|
|
330
|
+
"work repo branch",
|
|
331
|
+
f"Current branch: {branch.stdout.strip() or '(detached)'}",
|
|
332
|
+
environment=branch.environment,
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
checks.append(
|
|
337
|
+
_error(
|
|
338
|
+
"Development",
|
|
339
|
+
"work repo branch",
|
|
340
|
+
f"Could not get work repo branch: {_command_error_message(_as_command_error(branch))}",
|
|
341
|
+
environment=branch.environment,
|
|
342
|
+
next_action="Inspect the development work repository with git status.",
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
head = ssh.run_remote_git(
|
|
347
|
+
host, work_path, ["rev-parse", "--short", "HEAD"], user=user, check=False
|
|
348
|
+
)
|
|
349
|
+
head_value = head.stdout.strip() if head.returncode == 0 else "unknown"
|
|
350
|
+
status = ssh.run_remote_git(
|
|
351
|
+
host, work_path, ["status", "--porcelain"], user=user, check=False
|
|
352
|
+
)
|
|
353
|
+
if status.returncode == 0 and not status.stdout.strip():
|
|
354
|
+
checks.append(
|
|
355
|
+
_ok(
|
|
356
|
+
"Development",
|
|
357
|
+
"working tree",
|
|
358
|
+
f"Working tree is clean at {head_value}.",
|
|
359
|
+
environment=status.environment,
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
elif status.returncode == 0:
|
|
363
|
+
checks.append(
|
|
364
|
+
_error(
|
|
365
|
+
"Development",
|
|
366
|
+
"working tree",
|
|
367
|
+
f"Development working tree is dirty at {head_value}.",
|
|
368
|
+
environment=status.environment,
|
|
369
|
+
next_action="Commit or stash changes on the development environment.",
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
checks.append(
|
|
374
|
+
_error(
|
|
375
|
+
"Development",
|
|
376
|
+
"working tree",
|
|
377
|
+
f"Could not get working tree status: {_command_error_message(_as_command_error(status))}",
|
|
378
|
+
environment=status.environment,
|
|
379
|
+
next_action="Inspect the development work repository with git status.",
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return checks
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _current_local_branch(local_path: Path) -> str:
|
|
387
|
+
result = git.run_git(["branch", "--show-current"], cwd=local_path)
|
|
388
|
+
branch = result.stdout.strip()
|
|
389
|
+
if not branch:
|
|
390
|
+
raise DoctorError("Gateway repository is in detached HEAD state.")
|
|
391
|
+
return branch
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _check_repository(project_config: ProjectConfig, branch: str) -> list[DoctorCheck]:
|
|
395
|
+
checks: list[DoctorCheck] = []
|
|
396
|
+
local_path = Path(project_config.local.repo_path)
|
|
397
|
+
|
|
398
|
+
if _uses_lfs(local_path):
|
|
399
|
+
checks.append(
|
|
400
|
+
_warning(
|
|
401
|
+
"Repository",
|
|
402
|
+
"Git LFS",
|
|
403
|
+
"This repository appears to use Git LFS.",
|
|
404
|
+
environment="local",
|
|
405
|
+
next_action="Git LFS object synchronization is not supported in v0.1.",
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
checks.append(
|
|
410
|
+
_ok(
|
|
411
|
+
"Repository",
|
|
412
|
+
"Git LFS",
|
|
413
|
+
"Git LFS was not detected.",
|
|
414
|
+
environment="local",
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if _uses_submodules(local_path):
|
|
419
|
+
checks.append(
|
|
420
|
+
_warning(
|
|
421
|
+
"Repository",
|
|
422
|
+
"submodules",
|
|
423
|
+
"This repository uses Git submodules.",
|
|
424
|
+
environment="local",
|
|
425
|
+
next_action="Register each submodule as a separate git-ssh-sync project.",
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
checks.append(
|
|
430
|
+
_ok(
|
|
431
|
+
"Repository",
|
|
432
|
+
"submodules",
|
|
433
|
+
"Git submodules were not detected.",
|
|
434
|
+
environment="local",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
dev_repo_url = _ssh_repo_url(
|
|
439
|
+
host=project_config.dev.host,
|
|
440
|
+
user=project_config.dev.user,
|
|
441
|
+
repo_path=project_config.dev.work_path,
|
|
442
|
+
)
|
|
443
|
+
try:
|
|
444
|
+
git.fetch(
|
|
445
|
+
dev_repo_url,
|
|
446
|
+
[f"refs/heads/{branch}:refs/remotes/dev/{branch}"],
|
|
447
|
+
cwd=local_path,
|
|
448
|
+
)
|
|
449
|
+
except CommandExecutionError as error:
|
|
450
|
+
checks.append(
|
|
451
|
+
_error(
|
|
452
|
+
"Repository",
|
|
453
|
+
"dev branch fetch",
|
|
454
|
+
f"Could not fetch dev/{branch}: {_command_error_message(error)}",
|
|
455
|
+
environment=error.environment,
|
|
456
|
+
next_action="Check the development work repository and its current branches.",
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
return checks
|
|
460
|
+
|
|
461
|
+
connected = git.run_git(
|
|
462
|
+
["merge-base", f"origin/{branch}", f"dev/{branch}"], cwd=local_path, check=False
|
|
463
|
+
)
|
|
464
|
+
if connected.returncode == 0:
|
|
465
|
+
checks.append(
|
|
466
|
+
_ok(
|
|
467
|
+
"Repository",
|
|
468
|
+
"history connection",
|
|
469
|
+
f"origin/{branch} and dev/{branch} share history.",
|
|
470
|
+
environment="local",
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
else:
|
|
474
|
+
checks.append(
|
|
475
|
+
_error(
|
|
476
|
+
"Repository",
|
|
477
|
+
"history connection",
|
|
478
|
+
f"origin/{branch} and dev/{branch} do not appear to share history.",
|
|
479
|
+
environment="local",
|
|
480
|
+
next_action="Verify that origin and the development repository were cloned from the same project.",
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
return checks
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def inspect_doctor(project: str) -> DoctorReport:
|
|
487
|
+
"""Run diagnosis for a configured project and return a report."""
|
|
488
|
+
app_config = load_config()
|
|
489
|
+
project_config = get_project(app_config, project)
|
|
490
|
+
return inspect_project_doctor(project, project_config)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def inspect_project_doctor(project: str, project_config: ProjectConfig) -> DoctorReport:
|
|
494
|
+
"""Run diagnosis using an already loaded project configuration."""
|
|
495
|
+
local_path = Path(project_config.local.repo_path)
|
|
496
|
+
checks: list[DoctorCheck] = []
|
|
497
|
+
|
|
498
|
+
command_checks = _check_local_commands()
|
|
499
|
+
checks.extend(command_checks)
|
|
500
|
+
git_available = any(
|
|
501
|
+
check.name == "git command" and check.status == "ok" for check in command_checks
|
|
502
|
+
)
|
|
503
|
+
ssh_available = any(
|
|
504
|
+
check.name == "ssh command" and check.status == "ok" for check in command_checks
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if not git_available:
|
|
508
|
+
return DoctorReport(
|
|
509
|
+
project=project,
|
|
510
|
+
branch="unknown",
|
|
511
|
+
origin_url=project_config.origin,
|
|
512
|
+
dev_host=project_config.dev.host,
|
|
513
|
+
dev_work_path=project_config.dev.work_path,
|
|
514
|
+
checks=tuple(checks),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
checks.extend(_check_gateway_repo(local_path))
|
|
518
|
+
|
|
519
|
+
branch = "unknown"
|
|
520
|
+
if local_path.exists():
|
|
521
|
+
branch = _current_local_branch(local_path)
|
|
522
|
+
checks.extend(_check_origin(local_path, branch))
|
|
523
|
+
if ssh_available:
|
|
524
|
+
checks.extend(_check_development(project_config))
|
|
525
|
+
checks.extend(_check_repository(project_config, branch))
|
|
526
|
+
|
|
527
|
+
return DoctorReport(
|
|
528
|
+
project=project,
|
|
529
|
+
branch=branch,
|
|
530
|
+
origin_url=project_config.origin,
|
|
531
|
+
dev_host=project_config.dev.host,
|
|
532
|
+
dev_work_path=project_config.dev.work_path,
|
|
533
|
+
checks=tuple(checks),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def print_doctor(report: DoctorReport) -> None:
|
|
538
|
+
"""Print a Rich-formatted doctor report."""
|
|
539
|
+
console.print(f"Doctor report for [bold]{escape(report.project)}[/bold]")
|
|
540
|
+
console.print(f"Branch: {escape(report.branch)}")
|
|
541
|
+
console.print(f"Origin: {escape(report.origin_url)}")
|
|
542
|
+
console.print(
|
|
543
|
+
f"Development: {escape(report.dev_host)} {escape(report.dev_work_path)}"
|
|
544
|
+
)
|
|
545
|
+
console.print()
|
|
546
|
+
|
|
547
|
+
table = Table(show_header=True)
|
|
548
|
+
table.add_column("Section", style="bold", no_wrap=True)
|
|
549
|
+
table.add_column("Status", no_wrap=True)
|
|
550
|
+
table.add_column("Check", no_wrap=True)
|
|
551
|
+
table.add_column("Environment", no_wrap=True)
|
|
552
|
+
table.add_column("Message", overflow="fold")
|
|
553
|
+
table.add_column("Next action", overflow="fold")
|
|
554
|
+
|
|
555
|
+
styles = {
|
|
556
|
+
"ok": "[green]ok[/green]",
|
|
557
|
+
"warning": "[yellow]warning[/yellow]",
|
|
558
|
+
"error": "[red]error[/red]",
|
|
559
|
+
}
|
|
560
|
+
for check in report.checks:
|
|
561
|
+
table.add_row(
|
|
562
|
+
escape(check.section),
|
|
563
|
+
styles[check.status],
|
|
564
|
+
escape(check.name),
|
|
565
|
+
escape(check.environment),
|
|
566
|
+
escape(check.message),
|
|
567
|
+
escape(check.next_action or ""),
|
|
568
|
+
)
|
|
569
|
+
console.print(table)
|
|
570
|
+
|
|
571
|
+
actionable = [
|
|
572
|
+
check for check in report.checks if check.status != "ok" and check.next_action
|
|
573
|
+
]
|
|
574
|
+
if actionable:
|
|
575
|
+
console.print()
|
|
576
|
+
console.print("[bold]Actions[/bold]")
|
|
577
|
+
for check in actionable:
|
|
578
|
+
console.print(
|
|
579
|
+
f"- {escape(check.section)} / {escape(check.name)}: {escape(check.next_action or '')}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def doctor_project(project: str) -> None:
|
|
584
|
+
"""Run and print diagnosis for a configured project."""
|
|
585
|
+
report = inspect_doctor(project)
|
|
586
|
+
print_doctor(report)
|
|
587
|
+
if report.has_errors:
|
|
588
|
+
raise DoctorError("Doctor found errors. See diagnostics above.")
|
git_ssh_sync/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared error types for command execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_command(command: Sequence[str]) -> str:
|
|
12
|
+
"""Return a shell-readable command string for logs and errors."""
|
|
13
|
+
return shlex.join(str(part) for part in command)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CommandExecutionError(RuntimeError):
|
|
18
|
+
"""Raised when a local or remote command exits with a non-zero status."""
|
|
19
|
+
|
|
20
|
+
environment: str
|
|
21
|
+
command: tuple[str, ...]
|
|
22
|
+
returncode: int
|
|
23
|
+
cwd: Path | None = None
|
|
24
|
+
stdout: str = ""
|
|
25
|
+
stderr: str = ""
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
location = f" in {self.cwd}" if self.cwd is not None else ""
|
|
29
|
+
message = (
|
|
30
|
+
f"[{self.environment}] command failed with exit code {self.returncode}"
|
|
31
|
+
f"{location}: {format_command(self.command)}"
|
|
32
|
+
)
|
|
33
|
+
if self.stderr:
|
|
34
|
+
return f"{message}\nstderr: {self.stderr.rstrip()}"
|
|
35
|
+
if self.stdout:
|
|
36
|
+
return f"{message}\nstdout: {self.stdout.rstrip()}"
|
|
37
|
+
return message
|