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/__init__.py
ADDED
git_ssh_sync/branch.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Branch state inspection workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from git_ssh_sync import git, ssh
|
|
12
|
+
from git_ssh_sync.config import ProjectConfig, get_project, load_config
|
|
13
|
+
from git_ssh_sync.console import console
|
|
14
|
+
from git_ssh_sync.status import _ssh_repo_url
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BranchError(RuntimeError):
|
|
18
|
+
"""Raised when branch state cannot be inspected."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class BranchRow:
|
|
23
|
+
"""Single branch status row."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
in_origin: bool
|
|
27
|
+
in_cache: bool
|
|
28
|
+
in_work: bool
|
|
29
|
+
is_current: bool
|
|
30
|
+
origin_ahead: int | None
|
|
31
|
+
work_ahead: int | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class BranchReport:
|
|
36
|
+
"""Collected branch status for a configured project."""
|
|
37
|
+
|
|
38
|
+
project: str
|
|
39
|
+
current_branch: str
|
|
40
|
+
rows: tuple[BranchRow, ...]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clean_output(value: str) -> str:
|
|
44
|
+
return value.strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _split_lines(output: str) -> set[str]:
|
|
48
|
+
return {line.strip() for line in output.splitlines() if line.strip()}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _branch_names_from_refs(output: str, prefix: str) -> set[str]:
|
|
52
|
+
names: set[str] = set()
|
|
53
|
+
for line in output.splitlines():
|
|
54
|
+
ref = line.strip()
|
|
55
|
+
if ref.startswith(prefix):
|
|
56
|
+
names.add(ref.removeprefix(prefix))
|
|
57
|
+
return names
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _local_origin_branches(local_path: Path) -> set[str]:
|
|
61
|
+
result = git.run_git(
|
|
62
|
+
["for-each-ref", "--format=%(refname)", "refs/remotes/origin"],
|
|
63
|
+
cwd=local_path,
|
|
64
|
+
)
|
|
65
|
+
return _branch_names_from_refs(result.stdout, "refs/remotes/origin/") - {"HEAD"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _remote_cache_branches(project_config: ProjectConfig) -> set[str]:
|
|
69
|
+
result = ssh.run_remote_git(
|
|
70
|
+
project_config.dev.host,
|
|
71
|
+
project_config.dev.cache_path,
|
|
72
|
+
["for-each-ref", "--format=%(refname)", "refs/heads"],
|
|
73
|
+
user=project_config.dev.user,
|
|
74
|
+
)
|
|
75
|
+
return _branch_names_from_refs(result.stdout, "refs/heads/")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _remote_work_branches(project_config: ProjectConfig) -> set[str]:
|
|
79
|
+
result = ssh.run_remote_git(
|
|
80
|
+
project_config.dev.host,
|
|
81
|
+
project_config.dev.work_path,
|
|
82
|
+
["for-each-ref", "--format=%(refname)", "refs/heads"],
|
|
83
|
+
user=project_config.dev.user,
|
|
84
|
+
)
|
|
85
|
+
return _branch_names_from_refs(result.stdout, "refs/heads/")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _remote_current_branch(project_config: ProjectConfig) -> str:
|
|
89
|
+
result = ssh.run_remote_git(
|
|
90
|
+
project_config.dev.host,
|
|
91
|
+
project_config.dev.work_path,
|
|
92
|
+
["branch", "--show-current"],
|
|
93
|
+
user=project_config.dev.user,
|
|
94
|
+
)
|
|
95
|
+
branch = _clean_output(result.stdout)
|
|
96
|
+
if not branch:
|
|
97
|
+
raise BranchError("Development work repository is in detached HEAD state.")
|
|
98
|
+
return branch
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _split_ahead_counts(output: str) -> tuple[int, int]:
|
|
102
|
+
parts = output.split()
|
|
103
|
+
if len(parts) != 2:
|
|
104
|
+
raise BranchError(f"Unexpected rev-list output: {output.strip()}")
|
|
105
|
+
return int(parts[0]), int(parts[1])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ahead_counts(local_path: Path, branch: str) -> tuple[int | None, int | None]:
|
|
109
|
+
result = git.rev_list(
|
|
110
|
+
["--left-right", "--count", f"origin/{branch}...dev/{branch}"],
|
|
111
|
+
cwd=local_path,
|
|
112
|
+
)
|
|
113
|
+
return _split_ahead_counts(result.stdout)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def inspect_branch(project: str) -> BranchReport:
|
|
117
|
+
"""Inspect branch state for a configured project."""
|
|
118
|
+
app_config = load_config()
|
|
119
|
+
project_config = get_project(app_config, project)
|
|
120
|
+
return inspect_project_branch(project, project_config)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def inspect_project_branch(project: str, project_config: ProjectConfig) -> BranchReport:
|
|
124
|
+
"""Inspect branch state using an already loaded project configuration."""
|
|
125
|
+
local_path = Path(project_config.local.repo_path)
|
|
126
|
+
if not local_path.exists():
|
|
127
|
+
raise BranchError(f"[local] gateway repository does not exist: {local_path}")
|
|
128
|
+
|
|
129
|
+
git.fetch("origin", cwd=local_path)
|
|
130
|
+
current_branch = _remote_current_branch(project_config)
|
|
131
|
+
origin_branches = _local_origin_branches(local_path)
|
|
132
|
+
cache_branches = _remote_cache_branches(project_config)
|
|
133
|
+
work_branches = _remote_work_branches(project_config)
|
|
134
|
+
|
|
135
|
+
dev_repo_url = _ssh_repo_url(
|
|
136
|
+
host=project_config.dev.host,
|
|
137
|
+
user=project_config.dev.user,
|
|
138
|
+
repo_path=project_config.dev.work_path,
|
|
139
|
+
)
|
|
140
|
+
for branch in sorted(origin_branches & work_branches):
|
|
141
|
+
git.fetch(
|
|
142
|
+
dev_repo_url,
|
|
143
|
+
[f"refs/heads/{branch}:refs/remotes/dev/{branch}"],
|
|
144
|
+
cwd=local_path,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
rows: list[BranchRow] = []
|
|
148
|
+
for branch in sorted(origin_branches | cache_branches | work_branches):
|
|
149
|
+
origin_ahead: int | None = None
|
|
150
|
+
work_ahead: int | None = None
|
|
151
|
+
if branch in origin_branches and branch in work_branches:
|
|
152
|
+
origin_ahead, work_ahead = _ahead_counts(local_path, branch)
|
|
153
|
+
rows.append(
|
|
154
|
+
BranchRow(
|
|
155
|
+
name=branch,
|
|
156
|
+
in_origin=branch in origin_branches,
|
|
157
|
+
in_cache=branch in cache_branches,
|
|
158
|
+
in_work=branch in work_branches,
|
|
159
|
+
is_current=branch == current_branch,
|
|
160
|
+
origin_ahead=origin_ahead,
|
|
161
|
+
work_ahead=work_ahead,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return BranchReport(project=project, current_branch=current_branch, rows=tuple(rows))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _mark(value: bool) -> str:
|
|
168
|
+
return "yes" if value else "-"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _ahead(value: int | None) -> str:
|
|
172
|
+
return "-" if value is None else str(value)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def print_branch(report: BranchReport) -> None:
|
|
176
|
+
"""Print a Rich-formatted branch report."""
|
|
177
|
+
console.print(f"Branches for [bold]{escape(report.project)}[/bold]")
|
|
178
|
+
console.print(f"Current branch: {escape(report.current_branch)}")
|
|
179
|
+
console.print()
|
|
180
|
+
|
|
181
|
+
table = Table(show_header=True)
|
|
182
|
+
table.add_column("Branch", style="bold")
|
|
183
|
+
table.add_column("Current", no_wrap=True)
|
|
184
|
+
table.add_column("Origin", no_wrap=True)
|
|
185
|
+
table.add_column("Dev cache", no_wrap=True)
|
|
186
|
+
table.add_column("Work repo", no_wrap=True)
|
|
187
|
+
table.add_column("Origin ahead", justify="right", no_wrap=True)
|
|
188
|
+
table.add_column("Work ahead", justify="right", no_wrap=True)
|
|
189
|
+
|
|
190
|
+
for row in report.rows:
|
|
191
|
+
table.add_row(
|
|
192
|
+
escape(row.name),
|
|
193
|
+
"*" if row.is_current else "",
|
|
194
|
+
_mark(row.in_origin),
|
|
195
|
+
_mark(row.in_cache),
|
|
196
|
+
_mark(row.in_work),
|
|
197
|
+
_ahead(row.origin_ahead),
|
|
198
|
+
_ahead(row.work_ahead),
|
|
199
|
+
)
|
|
200
|
+
console.print(table)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def branch_project(project: str) -> None:
|
|
204
|
+
"""Inspect and print branch state for a configured project."""
|
|
205
|
+
print_branch(inspect_branch(project))
|
git_ssh_sync/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Command line interface for git-ssh-sync."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
|
|
8
|
+
from git_ssh_sync import __version__
|
|
9
|
+
from git_ssh_sync.branch import BranchError, branch_project
|
|
10
|
+
from git_ssh_sync.clone import CloneError, clone_project
|
|
11
|
+
from git_ssh_sync.config import (
|
|
12
|
+
ConfigError,
|
|
13
|
+
ProjectAlreadyExistsError,
|
|
14
|
+
default_config_path,
|
|
15
|
+
init_project,
|
|
16
|
+
)
|
|
17
|
+
from git_ssh_sync.console import console
|
|
18
|
+
from git_ssh_sync.doctor import DoctorError, doctor_project
|
|
19
|
+
from git_ssh_sync.errors import CommandExecutionError
|
|
20
|
+
from git_ssh_sync.status import StatusError, status_project
|
|
21
|
+
from git_ssh_sync.sync import SyncError, checkout_project, pull_project, push_project
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="git-ssh-sync",
|
|
25
|
+
help="Sync Git commits through a local machine over SSH.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _version_callback(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
console.print(f"git-ssh-sync {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def callback(
|
|
38
|
+
version: Annotated[
|
|
39
|
+
bool,
|
|
40
|
+
typer.Option(
|
|
41
|
+
"--version",
|
|
42
|
+
callback=_version_callback,
|
|
43
|
+
is_eager=True,
|
|
44
|
+
help="Show the application version and exit.",
|
|
45
|
+
),
|
|
46
|
+
] = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Sync Git commits through a local machine over SSH."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _not_implemented(command: str, project: str | None = None) -> None:
|
|
52
|
+
target = f" for project '{project}'" if project else ""
|
|
53
|
+
console.print(
|
|
54
|
+
f"[yellow]{command}[/yellow]{target} is defined, but the sync implementation is not available yet."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("init")
|
|
59
|
+
def init_command(
|
|
60
|
+
project: Annotated[str, typer.Argument(help="Project name to register.")],
|
|
61
|
+
origin: Annotated[
|
|
62
|
+
str | None,
|
|
63
|
+
typer.Option(
|
|
64
|
+
"--origin", help="Origin Git URL, such as git@github.com:org/repo.git."
|
|
65
|
+
),
|
|
66
|
+
] = None,
|
|
67
|
+
dev_host: Annotated[
|
|
68
|
+
str | None,
|
|
69
|
+
typer.Option("--dev-host", help="Development environment SSH host."),
|
|
70
|
+
] = None,
|
|
71
|
+
dev_user: Annotated[
|
|
72
|
+
str | None,
|
|
73
|
+
typer.Option("--dev-user", help="Development environment SSH user."),
|
|
74
|
+
] = None,
|
|
75
|
+
dev_path: Annotated[
|
|
76
|
+
str | None,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--dev-path", help="Development environment work repository path."
|
|
79
|
+
),
|
|
80
|
+
] = None,
|
|
81
|
+
force: Annotated[
|
|
82
|
+
bool,
|
|
83
|
+
typer.Option("--force", help="Overwrite an existing project configuration."),
|
|
84
|
+
] = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Create a project configuration."""
|
|
87
|
+
try:
|
|
88
|
+
project_config = init_project(
|
|
89
|
+
project,
|
|
90
|
+
origin=origin,
|
|
91
|
+
dev_host=dev_host,
|
|
92
|
+
dev_user=dev_user,
|
|
93
|
+
dev_work_path=dev_path,
|
|
94
|
+
force=force,
|
|
95
|
+
)
|
|
96
|
+
except ProjectAlreadyExistsError as error:
|
|
97
|
+
console.print(f"[red]{error}[/red]")
|
|
98
|
+
raise typer.Exit(code=1) from error
|
|
99
|
+
except ConfigError as error:
|
|
100
|
+
console.print(f"[red]{error}[/red]")
|
|
101
|
+
raise typer.Exit(code=1) from error
|
|
102
|
+
|
|
103
|
+
console.print(f"Project '{project}' saved to {default_config_path()}")
|
|
104
|
+
console.print(f"origin: {project_config.origin}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("clone")
|
|
108
|
+
def clone_command(
|
|
109
|
+
project: Annotated[str, typer.Argument(help="Project name to clone.")],
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Clone the project locally and initialize the development environment."""
|
|
112
|
+
try:
|
|
113
|
+
clone_project(project)
|
|
114
|
+
except (ConfigError, CloneError, CommandExecutionError) as error:
|
|
115
|
+
console.print(f"[red]{escape(str(error))}[/red]")
|
|
116
|
+
raise typer.Exit(code=1) from error
|
|
117
|
+
|
|
118
|
+
console.print(f"Project '{project}' cloned.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("status")
|
|
122
|
+
def status_command(
|
|
123
|
+
project: Annotated[str, typer.Argument(help="Project name to inspect.")],
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Show synchronization state for origin, gateway, and development repositories."""
|
|
126
|
+
try:
|
|
127
|
+
status_project(project)
|
|
128
|
+
except (ConfigError, StatusError, CommandExecutionError) as error:
|
|
129
|
+
console.print(f"[red]{escape(str(error))}[/red]")
|
|
130
|
+
raise typer.Exit(code=1) from error
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command("branch")
|
|
134
|
+
def branch_command(
|
|
135
|
+
project: Annotated[str, typer.Argument(help="Project name to inspect.")],
|
|
136
|
+
) -> None:
|
|
137
|
+
"""List branch state across origin, development cache, and work repo."""
|
|
138
|
+
try:
|
|
139
|
+
branch_project(project)
|
|
140
|
+
except (ConfigError, BranchError, CommandExecutionError) as error:
|
|
141
|
+
console.print(f"[red]{escape(str(error))}[/red]")
|
|
142
|
+
raise typer.Exit(code=1) from error
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command("pull")
|
|
146
|
+
def pull_command(
|
|
147
|
+
project: Annotated[str, typer.Argument(help="Project name to pull.")],
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Fetch origin changes and fast-forward the current development branch."""
|
|
150
|
+
try:
|
|
151
|
+
pull_project(project)
|
|
152
|
+
except (ConfigError, SyncError, CommandExecutionError) as error:
|
|
153
|
+
console.print(f"[red]{escape(str(error))}[/red]")
|
|
154
|
+
raise typer.Exit(code=1) from error
|
|
155
|
+
|
|
156
|
+
console.print(f"Project '{project}' pulled.")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command("push")
|
|
160
|
+
def push_command(
|
|
161
|
+
project: Annotated[str, typer.Argument(help="Project name to push.")],
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Push current development branch commits to origin when it is safe to do so."""
|
|
164
|
+
try:
|
|
165
|
+
push_project(project)
|
|
166
|
+
except (ConfigError, SyncError, CommandExecutionError) as error:
|
|
167
|
+
console.print(f"[red]{escape(str(error))}[/red]")
|
|
168
|
+
raise typer.Exit(code=1) from error
|
|
169
|
+
|
|
170
|
+
console.print(f"Project '{project}' pushed.")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command("checkout")
|
|
174
|
+
def checkout_command(
|
|
175
|
+
project: Annotated[str, typer.Argument(help="Project name to update.")],
|
|
176
|
+
branch: Annotated[
|
|
177
|
+
str | None,
|
|
178
|
+
typer.Argument(help="Branch to check out in the development repository."),
|
|
179
|
+
] = None,
|
|
180
|
+
create_branch: Annotated[
|
|
181
|
+
str | None,
|
|
182
|
+
typer.Option(
|
|
183
|
+
"-b",
|
|
184
|
+
"--create-branch",
|
|
185
|
+
help="Create and check out a new branch.",
|
|
186
|
+
),
|
|
187
|
+
] = None,
|
|
188
|
+
base_branch: Annotated[
|
|
189
|
+
str | None,
|
|
190
|
+
typer.Option("--base", help="Create the branch from this branch."),
|
|
191
|
+
] = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Switch the development repository to a branch."""
|
|
194
|
+
target_branch = create_branch or branch
|
|
195
|
+
if target_branch is None:
|
|
196
|
+
console.print("[red]Specify a branch or -b <branch>.[/red]")
|
|
197
|
+
raise typer.Exit(code=2)
|
|
198
|
+
if base_branch is not None and create_branch is None:
|
|
199
|
+
console.print("[red]--base can only be used with -b/--create-branch.[/red]")
|
|
200
|
+
raise typer.Exit(code=2)
|
|
201
|
+
try:
|
|
202
|
+
checkout_project(
|
|
203
|
+
project,
|
|
204
|
+
target_branch,
|
|
205
|
+
create=create_branch is not None,
|
|
206
|
+
base_branch=base_branch,
|
|
207
|
+
)
|
|
208
|
+
except (ConfigError, SyncError, CommandExecutionError) as error:
|
|
209
|
+
console.print(f"[red]{escape(str(error))}[/red]")
|
|
210
|
+
raise typer.Exit(code=1) from error
|
|
211
|
+
|
|
212
|
+
console.print(f"Project '{project}' checked out {target_branch}.")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command("doctor")
|
|
216
|
+
def doctor_command(
|
|
217
|
+
project: Annotated[str, typer.Argument(help="Project name to diagnose.")],
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Check local, SSH, Git, and repository layout prerequisites."""
|
|
220
|
+
try:
|
|
221
|
+
doctor_project(project)
|
|
222
|
+
except (ConfigError, DoctorError, CommandExecutionError) as error:
|
|
223
|
+
console.print(f"[red]{escape(str(error))}[/red]")
|
|
224
|
+
raise typer.Exit(code=1) from error
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main() -> None:
|
|
228
|
+
app()
|
git_ssh_sync/clone.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Project clone workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from git_ssh_sync import git, ssh
|
|
9
|
+
from git_ssh_sync.config import get_project, load_config
|
|
10
|
+
from git_ssh_sync.errors import CommandExecutionError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CloneError(RuntimeError):
|
|
14
|
+
"""Raised when the clone workflow would overwrite existing data."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cache_url(*, host: str, user: str, cache_path: str) -> str:
|
|
18
|
+
quoted_path = quote(cache_path, safe="/~")
|
|
19
|
+
return f"ssh://{user}@{host}{quoted_path}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ensure_local_missing(path: Path) -> None:
|
|
23
|
+
if path.exists():
|
|
24
|
+
raise CloneError(f"[local] path already exists: {path}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_remote_missing(*, host: str, user: str, path: str) -> None:
|
|
28
|
+
result = ssh.run_ssh(host, ["test", "-e", path], user=user, check=False)
|
|
29
|
+
if result.returncode == 0:
|
|
30
|
+
raise CloneError(f"[{result.environment}] path already exists: {path}")
|
|
31
|
+
if result.returncode == 1:
|
|
32
|
+
return
|
|
33
|
+
raise CommandExecutionError(
|
|
34
|
+
environment=result.environment,
|
|
35
|
+
command=result.command,
|
|
36
|
+
returncode=result.returncode,
|
|
37
|
+
cwd=result.cwd,
|
|
38
|
+
stdout=result.stdout,
|
|
39
|
+
stderr=result.stderr,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _remote_parent(path: str) -> str:
|
|
44
|
+
return str(Path(path).parent)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _local_current_branch(local_path: Path) -> str:
|
|
48
|
+
result = git.run_git(["branch", "--show-current"], cwd=local_path)
|
|
49
|
+
branch = result.stdout.strip()
|
|
50
|
+
if not branch:
|
|
51
|
+
raise CloneError("Could not determine the cloned repository's current branch.")
|
|
52
|
+
return branch
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clone_project(project: str) -> None:
|
|
56
|
+
"""Clone a configured project and initialize its development repositories."""
|
|
57
|
+
app_config = load_config()
|
|
58
|
+
project_config = get_project(app_config, project)
|
|
59
|
+
|
|
60
|
+
local_path = Path(project_config.local.repo_path)
|
|
61
|
+
dev_host = project_config.dev.host
|
|
62
|
+
dev_user = project_config.dev.user
|
|
63
|
+
cache_path = project_config.dev.cache_path
|
|
64
|
+
work_path = project_config.dev.work_path
|
|
65
|
+
|
|
66
|
+
_ensure_local_missing(local_path)
|
|
67
|
+
_ensure_remote_missing(host=dev_host, user=dev_user, path=cache_path)
|
|
68
|
+
_ensure_remote_missing(host=dev_host, user=dev_user, path=work_path)
|
|
69
|
+
|
|
70
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
git.run_git(["clone", project_config.origin, str(local_path)])
|
|
72
|
+
git.fetch("origin", cwd=local_path)
|
|
73
|
+
branch = _local_current_branch(local_path)
|
|
74
|
+
|
|
75
|
+
ssh.run_ssh(dev_host, ["mkdir", "-p", _remote_parent(cache_path)], user=dev_user)
|
|
76
|
+
ssh.run_ssh(dev_host, ["git", "init", "--bare", cache_path], user=dev_user)
|
|
77
|
+
|
|
78
|
+
remote_cache = _cache_url(host=dev_host, user=dev_user, cache_path=cache_path)
|
|
79
|
+
git.push(
|
|
80
|
+
remote_cache,
|
|
81
|
+
[f"refs/remotes/origin/{branch}:refs/heads/{branch}"],
|
|
82
|
+
cwd=local_path,
|
|
83
|
+
)
|
|
84
|
+
if project_config.options.sync_tags:
|
|
85
|
+
git.push(remote_cache, ["--tags"], cwd=local_path)
|
|
86
|
+
|
|
87
|
+
ssh.run_ssh(dev_host, ["mkdir", "-p", _remote_parent(work_path)], user=dev_user)
|
|
88
|
+
ssh.run_ssh(dev_host, ["git", "clone", cache_path, work_path], user=dev_user)
|
|
89
|
+
ssh.run_remote_git(
|
|
90
|
+
dev_host, work_path, ["remote", "rename", "origin", "gitsync"], user=dev_user
|
|
91
|
+
)
|
|
92
|
+
ssh.run_remote_git(dev_host, work_path, ["switch", branch], user=dev_user)
|