git-ssh-sync 0.2.0__py3-none-any.whl → 0.3.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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """git-ssh-sync package."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
git_ssh_sync/attach.py ADDED
@@ -0,0 +1,486 @@
1
+ """Attach existing repositories to git-ssh-sync management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
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.logging_config import logger
18
+
19
+ CheckStatus = Literal["ok", "error"]
20
+ OperationKind = Literal[
21
+ "create_cache",
22
+ "seed_cache_branch",
23
+ "add_gitsync_remote",
24
+ "update_gitsync_remote",
25
+ ]
26
+
27
+
28
+ class AttachError(RuntimeError):
29
+ """Raised when existing repositories cannot be attached safely."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AttachCheck:
34
+ """Single attach preflight check."""
35
+
36
+ name: str
37
+ status: CheckStatus
38
+ message: str
39
+ next_action: str | None = None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class AttachOperation:
44
+ """Repair operation that can be applied after preflight."""
45
+
46
+ kind: OperationKind
47
+ description: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class AttachPlan:
52
+ """Preflight result for attaching a configured project."""
53
+
54
+ project: str
55
+ branch: str
56
+ checks: tuple[AttachCheck, ...]
57
+ operations: tuple[AttachOperation, ...]
58
+
59
+ @property
60
+ def has_errors(self) -> bool:
61
+ return any(check.status == "error" for check in self.checks)
62
+
63
+
64
+ def _ok(name: str, message: str) -> AttachCheck:
65
+ return AttachCheck(name=name, status="ok", message=message)
66
+
67
+
68
+ def _error(name: str, message: str, *, next_action: str) -> AttachCheck:
69
+ return AttachCheck(
70
+ name=name, status="error", message=message, next_action=next_action
71
+ )
72
+
73
+
74
+ def _command_error_message(result: git.CommandResult | CommandExecutionError) -> str:
75
+ detail = result.stderr.strip() or result.stdout.strip()
76
+ if detail:
77
+ return detail
78
+ return f"exit code {result.returncode}"
79
+
80
+
81
+ def _as_command_error(result: git.CommandResult) -> CommandExecutionError:
82
+ return CommandExecutionError(
83
+ environment=result.environment,
84
+ command=result.command,
85
+ returncode=result.returncode,
86
+ cwd=result.cwd,
87
+ stdout=result.stdout,
88
+ stderr=result.stderr,
89
+ )
90
+
91
+
92
+ def _local_current_branch(local_path: Path) -> str:
93
+ result = git.run_git(["branch", "--show-current"], cwd=local_path)
94
+ branch = result.stdout.strip()
95
+ if not branch:
96
+ raise AttachError("Gateway repository is in detached HEAD state.")
97
+ return branch
98
+
99
+
100
+ def _origin_branch_exists(local_path: Path, branch: str) -> bool:
101
+ result = git.run_git(
102
+ ["show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}"],
103
+ cwd=local_path,
104
+ check=False,
105
+ )
106
+ if result.returncode == 0:
107
+ return True
108
+ if result.returncode == 1:
109
+ return False
110
+ raise _as_command_error(result)
111
+
112
+
113
+ def _remote_path_exists(project_config: ProjectConfig, path: str) -> bool:
114
+ result = ssh.remote_path_exists(
115
+ project_config.dev.host,
116
+ path,
117
+ user=project_config.dev.user,
118
+ remote_os=project_config.dev.os,
119
+ path_type="directory",
120
+ )
121
+ if result.returncode == 0:
122
+ return True
123
+ if result.returncode == 1:
124
+ return False
125
+ raise _as_command_error(result)
126
+
127
+
128
+ def _remote_git_check(
129
+ project_config: ProjectConfig, repo_path: str, args: list[str]
130
+ ) -> git.CommandResult:
131
+ return ssh.run_remote_git(
132
+ project_config.dev.host,
133
+ repo_path,
134
+ args,
135
+ user=project_config.dev.user,
136
+ remote_os=project_config.dev.os,
137
+ check=False,
138
+ )
139
+
140
+
141
+ def inspect_attach(project: str) -> AttachPlan:
142
+ """Inspect existing repositories and return planned attach repairs."""
143
+ app_config = load_config()
144
+ project_config = get_project(app_config, project)
145
+ return inspect_project_attach(project, project_config)
146
+
147
+
148
+ def inspect_project_attach(project: str, project_config: ProjectConfig) -> AttachPlan:
149
+ """Inspect existing repositories using an already loaded project configuration."""
150
+ checks: list[AttachCheck] = []
151
+ operations: list[AttachOperation] = []
152
+ local_path = Path(project_config.local.repo_path)
153
+ branch = "unknown"
154
+
155
+ if not local_path.exists():
156
+ checks.append(
157
+ _error(
158
+ "gateway repo",
159
+ f"Gateway repository does not exist: {local_path}",
160
+ next_action="Clone the repository locally or update local.repo_path.",
161
+ )
162
+ )
163
+ return AttachPlan(project, branch, tuple(checks), tuple(operations))
164
+
165
+ local_git = git.run_git(["rev-parse", "--git-dir"], cwd=local_path, check=False)
166
+ if local_git.returncode != 0:
167
+ checks.append(
168
+ _error(
169
+ "gateway repo",
170
+ f"Gateway repository is not a git repository: {_command_error_message(local_git)}",
171
+ next_action=f"Inspect or replace {local_path}.",
172
+ )
173
+ )
174
+ return AttachPlan(project, branch, tuple(checks), tuple(operations))
175
+ checks.append(_ok("gateway repo", "Gateway repository exists."))
176
+
177
+ origin = git.remote(["get-url", "origin"], cwd=local_path, check=False)
178
+ if origin.returncode != 0:
179
+ checks.append(
180
+ _error(
181
+ "origin URL",
182
+ f"Could not read origin URL: {_command_error_message(origin)}",
183
+ next_action="Add or fix the gateway repository origin remote.",
184
+ )
185
+ )
186
+ elif origin.stdout.strip() != project_config.origin:
187
+ checks.append(
188
+ _error(
189
+ "origin URL",
190
+ f"Configured origin does not match gateway origin: {origin.stdout.strip()}",
191
+ next_action="Update the project configuration or gateway origin URL.",
192
+ )
193
+ )
194
+ else:
195
+ checks.append(_ok("origin URL", "Gateway origin matches configuration."))
196
+
197
+ branch = _local_current_branch(local_path)
198
+ checks.append(_ok("current branch", f"Gateway current branch: {branch}"))
199
+
200
+ git.fetch("origin", cwd=local_path)
201
+ if not _origin_branch_exists(local_path, branch):
202
+ checks.append(
203
+ _error(
204
+ "origin branch",
205
+ f"origin/{branch} does not exist.",
206
+ next_action=f"Push or fetch {branch} on origin before attaching.",
207
+ )
208
+ )
209
+ else:
210
+ checks.append(_ok("origin branch", f"origin/{branch} exists."))
211
+
212
+ if _remote_path_exists(project_config, project_config.dev.work_path):
213
+ checks.append(_ok("work repo", "Development work repository exists."))
214
+ else:
215
+ checks.append(
216
+ _error(
217
+ "work repo",
218
+ f"Development work repository does not exist: {project_config.dev.work_path}",
219
+ next_action="Clone the work repository on the development environment.",
220
+ )
221
+ )
222
+ return AttachPlan(project, branch, tuple(checks), tuple(operations))
223
+
224
+ work_git = _remote_git_check(
225
+ project_config,
226
+ project_config.dev.work_path,
227
+ ["rev-parse", "--is-inside-work-tree"],
228
+ )
229
+ if work_git.returncode != 0 or work_git.stdout.strip() != "true":
230
+ checks.append(
231
+ _error(
232
+ "work repo git",
233
+ f"Development work path is not a git work tree: {_command_error_message(work_git)}",
234
+ next_action="Inspect or reclone the development work repository.",
235
+ )
236
+ )
237
+ return AttachPlan(project, branch, tuple(checks), tuple(operations))
238
+ checks.append(_ok("work repo git", "Development work path is a git repository."))
239
+
240
+ work_branch = _remote_git_check(
241
+ project_config, project_config.dev.work_path, ["branch", "--show-current"]
242
+ )
243
+ if work_branch.returncode != 0 or not work_branch.stdout.strip():
244
+ checks.append(
245
+ _error(
246
+ "work repo branch",
247
+ "Development work repository is in detached HEAD state.",
248
+ next_action="Switch the development work repository to a branch.",
249
+ )
250
+ )
251
+ else:
252
+ checks.append(
253
+ _ok(
254
+ "work repo branch",
255
+ f"Development current branch: {work_branch.stdout.strip()}",
256
+ )
257
+ )
258
+
259
+ status = _remote_git_check(
260
+ project_config, project_config.dev.work_path, ["status", "--porcelain"]
261
+ )
262
+ if status.returncode != 0:
263
+ checks.append(
264
+ _error(
265
+ "work repo dirty",
266
+ f"Could not inspect development working tree: {_command_error_message(status)}",
267
+ next_action="Run git status on the development environment.",
268
+ )
269
+ )
270
+ elif status.stdout.strip():
271
+ checks.append(
272
+ _error(
273
+ "work repo dirty",
274
+ "Development working tree is dirty.",
275
+ next_action="Commit or stash changes on the development environment.",
276
+ )
277
+ )
278
+ else:
279
+ checks.append(_ok("work repo dirty", "Development working tree is clean."))
280
+
281
+ cache_exists = _remote_path_exists(project_config, project_config.dev.cache_path)
282
+ if not cache_exists:
283
+ checks.append(
284
+ _ok("bare cache repo", "Development cache repository is missing.")
285
+ )
286
+ operations.append(
287
+ AttachOperation(
288
+ "create_cache",
289
+ f"Create bare cache repository at {project_config.dev.cache_path}.",
290
+ )
291
+ )
292
+ operations.append(
293
+ AttachOperation(
294
+ "seed_cache_branch",
295
+ f"Push origin/{branch} to cache branch {branch}.",
296
+ )
297
+ )
298
+ else:
299
+ cache_bare = _remote_git_check(
300
+ project_config,
301
+ project_config.dev.cache_path,
302
+ ["rev-parse", "--is-bare-repository"],
303
+ )
304
+ if cache_bare.returncode != 0 or cache_bare.stdout.strip() != "true":
305
+ checks.append(
306
+ _error(
307
+ "bare cache repo",
308
+ f"Cache path is not a bare git repository: {_command_error_message(cache_bare)}",
309
+ next_action="Move the existing path or configure a bare cache repository path.",
310
+ )
311
+ )
312
+ else:
313
+ checks.append(_ok("bare cache repo", "Cache repository is bare."))
314
+ cache_branch = _remote_git_check(
315
+ project_config,
316
+ project_config.dev.cache_path,
317
+ ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
318
+ )
319
+ if cache_branch.returncode == 1:
320
+ operations.append(
321
+ AttachOperation(
322
+ "seed_cache_branch",
323
+ f"Push origin/{branch} to cache branch {branch}.",
324
+ )
325
+ )
326
+ elif cache_branch.returncode != 0:
327
+ raise _as_command_error(cache_branch)
328
+
329
+ gitsync = _remote_git_check(
330
+ project_config, project_config.dev.work_path, ["remote", "get-url", "gitsync"]
331
+ )
332
+ if (
333
+ gitsync.returncode == 0
334
+ and gitsync.stdout.strip() == project_config.dev.cache_path
335
+ ):
336
+ checks.append(_ok("gitsync remote", "gitsync remote matches cache path."))
337
+ elif gitsync.returncode == 0:
338
+ checks.append(
339
+ _ok(
340
+ "gitsync remote",
341
+ f"gitsync remote will be updated from {gitsync.stdout.strip()}.",
342
+ )
343
+ )
344
+ operations.append(
345
+ AttachOperation(
346
+ "update_gitsync_remote",
347
+ f"Set gitsync remote URL to {project_config.dev.cache_path}.",
348
+ )
349
+ )
350
+ else:
351
+ checks.append(_ok("gitsync remote", "gitsync remote is missing."))
352
+ operations.append(
353
+ AttachOperation(
354
+ "add_gitsync_remote",
355
+ f"Add gitsync remote pointing to {project_config.dev.cache_path}.",
356
+ )
357
+ )
358
+
359
+ return AttachPlan(project, branch, tuple(checks), tuple(operations))
360
+
361
+
362
+ def print_attach_plan(plan: AttachPlan) -> None:
363
+ """Print an attach preflight and repair plan."""
364
+ console.print(f"Attach plan for [bold]{escape(plan.project)}[/bold]")
365
+ console.print(f"Branch: {escape(plan.branch)}")
366
+ console.print()
367
+
368
+ table = Table(show_header=True)
369
+ table.add_column("Status", no_wrap=True)
370
+ table.add_column("Check", no_wrap=True)
371
+ table.add_column("Message", overflow="fold")
372
+ table.add_column("Next action", overflow="fold")
373
+
374
+ styles = {"ok": "[green]ok[/green]", "error": "[red]error[/red]"}
375
+ for check in plan.checks:
376
+ table.add_row(
377
+ styles[check.status],
378
+ escape(check.name),
379
+ escape(check.message),
380
+ escape(check.next_action or ""),
381
+ )
382
+ console.print(table)
383
+
384
+ console.print()
385
+ if plan.operations:
386
+ console.print("[bold]Planned operations[/bold]")
387
+ for operation in plan.operations:
388
+ console.print(f"- {escape(operation.description)}")
389
+ else:
390
+ console.print("No repair operations needed.")
391
+
392
+
393
+ def _apply_operation(
394
+ project_config: ProjectConfig,
395
+ local_path: Path,
396
+ branch: str,
397
+ operation: AttachOperation,
398
+ ) -> None:
399
+ if operation.kind == "create_cache":
400
+ ssh.remote_mkdir(
401
+ project_config.dev.host,
402
+ ssh.remote_parent(project_config.dev.cache_path, project_config.dev.os),
403
+ user=project_config.dev.user,
404
+ remote_os=project_config.dev.os,
405
+ )
406
+ ssh.run_remote_command(
407
+ project_config.dev.host,
408
+ ["git", "init", "--bare", project_config.dev.cache_path],
409
+ user=project_config.dev.user,
410
+ remote_os=project_config.dev.os,
411
+ )
412
+ return
413
+
414
+ if operation.kind == "seed_cache_branch":
415
+ cache_url = ssh.remote_git_url(
416
+ host=project_config.dev.host,
417
+ user=project_config.dev.user,
418
+ repo_path=project_config.dev.cache_path,
419
+ remote_os=project_config.dev.os,
420
+ )
421
+ git.push(
422
+ cache_url,
423
+ [f"refs/remotes/origin/{branch}:refs/heads/{branch}"],
424
+ cwd=local_path,
425
+ )
426
+ return
427
+
428
+ if operation.kind == "add_gitsync_remote":
429
+ ssh.run_remote_git(
430
+ project_config.dev.host,
431
+ project_config.dev.work_path,
432
+ ["remote", "add", "gitsync", project_config.dev.cache_path],
433
+ user=project_config.dev.user,
434
+ remote_os=project_config.dev.os,
435
+ )
436
+ return
437
+
438
+ if operation.kind == "update_gitsync_remote":
439
+ ssh.run_remote_git(
440
+ project_config.dev.host,
441
+ project_config.dev.work_path,
442
+ ["remote", "set-url", "gitsync", project_config.dev.cache_path],
443
+ user=project_config.dev.user,
444
+ remote_os=project_config.dev.os,
445
+ )
446
+
447
+
448
+ def _apply_plan(project_config: ProjectConfig, plan: AttachPlan) -> None:
449
+ local_path = Path(project_config.local.repo_path)
450
+ for operation in plan.operations:
451
+ logger.info(f"Applying attach operation: {operation.kind}")
452
+ _apply_operation(project_config, local_path, plan.branch, operation)
453
+
454
+
455
+ def attach_project(
456
+ project: str,
457
+ *,
458
+ yes: bool = False,
459
+ dry_run: bool = False,
460
+ confirm: Callable[[str], bool] | None = None,
461
+ ) -> None:
462
+ """Inspect and attach existing repositories to a configured project."""
463
+ app_config = load_config()
464
+ project_config = get_project(app_config, project)
465
+ plan = inspect_project_attach(project, project_config)
466
+ print_attach_plan(plan)
467
+
468
+ if plan.has_errors:
469
+ raise AttachError("Attach preflight failed. See diagnostics above.")
470
+ if dry_run or not plan.operations:
471
+ return
472
+ if not yes:
473
+ if confirm is None or not confirm(f"Apply attach operations for '{project}'?"):
474
+ raise AttachError("Aborted.")
475
+ _apply_plan(project_config, plan)
476
+
477
+
478
+ def repair_project(
479
+ project: str,
480
+ *,
481
+ yes: bool = False,
482
+ dry_run: bool = False,
483
+ confirm: Callable[[str], bool] | None = None,
484
+ ) -> None:
485
+ """Repair missing or mismatched attach wiring for a configured project."""
486
+ attach_project(project, yes=yes, dry_run=dry_run, confirm=confirm)
git_ssh_sync/branch.py CHANGED
@@ -71,6 +71,7 @@ def _remote_cache_branches(project_config: ProjectConfig) -> set[str]:
71
71
  project_config.dev.cache_path,
72
72
  ["for-each-ref", "--format=%(refname)", "refs/heads"],
73
73
  user=project_config.dev.user,
74
+ remote_os=project_config.dev.os,
74
75
  )
75
76
  return _branch_names_from_refs(result.stdout, "refs/heads/")
76
77
 
@@ -81,6 +82,7 @@ def _remote_work_branches(project_config: ProjectConfig) -> set[str]:
81
82
  project_config.dev.work_path,
82
83
  ["for-each-ref", "--format=%(refname)", "refs/heads"],
83
84
  user=project_config.dev.user,
85
+ remote_os=project_config.dev.os,
84
86
  )
85
87
  return _branch_names_from_refs(result.stdout, "refs/heads/")
86
88
 
@@ -91,6 +93,7 @@ def _remote_current_branch(project_config: ProjectConfig) -> str:
91
93
  project_config.dev.work_path,
92
94
  ["branch", "--show-current"],
93
95
  user=project_config.dev.user,
96
+ remote_os=project_config.dev.os,
94
97
  )
95
98
  branch = _clean_output(result.stdout)
96
99
  if not branch:
@@ -136,6 +139,7 @@ def inspect_project_branch(project: str, project_config: ProjectConfig) -> Branc
136
139
  host=project_config.dev.host,
137
140
  user=project_config.dev.user,
138
141
  repo_path=project_config.dev.work_path,
142
+ remote_os=project_config.dev.os,
139
143
  )
140
144
  for branch in sorted(origin_branches & work_branches):
141
145
  git.fetch(
@@ -161,7 +165,9 @@ def inspect_project_branch(project: str, project_config: ProjectConfig) -> Branc
161
165
  work_ahead=work_ahead,
162
166
  )
163
167
  )
164
- return BranchReport(project=project, current_branch=current_branch, rows=tuple(rows))
168
+ return BranchReport(
169
+ project=project, current_branch=current_branch, rows=tuple(rows)
170
+ )
165
171
 
166
172
 
167
173
  def _mark(value: bool) -> str: