Skip to content

Commit 13818a2

Browse files
authored
feat(import[gitlab]): add --with-shared and --skip-group flags (#519)
## Summary - **`--with-shared`** — controls whether GitLab's `/groups/:id/projects` endpoint returns projects that have been shared into the group from other namespaces. GitLab's API default is `with_shared=true`; vcspull now sends `with_shared=false` by default so only the group's own repos appear, with `--with-shared` to opt back in. - **`--skip-group GROUP`** — client-side filter that excludes all repositories whose owner namespace path contains `GROUP` as a path segment (case-insensitive). Repeatable: `--skip-group bots --skip-group archived`. Matches `my-group/bots` and `my-group/bots/subteam` but not `my-group/robotics`. Both flags are GitLab-only; all other service importers (GitHub, Gitea, CodeCommit, Codeberg, Forgejo) are unaffected — new params carry safe defaults. ## What changed | Layer | File | Change | |-------|------|--------| | Model | `_internal/remotes/base.py` | `ImportOptions`: new `with_shared: bool = False` and `skip_groups: list[str]` fields; `filter_repo()`: segment-matching skip guard before min-stars check | | API | `_internal/remotes/gitlab.py` | `_paginate_repos()`: emit `with_shared=true/false` inside `if include_subgroups:` block (group mode only) | | CLI | `cli/import_cmd/gitlab.py` | `--with-shared` (`store_true`) and `--skip-group` (`action=append`, `default=None`) | | Plumbing | `cli/import_cmd/_common.py` | `_run_import()`: new `with_shared`/`skip_groups` kwargs; normalises `None → []` before `ImportOptions` construction | | Docs | `docs/cli/import/gitlab.md` | "Including shared repositories" and "Skipping subgroups" sections | **GitLab API reference:** - [`GET /groups/:id/projects`](https://docs.gitlab.com/api/groups/) — `with_shared` (boolean, optional, **default `true`**) - [Sharing projects and groups](https://docs.gitlab.com/user/project/members/sharing_projects_groups/) ## Test plan ### Automated (run with `uv run pytest`) All 1158 existing + new tests pass. New tests added: #### URL parameter verification (`tests/_internal/remotes/test_gitlab.py`) These tests monkeypatch `urllib.request.urlopen`, capture the actual URL sent to the GitLab API, and assert using `urllib.parse.parse_qs` (not substring search, so query-string ordering cannot cause false failures). - [ ] `test_gitlab_with_shared_false_by_default` — group mode, no flag → `parse_qs` confirms `with_shared=["false"]` in the request URL - [ ] `test_gitlab_with_shared_true_when_flag_set` — group mode, `ImportOptions(with_shared=True)` → `parse_qs` confirms `with_shared=["true"]` - [ ] `test_gitlab_with_shared_not_sent_in_user_mode` — user mode, `with_shared=True` in options (should be ignored) → `with_shared` key must be **absent** from the URL (GitLab `/users/:id/projects` does not accept this param) #### Filter integration (`tests/_internal/remotes/test_gitlab.py`) These tests inject a multi-repo mock response and assert which repos survive after the full `filter_repo()` pass inside `fetch_repos()`. - [ ] `test_gitlab_skip_groups_filters_repos` — response contains repos in `testgroup`, `testgroup/bots`, `testgroup/bots/subteam`; `skip_groups=["bots"]` → only the `testgroup` repo survives (segment match eliminates both bots tiers) - [ ] `test_gitlab_skip_groups_case_insensitive` — repo owner is `ORG/Bots`, `skip_groups=["bots"]` (lowercase) → repo is excluded (case-insensitive match) #### CLI parser roundtrip (`tests/cli/test_import_repos.py`) - [ ] `test_import_with_shared_flag_via_cli` — `parser.parse_args([..., "--with-shared"])` → `args.with_shared is True` - [ ] `test_import_skip_group_flag_via_cli` — `parser.parse_args([..., "--skip-group", "bots", "--skip-group", "archived"])` → `args.skip_groups == ["bots", "archived"]` #### End-to-end plumbing (`tests/cli/test_import_repos.py`) - [ ] `test_run_import_forwards_with_shared_and_skip_groups` — uses `CapturingMockImporter` (new helper) to record the `ImportOptions` object that `_run_import()` constructs and passes to `fetch_repos()`; asserts `opts.with_shared is True` and `opts.skip_groups == ["bots", "archived"]` ### Manual smoke tests (requires `GITLAB_TOKEN` with `api` scope) **E2E fixture setup (one-time):** `tony/external-shared-repo` (public) has been shared into `vcs-python-group-test` at Reporter (20) access. This makes `--with-shared` observably different from the default. The group now has 15 own repos + 1 externally-shared repo visible with `--with-shared`. All 5 permutations verified on 2026-02-21: | Flags | Expected | Actual | Status | |-------|----------|--------|--------| | (none) | 15 own repos | ✓ 15 | ✅ | | `--with-shared` | 15 + 1 shared | ✓ 16 | ✅ | | `--skip-group vcs-python-subgroup-test` | top-level only | ✓ 5 | ✅ | | `--with-shared --skip-group vcs-python-subgroup-test` | top-level + shared | ✓ 6 | ✅ | | `--skip-group subgroup` (segment guard) | all 15 | ✓ 15 | ✅ | The combined case (row 4) is the key proof: `skip_groups` does **not** accidentally drop `external-shared-repo` (whose namespace is `tony`, with no overlap with `vcs-python-subgroup-test`). **`--with-shared` default (shared repos excluded):** ```console $ vcspull import gl vcs-python-group-test \ --mode org \ --workspace /tmp/test-import \ --dry-run \ --yes ``` Expected: 15 repos (group's own repos only; `external-shared-repo` absent). **`--with-shared` enabled:** ```console $ vcspull import gl vcs-python-group-test \ --mode org \ --workspace /tmp/test-import \ --with-shared \ --dry-run \ --yes ``` Expected: 16 repos (`external-shared-repo` from `tony` namespace appears). **`--skip-group` skipping the subgroup:** ```console $ vcspull import gl vcs-python-group-test \ --mode org \ --workspace /tmp/test-import \ --skip-group vcs-python-subgroup-test \ --dry-run \ --yes ``` Expected: only top-level repos (5 repos). `sub-test-repo-{1-4}` and `subsub-test-repo-{1-4}` must be absent. **`--skip-group` skipping only the sub-subgroup:** ```console $ vcspull import gl vcs-python-group-test \ --mode org \ --workspace /tmp/test-import \ --skip-group vcs-python-subsubgroup-test \ --dry-run \ --yes ``` Expected: top-level + subgroup repos. `subsub-test-repo-{1-4}` must be absent. **Segment-not-substring guard:** ```console $ vcspull import gl vcs-python-group-test \ --mode org \ --workspace /tmp/test-import \ --skip-group subgroup \ --dry-run \ --yes ``` Expected: all 15 own repos returned — `vcs-python-subgroup-test` contains the string "subgroup" but the segment is `vcs-python-subgroup-test`, not `subgroup`, so no match. **`--with-shared` has no effect in user mode:** ```console $ vcspull import gl <your-gitlab-username> \ --mode user \ --workspace /tmp/test-import \ --with-shared \ --dry-run \ --yes ``` Expected: same result as without `--with-shared` (param not sent to API).
2 parents b8b6aff + 06330ff commit 13818a2

8 files changed

Lines changed: 1128 additions & 11 deletions

File tree

CHANGES

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ $ uvx --from 'vcspull' --prerelease allow vcspull
3333
_Notes on upcoming releases will be added here_
3434
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
3535

36+
### Breaking changes
37+
38+
#### `vcspull import gitlab`: `--with-shared` now defaults to excluded
39+
40+
Prior to this release, GitLab's group API returned shared projects by default.
41+
vcspull now explicitly requests `with_shared=false`, so shared repositories are
42+
excluded unless `--with-shared` is passed.
43+
44+
### New features
45+
46+
#### `vcspull import gitlab`: Add `--with-shared` and `--skip-group` flags
47+
48+
- `--with-shared`: Opt-in flag to include projects shared into a group from
49+
another namespace. Only applies in `--mode org`; a warning is emitted when
50+
used in user or search modes.
51+
- `--skip-group <name>`: Client-side filter that excludes all repositories
52+
whose owner path contains the specified group name segment (case-insensitive,
53+
segment-based). Repeat the flag to skip multiple groups.
54+
3655
## vcspull v1.56.1 (2026-02-18)
3756

3857
### Bug fixes

docs/cli/import/gitlab.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,50 @@ Given a group tree `my-group → sub → leaf`, importing from `~/code/`:
6161
When the target is already the deepest group (a leaf), `--flatten-groups` has
6262
no effect — all repositories already land in the base workspace.
6363

64+
## Including shared repositories
65+
66+
By default, repositories shared into a group from another namespace are
67+
excluded. Use `--with-shared` to include them when importing in `--mode org`:
68+
69+
```console
70+
$ vcspull import gl my-group \
71+
--mode org \
72+
--workspace ~/code/ \
73+
--with-shared
74+
```
75+
76+
`--with-shared` has no effect in user mode or search mode; a warning is
77+
emitted if the flag is passed in those contexts.
78+
79+
## Skipping subgroups
80+
81+
Use `--skip-group` to exclude all repositories whose owner path contains a
82+
specific group name segment. Matching is case-insensitive and segment-based:
83+
84+
```console
85+
$ vcspull import gl my-group \
86+
--mode org \
87+
--workspace ~/code/ \
88+
--skip-group bots
89+
```
90+
91+
Repeat the flag to skip multiple groups:
92+
93+
```console
94+
$ vcspull import gl my-group \
95+
--mode org \
96+
--workspace ~/code/ \
97+
--skip-group bots \
98+
--skip-group archived
99+
```
100+
101+
The flag matches any path segment: `--skip-group bots` skips repos owned by
102+
`my-group/bots` or `my-group/bots/subteam` but not `my-group/robotics`.
103+
104+
> **Note**: Passing the root group name itself (`--skip-group my-org` when
105+
> importing `my-org`) will silently exclude every repository, since all repo
106+
> paths include the root group as a path segment.
107+
64108
## Authentication
65109

66110
- **Env vars**: `GITLAB_TOKEN` (primary), `GL_TOKEN` (fallback)

src/vcspull/_internal/remotes/base.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,15 @@ class ImportOptions:
232232
Minimum star count (for search mode)
233233
limit : int
234234
Maximum number of repositories to return
235+
with_shared : bool
236+
Include projects shared into a group from other namespaces
237+
(GitLab group mode only; default: False)
238+
skip_groups : list[str]
239+
Exclude repos whose owner path contains any of these group name
240+
segments (case-insensitive segment matching). For flat-namespace
241+
hosts (e.g. GitHub orgs, which have a single path segment), passing
242+
the org name itself as a skip value would exclude every repository
243+
under that org.
235244
"""
236245

237246
mode: ImportMode = ImportMode.USER
@@ -244,6 +253,8 @@ class ImportOptions:
244253
topics: list[str] = dataclasses.field(default_factory=list)
245254
min_stars: int = 0
246255
limit: int = 100
256+
with_shared: bool = False
257+
skip_groups: list[str] = dataclasses.field(default_factory=list)
247258

248259
def __post_init__(self) -> None:
249260
"""Validate options after initialization.
@@ -263,6 +274,18 @@ def __post_init__(self) -> None:
263274
Traceback (most recent call last):
264275
...
265276
ValueError: limit must be >= 0, got -1
277+
278+
>>> opts = ImportOptions(with_shared=True)
279+
>>> opts.with_shared
280+
True
281+
282+
>>> opts = ImportOptions(skip_groups=["bots", "archived"])
283+
>>> opts.skip_groups
284+
['bots', 'archived']
285+
286+
>>> opts = ImportOptions()
287+
>>> opts.skip_groups
288+
[]
266289
"""
267290
if self.limit < 0:
268291
msg = f"limit must be >= 0, got {self.limit}"
@@ -606,6 +629,37 @@ def filter_repo(
606629
>>> options = ImportOptions(language="JavaScript")
607630
>>> filter_repo(repo, options)
608631
False
632+
633+
>>> options = ImportOptions(skip_groups=["user"])
634+
>>> filter_repo(repo, options)
635+
False
636+
637+
>>> options = ImportOptions(skip_groups=["other-group"])
638+
>>> filter_repo(repo, options)
639+
True
640+
641+
>>> repo_sub = RemoteRepo(
642+
... name="sub-project",
643+
... clone_url="https://github.com/org/sub/sub-project.git",
644+
... ssh_url="git@github.com:org/sub/sub-project.git",
645+
... html_url="https://github.com/org/sub/sub-project",
646+
... description=None,
647+
... language=None,
648+
... topics=(),
649+
... stars=0,
650+
... is_fork=False,
651+
... is_archived=False,
652+
... default_branch="main",
653+
... owner="org/sub",
654+
... )
655+
>>> filter_repo(repo_sub, ImportOptions(skip_groups=["sub"]))
656+
False
657+
>>> filter_repo(repo_sub, ImportOptions(skip_groups=["org"]))
658+
False
659+
>>> filter_repo(repo_sub, ImportOptions(skip_groups=["unrelated"]))
660+
True
661+
>>> filter_repo(repo_sub, ImportOptions(skip_groups=["SUB"]))
662+
False
609663
"""
610664
# Check fork filter
611665
if repo.is_fork and not options.include_forks:
@@ -628,5 +682,12 @@ def filter_repo(
628682
if not required_topics_lower.issubset(repo_topics_lower):
629683
return False
630684

685+
# Check skip_groups: exclude if any owner path segment matches
686+
if options.skip_groups:
687+
owner_segments = {s.lower() for s in repo.owner.split("/") if s}
688+
skip_set = {g.lower() for g in options.skip_groups if g}
689+
if owner_segments & skip_set:
690+
return False
691+
631692
# Check minimum stars
632693
return not (options.min_stars > 0 and repo.stars < options.min_stars)

src/vcspull/_internal/remotes/gitlab.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,18 @@ def _fetch_search(self, options: ImportOptions) -> t.Iterator[RemoteRepo]:
241241
page += 1
242242

243243
# Warn if results were truncated by --limit
244-
self._warn_truncation(count, options.limit, total_available, last_x_next_page)
244+
self._warn_truncation(
245+
count,
246+
options.limit,
247+
total_available,
248+
last_x_next_page,
249+
has_client_filters=bool(
250+
options.skip_groups
251+
or options.language
252+
or options.topics
253+
or options.min_stars > 0
254+
),
255+
)
245256

246257
def _paginate_repos(
247258
self,
@@ -283,6 +294,7 @@ def _paginate_repos(
283294

284295
if include_subgroups:
285296
params["include_subgroups"] = "true"
297+
params["with_shared"] = "true" if options.with_shared else "false"
286298

287299
if not options.include_archived:
288300
params["archived"] = "false"
@@ -324,37 +336,71 @@ def _paginate_repos(
324336
page += 1
325337

326338
# Warn if results were truncated by --limit
327-
self._warn_truncation(count, options.limit, total_available, last_x_next_page)
339+
self._warn_truncation(
340+
count,
341+
options.limit,
342+
total_available,
343+
last_x_next_page,
344+
has_client_filters=bool(
345+
options.skip_groups
346+
or options.language
347+
or options.topics
348+
or options.min_stars > 0
349+
),
350+
)
328351

329352
def _warn_truncation(
330353
self,
331354
count: int,
332355
limit: int,
333356
total_available: int | None,
334357
x_next_page: str | None,
358+
*,
359+
has_client_filters: bool = False,
335360
) -> None:
336361
"""Warn if results were truncated by the --limit option.
337362
338363
Parameters
339364
----------
340365
count : int
341-
Number of repositories actually returned
366+
Number of repositories returned after client-side filtering
342367
limit : int
343368
The configured limit
344369
total_available : int | None
345-
Value of x-total header (None if absent)
370+
Value of x-total header (None if absent). Reflects the unfiltered
371+
server-side total; may overstate the count of repos matching
372+
client-side filters (skip_groups, language, topics).
346373
x_next_page : str | None
347374
Value of x-next-page header (None if absent/empty)
375+
has_client_filters : bool
376+
True when any client-side filter (skip_groups, language, topics,
377+
min_stars) is active; suppresses the early ``count < limit`` return
378+
so the qualified warning can fire even when count < limit.
348379
"""
349-
if count < limit:
380+
# Without client-side filters, count < limit means we consumed all
381+
# server results without hitting the limit — no truncation to warn about.
382+
# With active filters, skip the early return: count < limit may simply
383+
# reflect that filters excluded repos, while total_available still
384+
# exceeds count and is worth surfacing to the user.
385+
if count < limit and not has_client_filters:
350386
return
351387

352388
if total_available is not None and total_available > count:
353-
log.warning(
354-
"Showing %d of %d repositories (use --limit 0 to fetch all)",
355-
count,
356-
total_available,
357-
)
389+
if has_client_filters:
390+
log.warning(
391+
"Showing %d repositories; %d total on server "
392+
"(note: server total includes projects excluded by "
393+
"--skip-group / --language / --topics / --min-stars; "
394+
"use --limit 0 to fetch all matching your filters)",
395+
count,
396+
total_available,
397+
)
398+
else:
399+
log.warning(
400+
"Showing %d of %d repositories (use --limit 0 to fetch all)",
401+
count,
402+
total_available,
403+
)
358404
elif x_next_page is not None:
359405
log.warning(
360406
"Showing %d repositories; more are available "

src/vcspull/cli/import_cmd/_common.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ def _run_import(
281281
color: str,
282282
use_https: bool = False,
283283
flatten_groups: bool = False,
284+
with_shared: bool = False,
285+
skip_groups: list[str] | None = None,
284286
) -> int:
285287
"""Run the import workflow for a single service.
286288
@@ -329,6 +331,10 @@ def _run_import(
329331
Use HTTPS clone URLs instead of SSH (default: False, i.e., SSH)
330332
flatten_groups : bool
331333
For GitLab org imports, flatten subgroup paths into base workspace
334+
with_shared : bool
335+
Include projects shared into a group from other namespaces (GitLab only)
336+
skip_groups : list[str] | None
337+
Exclude repos whose owner path contains any of these group name segments
332338
333339
Returns
334340
-------
@@ -347,6 +353,21 @@ def _run_import(
347353
else []
348354
)
349355

356+
skip_groups_list: list[str] = skip_groups or []
357+
358+
# Validate skip_groups: each value must be a single path segment.
359+
# A slash inside a value like "bots/subteam" would never match any
360+
# owner segment (filter_repo splits repo.owner on "/" and compares
361+
# individual parts), so it silently has no effect.
362+
for group in skip_groups_list:
363+
if "/" in group:
364+
log.error(
365+
"--skip-group values must be single path segments; "
366+
"'/' is not allowed: %r",
367+
group,
368+
)
369+
return 1
370+
350371
try:
351372
options = ImportOptions(
352373
mode=import_mode,
@@ -357,6 +378,8 @@ def _run_import(
357378
topics=topic_list,
358379
min_stars=min_stars,
359380
limit=limit,
381+
with_shared=with_shared,
382+
skip_groups=skip_groups_list,
360383
)
361384
except ValueError as exc_:
362385
log.error("%s %s", colors.error("✗"), exc_) # noqa: TRY400
@@ -384,6 +407,16 @@ def _run_import(
384407
colors.warning("!"),
385408
importer.service_name,
386409
)
410+
if (
411+
options.with_shared
412+
and service_name == "gitlab"
413+
and options.mode != ImportMode.ORG
414+
):
415+
log.warning(
416+
"%s --with-shared has no effect outside org mode; "
417+
"shared projects are only available via the GitLab group API",
418+
colors.warning("!"),
419+
)
387420

388421
# Resolve workspace path
389422
workspace_path = pathlib.Path(workspace).expanduser().resolve()

src/vcspull/cli/import_cmd/gitlab.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,27 @@ def create_gitlab_subparser(
5454
"workspace instead of preserving subgroup paths"
5555
),
5656
)
57+
parser.add_argument(
58+
"--with-shared",
59+
action="store_true",
60+
dest="with_shared",
61+
default=False,
62+
help=(
63+
"Include projects shared into the group from other namespaces "
64+
"(default: exclude shared projects)"
65+
),
66+
)
67+
parser.add_argument(
68+
"--skip-group",
69+
action="append",
70+
dest="skip_groups",
71+
metavar="GROUP",
72+
default=None,
73+
help=(
74+
"Exclude repos whose namespace contains GROUP as a path segment "
75+
"(repeatable: --skip-group bots --skip-group archived)"
76+
),
77+
)
5778
parser.set_defaults(import_handler=handle_gitlab)
5879

5980

@@ -98,4 +119,6 @@ def handle_gitlab(args: argparse.Namespace) -> int:
98119
color=getattr(args, "color", "auto"),
99120
use_https=getattr(args, "use_https", False),
100121
flatten_groups=getattr(args, "flatten_groups", False),
122+
with_shared=getattr(args, "with_shared", False),
123+
skip_groups=getattr(args, "skip_groups", None),
101124
)

0 commit comments

Comments
 (0)