git-ssh-sync 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: git-ssh-sync
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Sync Git commits through a local machine for development environments without direct GitHub or GitLab access.
5
5
  Requires-Dist: pydantic>=2.13.4
6
6
  Requires-Dist: pyyaml>=6.0.3
@@ -21,6 +21,8 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  `git-ssh-sync` is a CLI tool for synchronizing Git commits created in a development environment that cannot directly access GitHub/GitLab to external Git services via a local machine.
23
23
 
24
+ This tool is designed for niche environments where outbound network access is restricted, such as high-security enterprises and projects that only allow limited inbound communication (e.g., SSH, RDP).
25
+
24
26
  This is not a file synchronization tool. It synchronizes Git objects and branches. Source editing, building, testing, and committing are performed in the development environment, while communication with GitHub/GitLab is handled by the local machine.
25
27
 
26
28
  ## Prerequisites
@@ -113,6 +115,28 @@ git-ssh-sync init myproject \
113
115
  --force
114
116
  ```
115
117
 
118
+ You can inspect and maintain registered projects without opening the config file directly.
119
+
120
+ ```bash
121
+ # List registered projects
122
+ git-ssh-sync config list
123
+
124
+ # Show all settings for one project
125
+ git-ssh-sync config show myproject
126
+
127
+ # Update selected settings
128
+ git-ssh-sync config set myproject \
129
+ --origin git@github.com:example/myproject.git \
130
+ --dev-host devserver \
131
+ --dev-path /home/user/work/myproject
132
+
133
+ # Remove a project after confirmation
134
+ git-ssh-sync config remove myproject
135
+
136
+ # Remove a project without an interactive prompt
137
+ git-ssh-sync config remove myproject --yes
138
+ ```
139
+
116
140
  ## Initial Workflow
117
141
 
118
142
  For the first time, execute configuration, clone to the development environment, and diagnostics in order.
@@ -244,6 +268,12 @@ git-ssh-sync init myproject \
244
268
  --dev-user user \
245
269
  --dev-path /home/user/work/myproject
246
270
 
271
+ # List registered project settings
272
+ git-ssh-sync config list
273
+
274
+ # Show registered project settings
275
+ git-ssh-sync config show myproject
276
+
247
277
  # Initial clone
248
278
  git-ssh-sync clone myproject
249
279
 
@@ -277,6 +307,16 @@ To develop this repository itself, install dependencies using `uv sync`.
277
307
  uv sync
278
308
  ```
279
309
 
310
+ To install from TestPyPI:
311
+
312
+ ```bash
313
+ uv tool install \
314
+ --index-url https://test.pypi.org/simple/ \
315
+ --extra-index-url https://pypi.org/simple/ \
316
+ --index-strategy unsafe-best-match \
317
+ git-ssh-sync
318
+ ```
319
+
280
320
  To execute the CLI during development, you can run it via `uv run`.
281
321
 
282
322
  ```bash
@@ -10,6 +10,8 @@
10
10
 
11
11
  `git-ssh-sync` is a CLI tool for synchronizing Git commits created in a development environment that cannot directly access GitHub/GitLab to external Git services via a local machine.
12
12
 
13
+ This tool is designed for niche environments where outbound network access is restricted, such as high-security enterprises and projects that only allow limited inbound communication (e.g., SSH, RDP).
14
+
13
15
  This is not a file synchronization tool. It synchronizes Git objects and branches. Source editing, building, testing, and committing are performed in the development environment, while communication with GitHub/GitLab is handled by the local machine.
14
16
 
15
17
  ## Prerequisites
@@ -102,6 +104,28 @@ git-ssh-sync init myproject \
102
104
  --force
103
105
  ```
104
106
 
107
+ You can inspect and maintain registered projects without opening the config file directly.
108
+
109
+ ```bash
110
+ # List registered projects
111
+ git-ssh-sync config list
112
+
113
+ # Show all settings for one project
114
+ git-ssh-sync config show myproject
115
+
116
+ # Update selected settings
117
+ git-ssh-sync config set myproject \
118
+ --origin git@github.com:example/myproject.git \
119
+ --dev-host devserver \
120
+ --dev-path /home/user/work/myproject
121
+
122
+ # Remove a project after confirmation
123
+ git-ssh-sync config remove myproject
124
+
125
+ # Remove a project without an interactive prompt
126
+ git-ssh-sync config remove myproject --yes
127
+ ```
128
+
105
129
  ## Initial Workflow
106
130
 
107
131
  For the first time, execute configuration, clone to the development environment, and diagnostics in order.
@@ -233,6 +257,12 @@ git-ssh-sync init myproject \
233
257
  --dev-user user \
234
258
  --dev-path /home/user/work/myproject
235
259
 
260
+ # List registered project settings
261
+ git-ssh-sync config list
262
+
263
+ # Show registered project settings
264
+ git-ssh-sync config show myproject
265
+
236
266
  # Initial clone
237
267
  git-ssh-sync clone myproject
238
268
 
@@ -266,6 +296,16 @@ To develop this repository itself, install dependencies using `uv sync`.
266
296
  uv sync
267
297
  ```
268
298
 
299
+ To install from TestPyPI:
300
+
301
+ ```bash
302
+ uv tool install \
303
+ --index-url https://test.pypi.org/simple/ \
304
+ --extra-index-url https://pypi.org/simple/ \
305
+ --index-strategy unsafe-best-match \
306
+ git-ssh-sync
307
+ ```
308
+
269
309
  To execute the CLI during development, you can run it via `uv run`.
270
310
 
271
311
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-ssh-sync"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Sync Git commits through a local machine for development environments without direct GitHub or GitLab access."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -4,15 +4,22 @@ from typing import Annotated
4
4
 
5
5
  import typer
6
6
  from rich.markup import escape
7
+ from rich.table import Table
7
8
 
8
9
  from git_ssh_sync import __version__
9
10
  from git_ssh_sync.branch import BranchError, branch_project
10
11
  from git_ssh_sync.clone import CloneError, clone_project
11
12
  from git_ssh_sync.config import (
12
13
  ConfigError,
14
+ NoConfigUpdateError,
13
15
  ProjectAlreadyExistsError,
16
+ get_project,
17
+ list_project_names,
14
18
  default_config_path,
15
19
  init_project,
20
+ load_config,
21
+ remove_project,
22
+ update_project,
16
23
  )
17
24
  from git_ssh_sync.console import console
18
25
  from git_ssh_sync.doctor import DoctorError, doctor_project
@@ -25,6 +32,8 @@ app = typer.Typer(
25
32
  help="Sync Git commits through a local machine over SSH.",
26
33
  no_args_is_help=True,
27
34
  )
35
+ config_app = typer.Typer(help="Manage registered project configuration.")
36
+ app.add_typer(config_app, name="config")
28
37
 
29
38
 
30
39
  def _version_callback(value: bool) -> None:
@@ -55,6 +64,152 @@ def _not_implemented(command: str, project: str | None = None) -> None:
55
64
  )
56
65
 
57
66
 
67
+ @config_app.command("list")
68
+ def config_list_command() -> None:
69
+ """List registered projects."""
70
+ try:
71
+ config = load_config()
72
+ except ConfigError as error:
73
+ console.print(f"[red]{escape(str(error))}[/red]")
74
+ raise typer.Exit(code=1) from error
75
+
76
+ names = list_project_names(config)
77
+ if not names:
78
+ console.print("No projects configured.")
79
+ return
80
+
81
+ console.print("Configured projects:")
82
+ for name in names:
83
+ project_config = config.projects[name]
84
+ console.print(f"- {escape(name)}")
85
+ console.print(f" origin: {escape(project_config.origin)}")
86
+ console.print(f" local repo: {escape(project_config.local.repo_path)}")
87
+ console.print(f" dev host: {escape(project_config.dev.host)}")
88
+ console.print(f" dev path: {escape(project_config.dev.work_path)}")
89
+
90
+
91
+ @config_app.command("show")
92
+ def config_show_command(
93
+ project: Annotated[str, typer.Argument(help="Project name to show.")],
94
+ ) -> None:
95
+ """Show one registered project configuration."""
96
+ try:
97
+ project_config = get_project(load_config(), project)
98
+ except ConfigError as error:
99
+ console.print(f"[red]{escape(str(error))}[/red]")
100
+ raise typer.Exit(code=1) from error
101
+
102
+ table = Table(title=f"Project configuration: {escape(project)}")
103
+ table.add_column("Section", overflow="fold")
104
+ table.add_column("Name", overflow="fold")
105
+ table.add_column("Value", overflow="fold")
106
+ table.add_row("project", "name", escape(project))
107
+ table.add_row("origin", "url", escape(project_config.origin))
108
+ table.add_row("local", "repo_path", escape(project_config.local.repo_path))
109
+ table.add_row("dev", "host", escape(project_config.dev.host))
110
+ table.add_row("dev", "user", escape(project_config.dev.user))
111
+ table.add_row("dev", "work_path", escape(project_config.dev.work_path))
112
+ table.add_row("dev", "cache_path", escape(project_config.dev.cache_path))
113
+ table.add_row("options", "sync_tags", str(project_config.options.sync_tags))
114
+ table.add_row("options", "lfs", str(project_config.options.lfs))
115
+ table.add_row("options", "submodules", str(project_config.options.submodules))
116
+ table.add_row("options", "ff_only", str(project_config.options.ff_only))
117
+ console.print(table)
118
+
119
+
120
+ @config_app.command("remove")
121
+ def config_remove_command(
122
+ project: Annotated[str, typer.Argument(help="Project name to remove.")],
123
+ yes: Annotated[
124
+ bool,
125
+ typer.Option("--yes", "-y", help="Remove without confirmation."),
126
+ ] = False,
127
+ ) -> None:
128
+ """Remove a registered project configuration."""
129
+ if not yes and not typer.confirm(f"Remove project '{project}'?"):
130
+ console.print("Aborted.")
131
+ raise typer.Exit(code=1)
132
+
133
+ try:
134
+ remove_project(project)
135
+ except ConfigError as error:
136
+ console.print(f"[red]{escape(str(error))}[/red]")
137
+ raise typer.Exit(code=1) from error
138
+
139
+ console.print(f"Project '{project}' removed from {default_config_path()}")
140
+
141
+
142
+ @config_app.command("set")
143
+ def config_set_command(
144
+ project: Annotated[str, typer.Argument(help="Project name to update.")],
145
+ origin: Annotated[
146
+ str | None,
147
+ typer.Option("--origin", help="Origin Git URL."),
148
+ ] = None,
149
+ local_repo_path: Annotated[
150
+ str | None,
151
+ typer.Option("--local-repo-path", help="Local gateway repository path."),
152
+ ] = None,
153
+ dev_host: Annotated[
154
+ str | None,
155
+ typer.Option("--dev-host", help="Development environment SSH host."),
156
+ ] = None,
157
+ dev_user: Annotated[
158
+ str | None,
159
+ typer.Option("--dev-user", help="Development environment SSH user."),
160
+ ] = None,
161
+ dev_path: Annotated[
162
+ str | None,
163
+ typer.Option(
164
+ "--dev-path", help="Development environment work repository path."
165
+ ),
166
+ ] = None,
167
+ dev_cache_path: Annotated[
168
+ str | None,
169
+ typer.Option("--dev-cache-path", help="Development cache repository path."),
170
+ ] = None,
171
+ sync_tags: Annotated[
172
+ bool | None,
173
+ typer.Option("--sync-tags/--no-sync-tags", help="Enable or disable tag sync."),
174
+ ] = None,
175
+ lfs: Annotated[
176
+ bool | None,
177
+ typer.Option("--lfs/--no-lfs", help="Enable or disable Git LFS handling."),
178
+ ] = None,
179
+ submodules: Annotated[
180
+ bool | None,
181
+ typer.Option(
182
+ "--submodules/--no-submodules",
183
+ help="Enable or disable submodule handling.",
184
+ ),
185
+ ] = None,
186
+ ff_only: Annotated[
187
+ bool | None,
188
+ typer.Option("--ff-only/--no-ff-only", help="Enable or disable ff-only sync."),
189
+ ] = None,
190
+ ) -> None:
191
+ """Update one or more fields in a registered project configuration."""
192
+ try:
193
+ update_project(
194
+ project,
195
+ origin=origin,
196
+ local_repo_path=local_repo_path,
197
+ dev_host=dev_host,
198
+ dev_user=dev_user,
199
+ dev_work_path=dev_path,
200
+ dev_cache_path=dev_cache_path,
201
+ sync_tags=sync_tags,
202
+ lfs=lfs,
203
+ submodules=submodules,
204
+ ff_only=ff_only,
205
+ )
206
+ except (ConfigError, NoConfigUpdateError) as error:
207
+ console.print(f"[red]{escape(str(error))}[/red]")
208
+ raise typer.Exit(code=1) from error
209
+
210
+ console.print(f"Project '{project}' updated in {default_config_path()}")
211
+
212
+
58
213
  @app.command("init")
59
214
  def init_command(
60
215
  project: Annotated[str, typer.Argument(help="Project name to register.")],
@@ -23,6 +23,10 @@ class ProjectNotFoundError(ConfigError):
23
23
  """Raised when a project is not registered."""
24
24
 
25
25
 
26
+ class NoConfigUpdateError(ConfigError):
27
+ """Raised when no project configuration updates were provided."""
28
+
29
+
26
30
  def _expand_path(value: str) -> str:
27
31
  return str(Path(value).expanduser())
28
32
 
@@ -127,6 +131,91 @@ def get_project(config: AppConfig, project: str) -> ProjectConfig:
127
131
  raise ProjectNotFoundError(f"Project '{project}' is not configured.") from error
128
132
 
129
133
 
134
+ def list_project_names(config: AppConfig) -> list[str]:
135
+ """Return configured project names sorted for stable CLI output."""
136
+ return sorted(config.projects)
137
+
138
+
139
+ def remove_project(
140
+ project: str,
141
+ *,
142
+ config_path: Path | None = None,
143
+ ) -> None:
144
+ """Remove a project from config.yaml."""
145
+ config = load_config(config_path)
146
+ get_project(config, project)
147
+ projects = dict(config.projects)
148
+ del projects[project]
149
+ save_config(config.model_copy(update={"projects": projects}), config_path)
150
+
151
+
152
+ def update_project(
153
+ project: str,
154
+ *,
155
+ origin: str | None = None,
156
+ local_repo_path: str | None = None,
157
+ dev_host: str | None = None,
158
+ dev_user: str | None = None,
159
+ dev_work_path: str | None = None,
160
+ dev_cache_path: str | None = None,
161
+ sync_tags: bool | None = None,
162
+ lfs: bool | None = None,
163
+ submodules: bool | None = None,
164
+ ff_only: bool | None = None,
165
+ config_path: Path | None = None,
166
+ ) -> ProjectConfig:
167
+ """Partially update an existing project in config.yaml."""
168
+ config = load_config(config_path)
169
+ current = get_project(config, project)
170
+ raw = current.model_dump(mode="json")
171
+
172
+ updated = False
173
+ if origin is not None:
174
+ raw["origin"] = origin
175
+ updated = True
176
+ if local_repo_path is not None:
177
+ raw["local"]["repo_path"] = local_repo_path
178
+ updated = True
179
+ if dev_host is not None:
180
+ raw["dev"]["host"] = dev_host
181
+ updated = True
182
+ if dev_user is not None:
183
+ raw["dev"]["user"] = dev_user
184
+ updated = True
185
+ if dev_work_path is not None:
186
+ raw["dev"]["work_path"] = dev_work_path
187
+ updated = True
188
+ if dev_cache_path is not None:
189
+ raw["dev"]["cache_path"] = dev_cache_path
190
+ updated = True
191
+
192
+ for key, value in {
193
+ "sync_tags": sync_tags,
194
+ "lfs": lfs,
195
+ "submodules": submodules,
196
+ "ff_only": ff_only,
197
+ }.items():
198
+ if value is not None:
199
+ raw["options"][key] = value
200
+ updated = True
201
+
202
+ if not updated:
203
+ raise NoConfigUpdateError("Specify at least one setting to update.")
204
+
205
+ try:
206
+ updated_project = ProjectConfig.model_validate(raw)
207
+ except ValidationError as error:
208
+ raise ConfigError(
209
+ format_validation_error_for_project(project, error)
210
+ ) from error
211
+
212
+ save_config(
213
+ register_project(config, project, updated_project, force=True),
214
+ config_path,
215
+ )
216
+ return updated_project
217
+
218
+
130
219
  def build_project_config(
131
220
  project: str,
132
221
  *,