git-ssh-sync 0.3.0__tar.gz → 0.4.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.3.0
3
+ Version: 0.4.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
@@ -102,9 +102,20 @@ git-ssh-sync init myproject `
102
102
  --dev-host devserver `
103
103
  --dev-user user `
104
104
  --dev-os windows `
105
- --dev-path C:\Users\user\work\myproject
105
+ --dev-path 'C:\Users\user\work\myproject'
106
106
  ```
107
107
 
108
+ When running the command from macOS or Linux shells such as `zsh` or `bash`,
109
+ quote Windows paths that contain backslashes. Otherwise the shell can remove the
110
+ backslashes before `git-ssh-sync` receives the argument. You can also use forward
111
+ slashes, for example `C:/Users/user/work/myproject`.
112
+
113
+ When `--dev-os windows` is used, the default cache path is
114
+ `C:\Users\<dev-user>\.git-ssh-sync\cache\<project>.git`. `clone` stops if either
115
+ the configured work path or cache path already exists on the development
116
+ environment, so remove stale directories or use the attach/recover workflow for
117
+ existing repositories.
118
+
108
119
  For `--origin`, specify a remote URL that can be used with `git clone` or `git fetch`. Main formats are:
109
120
 
110
121
  ```text
@@ -128,6 +139,59 @@ git-ssh-sync init myproject \
128
139
  --force
129
140
  ```
130
141
 
142
+ ### Configuration file
143
+
144
+ Project settings are saved as YAML. The default path depends on the local
145
+ machine where `git-ssh-sync` runs:
146
+
147
+ ```text
148
+ macOS / Linux: ~/.config/git-ssh-sync/config.yaml
149
+ Windows: %APPDATA%\git-ssh-sync\config.yaml
150
+ ```
151
+
152
+ A generated configuration looks like this:
153
+
154
+ ```yaml
155
+ version: 1
156
+
157
+ projects:
158
+ myproject:
159
+ origin: git@github.com:example/myproject.git
160
+
161
+ local:
162
+ repo_path: ~/.git-ssh-sync/repos/myproject
163
+
164
+ dev:
165
+ host: devserver
166
+ user: user
167
+ os: posix
168
+ work_path: /home/user/work/myproject
169
+ cache_path: /home/user/.git-ssh-sync/cache/myproject.git
170
+
171
+ options:
172
+ sync_tags: true
173
+ lfs: false
174
+ submodules: false
175
+ ff_only: true
176
+ ```
177
+
178
+ Main fields:
179
+
180
+ - `origin`: GitHub / GitLab repository URL used by the local gateway repository
181
+ - `local.repo_path`: Local gateway repository path managed by `git-ssh-sync`
182
+ - `dev.host`, `dev.user`, `dev.os`: SSH connection target and remote OS
183
+ - `dev.work_path`: Work repository path on the development environment
184
+ - `dev.cache_path`: Bare cache repository path on the development environment
185
+ - `options.sync_tags`: Synchronize Git tags when pulling or pushing
186
+ - `options.lfs`: Reserved option for Git LFS support
187
+ - `options.submodules`: Reserved option for submodule support
188
+ - `options.ff_only`: Keep synchronization fast-forward only
189
+
190
+ In normal use, manage this file with `git-ssh-sync init` and
191
+ `git-ssh-sync config` commands. If you edit it manually, keep the YAML
192
+ structure unchanged and use paths that are valid on the machine or
193
+ development environment where each field is used.
194
+
131
195
  You can inspect and maintain registered projects without opening the config file directly.
132
196
 
133
197
  ```bash
@@ -250,6 +314,27 @@ git-ssh-sync push myproject
250
314
 
251
315
  `pull` and `push` target the current branch of the work repository on the development environment. To synchronize a different branch, switch the work repository branch with `checkout` first.
252
316
 
317
+ If you are not sure about the current state at the beginning of work, first check synchronization status from the local machine and run `pull` when needed.
318
+
319
+ ```bash
320
+ git-ssh-sync status myproject
321
+ git-ssh-sync pull myproject
322
+ git-ssh-sync dev status myproject
323
+ ```
324
+
325
+ If `dev status` shows a dirty working tree on the development environment, uncommitted changes are not synchronized. Inspect the diff on the development environment and commit the changes you want to synchronize before `push`.
326
+
327
+ ```bash
328
+ git-ssh-sync dev diff myproject --stat
329
+ ```
330
+
331
+ Before pushing, confirm that the development environment changes are committed, then run `status` and `push` from the local machine.
332
+
333
+ ```bash
334
+ git-ssh-sync status myproject
335
+ git-ssh-sync push myproject
336
+ ```
337
+
253
338
  Use `--dry-run` to inspect the planned operations and preflight checks before changing refs:
254
339
 
255
340
  ```bash
@@ -257,6 +342,51 @@ git-ssh-sync pull myproject --dry-run
257
342
  git-ssh-sync push myproject --dry-run
258
343
  ```
259
344
 
345
+ ## Workflow When Push Stops
346
+
347
+ `push` executes only when the branch on the origin side is an ancestor of the branch on the development environment side. It stops when origin has commits that have not been pulled yet, or when origin and the development environment have diverged.
348
+
349
+ In that case, run `pull` from the local machine to deliver origin changes to the development environment.
350
+
351
+ ```bash
352
+ git-ssh-sync pull myproject
353
+ ```
354
+
355
+ If `pull` cannot fast-forward, `git-ssh-sync` does not automatically merge or rebase. Resolve it with normal Git operations on the development environment, using either merge or rebase, then run `push` again from the local machine.
356
+
357
+ Example using merge:
358
+
359
+ ```bash
360
+ cd ~/work/myproject
361
+ git fetch gitsync
362
+ git merge gitsync/main
363
+ # If there are conflicts, edit the files
364
+ git status
365
+ git add <resolved-files>
366
+ git commit
367
+ ```
368
+
369
+ Example using rebase:
370
+
371
+ ```bash
372
+ cd ~/work/myproject
373
+ git fetch gitsync
374
+ git rebase gitsync/main
375
+ # If there are conflicts, edit the files
376
+ git status
377
+ git add <resolved-files>
378
+ git rebase --continue
379
+ ```
380
+
381
+ If the branch is not `main`, replace `gitsync/main` with the target branch. After merge or rebase completes, check status from the local machine and push.
382
+
383
+ ```bash
384
+ git-ssh-sync status myproject
385
+ git-ssh-sync push myproject
386
+ ```
387
+
388
+ After rebase, only rewrite commits that exist only on the development environment and have not been pushed to origin yet. If you want to avoid rewriting history on a shared branch, use merge.
389
+
260
390
  ## Branch Switching Workflow
261
391
 
262
392
  To switch to an existing branch, execute `checkout` from the local machine.
@@ -343,7 +473,7 @@ Uncommitted changes are not synchronized. If there are uncommitted changes in th
343
473
 
344
474
  `push` executes only when the branch on the origin side is an ancestor of the branch on the development environment side. If there are unobtained commits on origin, it stops.
345
475
 
346
- When diverged, automatic resolution is not performed. Execute `pull` on the local machine, follow the displayed instructions to merge or rebase in the development environment, then `push` again.
476
+ When diverged, automatic resolution is not performed. Follow "Workflow When Push Stops", merge or rebase in the development environment, then `push` again.
347
477
 
348
478
  ## Common Commands
349
479
 
@@ -91,9 +91,20 @@ git-ssh-sync init myproject `
91
91
  --dev-host devserver `
92
92
  --dev-user user `
93
93
  --dev-os windows `
94
- --dev-path C:\Users\user\work\myproject
94
+ --dev-path 'C:\Users\user\work\myproject'
95
95
  ```
96
96
 
97
+ When running the command from macOS or Linux shells such as `zsh` or `bash`,
98
+ quote Windows paths that contain backslashes. Otherwise the shell can remove the
99
+ backslashes before `git-ssh-sync` receives the argument. You can also use forward
100
+ slashes, for example `C:/Users/user/work/myproject`.
101
+
102
+ When `--dev-os windows` is used, the default cache path is
103
+ `C:\Users\<dev-user>\.git-ssh-sync\cache\<project>.git`. `clone` stops if either
104
+ the configured work path or cache path already exists on the development
105
+ environment, so remove stale directories or use the attach/recover workflow for
106
+ existing repositories.
107
+
97
108
  For `--origin`, specify a remote URL that can be used with `git clone` or `git fetch`. Main formats are:
98
109
 
99
110
  ```text
@@ -117,6 +128,59 @@ git-ssh-sync init myproject \
117
128
  --force
118
129
  ```
119
130
 
131
+ ### Configuration file
132
+
133
+ Project settings are saved as YAML. The default path depends on the local
134
+ machine where `git-ssh-sync` runs:
135
+
136
+ ```text
137
+ macOS / Linux: ~/.config/git-ssh-sync/config.yaml
138
+ Windows: %APPDATA%\git-ssh-sync\config.yaml
139
+ ```
140
+
141
+ A generated configuration looks like this:
142
+
143
+ ```yaml
144
+ version: 1
145
+
146
+ projects:
147
+ myproject:
148
+ origin: git@github.com:example/myproject.git
149
+
150
+ local:
151
+ repo_path: ~/.git-ssh-sync/repos/myproject
152
+
153
+ dev:
154
+ host: devserver
155
+ user: user
156
+ os: posix
157
+ work_path: /home/user/work/myproject
158
+ cache_path: /home/user/.git-ssh-sync/cache/myproject.git
159
+
160
+ options:
161
+ sync_tags: true
162
+ lfs: false
163
+ submodules: false
164
+ ff_only: true
165
+ ```
166
+
167
+ Main fields:
168
+
169
+ - `origin`: GitHub / GitLab repository URL used by the local gateway repository
170
+ - `local.repo_path`: Local gateway repository path managed by `git-ssh-sync`
171
+ - `dev.host`, `dev.user`, `dev.os`: SSH connection target and remote OS
172
+ - `dev.work_path`: Work repository path on the development environment
173
+ - `dev.cache_path`: Bare cache repository path on the development environment
174
+ - `options.sync_tags`: Synchronize Git tags when pulling or pushing
175
+ - `options.lfs`: Reserved option for Git LFS support
176
+ - `options.submodules`: Reserved option for submodule support
177
+ - `options.ff_only`: Keep synchronization fast-forward only
178
+
179
+ In normal use, manage this file with `git-ssh-sync init` and
180
+ `git-ssh-sync config` commands. If you edit it manually, keep the YAML
181
+ structure unchanged and use paths that are valid on the machine or
182
+ development environment where each field is used.
183
+
120
184
  You can inspect and maintain registered projects without opening the config file directly.
121
185
 
122
186
  ```bash
@@ -239,6 +303,27 @@ git-ssh-sync push myproject
239
303
 
240
304
  `pull` and `push` target the current branch of the work repository on the development environment. To synchronize a different branch, switch the work repository branch with `checkout` first.
241
305
 
306
+ If you are not sure about the current state at the beginning of work, first check synchronization status from the local machine and run `pull` when needed.
307
+
308
+ ```bash
309
+ git-ssh-sync status myproject
310
+ git-ssh-sync pull myproject
311
+ git-ssh-sync dev status myproject
312
+ ```
313
+
314
+ If `dev status` shows a dirty working tree on the development environment, uncommitted changes are not synchronized. Inspect the diff on the development environment and commit the changes you want to synchronize before `push`.
315
+
316
+ ```bash
317
+ git-ssh-sync dev diff myproject --stat
318
+ ```
319
+
320
+ Before pushing, confirm that the development environment changes are committed, then run `status` and `push` from the local machine.
321
+
322
+ ```bash
323
+ git-ssh-sync status myproject
324
+ git-ssh-sync push myproject
325
+ ```
326
+
242
327
  Use `--dry-run` to inspect the planned operations and preflight checks before changing refs:
243
328
 
244
329
  ```bash
@@ -246,6 +331,51 @@ git-ssh-sync pull myproject --dry-run
246
331
  git-ssh-sync push myproject --dry-run
247
332
  ```
248
333
 
334
+ ## Workflow When Push Stops
335
+
336
+ `push` executes only when the branch on the origin side is an ancestor of the branch on the development environment side. It stops when origin has commits that have not been pulled yet, or when origin and the development environment have diverged.
337
+
338
+ In that case, run `pull` from the local machine to deliver origin changes to the development environment.
339
+
340
+ ```bash
341
+ git-ssh-sync pull myproject
342
+ ```
343
+
344
+ If `pull` cannot fast-forward, `git-ssh-sync` does not automatically merge or rebase. Resolve it with normal Git operations on the development environment, using either merge or rebase, then run `push` again from the local machine.
345
+
346
+ Example using merge:
347
+
348
+ ```bash
349
+ cd ~/work/myproject
350
+ git fetch gitsync
351
+ git merge gitsync/main
352
+ # If there are conflicts, edit the files
353
+ git status
354
+ git add <resolved-files>
355
+ git commit
356
+ ```
357
+
358
+ Example using rebase:
359
+
360
+ ```bash
361
+ cd ~/work/myproject
362
+ git fetch gitsync
363
+ git rebase gitsync/main
364
+ # If there are conflicts, edit the files
365
+ git status
366
+ git add <resolved-files>
367
+ git rebase --continue
368
+ ```
369
+
370
+ If the branch is not `main`, replace `gitsync/main` with the target branch. After merge or rebase completes, check status from the local machine and push.
371
+
372
+ ```bash
373
+ git-ssh-sync status myproject
374
+ git-ssh-sync push myproject
375
+ ```
376
+
377
+ After rebase, only rewrite commits that exist only on the development environment and have not been pushed to origin yet. If you want to avoid rewriting history on a shared branch, use merge.
378
+
249
379
  ## Branch Switching Workflow
250
380
 
251
381
  To switch to an existing branch, execute `checkout` from the local machine.
@@ -332,7 +462,7 @@ Uncommitted changes are not synchronized. If there are uncommitted changes in th
332
462
 
333
463
  `push` executes only when the branch on the origin side is an ancestor of the branch on the development environment side. If there are unobtained commits on origin, it stops.
334
464
 
335
- When diverged, automatic resolution is not performed. Execute `pull` on the local machine, follow the displayed instructions to merge or rebase in the development environment, then `push` again.
465
+ When diverged, automatic resolution is not performed. Follow "Workflow When Push Stops", merge or rebase in the development environment, then `push` again.
336
466
 
337
467
  ## Common Commands
338
468
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-ssh-sync"
3
- version = "0.3.0"
3
+ version = "0.4.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"
@@ -1,3 +1,3 @@
1
1
  """git-ssh-sync package."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -422,6 +422,7 @@ def _apply_operation(
422
422
  cache_url,
423
423
  [f"refs/remotes/origin/{branch}:refs/heads/{branch}"],
424
424
  cwd=local_path,
425
+ env=ssh.git_ssh_environment(project_config.dev.os),
425
426
  )
426
427
  return
427
428
 
@@ -146,6 +146,7 @@ def inspect_project_branch(project: str, project_config: ProjectConfig) -> Branc
146
146
  dev_repo_url,
147
147
  [f"refs/heads/{branch}:refs/remotes/dev/{branch}"],
148
148
  cwd=local_path,
149
+ env=ssh.git_ssh_environment(project_config.dev.os),
149
150
  )
150
151
 
151
152
  rows: list[BranchRow] = []
@@ -0,0 +1,171 @@
1
+ """Project clone workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
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
+ from git_ssh_sync.logging_config import logger
12
+
13
+
14
+ class CloneError(RuntimeError):
15
+ """Raised when the clone workflow would overwrite existing data."""
16
+
17
+
18
+ def _ensure_local_missing(path: Path) -> None:
19
+ if path.exists():
20
+ raise CloneError(f"[local] path already exists: {path}")
21
+
22
+
23
+ def _ensure_remote_missing(
24
+ *, host: str, user: str, path: str, remote_os: ssh.RemoteOS
25
+ ) -> None:
26
+ result = ssh.remote_path_exists(
27
+ host, path, user=user, remote_os=remote_os, path_type="any"
28
+ )
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 _local_current_branch(local_path: Path) -> str:
44
+ result = git.run_git(["branch", "--show-current"], cwd=local_path)
45
+ branch = result.stdout.strip()
46
+ if not branch:
47
+ raise CloneError("Could not determine the cloned repository's current branch.")
48
+ return branch
49
+
50
+
51
+ def _cleanup_local_path(path: Path) -> None:
52
+ if not path.exists():
53
+ return
54
+ try:
55
+ if path.is_dir() and not path.is_symlink():
56
+ shutil.rmtree(path)
57
+ return
58
+ path.unlink()
59
+ except OSError as error:
60
+ logger.debug("Failed to clean up local path %s: %s", path, error)
61
+
62
+
63
+ def _cleanup_remote_path(
64
+ *, host: str, user: str, path: str, remote_os: ssh.RemoteOS
65
+ ) -> None:
66
+ result = ssh.remote_remove(host, path, user=user, remote_os=remote_os)
67
+ if result.returncode != 0:
68
+ logger.debug(
69
+ "Failed to clean up remote path %s on %s: %s",
70
+ path,
71
+ result.environment,
72
+ result.stderr.strip(),
73
+ )
74
+
75
+
76
+ def clone_project(project: str) -> None:
77
+ """Clone a configured project and initialize its development repositories."""
78
+ app_config = load_config()
79
+ project_config = get_project(app_config, project)
80
+
81
+ local_path = Path(project_config.local.repo_path)
82
+ dev_host = project_config.dev.host
83
+ dev_user = project_config.dev.user
84
+ dev_os = project_config.dev.os
85
+ cache_path = project_config.dev.cache_path
86
+ work_path = project_config.dev.work_path
87
+
88
+ _ensure_local_missing(local_path)
89
+ _ensure_remote_missing(
90
+ host=dev_host, user=dev_user, path=cache_path, remote_os=dev_os
91
+ )
92
+ _ensure_remote_missing(
93
+ host=dev_host, user=dev_user, path=work_path, remote_os=dev_os
94
+ )
95
+
96
+ local_started = False
97
+ cache_started = False
98
+ work_started = False
99
+ try:
100
+ local_path.parent.mkdir(parents=True, exist_ok=True)
101
+ local_started = True
102
+ git.run_git(["clone", project_config.origin, str(local_path)])
103
+ git.fetch("origin", cwd=local_path)
104
+ branch = _local_current_branch(local_path)
105
+
106
+ ssh.remote_mkdir(
107
+ dev_host,
108
+ ssh.remote_parent(cache_path, dev_os),
109
+ user=dev_user,
110
+ remote_os=dev_os,
111
+ )
112
+ cache_started = True
113
+ ssh.run_remote_command(
114
+ dev_host,
115
+ ["git", "init", "--bare", cache_path],
116
+ user=dev_user,
117
+ remote_os=dev_os,
118
+ )
119
+
120
+ remote_cache = ssh.remote_git_url(
121
+ host=dev_host, user=dev_user, repo_path=cache_path, remote_os=dev_os
122
+ )
123
+ git.push(
124
+ remote_cache,
125
+ [f"refs/remotes/origin/{branch}:refs/heads/{branch}"],
126
+ cwd=local_path,
127
+ env=ssh.git_ssh_environment(dev_os),
128
+ )
129
+ if project_config.options.sync_tags:
130
+ git.push(
131
+ remote_cache,
132
+ ["--tags"],
133
+ cwd=local_path,
134
+ env=ssh.git_ssh_environment(dev_os),
135
+ )
136
+
137
+ ssh.remote_mkdir(
138
+ dev_host,
139
+ ssh.remote_parent(work_path, dev_os),
140
+ user=dev_user,
141
+ remote_os=dev_os,
142
+ )
143
+ work_started = True
144
+ ssh.run_remote_command(
145
+ dev_host,
146
+ ["git", "clone", cache_path, work_path],
147
+ user=dev_user,
148
+ remote_os=dev_os,
149
+ )
150
+ ssh.run_remote_git(
151
+ dev_host,
152
+ work_path,
153
+ ["remote", "rename", "origin", "gitsync"],
154
+ user=dev_user,
155
+ remote_os=dev_os,
156
+ )
157
+ ssh.run_remote_git(
158
+ dev_host, work_path, ["switch", branch], user=dev_user, remote_os=dev_os
159
+ )
160
+ except Exception:
161
+ if work_started:
162
+ _cleanup_remote_path(
163
+ host=dev_host, user=dev_user, path=work_path, remote_os=dev_os
164
+ )
165
+ if cache_started:
166
+ _cleanup_remote_path(
167
+ host=dev_host, user=dev_user, path=cache_path, remote_os=dev_os
168
+ )
169
+ if local_started:
170
+ _cleanup_local_path(local_path)
171
+ raise
@@ -3,12 +3,19 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
  import sys
7
8
  from pathlib import Path
8
9
  from typing import Any, Literal
9
10
 
10
11
  import yaml
11
- from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
12
+ from pydantic import (
13
+ BaseModel,
14
+ ConfigDict,
15
+ Field,
16
+ ValidationError,
17
+ field_validator,
18
+ )
12
19
 
13
20
 
14
21
  class ConfigError(Exception):
@@ -31,6 +38,29 @@ def _expand_path(value: str) -> str:
31
38
  return str(Path(value).expanduser())
32
39
 
33
40
 
41
+ def _default_dev_cache_path(
42
+ project: str, dev_user: str | None, dev_os: Literal["posix", "windows"]
43
+ ) -> str:
44
+ if dev_os == "windows":
45
+ return f"C:\\Users\\{dev_user}\\.git-ssh-sync\\cache\\{project}.git"
46
+ return f"/home/{dev_user}/.git-ssh-sync/cache/{project}.git"
47
+
48
+
49
+ def _looks_like_unquoted_windows_path(value: str) -> bool:
50
+ return bool(re.match(r"^[A-Za-z]:[^\\/]", value))
51
+
52
+
53
+ def _validate_windows_path_input(field_name: str, value: str | None) -> None:
54
+ if value is None or not _looks_like_unquoted_windows_path(value):
55
+ return
56
+ raise ConfigError(
57
+ f"{field_name} looks like a Windows path whose separators were removed "
58
+ "by the shell. Quote backslash paths, for example "
59
+ r"'C:\Users\user\work\repo', or use forward slashes like "
60
+ "C:/Users/user/work/repo."
61
+ )
62
+
63
+
34
64
  class LocalConfig(BaseModel):
35
65
  model_config = ConfigDict(extra="forbid")
36
66
 
@@ -48,9 +78,6 @@ class DevConfig(BaseModel):
48
78
  work_path: str = Field(min_length=1)
49
79
  cache_path: str = Field(min_length=1)
50
80
 
51
- _expand_work_path = field_validator("work_path")(_expand_path)
52
- _expand_cache_path = field_validator("cache_path")(_expand_path)
53
-
54
81
 
55
82
  class OptionsConfig(BaseModel):
56
83
  model_config = ConfigDict(extra="forbid")
@@ -194,6 +221,13 @@ def update_project(
194
221
  raw["dev"]["cache_path"] = dev_cache_path
195
222
  updated = True
196
223
 
224
+ effective_dev_os = raw["dev"].get("os")
225
+ if effective_dev_os == "windows":
226
+ if dev_work_path is not None:
227
+ _validate_windows_path_input("dev.work_path", dev_work_path)
228
+ if dev_cache_path is not None:
229
+ _validate_windows_path_input("dev.cache_path", dev_cache_path)
230
+
197
231
  for key, value in {
198
232
  "sync_tags": sync_tags,
199
233
  "lfs": lfs,
@@ -234,6 +268,10 @@ def build_project_config(
234
268
  options: OptionsConfig | None = None,
235
269
  ) -> ProjectConfig:
236
270
  """Build and validate a project config, applying init defaults."""
271
+ if dev_os == "windows":
272
+ _validate_windows_path_input("dev.work_path", dev_work_path)
273
+ _validate_windows_path_input("dev.cache_path", dev_cache_path)
274
+
237
275
  raw: dict[str, Any] = {
238
276
  "origin": origin,
239
277
  "local": {
@@ -245,7 +283,7 @@ def build_project_config(
245
283
  "os": dev_os,
246
284
  "work_path": dev_work_path,
247
285
  "cache_path": dev_cache_path
248
- or f"/home/{dev_user}/.git-ssh-sync/cache/{project}.git",
286
+ or _default_dev_cache_path(project, dev_user, dev_os),
249
287
  },
250
288
  "options": (options or OptionsConfig()).model_dump(mode="json"),
251
289
  }
@@ -621,6 +621,7 @@ def _check_repository(
621
621
  cache_repo_url,
622
622
  [f"refs/heads/{branch}:refs/remotes/dev-cache/{branch}"],
623
623
  cwd=local_path,
624
+ env=ssh.git_ssh_environment(project_config.dev.os),
624
625
  )
625
626
  except CommandExecutionError as error:
626
627
  checks.append(
@@ -647,6 +648,7 @@ def _check_repository(
647
648
  dev_repo_url,
648
649
  [f"refs/heads/{branch}:refs/remotes/dev/{branch}"],
649
650
  cwd=local_path,
651
+ env=ssh.git_ssh_environment(project_config.dev.os),
650
652
  )
651
653
  except CommandExecutionError as error:
652
654
  checks.append(
@@ -57,6 +57,7 @@ def _run_command(
57
57
  env=_merged_env(env),
58
58
  capture_output=True,
59
59
  text=True,
60
+ errors="replace",
60
61
  check=False,
61
62
  )
62
63
 
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import shlex
6
+ import sys
7
+ from base64 import b64encode
6
8
  from collections.abc import Mapping, Sequence
7
9
  from pathlib import Path, PurePosixPath, PureWindowsPath
8
10
  from typing import Literal
@@ -86,9 +88,10 @@ def run_powershell(
86
88
  check: bool = True,
87
89
  ) -> CommandResult:
88
90
  """Run a PowerShell script on an SSH host."""
91
+ encoded_script = b64encode(script.encode("utf-16le")).decode("ascii")
89
92
  remote_command = (
90
- "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "
91
- f"{_powershell_quote(script)}"
93
+ "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass "
94
+ f"-EncodedCommand {encoded_script}"
92
95
  )
93
96
  return _run_ssh_command_string(
94
97
  host,
@@ -183,15 +186,31 @@ def run_remote_git(
183
186
 
184
187
  def remote_git_url(*, host: str, user: str, repo_path: str, remote_os: RemoteOS) -> str:
185
188
  """Build an SSH Git URL for a remote repository path."""
186
- normalized_path = (
187
- repo_path.replace("\\", "/") if remote_os == "windows" else repo_path
188
- )
189
+ if remote_os == "windows":
190
+ normalized_path = repo_path.replace("\\", "/")
191
+ return f"{user}@{host}:{normalized_path}"
192
+
193
+ normalized_path = repo_path
189
194
  quoted_path = quote(normalized_path, safe="/~:")
190
- if remote_os == "windows" and not quoted_path.startswith("/"):
191
- quoted_path = f"/{quoted_path}"
192
195
  return f"ssh://{user}@{host}{quoted_path}"
193
196
 
194
197
 
198
+ def git_ssh_environment(
199
+ remote_os: RemoteOS, env: Mapping[str, str] | None = None
200
+ ) -> Mapping[str, str] | None:
201
+ """Return environment overrides for local Git SSH transport."""
202
+ if remote_os != "windows":
203
+ return env
204
+
205
+ git_env = dict(env or {})
206
+ if existing_command := git_env.get("GIT_SSH_COMMAND"):
207
+ git_env["GIT_SSH_SYNC_BASE_SSH_COMMAND"] = existing_command
208
+ git_env["GIT_SSH_COMMAND"] = (
209
+ f"{shlex.quote(sys.executable)} -m git_ssh_sync.windows_git_ssh"
210
+ )
211
+ return git_env
212
+
213
+
195
214
  def remote_parent(path: str, remote_os: RemoteOS) -> str:
196
215
  """Return the parent directory using the remote operating system's path rules."""
197
216
  if remote_os == "windows":
@@ -237,6 +256,23 @@ def remote_mkdir(
237
256
  return run_ssh(host, ["mkdir", "-p", path], user=user)
238
257
 
239
258
 
259
+ def remote_remove(
260
+ host: str,
261
+ path: str,
262
+ *,
263
+ user: str,
264
+ remote_os: RemoteOS,
265
+ ) -> CommandResult:
266
+ """Remove a remote path recursively if it exists."""
267
+ if remote_os == "windows":
268
+ script = (
269
+ "Remove-Item -LiteralPath "
270
+ f"{_powershell_quote(path)} -Recurse -Force -ErrorAction SilentlyContinue"
271
+ )
272
+ return run_powershell(host, script, user=user, check=False)
273
+ return run_ssh(host, ["rm", "-rf", "--", path], user=user, check=False)
274
+
275
+
240
276
  def remote_command_exists(
241
277
  host: str,
242
278
  command: str,
@@ -134,7 +134,10 @@ def inspect_project_status(project: str, project_config: ProjectConfig) -> Statu
134
134
  host=dev_host, user=dev_user, repo_path=dev_work_path, remote_os=dev_os
135
135
  )
136
136
  git.fetch(
137
- dev_repo_url, [f"refs/heads/{branch}:refs/remotes/dev/{branch}"], cwd=local_path
137
+ dev_repo_url,
138
+ [f"refs/heads/{branch}:refs/remotes/dev/{branch}"],
139
+ cwd=local_path,
140
+ env=ssh.git_ssh_environment(dev_os),
138
141
  )
139
142
 
140
143
  origin_ref = f"origin/{branch}"
@@ -123,6 +123,7 @@ def _push_origin_branch_to_cache(
123
123
  remote_cache,
124
124
  [f"refs/remotes/origin/{branch}:refs/heads/{branch}"],
125
125
  cwd=local_path,
126
+ env=ssh.git_ssh_environment(project_config.dev.os),
126
127
  )
127
128
 
128
129
 
@@ -239,6 +240,7 @@ def _fetch_dev_branch_to_local(
239
240
  _work_url(project_config),
240
241
  [f"refs/heads/{branch}:refs/remotes/dev/{branch}"],
241
242
  cwd=local_path,
243
+ env=ssh.git_ssh_environment(project_config.dev.os),
242
244
  )
243
245
 
244
246
 
@@ -0,0 +1,52 @@
1
+ """SSH command wrapper for Git protocol commands targeting Windows hosts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import sys
8
+ from base64 import b64encode
9
+
10
+ GIT_PROTOCOL_COMMANDS = {"git-receive-pack", "git-upload-pack", "git-upload-archive"}
11
+
12
+
13
+ def _powershell_quote(value: str) -> str:
14
+ return "'" + value.replace("'", "''") + "'"
15
+
16
+
17
+ def _base_ssh_command() -> list[str]:
18
+ command = os.environ.get("GIT_SSH_SYNC_BASE_SSH_COMMAND", "ssh")
19
+ return shlex.split(command)
20
+
21
+
22
+ def _powershell_command(remote_command: str) -> str | None:
23
+ try:
24
+ parts = shlex.split(remote_command, posix=True)
25
+ except ValueError:
26
+ return None
27
+ if len(parts) < 2 or parts[0] not in GIT_PROTOCOL_COMMANDS:
28
+ return None
29
+
30
+ script = "& " + " ".join(_powershell_quote(part) for part in parts)
31
+ encoded_script = b64encode(script.encode("utf-16le")).decode("ascii")
32
+ return (
33
+ "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass "
34
+ f"-EncodedCommand {encoded_script}"
35
+ )
36
+
37
+
38
+ def main() -> None:
39
+ args = sys.argv[1:]
40
+ base_command = _base_ssh_command()
41
+ if len(args) < 2:
42
+ os.execvp(base_command[0], [*base_command, *args])
43
+
44
+ remote_command = _powershell_command(args[-1])
45
+ if remote_command is None:
46
+ os.execvp(base_command[0], [*base_command, *args])
47
+
48
+ os.execvp(base_command[0], [*base_command, *args[:-1], remote_command])
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
@@ -1,113 +0,0 @@
1
- """Project clone workflow."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- from git_ssh_sync import git, ssh
8
- from git_ssh_sync.config import get_project, load_config
9
- from git_ssh_sync.errors import CommandExecutionError
10
-
11
-
12
- class CloneError(RuntimeError):
13
- """Raised when the clone workflow would overwrite existing data."""
14
-
15
-
16
- def _ensure_local_missing(path: Path) -> None:
17
- if path.exists():
18
- raise CloneError(f"[local] path already exists: {path}")
19
-
20
-
21
- def _ensure_remote_missing(
22
- *, host: str, user: str, path: str, remote_os: ssh.RemoteOS
23
- ) -> None:
24
- result = ssh.remote_path_exists(
25
- host, path, user=user, remote_os=remote_os, path_type="any"
26
- )
27
- if result.returncode == 0:
28
- raise CloneError(f"[{result.environment}] path already exists: {path}")
29
- if result.returncode == 1:
30
- return
31
- raise CommandExecutionError(
32
- environment=result.environment,
33
- command=result.command,
34
- returncode=result.returncode,
35
- cwd=result.cwd,
36
- stdout=result.stdout,
37
- stderr=result.stderr,
38
- )
39
-
40
-
41
- def _local_current_branch(local_path: Path) -> str:
42
- result = git.run_git(["branch", "--show-current"], cwd=local_path)
43
- branch = result.stdout.strip()
44
- if not branch:
45
- raise CloneError("Could not determine the cloned repository's current branch.")
46
- return branch
47
-
48
-
49
- def clone_project(project: str) -> None:
50
- """Clone a configured project and initialize its development repositories."""
51
- app_config = load_config()
52
- project_config = get_project(app_config, project)
53
-
54
- local_path = Path(project_config.local.repo_path)
55
- dev_host = project_config.dev.host
56
- dev_user = project_config.dev.user
57
- dev_os = project_config.dev.os
58
- cache_path = project_config.dev.cache_path
59
- work_path = project_config.dev.work_path
60
-
61
- _ensure_local_missing(local_path)
62
- _ensure_remote_missing(
63
- host=dev_host, user=dev_user, path=cache_path, remote_os=dev_os
64
- )
65
- _ensure_remote_missing(
66
- host=dev_host, user=dev_user, path=work_path, remote_os=dev_os
67
- )
68
-
69
- local_path.parent.mkdir(parents=True, exist_ok=True)
70
- git.run_git(["clone", project_config.origin, str(local_path)])
71
- git.fetch("origin", cwd=local_path)
72
- branch = _local_current_branch(local_path)
73
-
74
- ssh.remote_mkdir(
75
- dev_host, ssh.remote_parent(cache_path, dev_os), user=dev_user, remote_os=dev_os
76
- )
77
- ssh.run_remote_command(
78
- dev_host,
79
- ["git", "init", "--bare", cache_path],
80
- user=dev_user,
81
- remote_os=dev_os,
82
- )
83
-
84
- remote_cache = ssh.remote_git_url(
85
- host=dev_host, user=dev_user, repo_path=cache_path, remote_os=dev_os
86
- )
87
- git.push(
88
- remote_cache,
89
- [f"refs/remotes/origin/{branch}:refs/heads/{branch}"],
90
- cwd=local_path,
91
- )
92
- if project_config.options.sync_tags:
93
- git.push(remote_cache, ["--tags"], cwd=local_path)
94
-
95
- ssh.remote_mkdir(
96
- dev_host, ssh.remote_parent(work_path, dev_os), user=dev_user, remote_os=dev_os
97
- )
98
- ssh.run_remote_command(
99
- dev_host,
100
- ["git", "clone", cache_path, work_path],
101
- user=dev_user,
102
- remote_os=dev_os,
103
- )
104
- ssh.run_remote_git(
105
- dev_host,
106
- work_path,
107
- ["remote", "rename", "origin", "gitsync"],
108
- user=dev_user,
109
- remote_os=dev_os,
110
- )
111
- ssh.run_remote_git(
112
- dev_host, work_path, ["switch", branch], user=dev_user, remote_os=dev_os
113
- )