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/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