-
Notifications
You must be signed in to change notification settings - Fork 128
Expand file tree
/
Copy pathtest_generic_git_urls.py
More file actions
726 lines (570 loc) · 30.4 KB
/
test_generic_git_urls.py
File metadata and controls
726 lines (570 loc) · 30.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
"""Unit tests for generic git URL support in dependency parsing.
Tests that APM can parse dependency references from any git host using
standard git protocol URLs (HTTPS and SSH), including GitLab, Bitbucket,
and self-hosted instances.
"""
from pathlib import Path
import pytest
from src.apm_cli.models.apm_package import DependencyReference
from src.apm_cli.utils.github_host import (
build_https_clone_url,
build_ssh_url,
is_supported_git_host,
)
class TestGenericHostSupport:
"""Test that any valid FQDN is accepted as a git host."""
def test_gitlab_com_is_supported(self):
assert is_supported_git_host("gitlab.com")
def test_bitbucket_org_is_supported(self):
assert is_supported_git_host("bitbucket.org")
def test_self_hosted_gitlab_is_supported(self):
assert is_supported_git_host("gitlab.company.internal")
def test_self_hosted_gitea_is_supported(self):
assert is_supported_git_host("gitea.myorg.com")
def test_custom_git_server_is_supported(self):
assert is_supported_git_host("git.example.com")
def test_localhost_not_supported(self):
"""Single-label hostnames are not valid FQDNs."""
assert not is_supported_git_host("localhost")
def test_empty_not_supported(self):
assert not is_supported_git_host("")
assert not is_supported_git_host(None)
class TestGitLabHTTPS:
"""Test HTTPS git URL parsing for GitLab repositories."""
def test_gitlab_https_url(self):
dep = DependencyReference.parse("https://gitlab.com/acme/coding-standards.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
assert dep.reference is None
def test_gitlab_https_url_no_git_suffix(self):
dep = DependencyReference.parse("https://gitlab.com/acme/coding-standards")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
def test_gitlab_https_url_with_ref(self):
dep = DependencyReference.parse("https://gitlab.com/acme/coding-standards.git#v2.0")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
assert dep.reference == "v2.0"
def test_gitlab_https_url_with_alias_shorthand_removed(self):
"""Shorthand @alias on HTTPS URLs is no longer supported."""
with pytest.raises(ValueError):
DependencyReference.parse("https://gitlab.com/acme/coding-standards.git@my-rules")
def test_gitlab_https_url_with_ref_and_alias_shorthand_not_parsed(self):
"""Shorthand #ref@alias on HTTPS URLs — @ is no longer parsed as alias separator."""
dep = DependencyReference.parse("https://gitlab.com/acme/coding-standards.git#main@rules")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
assert dep.reference == "main@rules"
assert dep.alias is None
def test_gitlab_fqdn_format(self):
"""Test gitlab.com/owner/repo format (without https://)."""
dep = DependencyReference.parse("gitlab.com/acme/coding-standards")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
def test_self_hosted_gitlab_https(self):
dep = DependencyReference.parse("https://gitlab.company.internal/team/rules.git")
assert dep.host == "gitlab.company.internal"
assert dep.repo_url == "team/rules"
def test_self_hosted_gitlab_fqdn(self):
dep = DependencyReference.parse("gitlab.company.internal/team/rules")
assert dep.host == "gitlab.company.internal"
assert dep.repo_url == "team/rules"
class TestGitLabSSH:
"""Test SSH git URL parsing for GitLab repositories."""
def test_gitlab_ssh_git_at(self):
dep = DependencyReference.parse("git@gitlab.com:acme/coding-standards.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
def test_gitlab_ssh_git_at_no_suffix(self):
dep = DependencyReference.parse("git@gitlab.com:acme/coding-standards")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
def test_gitlab_ssh_git_at_with_ref(self):
dep = DependencyReference.parse("git@gitlab.com:acme/coding-standards.git#v1.0")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
assert dep.reference == "v1.0"
def test_gitlab_ssh_protocol(self):
"""Test ssh:// protocol URL normalization."""
dep = DependencyReference.parse("ssh://git@gitlab.com/acme/coding-standards.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
def test_gitlab_ssh_protocol_with_ref(self):
dep = DependencyReference.parse("ssh://git@gitlab.com/acme/coding-standards.git#main")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/coding-standards"
assert dep.reference == "main"
def test_self_hosted_gitlab_ssh(self):
dep = DependencyReference.parse("git@gitlab.company.internal:team/rules.git")
assert dep.host == "gitlab.company.internal"
assert dep.repo_url == "team/rules"
def test_self_hosted_ssh_protocol(self):
dep = DependencyReference.parse("ssh://git@gitlab.company.internal/team/rules.git")
assert dep.host == "gitlab.company.internal"
assert dep.repo_url == "team/rules"
def test_ssh_protocol_with_port(self):
"""Port is stripped during normalization to git@ format, which uses SSH config for custom ports."""
dep = DependencyReference.parse("ssh://git@gitlab.com:2222/acme/repo.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/repo"
class TestBitbucketHTTPS:
"""Test HTTPS git URL parsing for Bitbucket repositories."""
def test_bitbucket_https_url(self):
dep = DependencyReference.parse("https://bitbucket.org/acme/security-rules.git")
assert dep.host == "bitbucket.org"
assert dep.repo_url == "acme/security-rules"
def test_bitbucket_https_no_suffix(self):
dep = DependencyReference.parse("https://bitbucket.org/acme/security-rules")
assert dep.host == "bitbucket.org"
assert dep.repo_url == "acme/security-rules"
def test_bitbucket_https_with_ref(self):
dep = DependencyReference.parse("https://bitbucket.org/acme/security-rules.git#v1.0")
assert dep.host == "bitbucket.org"
assert dep.repo_url == "acme/security-rules"
assert dep.reference == "v1.0"
def test_bitbucket_fqdn_format(self):
dep = DependencyReference.parse("bitbucket.org/acme/security-rules")
assert dep.host == "bitbucket.org"
assert dep.repo_url == "acme/security-rules"
class TestBitbucketSSH:
"""Test SSH git URL parsing for Bitbucket repositories."""
def test_bitbucket_ssh_git_at(self):
dep = DependencyReference.parse("git@bitbucket.org:acme/security-rules.git")
assert dep.host == "bitbucket.org"
assert dep.repo_url == "acme/security-rules"
def test_bitbucket_ssh_protocol(self):
dep = DependencyReference.parse("ssh://git@bitbucket.org/acme/security-rules.git")
assert dep.host == "bitbucket.org"
assert dep.repo_url == "acme/security-rules"
class TestGitHubURLs:
"""Test that GitHub URLs still work correctly with generic support."""
def test_github_https_url(self):
dep = DependencyReference.parse("https://github.com/microsoft/apm.git")
assert dep.host == "github.com"
assert dep.repo_url == "microsoft/apm"
def test_github_https_no_suffix(self):
dep = DependencyReference.parse("https://github.com/microsoft/apm")
assert dep.host == "github.com"
assert dep.repo_url == "microsoft/apm"
def test_github_ssh_url(self):
dep = DependencyReference.parse("git@github.com:microsoft/apm.git")
assert dep.host == "github.com"
assert dep.repo_url == "microsoft/apm"
def test_github_ssh_protocol(self):
dep = DependencyReference.parse("ssh://git@github.com/microsoft/apm.git")
assert dep.host == "github.com"
assert dep.repo_url == "microsoft/apm"
def test_github_shorthand_still_works(self):
dep = DependencyReference.parse("microsoft/apm")
assert dep.host == "github.com"
assert dep.repo_url == "microsoft/apm"
def test_github_fqdn_format(self):
dep = DependencyReference.parse("github.com/microsoft/apm")
assert dep.host == "github.com"
assert dep.repo_url == "microsoft/apm"
class TestSSHProtocolNormalization:
"""Test ssh:// protocol URL normalization."""
def test_basic_ssh_protocol(self):
result = DependencyReference._normalize_ssh_protocol_url(
"ssh://git@gitlab.com/acme/repo.git"
)
assert result == "git@gitlab.com:acme/repo.git"
def test_ssh_protocol_with_port(self):
result = DependencyReference._normalize_ssh_protocol_url(
"ssh://git@gitlab.com:2222/acme/repo.git"
)
assert result == "git@gitlab.com:acme/repo.git"
def test_ssh_protocol_no_user(self):
"""ssh:// without user@ defaults to git@."""
result = DependencyReference._normalize_ssh_protocol_url(
"ssh://gitlab.com/acme/repo.git"
)
assert result == "git@gitlab.com:acme/repo.git"
def test_non_ssh_url_unchanged(self):
result = DependencyReference._normalize_ssh_protocol_url(
"https://gitlab.com/acme/repo.git"
)
assert result == "https://gitlab.com/acme/repo.git"
def test_git_at_url_unchanged(self):
result = DependencyReference._normalize_ssh_protocol_url(
"git@gitlab.com:acme/repo.git"
)
assert result == "git@gitlab.com:acme/repo.git"
class TestBitbucketDatacenterSSH:
"""Regression tests for issue #661: ssh:// URLs with custom ports must be preserved.
Bitbucket Datacenter (and other self-hosted instances) commonly use non-standard
SSH ports (e.g. 7999). When a user explicitly specifies an ssh:// URL in apm.yml
the original URL must be kept verbatim so git clones against the correct port
instead of silently falling back to HTTPS.
"""
def test_preserve_bitbucket_datacenter_ssh_url_with_port(self):
"""ssh:// URL with custom port must be stored in original_ssh_url."""
url = "ssh://git@bitbucket.domain.ext:7999/project/repo.git"
dep = DependencyReference.parse(url)
assert dep.original_ssh_url == url
def test_bitbucket_datacenter_host_and_repo_still_parsed(self):
"""Parsed host/repo_url fields should still be populated correctly."""
dep = DependencyReference.parse(
"ssh://git@bitbucket.domain.ext:7999/project/repo.git"
)
assert dep.host == "bitbucket.domain.ext"
assert dep.repo_url == "project/repo"
def test_preserve_standard_ssh_protocol_url(self):
"""ssh:// without a port also stores the original URL."""
url = "ssh://git@github.com/org/repo.git"
dep = DependencyReference.parse(url)
assert dep.original_ssh_url == url
def test_https_url_does_not_set_original_ssh_url(self):
"""HTTPS dependencies must not set original_ssh_url."""
dep = DependencyReference.parse(
"https://bitbucket.domain.ext/scm/project/repo.git"
)
assert dep.original_ssh_url is None
def test_git_at_url_does_not_set_original_ssh_url(self):
"""git@ SSH shorthand does not go through ssh:// normalisation."""
dep = DependencyReference.parse("git@bitbucket.org:acme/rules.git")
assert dep.original_ssh_url is None
class TestCloneURLBuilding:
"""Test that clone URLs are correctly built for generic hosts."""
def test_gitlab_https_clone_url(self):
url = build_https_clone_url("gitlab.com", "acme/repo")
assert url == "https://gitlab.com/acme/repo"
def test_gitlab_https_clone_url_with_token(self):
url = build_https_clone_url("gitlab.com", "acme/repo", token="glpat-xxx")
assert url == "https://x-access-token:glpat-xxx@gitlab.com/acme/repo.git"
def test_bitbucket_https_clone_url(self):
url = build_https_clone_url("bitbucket.org", "acme/repo")
assert url == "https://bitbucket.org/acme/repo"
def test_gitlab_ssh_clone_url(self):
url = build_ssh_url("gitlab.com", "acme/repo")
assert url == "git@gitlab.com:acme/repo.git"
def test_bitbucket_ssh_clone_url(self):
url = build_ssh_url("bitbucket.org", "acme/repo")
assert url == "git@bitbucket.org:acme/repo.git"
def test_self_hosted_ssh_clone_url(self):
url = build_ssh_url("git.company.internal", "team/repo")
assert url == "git@git.company.internal:team/repo.git"
class TestToGithubURLGenericHosts:
"""Test that to_github_url works correctly for generic hosts."""
def test_gitlab_to_url(self):
dep = DependencyReference.parse("https://gitlab.com/acme/repo.git")
assert dep.to_github_url() == "https://gitlab.com/acme/repo"
def test_bitbucket_to_url(self):
dep = DependencyReference.parse("git@bitbucket.org:acme/repo.git")
assert dep.to_github_url() == "https://bitbucket.org/acme/repo"
def test_self_hosted_to_url(self):
dep = DependencyReference.parse("git@git.company.internal:team/rules.git")
assert dep.to_github_url() == "https://git.company.internal/team/rules"
class TestGetInstallPathGenericHosts:
"""Test that install paths work correctly for generic hosts."""
def test_gitlab_install_path(self):
dep = DependencyReference.parse("https://gitlab.com/acme/repo.git")
path = dep.get_install_path(Path("apm_modules"))
assert path == Path("apm_modules/acme/repo")
def test_bitbucket_install_path(self):
dep = DependencyReference.parse("git@bitbucket.org:team/rules.git")
path = dep.get_install_path(Path("apm_modules"))
assert path == Path("apm_modules/team/rules")
def test_self_hosted_install_path(self):
dep = DependencyReference.parse("git@git.company.internal:team/rules.git")
path = dep.get_install_path(Path("apm_modules"))
assert path == Path("apm_modules/team/rules")
class TestSecurityWithGenericHosts:
"""Test that security protections still work with generic host support."""
def test_protocol_relative_rejected(self):
with pytest.raises(ValueError, match="Protocol-relative"):
DependencyReference.parse("//evil.com/user/repo")
def test_control_characters_rejected(self):
with pytest.raises(ValueError, match="control characters"):
DependencyReference.parse("gitlab.com/user/repo\n")
def test_empty_string_rejected(self):
with pytest.raises(ValueError, match="Empty"):
DependencyReference.parse("")
def test_path_injection_still_rejected(self):
"""Embedding a hostname in a sub-path position is valid with nested groups.
With nested group support on generic hosts, all path segments are part
of the repo path. The host is correctly identified from the first segment.
"""
dep = DependencyReference.parse("evil.com/github.com/user/repo")
assert dep.host == "evil.com"
assert dep.repo_url == "github.com/user/repo"
assert dep.is_virtual is False
def test_invalid_characters_rejected(self):
with pytest.raises(ValueError, match="Invalid repository path component"):
DependencyReference.parse("https://gitlab.com/user/repo$bad")
class TestFQDNVirtualPaths:
"""Test FQDN shorthand with virtual paths on generic hosts.
Git protocol URLs (https://, git@) are repo-level and cannot embed paths.
Use FQDN shorthand (host/owner/repo/path) for virtual packages on any host.
"""
def test_gitlab_virtual_file(self):
dep = DependencyReference.parse("gitlab.com/acme/repo/prompts/file.prompt.md")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/repo"
assert dep.virtual_path == "prompts/file.prompt.md"
assert dep.is_virtual is True
assert dep.is_virtual_file() is True
def test_bitbucket_virtual_collection(self):
dep = DependencyReference.parse("bitbucket.org/team/rules/collections/security")
assert dep.host == "bitbucket.org"
assert dep.repo_url == "team/rules"
assert dep.virtual_path == "collections/security"
assert dep.is_virtual is True
assert dep.is_virtual_collection() is True
def test_self_hosted_virtual_subdirectory(self):
"""Without virtual indicators, all segments are repo path on generic hosts.
Virtual subdirectory packages on generic hosts with nested groups
require the dict format: {git: 'host/group/repo', path: 'subdir'}
"""
dep = DependencyReference.parse("git.company.internal/team/skills/brand-guidelines")
assert dep.host == "git.company.internal"
assert dep.repo_url == "team/skills/brand-guidelines"
assert dep.is_virtual is False
def test_gitlab_virtual_file_with_ref(self):
dep = DependencyReference.parse("gitlab.com/acme/repo/prompts/file.prompt.md#v2.0")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/repo"
assert dep.virtual_path == "prompts/file.prompt.md"
assert dep.reference == "v2.0"
def test_https_url_with_path_rejected(self):
"""HTTPS git URLs can't embed virtual paths — use dict format instead."""
with pytest.raises(ValueError, match="virtual file extension"):
DependencyReference.parse("https://gitlab.com/acme/repo/prompts/file.prompt.md")
def test_ssh_url_with_path_rejected(self):
"""SSH git URLs can't embed virtual paths — use dict format instead."""
with pytest.raises(ValueError, match="virtual file extension"):
DependencyReference.parse("git@gitlab.com:acme/repo/prompts/code-review.prompt.md")
class TestNestedGroupSupport:
"""Test nested group/subgroup support for generic hosts (GitLab, Gitea, etc.).
GitLab supports up to 20 levels of nested groups: gitlab.com/group/subgroup/.../repo.
For generic hosts (non-GitHub, non-ADO), ALL path segments are treated as repo path
unless virtual indicators (file extensions, /collections/) are present.
"""
# --- FQDN shorthand ---
def test_gitlab_two_level_group(self):
dep = DependencyReference.parse("gitlab.com/group/subgroup/repo")
assert dep.host == "gitlab.com"
assert dep.repo_url == "group/subgroup/repo"
assert dep.is_virtual is False
def test_gitlab_three_level_group(self):
dep = DependencyReference.parse("gitlab.com/org/team/project/repo")
assert dep.host == "gitlab.com"
assert dep.repo_url == "org/team/project/repo"
assert dep.is_virtual is False
def test_gitlab_simple_owner_repo_unchanged(self):
dep = DependencyReference.parse("gitlab.com/owner/repo")
assert dep.host == "gitlab.com"
assert dep.repo_url == "owner/repo"
assert dep.is_virtual is False
def test_nested_group_with_ref(self):
dep = DependencyReference.parse("gitlab.com/group/subgroup/repo#v2.0")
assert dep.host == "gitlab.com"
assert dep.repo_url == "group/subgroup/repo"
assert dep.reference == "v2.0"
assert dep.is_virtual is False
def test_nested_group_with_alias_shorthand_removed(self):
"""Shorthand @alias on nested groups is no longer supported."""
with pytest.raises(ValueError):
DependencyReference.parse("gitlab.com/group/subgroup/repo@my-alias")
def test_nested_group_with_ref_and_alias_shorthand_not_parsed(self):
"""Shorthand #ref@alias on nested groups — @ is no longer parsed as alias separator."""
dep = DependencyReference.parse("gitlab.com/group/subgroup/repo#main@alias")
assert dep.repo_url == "group/subgroup/repo"
assert dep.reference == "main@alias"
assert dep.alias is None
# --- SSH URLs ---
def test_ssh_nested_group(self):
dep = DependencyReference.parse("git@gitlab.com:group/subgroup/repo.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "group/subgroup/repo"
assert dep.is_virtual is False
def test_ssh_three_level_group(self):
dep = DependencyReference.parse("git@gitlab.com:org/team/project/repo.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "org/team/project/repo"
def test_ssh_nested_group_no_git_suffix(self):
dep = DependencyReference.parse("git@gitlab.com:group/subgroup/repo")
assert dep.repo_url == "group/subgroup/repo"
def test_ssh_nested_group_with_ref(self):
dep = DependencyReference.parse("git@gitlab.com:group/subgroup/repo.git#v1.0")
assert dep.repo_url == "group/subgroup/repo"
assert dep.reference == "v1.0"
# --- HTTPS URLs ---
def test_https_nested_group(self):
dep = DependencyReference.parse("https://gitlab.com/group/subgroup/repo.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "group/subgroup/repo"
assert dep.is_virtual is False
def test_https_three_level_group(self):
dep = DependencyReference.parse("https://gitlab.com/org/team/project/repo.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "org/team/project/repo"
def test_https_nested_group_no_git_suffix(self):
dep = DependencyReference.parse("https://gitlab.com/group/subgroup/repo")
assert dep.host == "gitlab.com"
assert dep.repo_url == "group/subgroup/repo"
# --- ssh:// protocol URLs ---
def test_ssh_protocol_nested_group(self):
dep = DependencyReference.parse("ssh://git@gitlab.com/group/subgroup/repo.git")
assert dep.host == "gitlab.com"
assert dep.repo_url == "group/subgroup/repo"
# --- Virtual packages with nested groups ---
def test_nested_group_simple_repo_with_virtual_file(self):
"""Simple 2-segment repo on generic host with virtual file extension."""
dep = DependencyReference.parse("gitlab.com/acme/repo/design.prompt.md")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/repo"
assert dep.virtual_path == "design.prompt.md"
assert dep.is_virtual is True
def test_nested_group_simple_repo_with_collection(self):
"""Simple 2-segment repo on generic host with collections path."""
dep = DependencyReference.parse("gitlab.com/acme/repo/collections/security")
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/repo"
assert dep.virtual_path == "collections/security"
assert dep.is_virtual is True
def test_nested_group_virtual_requires_dict_format(self):
"""For nested groups + virtual, dict format is required."""
dep = DependencyReference.parse_from_dict({
"git": "gitlab.com/group/subgroup/repo",
"path": "prompts/review.prompt.md"
})
assert dep.host == "gitlab.com"
assert dep.repo_url == "group/subgroup/repo"
assert dep.virtual_path == "prompts/review.prompt.md"
assert dep.is_virtual is True
# --- Install paths ---
def test_install_path_nested_group(self):
dep = DependencyReference.parse("gitlab.com/group/subgroup/repo")
path = dep.get_install_path(Path("/apm_modules"))
assert path == Path("/apm_modules/group/subgroup/repo")
def test_install_path_three_level_group(self):
dep = DependencyReference.parse("gitlab.com/org/team/project/repo")
path = dep.get_install_path(Path("/apm_modules"))
assert path == Path("/apm_modules/org/team/project/repo")
def test_install_path_simple_generic_host(self):
dep = DependencyReference.parse("gitlab.com/owner/repo")
path = dep.get_install_path(Path("/apm_modules"))
assert path == Path("/apm_modules/owner/repo")
# --- Canonical form ---
def test_canonical_nested_group(self):
dep = DependencyReference.parse("gitlab.com/group/subgroup/repo")
assert dep.to_canonical() == "gitlab.com/group/subgroup/repo"
def test_canonical_nested_group_with_ref(self):
dep = DependencyReference.parse("gitlab.com/group/subgroup/repo#v2.0")
assert dep.to_canonical() == "gitlab.com/group/subgroup/repo#v2.0"
def test_canonical_ssh_nested_group(self):
dep = DependencyReference.parse("git@gitlab.com:group/subgroup/repo.git")
assert dep.to_canonical() == "gitlab.com/group/subgroup/repo"
def test_canonical_https_nested_group(self):
dep = DependencyReference.parse("https://gitlab.com/group/subgroup/repo.git")
assert dep.to_canonical() == "gitlab.com/group/subgroup/repo"
# --- to_github_url (clone URL) ---
def test_to_github_url_nested_group(self):
dep = DependencyReference.parse("gitlab.com/group/subgroup/repo")
assert dep.to_github_url() == "https://gitlab.com/group/subgroup/repo"
# --- GitHub unchanged ---
def test_github_shorthand_unchanged(self):
"""GitHub 2-segment shorthand is unchanged by nested group support."""
dep = DependencyReference.parse("owner/repo")
assert dep.host == "github.com"
assert dep.repo_url == "owner/repo"
assert dep.is_virtual is False
def test_github_virtual_unchanged(self):
"""GitHub 3+ segments still mean virtual package."""
dep = DependencyReference.parse("owner/repo/file.prompt.md")
assert dep.repo_url == "owner/repo"
assert dep.virtual_path == "file.prompt.md"
assert dep.is_virtual is True
# --- Rejection cases ---
# --- Ambiguity: nested group + virtual path (shorthand vs dict) ---
def test_shorthand_ambiguity_virtual_ext_collapses_repo(self):
"""Shorthand with virtual extension treats owner/repo as 2-segment base.
gitlab.com/group/subgroup/repo/file.prompt.md → the parser sees the
.prompt.md extension and assumes a 2-segment repo (group/subgroup)
with virtual path repo/file.prompt.md. This is WRONG if the user
meant repo=group/subgroup/repo. That's why dict format is required.
"""
dep = DependencyReference.parse("gitlab.com/group/subgroup/repo/file.prompt.md")
# Parser sees virtual indicator → assumes 2-segment base
assert dep.repo_url == "group/subgroup"
assert dep.virtual_path == "repo/file.prompt.md"
assert dep.is_virtual is True
def test_dict_format_resolves_ambiguity(self):
"""Dict format makes nested-group + virtual path unambiguous.
The dict format explicitly separates the repo URL from the virtual
path, so there's no ambiguity about where the repo path ends.
"""
dep = DependencyReference.parse_from_dict({
"git": "gitlab.com/group/subgroup/repo",
"path": "file.prompt.md"
})
assert dep.repo_url == "group/subgroup/repo"
assert dep.virtual_path == "file.prompt.md"
assert dep.is_virtual is True
assert dep.host == "gitlab.com"
def test_dict_format_nested_group_with_collection(self):
"""Dict format works for nested-group repos with collections."""
dep = DependencyReference.parse_from_dict({
"git": "gitlab.com/acme/platform/infra/repo",
"path": "collections/security"
})
assert dep.repo_url == "acme/platform/infra/repo"
assert dep.virtual_path == "collections/security"
assert dep.is_virtual is True
def test_dict_format_nested_group_install_path_subdir(self):
"""Install path for dict-based virtual subdirectory nested-group dep."""
dep = DependencyReference.parse_from_dict({
"git": "gitlab.com/group/subgroup/repo",
"path": "skills/code-review"
})
path = dep.get_install_path(Path("/apm_modules"))
# Subdirectory virtual: repo path + virtual path
assert path == Path("/apm_modules/group/subgroup/repo/skills/code-review")
def test_dict_format_nested_group_install_path_file(self):
"""Install path for dict-based virtual file nested-group dep."""
dep = DependencyReference.parse_from_dict({
"git": "gitlab.com/group/subgroup/repo",
"path": "prompts/review.prompt.md"
})
path = dep.get_install_path(Path("/apm_modules"))
# Virtual file: first segment / sanitized package name
assert path == Path("/apm_modules/group/" + dep.get_virtual_package_name())
def test_dict_format_nested_group_canonical(self):
"""Canonical form for dict-based nested-group dep includes virtual path."""
dep = DependencyReference.parse_from_dict({
"git": "gitlab.com/group/subgroup/repo",
"path": "prompts/review.prompt.md"
})
# Canonical includes virtual path since it's a virtual package
assert dep.to_canonical() == "gitlab.com/group/subgroup/repo/prompts/review.prompt.md"
def test_dict_format_nested_group_clone_url(self):
"""Clone URL for dict-based nested-group dep."""
dep = DependencyReference.parse_from_dict({
"git": "gitlab.com/group/subgroup/repo",
"path": "prompts/review.prompt.md"
})
assert dep.to_github_url() == "https://gitlab.com/group/subgroup/repo"
def test_dict_format_nested_group_with_ref_and_alias(self):
"""Dict format with all fields on nested-group repo."""
dep = DependencyReference.parse_from_dict({
"git": "https://gitlab.com/acme/team/project/repo.git",
"path": "instructions/security",
"ref": "v2.0",
"alias": "sec-rules"
})
assert dep.host == "gitlab.com"
assert dep.repo_url == "acme/team/project/repo"
assert dep.virtual_path == "instructions/security"
assert dep.reference == "v2.0"
assert dep.alias == "sec-rules"
assert dep.is_virtual is True
# --- SSH/HTTPS rejection for nested groups with virtual extensions ---
def test_ssh_nested_group_with_virtual_ext_rejected(self):
"""SSH URLs can't embed virtual paths even with nested groups."""
with pytest.raises(ValueError, match="virtual file extension"):
DependencyReference.parse("git@gitlab.com:group/subgroup/file.prompt.md")
def test_https_nested_group_with_virtual_ext_rejected(self):
"""HTTPS URLs can't embed virtual paths even with nested groups."""
with pytest.raises(ValueError, match="virtual file extension"):
DependencyReference.parse("https://gitlab.com/group/subgroup/file.prompt.md")