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 +1 -1
- git_ssh_sync/attach.py +486 -0
- git_ssh_sync/branch.py +7 -1
- git_ssh_sync/cli.py +214 -7
- git_ssh_sync/clone.py +42 -21
- git_ssh_sync/config.py +10 -1
- git_ssh_sync/dev.py +110 -0
- git_ssh_sync/doctor.py +291 -48
- git_ssh_sync/git.py +15 -0
- git_ssh_sync/logging_config.py +84 -0
- git_ssh_sync/ssh.py +207 -3
- git_ssh_sync/status.py +37 -14
- git_ssh_sync/sync.py +164 -8
- {git_ssh_sync-0.2.0.dist-info → git_ssh_sync-0.3.0.dist-info}/METADATA +150 -1
- git_ssh_sync-0.3.0.dist-info/RECORD +19 -0
- git_ssh_sync-0.2.0.dist-info/RECORD +0 -16
- {git_ssh_sync-0.2.0.dist-info → git_ssh_sync-0.3.0.dist-info}/WHEEL +0 -0
- {git_ssh_sync-0.2.0.dist-info → git_ssh_sync-0.3.0.dist-info}/entry_points.txt +0 -0
git_ssh_sync/__init__.py
CHANGED
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(
|
|
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:
|