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/git.py ADDED
@@ -0,0 +1,186 @@
1
+ """Local Git command execution helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from git_ssh_sync.console import console
12
+ from git_ssh_sync.errors import CommandExecutionError, format_command
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CommandResult:
17
+ """Completed command details for callers that need user-facing output."""
18
+
19
+ environment: str
20
+ command: tuple[str, ...]
21
+ returncode: int
22
+ stdout: str
23
+ stderr: str
24
+ cwd: Path | None = None
25
+
26
+
27
+ def _merged_env(env: Mapping[str, str] | None) -> dict[str, str] | None:
28
+ if env is None:
29
+ return None
30
+ return {**os.environ, **env}
31
+
32
+
33
+ def _run_command(
34
+ command: Sequence[str],
35
+ *,
36
+ environment: str,
37
+ cwd: str | Path | None = None,
38
+ env: Mapping[str, str] | None = None,
39
+ verbose: bool = False,
40
+ check: bool = True,
41
+ ) -> CommandResult:
42
+ cwd_path = Path(cwd) if cwd is not None else None
43
+ command_tuple = tuple(str(part) for part in command)
44
+ if verbose:
45
+ console.print(f"$ {format_command(command_tuple)}")
46
+
47
+ completed = subprocess.run(
48
+ list(command_tuple),
49
+ cwd=cwd_path,
50
+ env=_merged_env(env),
51
+ capture_output=True,
52
+ text=True,
53
+ check=False,
54
+ )
55
+ result = CommandResult(
56
+ environment=environment,
57
+ command=command_tuple,
58
+ returncode=completed.returncode,
59
+ stdout=completed.stdout,
60
+ stderr=completed.stderr,
61
+ cwd=cwd_path,
62
+ )
63
+ if check and completed.returncode != 0:
64
+ raise CommandExecutionError(
65
+ environment=environment,
66
+ command=command_tuple,
67
+ returncode=completed.returncode,
68
+ cwd=cwd_path,
69
+ stdout=completed.stdout,
70
+ stderr=completed.stderr,
71
+ )
72
+ return result
73
+
74
+
75
+ def run_git(
76
+ args: Sequence[str],
77
+ *,
78
+ cwd: str | Path | None = None,
79
+ env: Mapping[str, str] | None = None,
80
+ verbose: bool = False,
81
+ check: bool = True,
82
+ ) -> CommandResult:
83
+ """Run a local git command using argv arguments."""
84
+ return _run_command(
85
+ ["git", *args],
86
+ environment="local",
87
+ cwd=cwd,
88
+ env=env,
89
+ verbose=verbose,
90
+ check=check,
91
+ )
92
+
93
+
94
+ def fetch(
95
+ remote: str = "origin",
96
+ refspecs: Sequence[str] = (),
97
+ *,
98
+ cwd: str | Path | None = None,
99
+ env: Mapping[str, str] | None = None,
100
+ verbose: bool = False,
101
+ ) -> CommandResult:
102
+ """Run `git fetch`."""
103
+ return run_git(["fetch", remote, *refspecs], cwd=cwd, env=env, verbose=verbose)
104
+
105
+
106
+ def push(
107
+ remote: str = "origin",
108
+ refspecs: Sequence[str] = (),
109
+ *,
110
+ cwd: str | Path | None = None,
111
+ env: Mapping[str, str] | None = None,
112
+ verbose: bool = False,
113
+ ) -> CommandResult:
114
+ """Run `git push`."""
115
+ return run_git(["push", remote, *refspecs], cwd=cwd, env=env, verbose=verbose)
116
+
117
+
118
+ def rev_parse(
119
+ revisions: Sequence[str],
120
+ *,
121
+ cwd: str | Path | None = None,
122
+ env: Mapping[str, str] | None = None,
123
+ verbose: bool = False,
124
+ check: bool = True,
125
+ ) -> CommandResult:
126
+ """Run `git rev-parse`."""
127
+ return run_git(
128
+ ["rev-parse", *revisions], cwd=cwd, env=env, verbose=verbose, check=check
129
+ )
130
+
131
+
132
+ def log_oneline(
133
+ revision: str = "HEAD",
134
+ *,
135
+ cwd: str | Path | None = None,
136
+ env: Mapping[str, str] | None = None,
137
+ verbose: bool = False,
138
+ ) -> CommandResult:
139
+ """Run `git log -1 --format=%h %s` for a revision."""
140
+ return run_git(
141
+ ["log", "-1", "--format=%h %s", revision], cwd=cwd, env=env, verbose=verbose
142
+ )
143
+
144
+
145
+ def status_porcelain(
146
+ *,
147
+ cwd: str | Path | None = None,
148
+ env: Mapping[str, str] | None = None,
149
+ verbose: bool = False,
150
+ ) -> CommandResult:
151
+ """Run `git status --porcelain`."""
152
+ return run_git(["status", "--porcelain"], cwd=cwd, env=env, verbose=verbose)
153
+
154
+
155
+ def merge_base(
156
+ left: str,
157
+ right: str,
158
+ *,
159
+ cwd: str | Path | None = None,
160
+ env: Mapping[str, str] | None = None,
161
+ verbose: bool = False,
162
+ ) -> CommandResult:
163
+ """Run `git merge-base`."""
164
+ return run_git(["merge-base", left, right], cwd=cwd, env=env, verbose=verbose)
165
+
166
+
167
+ def rev_list(
168
+ revisions: Sequence[str],
169
+ *,
170
+ cwd: str | Path | None = None,
171
+ env: Mapping[str, str] | None = None,
172
+ verbose: bool = False,
173
+ ) -> CommandResult:
174
+ """Run `git rev-list`."""
175
+ return run_git(["rev-list", *revisions], cwd=cwd, env=env, verbose=verbose)
176
+
177
+
178
+ def remote(
179
+ args: Sequence[str] = (),
180
+ *,
181
+ cwd: str | Path | None = None,
182
+ env: Mapping[str, str] | None = None,
183
+ verbose: bool = False,
184
+ ) -> CommandResult:
185
+ """Run `git remote`."""
186
+ return run_git(["remote", *args], cwd=cwd, env=env, verbose=verbose)
git_ssh_sync/ssh.py ADDED
@@ -0,0 +1,55 @@
1
+ """SSH command execution helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from collections.abc import Mapping, Sequence
7
+ from pathlib import Path
8
+
9
+ from git_ssh_sync.git import CommandResult, _run_command
10
+
11
+
12
+ def run_ssh(
13
+ host: str,
14
+ command: Sequence[str],
15
+ *,
16
+ user: str | None = None,
17
+ cwd: str | Path | None = None,
18
+ env: Mapping[str, str] | None = None,
19
+ verbose: bool = False,
20
+ check: bool = True,
21
+ ) -> CommandResult:
22
+ """Run a command on an SSH host."""
23
+ target = f"{user}@{host}" if user else host
24
+ remote_command = shlex.join(str(part) for part in command)
25
+ return _run_command(
26
+ ["ssh", target, remote_command],
27
+ environment=f"ssh:{target}",
28
+ cwd=cwd,
29
+ env=env,
30
+ verbose=verbose,
31
+ check=check,
32
+ )
33
+
34
+
35
+ def run_remote_git(
36
+ host: str,
37
+ repo_path: str | Path,
38
+ args: Sequence[str],
39
+ *,
40
+ user: str | None = None,
41
+ cwd: str | Path | None = None,
42
+ env: Mapping[str, str] | None = None,
43
+ verbose: bool = False,
44
+ check: bool = True,
45
+ ) -> CommandResult:
46
+ """Run `git -C <path> ...` on an SSH host."""
47
+ return run_ssh(
48
+ host,
49
+ ["git", "-C", str(repo_path), *args],
50
+ user=user,
51
+ cwd=cwd,
52
+ env=env,
53
+ verbose=verbose,
54
+ check=check,
55
+ )
git_ssh_sync/status.py ADDED
@@ -0,0 +1,228 @@
1
+ """Project status inspection workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from urllib.parse import quote
8
+
9
+ from rich.markup import escape
10
+ from rich.table import Table
11
+
12
+ from git_ssh_sync import git, ssh
13
+ from git_ssh_sync.config import ProjectConfig, get_project, load_config
14
+ from git_ssh_sync.console import console
15
+ from git_ssh_sync.errors import CommandExecutionError
16
+
17
+
18
+ class StatusError(RuntimeError):
19
+ """Raised when project status cannot be inspected."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class StatusReport:
24
+ """Collected synchronization status for a configured project."""
25
+
26
+ project: str
27
+ origin_url: str
28
+ branch: str
29
+ origin_head: str
30
+ dev_host: str
31
+ dev_work_path: str
32
+ dev_branch: str
33
+ dev_head: str
34
+ dev_working_tree_clean: bool
35
+ origin_ahead: int
36
+ dev_ahead: int
37
+ uses_lfs: bool
38
+ uses_submodules: bool
39
+
40
+
41
+ def _ssh_repo_url(*, host: str, user: str, repo_path: str) -> str:
42
+ quoted_path = quote(repo_path, safe="/~")
43
+ return f"ssh://{user}@{host}{quoted_path}"
44
+
45
+
46
+ def _clean_output(value: str) -> str:
47
+ return value.strip()
48
+
49
+
50
+ def _ensure_remote_work_repo(*, host: str, user: str, path: str) -> None:
51
+ result = ssh.run_ssh(host, ["test", "-d", path], user=user, check=False)
52
+ if result.returncode == 0:
53
+ return
54
+ if result.returncode == 1:
55
+ raise StatusError(
56
+ f"[{result.environment}] work repository does not exist: {path}"
57
+ )
58
+ raise CommandExecutionError(
59
+ environment=result.environment,
60
+ command=result.command,
61
+ returncode=result.returncode,
62
+ cwd=result.cwd,
63
+ stdout=result.stdout,
64
+ stderr=result.stderr,
65
+ )
66
+
67
+
68
+ def _split_ahead_counts(output: str) -> tuple[int, int]:
69
+ parts = output.split()
70
+ if len(parts) != 2:
71
+ raise StatusError(f"Unexpected rev-list output: {output.strip()}")
72
+ return int(parts[0]), int(parts[1])
73
+
74
+
75
+ def _uses_lfs(local_path: Path) -> bool:
76
+ result = git.run_git(["lfs", "ls-files"], cwd=local_path, check=False)
77
+ if result.returncode == 0 and result.stdout.strip():
78
+ return True
79
+
80
+ attributes_path = local_path / ".gitattributes"
81
+ if not attributes_path.exists():
82
+ return False
83
+ return "filter=lfs" in attributes_path.read_text(encoding="utf-8")
84
+
85
+
86
+ def _uses_submodules(local_path: Path) -> bool:
87
+ return (local_path / ".gitmodules").exists()
88
+
89
+
90
+ def inspect_status(project: str) -> StatusReport:
91
+ """Inspect configured origin and development repository status."""
92
+ app_config = load_config()
93
+ project_config = get_project(app_config, project)
94
+ return inspect_project_status(project, project_config)
95
+
96
+
97
+ def inspect_project_status(project: str, project_config: ProjectConfig) -> StatusReport:
98
+ """Inspect a project using an already loaded configuration."""
99
+ local_path = Path(project_config.local.repo_path)
100
+ dev_host = project_config.dev.host
101
+ dev_user = project_config.dev.user
102
+ dev_work_path = project_config.dev.work_path
103
+
104
+ if not local_path.exists():
105
+ raise StatusError(f"[local] gateway repository does not exist: {local_path}")
106
+
107
+ git.fetch("origin", cwd=local_path)
108
+ ssh.run_ssh(dev_host, ["true"], user=dev_user)
109
+ _ensure_remote_work_repo(host=dev_host, user=dev_user, path=dev_work_path)
110
+
111
+ dev_branch = _clean_output(
112
+ ssh.run_remote_git(
113
+ dev_host, dev_work_path, ["branch", "--show-current"], user=dev_user
114
+ ).stdout
115
+ )
116
+ if not dev_branch:
117
+ raise StatusError("Development work repository is in detached HEAD state.")
118
+ branch = dev_branch
119
+
120
+ dev_repo_url = _ssh_repo_url(host=dev_host, user=dev_user, repo_path=dev_work_path)
121
+ git.fetch(
122
+ dev_repo_url, [f"refs/heads/{branch}:refs/remotes/dev/{branch}"], cwd=local_path
123
+ )
124
+
125
+ origin_ref = f"origin/{branch}"
126
+ dev_ref = f"dev/{branch}"
127
+ origin_head = _clean_output(git.log_oneline(origin_ref, cwd=local_path).stdout)
128
+ dev_head = _clean_output(git.log_oneline(dev_ref, cwd=local_path).stdout)
129
+
130
+ remote_head = _clean_output(
131
+ ssh.run_remote_git(
132
+ dev_host, dev_work_path, ["log", "-1", "--format=%h %s"], user=dev_user
133
+ ).stdout
134
+ )
135
+ remote_status = ssh.run_remote_git(
136
+ dev_host, dev_work_path, ["status", "--porcelain"], user=dev_user
137
+ )
138
+ origin_ahead, dev_ahead = _split_ahead_counts(
139
+ git.rev_list(
140
+ ["--left-right", "--count", f"{origin_ref}...{dev_ref}"], cwd=local_path
141
+ ).stdout
142
+ )
143
+
144
+ return StatusReport(
145
+ project=project,
146
+ origin_url=project_config.origin,
147
+ branch=branch,
148
+ origin_head=origin_head,
149
+ dev_host=dev_host,
150
+ dev_work_path=dev_work_path,
151
+ dev_branch=dev_branch,
152
+ dev_head=remote_head or dev_head,
153
+ dev_working_tree_clean=not remote_status.stdout.strip(),
154
+ origin_ahead=origin_ahead,
155
+ dev_ahead=dev_ahead,
156
+ uses_lfs=_uses_lfs(local_path),
157
+ uses_submodules=_uses_submodules(local_path),
158
+ )
159
+
160
+
161
+ def _recommendation(report: StatusReport) -> str:
162
+ if not report.dev_working_tree_clean:
163
+ return "Commit or stash changes on the development environment."
164
+ if report.origin_ahead and report.dev_ahead:
165
+ return f"git-ssh-sync pull {report.project}, then resolve divergence on the development environment."
166
+ if report.origin_ahead:
167
+ return f"git-ssh-sync pull {report.project}"
168
+ if report.dev_ahead:
169
+ return f"git-ssh-sync push {report.project}"
170
+ return "No action needed."
171
+
172
+
173
+ def _state_lines(report: StatusReport) -> list[str]:
174
+ lines = [
175
+ f"dev is ahead of origin by {report.dev_ahead} commits",
176
+ f"origin is ahead of dev by {report.origin_ahead} commits",
177
+ ]
178
+ if not report.dev_working_tree_clean:
179
+ lines.append("development working tree is dirty")
180
+ return lines
181
+
182
+
183
+ def print_status(report: StatusReport) -> None:
184
+ """Print a Rich-formatted status report."""
185
+ table = Table(show_header=False, box=None, padding=(0, 2))
186
+ table.add_column("section", style="bold")
187
+ table.add_column("field")
188
+ table.add_column("value")
189
+
190
+ table.add_row("Project", "name", escape(report.project))
191
+ table.add_row("", "", "")
192
+ table.add_row("Origin", "url", escape(report.origin_url))
193
+ table.add_row("", "branch", escape(report.branch))
194
+ table.add_row("", "head", escape(report.origin_head))
195
+ table.add_row("", "", "")
196
+ table.add_row("Development", "host", escape(report.dev_host))
197
+ table.add_row("", "work path", escape(report.dev_work_path))
198
+ table.add_row("", "branch", escape(report.dev_branch))
199
+ table.add_row("", "head", escape(report.dev_head))
200
+ table.add_row(
201
+ "",
202
+ "working tree",
203
+ "clean" if report.dev_working_tree_clean else "[yellow]dirty[/yellow]",
204
+ )
205
+ table.add_row("", "", "")
206
+ for index, line in enumerate(_state_lines(report)):
207
+ table.add_row("State" if index == 0 else "", "", escape(line))
208
+ table.add_row("", "", "")
209
+ table.add_row("Recommendation", "", escape(_recommendation(report)))
210
+ console.print(table)
211
+
212
+ if report.uses_lfs or report.uses_submodules:
213
+ console.print()
214
+ if report.uses_lfs:
215
+ console.print("[yellow]This repository appears to use Git LFS.[/yellow]")
216
+ console.print("Git LFS object synchronization is not supported in v0.1.")
217
+ console.print(
218
+ "Normal Git commits may sync, but LFS file contents may be missing."
219
+ )
220
+ if report.uses_submodules:
221
+ console.print("[yellow]This repository uses Git submodules.[/yellow]")
222
+ console.print("Submodule synchronization is not supported in v0.1.")
223
+ console.print("Register each submodule as a separate git-ssh-sync project.")
224
+
225
+
226
+ def status_project(project: str) -> None:
227
+ """Inspect and print status for a configured project."""
228
+ print_status(inspect_status(project))