scc-cli 1.4.1__py3-none-any.whl

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.

Potentially problematic release.


This version of scc-cli might be problematic. Click here for more details.

Files changed (113) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +259 -0
  8. scc_cli/cli_admin.py +706 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1454 -0
  16. scc_cli/cli_org.py +1428 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +892 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +604 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1074 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1521 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/adapter.py +74 -0
  46. scc_cli/marketplace/compute.py +377 -0
  47. scc_cli/marketplace/constants.py +87 -0
  48. scc_cli/marketplace/managed.py +135 -0
  49. scc_cli/marketplace/materialize.py +723 -0
  50. scc_cli/marketplace/normalize.py +548 -0
  51. scc_cli/marketplace/render.py +257 -0
  52. scc_cli/marketplace/resolve.py +459 -0
  53. scc_cli/marketplace/schema.py +506 -0
  54. scc_cli/marketplace/sync.py +260 -0
  55. scc_cli/marketplace/team_cache.py +195 -0
  56. scc_cli/marketplace/team_fetch.py +688 -0
  57. scc_cli/marketplace/trust.py +244 -0
  58. scc_cli/models/__init__.py +41 -0
  59. scc_cli/models/exceptions.py +273 -0
  60. scc_cli/models/plugin_audit.py +434 -0
  61. scc_cli/org_templates.py +269 -0
  62. scc_cli/output_mode.py +167 -0
  63. scc_cli/panels.py +113 -0
  64. scc_cli/platform.py +350 -0
  65. scc_cli/profiles.py +960 -0
  66. scc_cli/remote.py +443 -0
  67. scc_cli/schemas/__init__.py +1 -0
  68. scc_cli/schemas/org-v1.schema.json +456 -0
  69. scc_cli/schemas/team-config.v1.schema.json +163 -0
  70. scc_cli/sessions.py +425 -0
  71. scc_cli/setup.py +588 -0
  72. scc_cli/source_resolver.py +470 -0
  73. scc_cli/stats.py +378 -0
  74. scc_cli/stores/__init__.py +13 -0
  75. scc_cli/stores/exception_store.py +251 -0
  76. scc_cli/subprocess_utils.py +88 -0
  77. scc_cli/teams.py +382 -0
  78. scc_cli/templates/__init__.py +2 -0
  79. scc_cli/templates/org/__init__.py +0 -0
  80. scc_cli/templates/org/minimal.json +19 -0
  81. scc_cli/templates/org/reference.json +74 -0
  82. scc_cli/templates/org/strict.json +38 -0
  83. scc_cli/templates/org/teams.json +42 -0
  84. scc_cli/templates/statusline.sh +75 -0
  85. scc_cli/theme.py +348 -0
  86. scc_cli/ui/__init__.py +124 -0
  87. scc_cli/ui/branding.py +68 -0
  88. scc_cli/ui/chrome.py +395 -0
  89. scc_cli/ui/dashboard/__init__.py +62 -0
  90. scc_cli/ui/dashboard/_dashboard.py +677 -0
  91. scc_cli/ui/dashboard/loaders.py +395 -0
  92. scc_cli/ui/dashboard/models.py +184 -0
  93. scc_cli/ui/dashboard/orchestrator.py +390 -0
  94. scc_cli/ui/formatters.py +443 -0
  95. scc_cli/ui/gate.py +350 -0
  96. scc_cli/ui/help.py +157 -0
  97. scc_cli/ui/keys.py +538 -0
  98. scc_cli/ui/list_screen.py +431 -0
  99. scc_cli/ui/picker.py +700 -0
  100. scc_cli/ui/prompts.py +200 -0
  101. scc_cli/ui/wizard.py +675 -0
  102. scc_cli/update.py +680 -0
  103. scc_cli/utils/__init__.py +39 -0
  104. scc_cli/utils/fixit.py +264 -0
  105. scc_cli/utils/fuzzy.py +124 -0
  106. scc_cli/utils/locks.py +101 -0
  107. scc_cli/utils/ttl.py +376 -0
  108. scc_cli/validate.py +455 -0
  109. scc_cli-1.4.1.dist-info/METADATA +369 -0
  110. scc_cli-1.4.1.dist-info/RECORD +113 -0
  111. scc_cli-1.4.1.dist-info/WHEEL +4 -0
  112. scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
  113. scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,688 @@
1
+ """Team config fetching for federated configurations (Phase 2).
2
+
3
+ This module provides:
4
+ - TeamFetchResult: Result of fetching a team configuration
5
+ - fetch_team_config(): Main entry point with ConfigSource dispatch
6
+ - save_team_config_cache(): Save fetched config to cache
7
+ - load_team_config_cache(): Load config from cache
8
+
9
+ Fetching Flow:
10
+ 1. Dispatch to appropriate fetcher based on ConfigSource type
11
+ 2. Clone/fetch the config from remote source
12
+ 3. Validate against team-config schema
13
+ 4. Save to cache with metadata
14
+ 5. Return TeamFetchResult with config and version info
15
+
16
+ Source Types:
17
+ - GitHub: Clone repo, read config file from path
18
+ - Git: Clone generic git repo, read config file
19
+ - URL: HTTP GET request with ETag support
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import subprocess
26
+ import tempfile
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ import requests
33
+
34
+ from scc_cli.marketplace.team_cache import (
35
+ DEFAULT_TTL,
36
+ MAX_STALE_AGE,
37
+ TeamCacheMeta,
38
+ get_team_config_cache_path,
39
+ get_team_meta_cache_path,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from scc_cli.marketplace.schema import (
44
+ ConfigSource,
45
+ ConfigSourceGit,
46
+ ConfigSourceGitHub,
47
+ ConfigSourceURL,
48
+ )
49
+
50
+
51
+ # ─────────────────────────────────────────────────────────────────────────────
52
+ # Result Types
53
+ # ─────────────────────────────────────────────────────────────────────────────
54
+
55
+
56
+ @dataclass
57
+ class TeamFetchResult:
58
+ """Result of fetching a team configuration.
59
+
60
+ Attributes:
61
+ success: Whether fetch was successful
62
+ team_config: Parsed team config dict (None if failed)
63
+ source_type: Type of source (github, git, url)
64
+ source_url: Normalized source URL
65
+ commit_sha: Git commit SHA (for git/github sources)
66
+ etag: HTTP ETag (for URL sources)
67
+ branch: Git branch (for git/github sources)
68
+ error: Error message (if failed)
69
+ """
70
+
71
+ success: bool
72
+ source_type: str
73
+ source_url: str
74
+ team_config: dict[str, Any] | None = None
75
+ commit_sha: str | None = None
76
+ etag: str | None = None
77
+ branch: str | None = None
78
+ error: str | None = None
79
+
80
+
81
+ @dataclass
82
+ class FallbackFetchResult:
83
+ """Result of fetching with cache fallback support.
84
+
85
+ Extends TeamFetchResult with additional metadata about cache usage
86
+ and staleness warnings to inform the user.
87
+
88
+ Attributes:
89
+ result: The underlying TeamFetchResult
90
+ used_cache: True if result came from cache (not fresh fetch)
91
+ is_stale: True if cache is past TTL but within MAX_STALE_AGE
92
+ staleness_warning: Human-readable warning about stale data
93
+ cache_meta: Metadata about the cached config (if used)
94
+ """
95
+
96
+ result: TeamFetchResult
97
+ used_cache: bool = False
98
+ is_stale: bool = False
99
+ staleness_warning: str | None = None
100
+ cache_meta: TeamCacheMeta | None = None
101
+
102
+ @property
103
+ def success(self) -> bool:
104
+ """Delegate to underlying result."""
105
+ return self.result.success
106
+
107
+ @property
108
+ def team_config(self) -> dict[str, Any] | None:
109
+ """Delegate to underlying result."""
110
+ return self.result.team_config
111
+
112
+ @property
113
+ def error(self) -> str | None:
114
+ """Delegate to underlying result."""
115
+ return self.result.error
116
+
117
+
118
+ # ─────────────────────────────────────────────────────────────────────────────
119
+ # Main Entry Point
120
+ # ─────────────────────────────────────────────────────────────────────────────
121
+
122
+
123
+ def fetch_team_config(
124
+ source: ConfigSource,
125
+ team_name: str,
126
+ cache_root: Path | None = None,
127
+ ) -> TeamFetchResult:
128
+ """Fetch team config from ConfigSource with dispatch.
129
+
130
+ Dispatches to appropriate fetcher based on source type:
131
+ - github: Clone GitHub repo and read config
132
+ - git: Clone generic git repo and read config
133
+ - url: HTTP GET with ETag support
134
+
135
+ Args:
136
+ source: ConfigSource defining where to fetch from
137
+ team_name: Team name for cache key
138
+ cache_root: Cache directory (defaults to XDG cache)
139
+
140
+ Returns:
141
+ TeamFetchResult with config data or error
142
+ """
143
+ # Import here to avoid circular imports
144
+ from scc_cli.marketplace.schema import (
145
+ ConfigSourceGit,
146
+ ConfigSourceGitHub,
147
+ ConfigSourceURL,
148
+ )
149
+
150
+ # Dispatch based on source type
151
+ if isinstance(source, ConfigSourceGitHub):
152
+ result = _fetch_from_github(source, team_name)
153
+ elif isinstance(source, ConfigSourceGit):
154
+ result = _fetch_from_git(source, team_name)
155
+ elif isinstance(source, ConfigSourceURL):
156
+ result = _fetch_from_url(source, team_name)
157
+ else:
158
+ return TeamFetchResult(
159
+ success=False,
160
+ source_type="unknown",
161
+ source_url="",
162
+ error=f"Unknown source type: {type(source).__name__}",
163
+ )
164
+
165
+ # Save to cache on success
166
+ if result.success:
167
+ save_team_config_cache(result, team_name, cache_root)
168
+
169
+ return result
170
+
171
+
172
+ def fetch_team_config_with_fallback(
173
+ source: ConfigSource,
174
+ team_name: str,
175
+ cache_root: Path | None = None,
176
+ ) -> FallbackFetchResult:
177
+ """Fetch team config with graceful degradation to cache.
178
+
179
+ Implements the freshness model:
180
+ - Fresh (age < DEFAULT_TTL): Use cached config directly, skip fetch
181
+ - Stale (TTL < age < MAX_STALE_AGE): Try fetch, fallback to cache on failure
182
+ - Expired (age > MAX_STALE_AGE): Must fetch, no fallback allowed
183
+
184
+ This is the recommended entry point for production use as it provides
185
+ resilience against network failures while maintaining freshness guarantees.
186
+
187
+ Args:
188
+ source: ConfigSource defining where to fetch from
189
+ team_name: Team name for cache key
190
+ cache_root: Cache directory (defaults to XDG cache)
191
+
192
+ Returns:
193
+ FallbackFetchResult with config, cache status, and staleness warnings
194
+ """
195
+ # Step 1: Check if we have cached config
196
+ cached = load_team_config_cache(team_name, cache_root)
197
+
198
+ if cached is not None:
199
+ config, meta = cached
200
+
201
+ # Case A: Cache is fresh - use directly, no fetch needed
202
+ if meta.is_fresh(DEFAULT_TTL):
203
+ return FallbackFetchResult(
204
+ result=TeamFetchResult(
205
+ success=True,
206
+ source_type=meta.source_type,
207
+ source_url=meta.source_url,
208
+ team_config=config,
209
+ commit_sha=meta.commit_sha,
210
+ etag=meta.etag,
211
+ branch=meta.branch,
212
+ ),
213
+ used_cache=True,
214
+ is_stale=False,
215
+ cache_meta=meta,
216
+ )
217
+
218
+ # Case B: Cache is stale but within MAX_STALE_AGE - try fetch, fallback on failure
219
+ if meta.is_within_max_stale_age(MAX_STALE_AGE):
220
+ # Try to fetch fresh config
221
+ result = fetch_team_config(source, team_name, cache_root)
222
+
223
+ if result.success:
224
+ # Fresh fetch succeeded - return it
225
+ return FallbackFetchResult(
226
+ result=result,
227
+ used_cache=False,
228
+ is_stale=False,
229
+ )
230
+ else:
231
+ # Fetch failed - fallback to stale cache with warning
232
+ age_hours = int(meta.age.total_seconds() / 3600)
233
+ staleness_warning = (
234
+ f"Using cached config from {age_hours}h ago (fetch failed: {result.error}). "
235
+ f"Cache will expire in {int((MAX_STALE_AGE - meta.age).total_seconds() / 3600)}h."
236
+ )
237
+
238
+ return FallbackFetchResult(
239
+ result=TeamFetchResult(
240
+ success=True,
241
+ source_type=meta.source_type,
242
+ source_url=meta.source_url,
243
+ team_config=config,
244
+ commit_sha=meta.commit_sha,
245
+ etag=meta.etag,
246
+ branch=meta.branch,
247
+ ),
248
+ used_cache=True,
249
+ is_stale=True,
250
+ staleness_warning=staleness_warning,
251
+ cache_meta=meta,
252
+ )
253
+
254
+ # Case C: Cache is expired (> MAX_STALE_AGE) - fall through to force fetch
255
+
256
+ # Step 2: No usable cache - must fetch
257
+ result = fetch_team_config(source, team_name, cache_root)
258
+
259
+ if not result.success and cached is not None:
260
+ # We had an expired cache but fetch also failed
261
+ _, meta = cached
262
+ age_days = int(meta.age.total_seconds() / 86400)
263
+ result = TeamFetchResult(
264
+ success=False,
265
+ source_type=result.source_type,
266
+ source_url=result.source_url,
267
+ error=(
268
+ f"{result.error}. "
269
+ f"Cached config ({age_days}d old) has expired beyond MAX_STALE_AGE ({MAX_STALE_AGE.days}d) "
270
+ f"and cannot be used as fallback."
271
+ ),
272
+ )
273
+
274
+ return FallbackFetchResult(
275
+ result=result,
276
+ used_cache=False,
277
+ is_stale=False,
278
+ )
279
+
280
+
281
+ # ─────────────────────────────────────────────────────────────────────────────
282
+ # GitHub Fetching
283
+ # ─────────────────────────────────────────────────────────────────────────────
284
+
285
+
286
+ def _fetch_from_github(
287
+ source: ConfigSourceGitHub,
288
+ team_name: str,
289
+ ) -> TeamFetchResult:
290
+ """Fetch team config from GitHub repository.
291
+
292
+ Constructs HTTPS clone URL and delegates to _clone_and_read_config.
293
+
294
+ Args:
295
+ source: GitHub config source
296
+ team_name: Team name for logging
297
+
298
+ Returns:
299
+ TeamFetchResult with config or error
300
+ """
301
+ # Construct GitHub clone URL
302
+ clone_url = f"https://github.com/{source.owner}/{source.repo}.git"
303
+ source_url = f"github.com/{source.owner}/{source.repo}"
304
+
305
+ # Determine branch and path
306
+ branch = source.branch if source.branch else "main"
307
+ config_path = source.path if source.path else "team-config.json"
308
+
309
+ try:
310
+ config, commit_sha, error = _clone_and_read_config(
311
+ clone_url,
312
+ branch=branch,
313
+ path=config_path,
314
+ )
315
+
316
+ if error:
317
+ return TeamFetchResult(
318
+ success=False,
319
+ source_type="github",
320
+ source_url=source_url,
321
+ error=error,
322
+ )
323
+
324
+ return TeamFetchResult(
325
+ success=True,
326
+ team_config=config,
327
+ source_type="github",
328
+ source_url=source_url,
329
+ commit_sha=commit_sha,
330
+ branch=branch,
331
+ )
332
+
333
+ except Exception as e:
334
+ return TeamFetchResult(
335
+ success=False,
336
+ source_type="github",
337
+ source_url=source_url,
338
+ error=str(e),
339
+ )
340
+
341
+
342
+ # ─────────────────────────────────────────────────────────────────────────────
343
+ # Generic Git Fetching
344
+ # ─────────────────────────────────────────────────────────────────────────────
345
+
346
+
347
+ def _fetch_from_git(
348
+ source: ConfigSourceGit,
349
+ team_name: str,
350
+ ) -> TeamFetchResult:
351
+ """Fetch team config from generic Git repository.
352
+
353
+ Uses provided URL directly for cloning.
354
+
355
+ Args:
356
+ source: Git config source
357
+ team_name: Team name for logging
358
+
359
+ Returns:
360
+ TeamFetchResult with config or error
361
+ """
362
+ clone_url = source.url
363
+
364
+ # Normalize source URL for display (remove protocol, .git suffix)
365
+ source_url = clone_url
366
+ if source_url.startswith("https://"):
367
+ source_url = source_url[8:]
368
+ elif source_url.startswith("git@"):
369
+ source_url = source_url[4:].replace(":", "/", 1)
370
+ if source_url.endswith(".git"):
371
+ source_url = source_url[:-4]
372
+
373
+ # Determine branch and path
374
+ branch = source.branch if source.branch else "main"
375
+ config_path = source.path if source.path else "team-config.json"
376
+
377
+ try:
378
+ config, commit_sha, error = _clone_and_read_config(
379
+ clone_url,
380
+ branch=branch,
381
+ path=config_path,
382
+ )
383
+
384
+ if error:
385
+ return TeamFetchResult(
386
+ success=False,
387
+ source_type="git",
388
+ source_url=source_url,
389
+ error=error,
390
+ )
391
+
392
+ return TeamFetchResult(
393
+ success=True,
394
+ team_config=config,
395
+ source_type="git",
396
+ source_url=source_url,
397
+ commit_sha=commit_sha,
398
+ branch=branch,
399
+ )
400
+
401
+ except Exception as e:
402
+ return TeamFetchResult(
403
+ success=False,
404
+ source_type="git",
405
+ source_url=source_url,
406
+ error=str(e),
407
+ )
408
+
409
+
410
+ # ─────────────────────────────────────────────────────────────────────────────
411
+ # URL Fetching
412
+ # ─────────────────────────────────────────────────────────────────────────────
413
+
414
+
415
+ def _fetch_from_url(
416
+ source: ConfigSourceURL,
417
+ team_name: str,
418
+ ) -> TeamFetchResult:
419
+ """Fetch team config from HTTPS URL.
420
+
421
+ Supports ETag for cache validation and custom headers.
422
+
423
+ Args:
424
+ source: URL config source
425
+ team_name: Team name for logging
426
+
427
+ Returns:
428
+ TeamFetchResult with config or error
429
+ """
430
+ url = source.url
431
+
432
+ # Normalize source URL for display
433
+ source_url = url
434
+ if source_url.startswith("https://"):
435
+ source_url = source_url[8:]
436
+
437
+ # Build headers
438
+ headers: dict[str, str] = {}
439
+ if source.headers:
440
+ headers.update(source.headers)
441
+
442
+ try:
443
+ response = requests.get(url, headers=headers, timeout=30)
444
+
445
+ # Handle error status codes
446
+ if response.status_code == 404:
447
+ return TeamFetchResult(
448
+ success=False,
449
+ source_type="url",
450
+ source_url=source_url,
451
+ error=(
452
+ f"HTTP 404: Team config not found at {url}. "
453
+ "Verify the URL is correct and the config file exists."
454
+ ),
455
+ )
456
+
457
+ if response.status_code == 401:
458
+ return TeamFetchResult(
459
+ success=False,
460
+ source_type="url",
461
+ source_url=source_url,
462
+ error=(
463
+ f"HTTP 401: Unauthorized access to {url}. "
464
+ "Add authentication headers in config_source or check credentials."
465
+ ),
466
+ )
467
+
468
+ if response.status_code == 403:
469
+ return TeamFetchResult(
470
+ success=False,
471
+ source_type="url",
472
+ source_url=source_url,
473
+ error=(
474
+ f"HTTP 403: Access denied to {url}. Check permissions or firewall settings."
475
+ ),
476
+ )
477
+
478
+ if response.status_code != 200:
479
+ return TeamFetchResult(
480
+ success=False,
481
+ source_type="url",
482
+ source_url=source_url,
483
+ error=(
484
+ f"HTTP {response.status_code}: Failed to fetch team config from {url}. "
485
+ "Check if the server is reachable and the URL is correct."
486
+ ),
487
+ )
488
+
489
+ # Parse JSON response
490
+ try:
491
+ config = response.json()
492
+ except json.JSONDecodeError as e:
493
+ return TeamFetchResult(
494
+ success=False,
495
+ source_type="url",
496
+ source_url=source_url,
497
+ error=(
498
+ f"Invalid JSON in team config: {e}. "
499
+ "Check that the file contains valid JSON (try a JSON validator)."
500
+ ),
501
+ )
502
+
503
+ # Extract ETag
504
+ etag = response.headers.get("ETag")
505
+
506
+ return TeamFetchResult(
507
+ success=True,
508
+ team_config=config,
509
+ source_type="url",
510
+ source_url=source_url,
511
+ etag=etag,
512
+ )
513
+
514
+ except requests.RequestException as e:
515
+ return TeamFetchResult(
516
+ success=False,
517
+ source_type="url",
518
+ source_url=source_url,
519
+ error=(
520
+ f"Network error fetching team config: {e}. "
521
+ "Check network connection, VPN status, and firewall settings."
522
+ ),
523
+ )
524
+
525
+
526
+ # ─────────────────────────────────────────────────────────────────────────────
527
+ # Git Clone Helper
528
+ # ─────────────────────────────────────────────────────────────────────────────
529
+
530
+
531
+ def _clone_and_read_config(
532
+ clone_url: str,
533
+ branch: str = "main",
534
+ path: str = "team-config.json",
535
+ cache_dir: Path | None = None,
536
+ ) -> tuple[dict[str, Any] | None, str | None, str | None]:
537
+ """Clone git repo and read config file.
538
+
539
+ Args:
540
+ clone_url: Git clone URL
541
+ branch: Branch to checkout
542
+ path: Path to config file within repo
543
+ cache_dir: Cache directory for clone (uses temp if None)
544
+
545
+ Returns:
546
+ Tuple of (config_dict, commit_sha, error_message)
547
+ """
548
+ # Use temp directory for clone
549
+ with tempfile.TemporaryDirectory(prefix="scc_team_") as tmp_dir:
550
+ target_dir = Path(tmp_dir) / "repo"
551
+
552
+ # Clone with shallow depth
553
+ cmd = [
554
+ "git",
555
+ "clone",
556
+ "--depth",
557
+ "1",
558
+ "--branch",
559
+ branch,
560
+ clone_url,
561
+ str(target_dir),
562
+ ]
563
+
564
+ try:
565
+ result = subprocess.run(
566
+ cmd,
567
+ capture_output=True,
568
+ text=True,
569
+ timeout=120,
570
+ )
571
+
572
+ if result.returncode != 0:
573
+ return (None, None, f"Git clone failed: {result.stderr}")
574
+
575
+ except FileNotFoundError:
576
+ return (None, None, "Git not available")
577
+ except subprocess.TimeoutExpired:
578
+ return (None, None, "Git clone timed out")
579
+
580
+ # Get commit SHA
581
+ sha_result = subprocess.run(
582
+ ["git", "-C", str(target_dir), "rev-parse", "HEAD"],
583
+ capture_output=True,
584
+ text=True,
585
+ )
586
+ commit_sha = sha_result.stdout.strip() if sha_result.returncode == 0 else None
587
+
588
+ # Read config file
589
+ config_path = target_dir / path
590
+
591
+ if not config_path.exists():
592
+ return (None, commit_sha, f"Config file not found: {path}")
593
+
594
+ try:
595
+ config_text = config_path.read_text(encoding="utf-8")
596
+ config = json.loads(config_text)
597
+ return (config, commit_sha, None)
598
+ except json.JSONDecodeError as e:
599
+ return (None, commit_sha, f"Invalid JSON in config: {e}")
600
+
601
+
602
+ # ─────────────────────────────────────────────────────────────────────────────
603
+ # Cache Operations
604
+ # ─────────────────────────────────────────────────────────────────────────────
605
+
606
+
607
+ def save_team_config_cache(
608
+ result: TeamFetchResult,
609
+ team_name: str,
610
+ cache_root: Path | None = None,
611
+ ) -> None:
612
+ """Save fetched team config to cache.
613
+
614
+ Creates two files:
615
+ - {team_name}.json: The team config
616
+ - {team_name}.meta.json: Metadata about the fetch
617
+
618
+ Args:
619
+ result: Successful fetch result
620
+ team_name: Team name for cache key
621
+ cache_root: Cache root directory
622
+ """
623
+ if not result.success or result.team_config is None:
624
+ return
625
+
626
+ config_path = get_team_config_cache_path(team_name, cache_root)
627
+ meta_path = get_team_meta_cache_path(team_name, cache_root)
628
+
629
+ # Ensure directory exists
630
+ config_path.parent.mkdir(parents=True, exist_ok=True)
631
+
632
+ # Save config
633
+ config_path.write_text(
634
+ json.dumps(result.team_config, indent=2),
635
+ encoding="utf-8",
636
+ )
637
+
638
+ # Save metadata
639
+ meta = TeamCacheMeta(
640
+ team_name=team_name,
641
+ source_type=result.source_type,
642
+ source_url=result.source_url,
643
+ fetched_at=datetime.now(timezone.utc),
644
+ commit_sha=result.commit_sha,
645
+ etag=result.etag,
646
+ branch=result.branch,
647
+ )
648
+
649
+ meta_path.write_text(
650
+ json.dumps(meta.to_dict(), indent=2),
651
+ encoding="utf-8",
652
+ )
653
+
654
+
655
+ def load_team_config_cache(
656
+ team_name: str,
657
+ cache_root: Path | None = None,
658
+ ) -> tuple[dict[str, Any], TeamCacheMeta] | None:
659
+ """Load team config from cache.
660
+
661
+ Args:
662
+ team_name: Team name to load
663
+ cache_root: Cache root directory
664
+
665
+ Returns:
666
+ Tuple of (config_dict, cache_meta) or None if not cached
667
+ """
668
+ config_path = get_team_config_cache_path(team_name, cache_root)
669
+ meta_path = get_team_meta_cache_path(team_name, cache_root)
670
+
671
+ # Check if both files exist
672
+ if not config_path.exists() or not meta_path.exists():
673
+ return None
674
+
675
+ try:
676
+ # Load config
677
+ config_text = config_path.read_text(encoding="utf-8")
678
+ config = json.loads(config_text)
679
+
680
+ # Load metadata
681
+ meta_text = meta_path.read_text(encoding="utf-8")
682
+ meta_dict = json.loads(meta_text)
683
+ meta = TeamCacheMeta.from_dict(meta_dict)
684
+
685
+ return (config, meta)
686
+
687
+ except (json.JSONDecodeError, KeyError):
688
+ return None