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.
@@ -0,0 +1,3 @@
1
+ """git-ssh-sync package."""
2
+
3
+ __version__ = "0.1.0"
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)