pjdev-gitlab 5.1.10__tar.gz → 5.1.12__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.
Files changed (40) hide show
  1. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/PKG-INFO +29 -1
  2. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/README.md +28 -0
  3. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/pyproject.toml +1 -0
  4. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/__about__.py +1 -1
  5. pjdev_gitlab-5.1.12/src/pjdev_gitlab/git_sync_service.py +286 -0
  6. pjdev_gitlab-5.1.12/src/pjdev_gitlab/sync_cli.py +164 -0
  7. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/test_report_pjdev-gitlab.txt +89 -63
  8. pjdev_gitlab-5.1.12/tests/tests_for_git_sync_service.py +322 -0
  9. pjdev_gitlab-5.1.12/tests/tests_for_sync_cli.py +127 -0
  10. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/.gitignore +0 -0
  11. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/LICENSE.txt +0 -0
  12. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  13. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  14. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  15. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  16. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +0 -0
  17. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/__init__.py +0 -0
  18. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/api_utilities.py +0 -0
  19. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/config_service.py +0 -0
  20. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/issues_service.py +0 -0
  21. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/merge_requests_service.py +0 -0
  22. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/models.py +0 -0
  23. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/oauth_cli.py +0 -0
  24. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/oauth_service.py +0 -0
  25. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/packages_service.py +0 -0
  26. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/py.typed +0 -0
  27. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/repo_files_service.py +0 -0
  28. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/work_items_service.py +0 -0
  29. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/test.sh +0 -0
  30. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/__init__.py +0 -0
  31. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/conftest.py +0 -0
  32. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_api_utilities.py +0 -0
  33. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_issues_service.py +0 -0
  34. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_merge_requests_service.py +0 -0
  35. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_models.py +0 -0
  36. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_oauth_cli.py +0 -0
  37. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_oauth_service.py +0 -0
  38. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_packages_service.py +0 -0
  39. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_repo_files_service.py +0 -0
  40. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.12}/tests/tests_for_work_items_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pjdev-gitlab
3
- Version: 5.1.10
3
+ Version: 5.1.12
4
4
  Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-gitlab/README.md
5
5
  Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
6
6
  Project-URL: Source, https://gitlab.purplejay.io/keystone/python
@@ -97,6 +97,34 @@ required and the host defaults to `https://gitlab.com` (override with
97
97
  `--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
98
98
  equivalent).
99
99
 
100
+ #### `pjdev-gitlab-sync` console script
101
+
102
+ `pjdev-gitlab-sync` clones a repo over HTTPS using an OAuth token, or
103
+ fast-forwards it if it is already present — **no SSH key setup required**. It is
104
+ deterministic and idempotent: the *same* command both installs a repo the first
105
+ time and pulls updates on every later run, so it is the one thing a non-technical
106
+ user has to remember.
107
+
108
+ ```bash
109
+ pjdev-gitlab-sync developers/claude-skills \
110
+ --gitlab-url https://gitlab.example.com \
111
+ --client-id <client-id> \
112
+ --target ~/git/claude-skills # optional; defaults to the repo basename
113
+ ```
114
+
115
+ It mints/reuses the token via the same PKCE flow as `pjdev-gitlab-auth` (opening
116
+ a browser only when there is no valid cached token), then hands it to git through
117
+ `GIT_ASKPASS`. The token is therefore **never** written to `.git/config`, placed
118
+ on a git command line, or cached in a system keychain — the stored `origin`
119
+ remote is always a plain, tokenless HTTPS URL you can safely inspect. The
120
+ resolved checkout path is printed to stdout (so it is scriptable, e.g.
121
+ `cd "$(pjdev-gitlab-sync group/name)"`); status goes to stderr.
122
+
123
+ Updates are `merge --ff-only`: if the checkout has diverging local commits, the
124
+ command fails loudly rather than discarding your work. Flags mirror
125
+ `pjdev-gitlab-auth` plus `--target` (`GL_SYNC_TARGET`) and `--branch`
126
+ (`GL_SYNC_BRANCH`); the repo path may also come from `GL_REPO`.
127
+
100
128
  ### Recommended: 1Password + `op run`
101
129
 
102
130
  On a developer laptop, keep the token in 1Password and inject it into the host
@@ -69,6 +69,34 @@ required and the host defaults to `https://gitlab.com` (override with
69
69
  `--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
70
70
  equivalent).
71
71
 
72
+ #### `pjdev-gitlab-sync` console script
73
+
74
+ `pjdev-gitlab-sync` clones a repo over HTTPS using an OAuth token, or
75
+ fast-forwards it if it is already present — **no SSH key setup required**. It is
76
+ deterministic and idempotent: the *same* command both installs a repo the first
77
+ time and pulls updates on every later run, so it is the one thing a non-technical
78
+ user has to remember.
79
+
80
+ ```bash
81
+ pjdev-gitlab-sync developers/claude-skills \
82
+ --gitlab-url https://gitlab.example.com \
83
+ --client-id <client-id> \
84
+ --target ~/git/claude-skills # optional; defaults to the repo basename
85
+ ```
86
+
87
+ It mints/reuses the token via the same PKCE flow as `pjdev-gitlab-auth` (opening
88
+ a browser only when there is no valid cached token), then hands it to git through
89
+ `GIT_ASKPASS`. The token is therefore **never** written to `.git/config`, placed
90
+ on a git command line, or cached in a system keychain — the stored `origin`
91
+ remote is always a plain, tokenless HTTPS URL you can safely inspect. The
92
+ resolved checkout path is printed to stdout (so it is scriptable, e.g.
93
+ `cd "$(pjdev-gitlab-sync group/name)"`); status goes to stderr.
94
+
95
+ Updates are `merge --ff-only`: if the checkout has diverging local commits, the
96
+ command fails loudly rather than discarding your work. Flags mirror
97
+ `pjdev-gitlab-auth` plus `--target` (`GL_SYNC_TARGET`) and `--branch`
98
+ (`GL_SYNC_BRANCH`); the repo path may also come from `GL_REPO`.
99
+
72
100
  ### Recommended: 1Password + `op run`
73
101
 
74
102
  On a developer laptop, keep the token in 1Password and inject it into the host
@@ -29,6 +29,7 @@ dependencies = [
29
29
 
30
30
  [project.scripts]
31
31
  pjdev-gitlab-auth = "pjdev_gitlab.oauth_cli:main"
32
+ pjdev-gitlab-sync = "pjdev_gitlab.sync_cli:main"
32
33
 
33
34
  [project.optional-dependencies]
34
35
  dev = [
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "5.1.10"
4
+ __version__ = "5.1.12"
@@ -0,0 +1,286 @@
1
+ """Clone or update a GitLab repo over HTTPS using a short-lived OAuth token.
2
+
3
+ Deterministic, idempotent counterpart to the interactive auth flow: given an
4
+ OAuth access token (see :func:`pjdev_gitlab.oauth_service.get_access_token`),
5
+ clone the repo when the target directory does not yet exist, or fast-forward it
6
+ when it does. Running the same command again is always safe -- it is the single
7
+ operation a non-technical user needs both to *install* and to *update* a repo.
8
+
9
+ The token is handed to git through ``GIT_ASKPASS`` (the token itself is passed to
10
+ the helper via an environment variable, never on a command line), so it never
11
+ lands in ``.git/config``, in git's argv, or in a system credential store. The
12
+ stored ``origin`` remote is therefore always a plain, tokenless HTTPS URL that
13
+ anyone can safely inspect -- and, because it is HTTPS rather than SSH, no SSH key
14
+ setup is required.
15
+
16
+ Updates use ``merge --ff-only``: if the checkout has diverging local commits or
17
+ uncommitted changes that would be clobbered, the command fails loudly rather than
18
+ discarding the user's work.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shutil
25
+ import stat
26
+ import subprocess # noqa: S404 - invoking git is the entire point of this module
27
+ import sys
28
+ import tempfile
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Callable, Optional, Sequence
32
+
33
+ StatusFn = Callable[[str], None]
34
+
35
+ # GitLab requires the literal username ``oauth2`` when using an OAuth token as
36
+ # HTTPS basic-auth; an ``Authorization: Bearer`` header does NOT work for
37
+ # git-over-HTTPS, only this basic-auth form does.
38
+ OAUTH2_USER = "oauth2"
39
+
40
+ # Name of the env var the generated askpass helper reads the token from, so the
41
+ # token is never written into the helper script or onto any command line.
42
+ _TOKEN_ENV = "PJDEV_GITLAB_SYNC_TOKEN"
43
+
44
+
45
+ class SyncError(RuntimeError):
46
+ """Raised when the clone/update cannot complete."""
47
+
48
+
49
+ def _default_status(msg: str) -> None:
50
+ print(msg, file=sys.stderr)
51
+
52
+
53
+ # A git runner: run ``git <args>`` in ``cwd`` with ``env``, returning captured
54
+ # stdout and raising SyncError on a non-zero exit. Injectable so tests need no
55
+ # real git or network.
56
+ GitRunner = Callable[..., str]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SyncResult:
61
+ """Outcome of a :func:`sync_repo` call."""
62
+
63
+ action: str # "cloned" or "updated"
64
+ path: Path # absolute path to the checkout
65
+ remote_url: str # tokenless HTTPS URL stored as origin
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # URL / path helpers (pure)
70
+ # --------------------------------------------------------------------------- #
71
+ def build_remote_url(gitlab_url: str, repo_path: str) -> str:
72
+ """Return the plain, tokenless HTTPS clone URL for ``repo_path``.
73
+
74
+ ``repo_path`` is a ``group/subgroup/name`` path; a trailing ``.git`` and
75
+ surrounding slashes are tolerated.
76
+ """
77
+ base = gitlab_url.strip().rstrip("/")
78
+ path = repo_path.strip().strip("/")
79
+ if path.endswith(".git"):
80
+ path = path[:-4]
81
+ if not path:
82
+ raise SyncError("repo path is empty")
83
+ return f"{base}/{path}.git"
84
+
85
+
86
+ def _authed_url(remote_url: str) -> str:
87
+ """Insert the ``oauth2`` username into ``remote_url`` (no token -- that comes
88
+ from the askpass helper)."""
89
+ scheme, _, rest = remote_url.partition("://")
90
+ if not rest:
91
+ raise SyncError(f"remote url is not absolute: {remote_url}")
92
+ return f"{scheme}://{OAUTH2_USER}@{rest}"
93
+
94
+
95
+ def default_target_dir(repo_path: str) -> Path:
96
+ """Default checkout directory: the repo's basename in the current directory."""
97
+ name = repo_path.strip().strip("/").rsplit("/", 1)[-1]
98
+ if name.endswith(".git"):
99
+ name = name[:-4]
100
+ if not name:
101
+ raise SyncError("could not derive a target directory from the repo path")
102
+ return Path(name)
103
+
104
+
105
+ # --------------------------------------------------------------------------- #
106
+ # git invocation
107
+ # --------------------------------------------------------------------------- #
108
+ def run_git(
109
+ args: Sequence[str],
110
+ *,
111
+ cwd: Optional[Path] = None,
112
+ env: Optional[dict[str, str]] = None,
113
+ ) -> str:
114
+ """Run ``git <args>`` (credential helpers disabled), returning its stdout.
115
+
116
+ ``-c credential.helper=`` blanks any configured helper so our ``GIT_ASKPASS``
117
+ is the only credential source and nothing gets cached into a system keychain.
118
+ Raises :class:`SyncError` on a non-zero exit.
119
+ """
120
+ full = ["git", "-c", "credential.helper=", *args]
121
+ proc = subprocess.run( # noqa: S603 - args are internally constructed, not user shell
122
+ full,
123
+ cwd=str(cwd) if cwd is not None else None,
124
+ env=env,
125
+ capture_output=True,
126
+ text=True,
127
+ )
128
+ if proc.returncode != 0:
129
+ detail = (proc.stderr or proc.stdout or "").strip()
130
+ raise SyncError(f"`git {' '.join(args)}` failed ({proc.returncode}): {detail}")
131
+ return proc.stdout
132
+
133
+
134
+ def _write_askpass(dir_path: Path) -> Path:
135
+ """Write a POSIX askpass helper that echoes the token from the environment.
136
+
137
+ The username is supplied in the URL, so git only ever asks for the password;
138
+ the helper unconditionally returns the token read from ``_TOKEN_ENV``.
139
+ """
140
+ script = dir_path / "askpass.sh"
141
+ script.write_text(f'#!/bin/sh\nprintf "%s" "${_TOKEN_ENV}"\n')
142
+ script.chmod(stat.S_IRWXU) # 0700
143
+ return script
144
+
145
+
146
+ def _current_branch(runner: GitRunner, target: Path) -> str:
147
+ """Return the branch currently checked out in ``target``.
148
+
149
+ Raises :class:`SyncError` on a detached HEAD, where there is no branch to
150
+ fast-forward and the caller must pass ``--branch`` explicitly.
151
+ """
152
+ try:
153
+ out = runner(["symbolic-ref", "--short", "HEAD"], cwd=target)
154
+ except SyncError as exc:
155
+ raise SyncError(
156
+ f"{target} is not on a branch (detached HEAD); "
157
+ "pass --branch to choose one to fast-forward"
158
+ ) from exc
159
+ branch = (out or "").strip()
160
+ if not branch:
161
+ raise SyncError(
162
+ f"{target} is not on a branch (detached HEAD); "
163
+ "pass --branch to choose one to fast-forward"
164
+ )
165
+ return branch
166
+
167
+
168
+ def _local_branch_exists(runner: GitRunner, target: Path, branch: str) -> bool:
169
+ """Whether ``branch`` already exists as a local branch in ``target``."""
170
+ try:
171
+ runner(["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=target)
172
+ return True
173
+ except SyncError:
174
+ return False
175
+
176
+
177
+ def _ensure_origin(runner: GitRunner, target: Path, remote_url: str) -> None:
178
+ """Point ``origin`` at the tokenless ``remote_url``, adding it if absent.
179
+
180
+ A fresh clone always has an ``origin``, but a pre-existing checkout adopted
181
+ by this tool might have none (or one named differently), so create the remote
182
+ rather than fail an update that has already succeeded.
183
+ """
184
+ try:
185
+ runner(["remote", "get-url", "origin"], cwd=target)
186
+ except SyncError:
187
+ runner(["remote", "add", "origin", remote_url], cwd=target)
188
+ else:
189
+ runner(["remote", "set-url", "origin", remote_url], cwd=target)
190
+
191
+
192
+ # --------------------------------------------------------------------------- #
193
+ # Public entry point
194
+ # --------------------------------------------------------------------------- #
195
+ def sync_repo(
196
+ *,
197
+ gitlab_url: str,
198
+ repo_path: str,
199
+ token: str,
200
+ target_dir: Optional[os.PathLike[str] | str] = None,
201
+ branch: Optional[str] = None,
202
+ on_status: Optional[StatusFn] = None,
203
+ runner: GitRunner = run_git,
204
+ ) -> SyncResult:
205
+ """Clone ``repo_path`` if absent, else fast-forward the existing checkout.
206
+
207
+ Returns a :class:`SyncResult`. Raises :class:`SyncError` on any failure,
208
+ including a fast-forward that would clobber local work.
209
+ """
210
+ if not token:
211
+ raise SyncError("an OAuth access token is required")
212
+ status = on_status or _default_status
213
+ remote_url = build_remote_url(gitlab_url, repo_path)
214
+ authed_url = _authed_url(remote_url)
215
+
216
+ target = Path(target_dir).expanduser() if target_dir else default_target_dir(repo_path)
217
+ target = target.absolute()
218
+
219
+ if target.exists():
220
+ if not (target / ".git").is_dir():
221
+ raise SyncError(
222
+ f"{target} already exists but is not a git repository; "
223
+ "choose a different target directory"
224
+ )
225
+ action = "updated"
226
+ else:
227
+ action = "cloned"
228
+
229
+ # Sensitive git operations (clone/fetch) run with this env; the token is
230
+ # reachable only via the askpass helper, never on a command line.
231
+ askpass_dir = Path(tempfile.mkdtemp(prefix="pjdev-gitlab-sync-"))
232
+ try:
233
+ askpass_dir.chmod(stat.S_IRWXU) # 0700
234
+ askpass = _write_askpass(askpass_dir)
235
+ env = dict(os.environ)
236
+ env["GIT_ASKPASS"] = str(askpass)
237
+ env["GIT_TERMINAL_PROMPT"] = "0" # never fall back to an interactive prompt
238
+ env[_TOKEN_ENV] = token
239
+
240
+ if action == "cloned":
241
+ status(f"Cloning {remote_url} into {target} ...")
242
+ target.parent.mkdir(parents=True, exist_ok=True)
243
+ clone_args = ["clone"]
244
+ if branch:
245
+ clone_args += ["--branch", branch]
246
+ clone_args += [authed_url, str(target)]
247
+ try:
248
+ runner(clone_args, env=env)
249
+ except SyncError:
250
+ # A failed clone can leave a partial target dir behind. Since
251
+ # this call created it, remove it so a re-run starts clean
252
+ # instead of tripping the "exists but not a git repo" guard.
253
+ shutil.rmtree(target, ignore_errors=True)
254
+ raise
255
+ else:
256
+ status(f"Updating {target} from {remote_url} ...")
257
+ # Fast-forward the branch the caller asked for, or -- when none is
258
+ # given -- the one actually checked out (NOT the remote's default,
259
+ # which is what a bare `fetch` + `FETCH_HEAD` would pick up once the
260
+ # user has switched branches). Fetch that branch by name over the raw
261
+ # URL so FETCH_HEAD is exactly it (no named remote, no tracking refs).
262
+ sync_branch = branch or _current_branch(runner, target)
263
+ runner(["fetch", authed_url, sync_branch], cwd=target, env=env)
264
+ if branch and not _local_branch_exists(runner, target, branch):
265
+ # First time this checkout sees the branch: create it at the
266
+ # fetched tip. Safe -- there is no local history to clobber.
267
+ runner(["checkout", "-b", branch, "FETCH_HEAD"], cwd=target, env=env)
268
+ else:
269
+ # Make sure the branch is checked out, then fast-forward it.
270
+ # --ff-only refuses to clobber diverging local commits.
271
+ runner(["checkout", sync_branch], cwd=target, env=env)
272
+ runner(["merge", "--ff-only", "FETCH_HEAD"], cwd=target, env=env)
273
+ finally:
274
+ # Best-effort cleanup of the transient askpass helper.
275
+ try:
276
+ for child in askpass_dir.iterdir():
277
+ child.unlink()
278
+ askpass_dir.rmdir()
279
+ except OSError:
280
+ pass
281
+
282
+ # Guarantee origin is the plain, tokenless (and username-less) HTTPS URL.
283
+ _ensure_origin(runner, target, remote_url)
284
+
285
+ status(f"{action.capitalize()} {target}")
286
+ return SyncResult(action=action, path=target, remote_url=remote_url)
@@ -0,0 +1,164 @@
1
+ """Command-line entry point: clone or update a GitLab repo over OAuth HTTPS.
2
+
3
+ Installed as the ``pjdev-gitlab-sync`` console script (see ``[project.scripts]``
4
+ in ``pyproject.toml``). It mints (or reuses) an OAuth token via the same PKCE
5
+ flow as ``pjdev-gitlab-auth`` and then hands it to
6
+ :func:`pjdev_gitlab.git_sync_service.sync_repo`, which clones the repo the first
7
+ time and fast-forwards it on every subsequent run. This is the one command a
8
+ non-technical user needs to *install* a repo and to *get updates* -- no SSH key
9
+ setup and no token handling required.
10
+
11
+ The stored ``origin`` remote is always a plain, tokenless HTTPS URL. The
12
+ resolved checkout path is printed to stdout (so it is scriptable, e.g.
13
+ ``cd "$(pjdev-gitlab-sync group/name)"``); all status goes to stderr.
14
+
15
+ Usage::
16
+
17
+ pjdev-gitlab-sync group/subgroup/name \\
18
+ --gitlab-url https://gitlab.example.com --client-id <id> [--target DIR]
19
+
20
+ Like ``pjdev-gitlab-auth``, the host defaults to ``https://gitlab.com`` and every
21
+ flag has a ``GL_*`` environment equivalent.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import os
28
+ import sys
29
+
30
+ from pjdev_gitlab.git_sync_service import SyncError, sync_repo
31
+ from pjdev_gitlab.oauth_service import (
32
+ DEFAULT_REDIRECT_PORT,
33
+ DEFAULT_SCOPES,
34
+ OAuthError,
35
+ get_access_token,
36
+ )
37
+
38
+ DEFAULT_GITLAB_URL = "https://gitlab.com"
39
+
40
+
41
+ def main() -> int:
42
+ parser = argparse.ArgumentParser(
43
+ prog="pjdev-gitlab-sync",
44
+ description=(
45
+ "Clone or update a GitLab repo over HTTPS using an OAuth token "
46
+ "(no SSH key needed). Safe to re-run: clones once, fast-forwards after."
47
+ ),
48
+ )
49
+ parser.add_argument(
50
+ "repo",
51
+ nargs="?",
52
+ default=None,
53
+ help="repo path 'group/subgroup/name' (or GL_REPO) -- required",
54
+ )
55
+ parser.add_argument(
56
+ "--target",
57
+ default=None,
58
+ help="directory to clone/update into (or GL_SYNC_TARGET; "
59
+ "default: the repo basename in the current directory)",
60
+ )
61
+ parser.add_argument(
62
+ "--branch",
63
+ default=None,
64
+ help="branch to check out and fast-forward (or GL_SYNC_BRANCH; "
65
+ "default: the repo's default branch)",
66
+ )
67
+ parser.add_argument(
68
+ "--gitlab-url",
69
+ default=None,
70
+ help="GitLab base URL (or GL_GITLAB_URL; default https://gitlab.com)",
71
+ )
72
+ parser.add_argument(
73
+ "--client-id",
74
+ default=None,
75
+ help="registered OAuth app client id (or GL_OAUTH_CLIENT_ID) -- required",
76
+ )
77
+ parser.add_argument(
78
+ "--client-secret",
79
+ default=None,
80
+ help="only for a confidential app (or GL_OAUTH_CLIENT_SECRET)",
81
+ )
82
+ parser.add_argument(
83
+ "--redirect-port",
84
+ type=int,
85
+ default=None,
86
+ help=f"loopback redirect port (or GL_OAUTH_REDIRECT_PORT; default {DEFAULT_REDIRECT_PORT})",
87
+ )
88
+ parser.add_argument(
89
+ "--scopes",
90
+ default=None,
91
+ help=f'space-separated scopes (or GL_OAUTH_SCOPES; default "{DEFAULT_SCOPES}")',
92
+ )
93
+ parser.add_argument(
94
+ "--force",
95
+ action="store_true",
96
+ help="ignore any cached token and run the browser sign-in flow",
97
+ )
98
+ args = parser.parse_args()
99
+
100
+ repo = args.repo or os.environ.get("GL_REPO")
101
+ if not repo:
102
+ print(
103
+ "no repo: pass a 'group/subgroup/name' path or set GL_REPO.",
104
+ file=sys.stderr,
105
+ )
106
+ return 2
107
+
108
+ gitlab_url = args.gitlab_url or os.environ.get("GL_GITLAB_URL") or DEFAULT_GITLAB_URL
109
+ client_id = args.client_id or os.environ.get("GL_OAUTH_CLIENT_ID")
110
+ if not client_id:
111
+ print(
112
+ "no OAuth client id: pass --client-id or set GL_OAUTH_CLIENT_ID to the "
113
+ "registered application's client id.",
114
+ file=sys.stderr,
115
+ )
116
+ return 2
117
+ client_secret = args.client_secret or os.environ.get("GL_OAUTH_CLIENT_SECRET") or None
118
+ scopes = args.scopes or os.environ.get("GL_OAUTH_SCOPES") or DEFAULT_SCOPES
119
+ target = args.target or os.environ.get("GL_SYNC_TARGET") or None
120
+ branch = args.branch or os.environ.get("GL_SYNC_BRANCH") or None
121
+
122
+ port_raw = (
123
+ args.redirect_port
124
+ if args.redirect_port is not None
125
+ else os.environ.get("GL_OAUTH_REDIRECT_PORT", DEFAULT_REDIRECT_PORT)
126
+ )
127
+ try:
128
+ redirect_port = int(port_raw)
129
+ except (TypeError, ValueError):
130
+ print("redirect port must be an integer", file=sys.stderr)
131
+ return 2
132
+
133
+ try:
134
+ token = get_access_token(
135
+ gitlab_url=gitlab_url,
136
+ client_id=client_id,
137
+ client_secret=client_secret,
138
+ redirect_port=redirect_port,
139
+ scopes=scopes,
140
+ force=args.force,
141
+ )
142
+ except OAuthError as exc:
143
+ print(str(exc), file=sys.stderr)
144
+ return 1
145
+
146
+ try:
147
+ result = sync_repo(
148
+ gitlab_url=gitlab_url,
149
+ repo_path=repo,
150
+ token=token,
151
+ target_dir=target,
152
+ branch=branch,
153
+ )
154
+ except SyncError as exc:
155
+ print(str(exc), file=sys.stderr)
156
+ return 1
157
+
158
+ # stdout: just the path, so the command is scriptable.
159
+ print(result.path)
160
+ return 0
161
+
162
+
163
+ if __name__ == "__main__":
164
+ raise SystemExit(main())
@@ -3,72 +3,98 @@ platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /venv/bin/python3
3
3
  cachedir: .pytest_cache
4
4
  rootdir: /builds/keystone/python/pjdev-gitlab
5
5
  configfile: pyproject.toml
6
- plugins: asyncio-1.4.0, respx-0.23.1, anyio-4.14.1
6
+ plugins: asyncio-1.4.0, respx-0.23.1, anyio-4.14.2
7
7
  asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
8
- collecting ... collected 63 items
8
+ collecting ... collected 89 items
9
9
 
10
10
  tests/tests_for_api_utilities.py::test_build_auth_defaults_to_private_token PASSED [ 1%]
11
- tests/tests_for_api_utilities.py::test_build_auth_selects_bearer_for_oauth PASSED [ 3%]
12
- tests/tests_for_api_utilities.py::test_bearer_scheme_sends_authorization_header PASSED [ 4%]
13
- tests/tests_for_api_utilities.py::test_encode_path_segment PASSED [ 6%]
14
- tests/tests_for_api_utilities.py::test_paginate_consumes_x_next_page PASSED [ 7%]
15
- tests/tests_for_api_utilities.py::test_async_retry_http_aborts_on_ignored_status PASSED [ 9%]
16
- tests/tests_for_api_utilities.py::test_async_retry_http_retries_on_5xx PASSED [ 11%]
17
- tests/tests_for_api_utilities.py::test_upload_to_project_returns_markdown PASSED [ 12%]
18
- tests/tests_for_issues_service.py::test_search_issues_paginates PASSED [ 14%]
19
- tests/tests_for_issues_service.py::test_create_issue_with_image_uploads_first_then_creates PASSED [ 15%]
20
- tests/tests_for_issues_service.py::test_set_issue_state PASSED [ 17%]
21
- tests/tests_for_issues_service.py::test_set_issue_labels_modes PASSED [ 19%]
22
- tests/tests_for_issues_service.py::test_aggregate_issues_by_label PASSED [ 20%]
23
- tests/tests_for_issues_service.py::test_comment_on_issue PASSED [ 22%]
24
- tests/tests_for_merge_requests_service.py::test_get_merge_request PASSED [ 23%]
25
- tests/tests_for_merge_requests_service.py::test_comment_on_merge_request PASSED [ 25%]
26
- tests/tests_for_merge_requests_service.py::test_inline_diff_comment_payload_shape PASSED [ 26%]
27
- tests/tests_for_merge_requests_service.py::test_approve_and_unapprove PASSED [ 28%]
28
- tests/tests_for_models.py::test_config_api_base_url_strips_trailing_slash PASSED [ 30%]
29
- tests/tests_for_models.py::test_issue_extra_fields_ignored PASSED [ 31%]
30
- tests/tests_for_models.py::test_merge_request_basic PASSED [ 33%]
31
- tests/tests_for_models.py::test_config_env_loading PASSED [ 34%]
32
- tests/tests_for_oauth_cli.py::test_missing_client_id_errors PASSED [ 36%]
33
- tests/tests_for_oauth_cli.py::test_success_prints_only_token PASSED [ 38%]
34
- tests/tests_for_oauth_cli.py::test_reads_config_from_env PASSED [ 39%]
35
- tests/tests_for_oauth_cli.py::test_bad_redirect_port_errors PASSED [ 41%]
36
- tests/tests_for_oauth_cli.py::test_oauth_error_returns_one PASSED [ 42%]
37
- tests/tests_for_oauth_service.py::test_make_pkce_challenge_is_s256_of_verifier PASSED [ 44%]
38
- tests/tests_for_oauth_service.py::test_cache_path_is_per_host_and_private PASSED [ 46%]
39
- tests/tests_for_oauth_service.py::test_token_if_valid_expiry PASSED [ 47%]
40
- tests/tests_for_oauth_service.py::test_store_token_response_roundtrip PASSED [ 49%]
41
- tests/tests_for_oauth_service.py::test_store_token_response_requires_access_token PASSED [ 50%]
42
- tests/tests_for_oauth_service.py::test_refresh_access_token_success PASSED [ 52%]
43
- tests/tests_for_oauth_service.py::test_refresh_access_token_returns_none_on_error PASSED [ 53%]
44
- tests/tests_for_oauth_service.py::test_get_access_token_reuses_valid_cache PASSED [ 55%]
45
- tests/tests_for_oauth_service.py::test_get_access_token_refreshes_expired_cache PASSED [ 57%]
46
- tests/tests_for_oauth_service.py::test_get_access_token_requires_client_id PASSED [ 58%]
47
- tests/tests_for_packages_service.py::test_upload_generic_package_url_shape PASSED [ 60%]
48
- tests/tests_for_packages_service.py::test_download_generic_package PASSED [ 61%]
49
- tests/tests_for_packages_service.py::test_list_packages PASSED [ 63%]
50
- tests/tests_for_repo_files_service.py::test_get_file_url_encoded PASSED [ 65%]
51
- tests/tests_for_repo_files_service.py::test_download_file_writes_to_dest PASSED [ 66%]
52
- tests/tests_for_repo_files_service.py::test_download_directory_walks_tree PASSED [ 68%]
53
- tests/tests_for_work_items_service.py::test_make_work_item_gid_passthrough_and_coerce PASSED [ 69%]
54
- tests/tests_for_work_items_service.py::test_work_item_from_node_flattens_widgets PASSED [ 71%]
55
- tests/tests_for_work_items_service.py::test_graphql_raises_on_errors PASSED [ 73%]
56
- tests/tests_for_work_items_service.py::test_paginate_graphql_follows_end_cursor PASSED [ 74%]
57
- tests/tests_for_work_items_service.py::test_get_work_item_by_global_id PASSED [ 76%]
58
- tests/tests_for_work_items_service.py::test_get_work_item_by_project_path_and_iid_uses_namespace_lookup PASSED [ 77%]
59
- tests/tests_for_work_items_service.py::test_get_work_item_missing_raises PASSED [ 79%]
60
- tests/tests_for_work_items_service.py::test_search_work_items_paginates_and_translates_state PASSED [ 80%]
61
- tests/tests_for_work_items_service.py::test_search_work_items_group_scope_uses_group_field PASSED [ 82%]
62
- tests/tests_for_work_items_service.py::test_search_work_items_requires_one_scope PASSED [ 84%]
63
- tests/tests_for_work_items_service.py::test_set_work_item_state_closes_via_workItemUpdate PASSED [ 85%]
64
- tests/tests_for_work_items_service.py::test_set_work_item_state_mutation_errors_raise PASSED [ 87%]
65
- tests/tests_for_work_items_service.py::test_list_work_item_notes_filters_system_notes PASSED [ 88%]
66
- tests/tests_for_work_items_service.py::test_comment_on_work_item_creates_note PASSED [ 90%]
67
- tests/tests_for_work_items_service.py::test_reply_to_work_item_discussion_threads_via_discussion_id PASSED [ 92%]
68
- tests/tests_for_work_items_service.py::test_set_work_item_labels_add_passes_addLabelIds PASSED [ 93%]
69
- tests/tests_for_work_items_service.py::test_set_work_item_labels_remove_passes_removeLabelIds PASSED [ 95%]
70
- tests/tests_for_work_items_service.py::test_list_labels_for_project_includes_ancestor_groups PASSED [ 96%]
11
+ tests/tests_for_api_utilities.py::test_build_auth_selects_bearer_for_oauth PASSED [ 2%]
12
+ tests/tests_for_api_utilities.py::test_bearer_scheme_sends_authorization_header PASSED [ 3%]
13
+ tests/tests_for_api_utilities.py::test_encode_path_segment PASSED [ 4%]
14
+ tests/tests_for_api_utilities.py::test_paginate_consumes_x_next_page PASSED [ 5%]
15
+ tests/tests_for_api_utilities.py::test_async_retry_http_aborts_on_ignored_status PASSED [ 6%]
16
+ tests/tests_for_api_utilities.py::test_async_retry_http_retries_on_5xx PASSED [ 7%]
17
+ tests/tests_for_api_utilities.py::test_upload_to_project_returns_markdown PASSED [ 8%]
18
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/s/n-https://gitlab.example.com/g/s/n.git] PASSED [ 10%]
19
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com/-/g/n/-https://gitlab.example.com/g/n.git] PASSED [ 11%]
20
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/n.git-https://gitlab.example.com/g/n.git] PASSED [ 12%]
21
+ tests/tests_for_git_sync_service.py::test_build_remote_url_empty_repo_errors PASSED [ 13%]
22
+ tests/tests_for_git_sync_service.py::test_default_target_dir[developers/claude-skills-claude-skills] PASSED [ 14%]
23
+ tests/tests_for_git_sync_service.py::test_default_target_dir[g/s/name.git-name] PASSED [ 15%]
24
+ tests/tests_for_git_sync_service.py::test_default_target_dir[solo-solo] PASSED [ 16%]
25
+ tests/tests_for_git_sync_service.py::test_clone_when_target_missing PASSED [ 17%]
26
+ tests/tests_for_git_sync_service.py::test_clone_passes_token_via_askpass_env_only PASSED [ 19%]
27
+ tests/tests_for_git_sync_service.py::test_clone_with_branch PASSED [ 20%]
28
+ tests/tests_for_git_sync_service.py::test_update_when_target_is_repo PASSED [ 21%]
29
+ tests/tests_for_git_sync_service.py::test_update_with_existing_branch_checks_out_and_ff PASSED [ 22%]
30
+ tests/tests_for_git_sync_service.py::test_update_with_new_branch_creates_from_fetch_head PASSED [ 23%]
31
+ tests/tests_for_git_sync_service.py::test_update_adds_origin_when_absent PASSED [ 24%]
32
+ tests/tests_for_git_sync_service.py::test_update_detached_head_errors PASSED [ 25%]
33
+ tests/tests_for_git_sync_service.py::test_clone_failure_removes_partial_target PASSED [ 26%]
34
+ tests/tests_for_git_sync_service.py::test_target_exists_but_not_a_repo_errors PASSED [ 28%]
35
+ tests/tests_for_git_sync_service.py::test_missing_token_errors PASSED [ 29%]
36
+ tests/tests_for_git_sync_service.py::test_ff_only_failure_propagates PASSED [ 30%]
37
+ tests/tests_for_issues_service.py::test_search_issues_paginates PASSED [ 31%]
38
+ tests/tests_for_issues_service.py::test_create_issue_with_image_uploads_first_then_creates PASSED [ 32%]
39
+ tests/tests_for_issues_service.py::test_set_issue_state PASSED [ 33%]
40
+ tests/tests_for_issues_service.py::test_set_issue_labels_modes PASSED [ 34%]
41
+ tests/tests_for_issues_service.py::test_aggregate_issues_by_label PASSED [ 35%]
42
+ tests/tests_for_issues_service.py::test_comment_on_issue PASSED [ 37%]
43
+ tests/tests_for_merge_requests_service.py::test_get_merge_request PASSED [ 38%]
44
+ tests/tests_for_merge_requests_service.py::test_comment_on_merge_request PASSED [ 39%]
45
+ tests/tests_for_merge_requests_service.py::test_inline_diff_comment_payload_shape PASSED [ 40%]
46
+ tests/tests_for_merge_requests_service.py::test_approve_and_unapprove PASSED [ 41%]
47
+ tests/tests_for_models.py::test_config_api_base_url_strips_trailing_slash PASSED [ 42%]
48
+ tests/tests_for_models.py::test_issue_extra_fields_ignored PASSED [ 43%]
49
+ tests/tests_for_models.py::test_merge_request_basic PASSED [ 44%]
50
+ tests/tests_for_models.py::test_config_env_loading PASSED [ 46%]
51
+ tests/tests_for_oauth_cli.py::test_missing_client_id_errors PASSED [ 47%]
52
+ tests/tests_for_oauth_cli.py::test_success_prints_only_token PASSED [ 48%]
53
+ tests/tests_for_oauth_cli.py::test_reads_config_from_env PASSED [ 49%]
54
+ tests/tests_for_oauth_cli.py::test_bad_redirect_port_errors PASSED [ 50%]
55
+ tests/tests_for_oauth_cli.py::test_oauth_error_returns_one PASSED [ 51%]
56
+ tests/tests_for_oauth_service.py::test_make_pkce_challenge_is_s256_of_verifier PASSED [ 52%]
57
+ tests/tests_for_oauth_service.py::test_cache_path_is_per_host_and_private PASSED [ 53%]
58
+ tests/tests_for_oauth_service.py::test_token_if_valid_expiry PASSED [ 55%]
59
+ tests/tests_for_oauth_service.py::test_store_token_response_roundtrip PASSED [ 56%]
60
+ tests/tests_for_oauth_service.py::test_store_token_response_requires_access_token PASSED [ 57%]
61
+ tests/tests_for_oauth_service.py::test_refresh_access_token_success PASSED [ 58%]
62
+ tests/tests_for_oauth_service.py::test_refresh_access_token_returns_none_on_error PASSED [ 59%]
63
+ tests/tests_for_oauth_service.py::test_get_access_token_reuses_valid_cache PASSED [ 60%]
64
+ tests/tests_for_oauth_service.py::test_get_access_token_refreshes_expired_cache PASSED [ 61%]
65
+ tests/tests_for_oauth_service.py::test_get_access_token_requires_client_id PASSED [ 62%]
66
+ tests/tests_for_packages_service.py::test_upload_generic_package_url_shape PASSED [ 64%]
67
+ tests/tests_for_packages_service.py::test_download_generic_package PASSED [ 65%]
68
+ tests/tests_for_packages_service.py::test_list_packages PASSED [ 66%]
69
+ tests/tests_for_repo_files_service.py::test_get_file_url_encoded PASSED [ 67%]
70
+ tests/tests_for_repo_files_service.py::test_download_file_writes_to_dest PASSED [ 68%]
71
+ tests/tests_for_repo_files_service.py::test_download_directory_walks_tree PASSED [ 69%]
72
+ tests/tests_for_sync_cli.py::test_missing_repo_errors PASSED [ 70%]
73
+ tests/tests_for_sync_cli.py::test_missing_client_id_errors PASSED [ 71%]
74
+ tests/tests_for_sync_cli.py::test_success_prints_path_and_forwards_args PASSED [ 73%]
75
+ tests/tests_for_sync_cli.py::test_reads_config_from_env PASSED [ 74%]
76
+ tests/tests_for_sync_cli.py::test_oauth_error_returns_one PASSED [ 75%]
77
+ tests/tests_for_sync_cli.py::test_sync_error_returns_one PASSED [ 76%]
78
+ tests/tests_for_sync_cli.py::test_bad_redirect_port_errors PASSED [ 77%]
79
+ tests/tests_for_work_items_service.py::test_make_work_item_gid_passthrough_and_coerce PASSED [ 78%]
80
+ tests/tests_for_work_items_service.py::test_work_item_from_node_flattens_widgets PASSED [ 79%]
81
+ tests/tests_for_work_items_service.py::test_graphql_raises_on_errors PASSED [ 80%]
82
+ tests/tests_for_work_items_service.py::test_paginate_graphql_follows_end_cursor PASSED [ 82%]
83
+ tests/tests_for_work_items_service.py::test_get_work_item_by_global_id PASSED [ 83%]
84
+ tests/tests_for_work_items_service.py::test_get_work_item_by_project_path_and_iid_uses_namespace_lookup PASSED [ 84%]
85
+ tests/tests_for_work_items_service.py::test_get_work_item_missing_raises PASSED [ 85%]
86
+ tests/tests_for_work_items_service.py::test_search_work_items_paginates_and_translates_state PASSED [ 86%]
87
+ tests/tests_for_work_items_service.py::test_search_work_items_group_scope_uses_group_field PASSED [ 87%]
88
+ tests/tests_for_work_items_service.py::test_search_work_items_requires_one_scope PASSED [ 88%]
89
+ tests/tests_for_work_items_service.py::test_set_work_item_state_closes_via_workItemUpdate PASSED [ 89%]
90
+ tests/tests_for_work_items_service.py::test_set_work_item_state_mutation_errors_raise PASSED [ 91%]
91
+ tests/tests_for_work_items_service.py::test_list_work_item_notes_filters_system_notes PASSED [ 92%]
92
+ tests/tests_for_work_items_service.py::test_comment_on_work_item_creates_note PASSED [ 93%]
93
+ tests/tests_for_work_items_service.py::test_reply_to_work_item_discussion_threads_via_discussion_id PASSED [ 94%]
94
+ tests/tests_for_work_items_service.py::test_set_work_item_labels_add_passes_addLabelIds PASSED [ 95%]
95
+ tests/tests_for_work_items_service.py::test_set_work_item_labels_remove_passes_removeLabelIds PASSED [ 96%]
96
+ tests/tests_for_work_items_service.py::test_list_labels_for_project_includes_ancestor_groups PASSED [ 97%]
71
97
  tests/tests_for_work_items_service.py::test_create_label_passes_project_path_color_and_description PASSED [ 98%]
72
98
  tests/tests_for_work_items_service.py::test_create_label_group_scope_passes_group_path PASSED [100%]
73
99
 
74
- ============================== 63 passed in 1.91s ==============================
100
+ ============================== 89 passed in 1.80s ==============================
@@ -0,0 +1,322 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from pjdev_gitlab import git_sync_service as gss
6
+ from pjdev_gitlab.git_sync_service import SyncError, build_remote_url, default_target_dir
7
+
8
+
9
+ class FakeGit:
10
+ """Records git invocations and answers the read-only queries ``sync_repo``
11
+ makes, so the update path can be exercised without real git or network.
12
+
13
+ Knobs model repo state: which branch is checked out, whether ``origin``
14
+ exists, and which branches exist locally.
15
+ """
16
+
17
+ def __init__(self, *, current_branch="main", has_origin=True, local_branches=("main",)):
18
+ self.calls = []
19
+ self.current_branch = current_branch
20
+ self.has_origin = has_origin
21
+ self.local_branches = set(local_branches)
22
+
23
+ def __call__(self, args, *, cwd=None, env=None):
24
+ args = list(args)
25
+ self.calls.append({"args": args, "cwd": cwd, "env": env})
26
+ if args and args[0] == "clone":
27
+ target = Path(args[-1]) # clone's final arg is the destination
28
+ (target / ".git").mkdir(parents=True, exist_ok=True)
29
+ elif args[:2] == ["symbolic-ref", "--short"]:
30
+ return self.current_branch
31
+ elif args[:2] == ["remote", "get-url"]:
32
+ if not self.has_origin:
33
+ raise SyncError("error: No such remote 'origin'")
34
+ elif args[:3] == ["rev-parse", "--verify", "--quiet"]:
35
+ branch = args[-1].rsplit("/", 1)[-1] # refs/heads/<branch>
36
+ if branch not in self.local_branches:
37
+ raise SyncError("not a valid ref")
38
+ return ""
39
+
40
+
41
+ # --------------------------------------------------------------------------- #
42
+ # pure helpers
43
+ # --------------------------------------------------------------------------- #
44
+ @pytest.mark.parametrize(
45
+ "url,repo,expected",
46
+ [
47
+ ("https://gitlab.example.com", "g/s/n", "https://gitlab.example.com/g/s/n.git"),
48
+ ("https://gitlab.example.com/", "/g/n/", "https://gitlab.example.com/g/n.git"),
49
+ ("https://gitlab.example.com", "g/n.git", "https://gitlab.example.com/g/n.git"),
50
+ ],
51
+ )
52
+ def test_build_remote_url(url, repo, expected):
53
+ assert build_remote_url(url, repo) == expected
54
+
55
+
56
+ def test_build_remote_url_empty_repo_errors():
57
+ with pytest.raises(SyncError):
58
+ build_remote_url("https://gitlab.example.com", " ")
59
+
60
+
61
+ @pytest.mark.parametrize(
62
+ "repo,expected",
63
+ [
64
+ ("developers/claude-skills", "claude-skills"),
65
+ ("g/s/name.git", "name"),
66
+ ("solo", "solo"),
67
+ ],
68
+ )
69
+ def test_default_target_dir(repo, expected):
70
+ assert default_target_dir(repo) == Path(expected)
71
+
72
+
73
+ # --------------------------------------------------------------------------- #
74
+ # sync_repo -- clone
75
+ # --------------------------------------------------------------------------- #
76
+ def test_clone_when_target_missing(tmp_path):
77
+ git = FakeGit()
78
+ target = tmp_path / "sub" / "claude-skills"
79
+ result = gss.sync_repo(
80
+ gitlab_url="https://gitlab.example.com",
81
+ repo_path="developers/claude-skills",
82
+ token="TOK",
83
+ target_dir=target,
84
+ runner=git,
85
+ on_status=lambda _m: None,
86
+ )
87
+
88
+ assert result.action == "cloned"
89
+ assert result.path == target.absolute()
90
+ assert result.remote_url == "https://gitlab.example.com/developers/claude-skills.git"
91
+
92
+ clone = next(c for c in git.calls if c["args"][0] == "clone")
93
+ # Username is embedded; the raw token never appears on the command line.
94
+ assert "https://oauth2@gitlab.example.com/developers/claude-skills.git" in clone["args"]
95
+ assert not any("TOK" in a for a in clone["args"])
96
+
97
+ # origin is reset to the plain, username-less, tokenless URL -- but only
98
+ # after confirming the remote exists, and addressed via cwd (not `-C`).
99
+ assert git.calls[-2]["args"] == ["remote", "get-url", "origin"]
100
+ set_url = git.calls[-1]
101
+ assert set_url["args"] == ["remote", "set-url", "origin", result.remote_url]
102
+ assert set_url["cwd"] == target.absolute()
103
+
104
+
105
+ def test_clone_passes_token_via_askpass_env_only(tmp_path):
106
+ git = FakeGit()
107
+ gss.sync_repo(
108
+ gitlab_url="https://gitlab.example.com",
109
+ repo_path="g/n",
110
+ token="SECRET",
111
+ target_dir=tmp_path / "n",
112
+ runner=git,
113
+ on_status=lambda _m: None,
114
+ )
115
+ clone = next(c for c in git.calls if c["args"][0] == "clone")
116
+ env = clone["env"]
117
+ assert env["GIT_ASKPASS"]
118
+ assert env["GIT_TERMINAL_PROMPT"] == "0"
119
+ assert env[gss._TOKEN_ENV] == "SECRET"
120
+ # The askpass helper is cleaned up after the run.
121
+ assert not Path(env["GIT_ASKPASS"]).exists()
122
+
123
+
124
+ def test_clone_with_branch(tmp_path):
125
+ git = FakeGit()
126
+ gss.sync_repo(
127
+ gitlab_url="https://gitlab.example.com",
128
+ repo_path="g/n",
129
+ token="TOK",
130
+ target_dir=tmp_path / "n",
131
+ branch="develop",
132
+ runner=git,
133
+ on_status=lambda _m: None,
134
+ )
135
+ clone = next(c for c in git.calls if c["args"][0] == "clone")
136
+ assert clone["args"][:3] == ["clone", "--branch", "develop"]
137
+
138
+
139
+ # --------------------------------------------------------------------------- #
140
+ # sync_repo -- update
141
+ # --------------------------------------------------------------------------- #
142
+ def test_update_when_target_is_repo(tmp_path):
143
+ target = tmp_path / "existing"
144
+ (target / ".git").mkdir(parents=True)
145
+ git = FakeGit()
146
+
147
+ result = gss.sync_repo(
148
+ gitlab_url="https://gitlab.example.com",
149
+ repo_path="g/n",
150
+ token="TOK",
151
+ target_dir=target,
152
+ runner=git,
153
+ on_status=lambda _m: None,
154
+ )
155
+
156
+ assert result.action == "updated"
157
+ args = [c["args"] for c in git.calls]
158
+ assert not any(a[0] == "clone" for a in args)
159
+ # With no --branch, sync the branch actually checked out (here "main"),
160
+ # fetched by name so FETCH_HEAD is that branch and not the remote default.
161
+ assert ["symbolic-ref", "--short", "HEAD"] in args
162
+ fetch = next(c for c in git.calls if c["args"][0] == "fetch")
163
+ assert fetch["args"] == ["fetch", "https://oauth2@gitlab.example.com/g/n.git", "main"]
164
+ assert fetch["cwd"] == target.absolute()
165
+ assert ["checkout", "main"] in args
166
+ assert ["merge", "--ff-only", "FETCH_HEAD"] in args
167
+
168
+
169
+ def test_update_with_existing_branch_checks_out_and_ff(tmp_path):
170
+ target = tmp_path / "existing"
171
+ (target / ".git").mkdir(parents=True)
172
+ git = FakeGit(local_branches=("main", "release"))
173
+
174
+ gss.sync_repo(
175
+ gitlab_url="https://gitlab.example.com",
176
+ repo_path="g/n",
177
+ token="TOK",
178
+ target_dir=target,
179
+ branch="release",
180
+ runner=git,
181
+ on_status=lambda _m: None,
182
+ )
183
+ args = [c["args"] for c in git.calls]
184
+ assert ["fetch", "https://oauth2@gitlab.example.com/g/n.git", "release"] in args
185
+ assert ["checkout", "release"] in args
186
+ assert ["merge", "--ff-only", "FETCH_HEAD"] in args
187
+ # An existing branch is checked out, never recreated.
188
+ assert not any(a[:2] == ["checkout", "-b"] for a in args)
189
+
190
+
191
+ def test_update_with_new_branch_creates_from_fetch_head(tmp_path):
192
+ target = tmp_path / "existing"
193
+ (target / ".git").mkdir(parents=True)
194
+ git = FakeGit(current_branch="main", local_branches=("main",)) # "release" absent
195
+
196
+ gss.sync_repo(
197
+ gitlab_url="https://gitlab.example.com",
198
+ repo_path="g/n",
199
+ token="TOK",
200
+ target_dir=target,
201
+ branch="release",
202
+ runner=git,
203
+ on_status=lambda _m: None,
204
+ )
205
+ args = [c["args"] for c in git.calls]
206
+ assert ["fetch", "https://oauth2@gitlab.example.com/g/n.git", "release"] in args
207
+ # Branch is new to this checkout: create it at the fetched tip, no ff-merge.
208
+ assert ["checkout", "-b", "release", "FETCH_HEAD"] in args
209
+ assert not any(a[:2] == ["merge", "--ff-only"] for a in args)
210
+
211
+
212
+ def test_update_adds_origin_when_absent(tmp_path):
213
+ target = tmp_path / "existing"
214
+ (target / ".git").mkdir(parents=True)
215
+ git = FakeGit(has_origin=False)
216
+
217
+ result = gss.sync_repo(
218
+ gitlab_url="https://gitlab.example.com",
219
+ repo_path="g/n",
220
+ token="TOK",
221
+ target_dir=target,
222
+ runner=git,
223
+ on_status=lambda _m: None,
224
+ )
225
+ args = [c["args"] for c in git.calls]
226
+ # A checkout without an `origin` gets one added rather than failing set-url.
227
+ assert ["remote", "add", "origin", result.remote_url] in args
228
+ assert not any(a[:2] == ["remote", "set-url"] for a in args)
229
+
230
+
231
+ def test_update_detached_head_errors(tmp_path):
232
+ target = tmp_path / "existing"
233
+ (target / ".git").mkdir(parents=True)
234
+ git = FakeGit(current_branch="") # symbolic-ref yields nothing -> detached
235
+
236
+ with pytest.raises(SyncError, match="detached HEAD"):
237
+ gss.sync_repo(
238
+ gitlab_url="https://gitlab.example.com",
239
+ repo_path="g/n",
240
+ token="TOK",
241
+ target_dir=target,
242
+ runner=git,
243
+ on_status=lambda _m: None,
244
+ )
245
+ # It never fetched or merged after failing to resolve the branch.
246
+ assert not any(c["args"][0] in ("fetch", "merge") for c in git.calls)
247
+
248
+
249
+ def test_clone_failure_removes_partial_target(tmp_path):
250
+ target = tmp_path / "n"
251
+
252
+ def failing_clone(args, *, cwd=None, env=None):
253
+ args = list(args)
254
+ if args[0] == "clone":
255
+ # git often creates the target dir before failing on auth/network.
256
+ Path(args[-1]).mkdir(parents=True, exist_ok=True)
257
+ raise SyncError("`git clone ...` failed (128): auth")
258
+ return ""
259
+
260
+ with pytest.raises(SyncError, match="auth"):
261
+ gss.sync_repo(
262
+ gitlab_url="https://gitlab.example.com",
263
+ repo_path="g/n",
264
+ token="TOK",
265
+ target_dir=target,
266
+ runner=failing_clone,
267
+ on_status=lambda _m: None,
268
+ )
269
+ # The partial checkout is cleaned up so a re-run starts fresh.
270
+ assert not target.exists()
271
+
272
+
273
+ def test_target_exists_but_not_a_repo_errors(tmp_path):
274
+ target = tmp_path / "plainfile"
275
+ target.mkdir()
276
+ (target / "some.txt").write_text("hi") # a dir, but no .git
277
+ git = FakeGit()
278
+
279
+ with pytest.raises(SyncError, match="not a git repository"):
280
+ gss.sync_repo(
281
+ gitlab_url="https://gitlab.example.com",
282
+ repo_path="g/n",
283
+ token="TOK",
284
+ target_dir=target,
285
+ runner=git,
286
+ on_status=lambda _m: None,
287
+ )
288
+ assert git.calls == []
289
+
290
+
291
+ def test_missing_token_errors(tmp_path):
292
+ with pytest.raises(SyncError, match="token is required"):
293
+ gss.sync_repo(
294
+ gitlab_url="https://gitlab.example.com",
295
+ repo_path="g/n",
296
+ token="",
297
+ target_dir=tmp_path / "n",
298
+ runner=FakeGit(),
299
+ )
300
+
301
+
302
+ def test_ff_only_failure_propagates(tmp_path):
303
+ target = tmp_path / "existing"
304
+ (target / ".git").mkdir(parents=True)
305
+
306
+ def failing_git(args, *, cwd=None, env=None):
307
+ args = list(args)
308
+ if args[:2] == ["symbolic-ref", "--short"]:
309
+ return "main"
310
+ if args[:2] == ["merge", "--ff-only"]:
311
+ raise SyncError("`git merge --ff-only FETCH_HEAD` failed (128): diverged")
312
+ return ""
313
+
314
+ with pytest.raises(SyncError, match="diverged"):
315
+ gss.sync_repo(
316
+ gitlab_url="https://gitlab.example.com",
317
+ repo_path="g/n",
318
+ token="TOK",
319
+ target_dir=target,
320
+ runner=failing_git,
321
+ on_status=lambda _m: None,
322
+ )
@@ -0,0 +1,127 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from pjdev_gitlab import sync_cli
7
+ from pjdev_gitlab.git_sync_service import SyncError, SyncResult
8
+ from pjdev_gitlab.oauth_service import OAuthError
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def _clear_env(monkeypatch):
13
+ for key in (
14
+ "GL_REPO",
15
+ "GL_SYNC_TARGET",
16
+ "GL_SYNC_BRANCH",
17
+ "GL_GITLAB_URL",
18
+ "GL_OAUTH_CLIENT_ID",
19
+ "GL_OAUTH_CLIENT_SECRET",
20
+ "GL_OAUTH_REDIRECT_PORT",
21
+ "GL_OAUTH_SCOPES",
22
+ ):
23
+ monkeypatch.delenv(key, raising=False)
24
+
25
+
26
+ def _run(monkeypatch, argv):
27
+ monkeypatch.setattr(sys, "argv", ["pjdev-gitlab-sync", *argv])
28
+ return sync_cli.main()
29
+
30
+
31
+ def _stub_token(monkeypatch, token="TOK"):
32
+ monkeypatch.setattr(sync_cli, "get_access_token", lambda **_k: token)
33
+
34
+
35
+ def test_missing_repo_errors(monkeypatch, capsys):
36
+ _stub_token(monkeypatch)
37
+ rc = _run(monkeypatch, ["--client-id", "cid"])
38
+ assert rc == 2
39
+ assert "repo" in capsys.readouterr().err.lower()
40
+
41
+
42
+ def test_missing_client_id_errors(monkeypatch, capsys):
43
+ rc = _run(monkeypatch, ["g/n"])
44
+ assert rc == 2
45
+ assert "client id" in capsys.readouterr().err.lower()
46
+
47
+
48
+ def test_success_prints_path_and_forwards_args(monkeypatch, capsys):
49
+ _stub_token(monkeypatch, "TOK123")
50
+ captured = {}
51
+
52
+ def fake_sync(**kwargs):
53
+ captured.update(kwargs)
54
+ return SyncResult(action="cloned", path=Path("/abs/claude-skills"), remote_url="url")
55
+
56
+ monkeypatch.setattr(sync_cli, "sync_repo", fake_sync)
57
+ rc = _run(
58
+ monkeypatch,
59
+ [
60
+ "developers/claude-skills",
61
+ "--gitlab-url",
62
+ "https://gitlab.example.com",
63
+ "--client-id",
64
+ "cid",
65
+ "--target",
66
+ "/abs/claude-skills",
67
+ "--branch",
68
+ "main",
69
+ ],
70
+ )
71
+ assert rc == 0
72
+ assert capsys.readouterr().out.strip() == "/abs/claude-skills"
73
+ assert captured["gitlab_url"] == "https://gitlab.example.com"
74
+ assert captured["repo_path"] == "developers/claude-skills"
75
+ assert captured["token"] == "TOK123"
76
+ assert captured["target_dir"] == "/abs/claude-skills"
77
+ assert captured["branch"] == "main"
78
+
79
+
80
+ def test_reads_config_from_env(monkeypatch, capsys):
81
+ monkeypatch.setenv("GL_REPO", "g/env-repo")
82
+ monkeypatch.setenv("GL_GITLAB_URL", "https://env.example.com")
83
+ monkeypatch.setenv("GL_OAUTH_CLIENT_ID", "env-cid")
84
+ _stub_token(monkeypatch)
85
+ captured = {}
86
+ monkeypatch.setattr(
87
+ sync_cli,
88
+ "sync_repo",
89
+ lambda **k: captured.update(k)
90
+ or SyncResult(action="updated", path=Path("/x"), remote_url="u"),
91
+ )
92
+ rc = _run(monkeypatch, [])
93
+ assert rc == 0
94
+ assert captured["repo_path"] == "g/env-repo"
95
+ assert captured["gitlab_url"] == "https://env.example.com"
96
+
97
+
98
+ def test_oauth_error_returns_one(monkeypatch, capsys):
99
+ def boom(**_k):
100
+ raise OAuthError("auth nope")
101
+
102
+ monkeypatch.setattr(sync_cli, "get_access_token", boom)
103
+ rc = _run(monkeypatch, ["g/n", "--client-id", "cid"])
104
+ assert rc == 1
105
+ out = capsys.readouterr()
106
+ assert out.out == ""
107
+ assert "auth nope" in out.err
108
+
109
+
110
+ def test_sync_error_returns_one(monkeypatch, capsys):
111
+ _stub_token(monkeypatch)
112
+
113
+ def boom(**_k):
114
+ raise SyncError("clone nope")
115
+
116
+ monkeypatch.setattr(sync_cli, "sync_repo", boom)
117
+ rc = _run(monkeypatch, ["g/n", "--client-id", "cid"])
118
+ assert rc == 1
119
+ out = capsys.readouterr()
120
+ assert out.out == ""
121
+ assert "clone nope" in out.err
122
+
123
+
124
+ def test_bad_redirect_port_errors(monkeypatch):
125
+ with pytest.raises(SystemExit) as exc:
126
+ _run(monkeypatch, ["g/n", "--client-id", "cid", "--redirect-port", "notaport"])
127
+ assert exc.value.code == 2
File without changes
File without changes
File without changes