git-getpkg 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {git_getpkg-0.1.0/src/git_getpkg.egg-info → git_getpkg-0.2.0}/PKG-INFO +10 -6
  2. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/README.md +8 -5
  3. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/pyproject.toml +2 -1
  4. git_getpkg-0.2.0/setup.cfg +4 -0
  5. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/__init__.py +1 -1
  6. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/cli.py +208 -138
  7. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/command.py +16 -2
  8. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/discovery.py +33 -3
  9. git_getpkg-0.2.0/src/git_getpkg/installer.py +164 -0
  10. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/models.py +1 -0
  11. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/security.py +5 -5
  12. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/source.py +64 -53
  13. {git_getpkg-0.1.0 → git_getpkg-0.2.0/src/git_getpkg.egg-info}/PKG-INFO +10 -6
  14. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/SOURCES.txt +1 -1
  15. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/requires.txt +1 -0
  16. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/tests/test_cli.py +16 -0
  17. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/tests/test_discovery.py +27 -28
  18. git_getpkg-0.2.0/tests/test_installer.py +48 -0
  19. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/tests/test_source.py +23 -4
  20. git_getpkg-0.1.0/setup.cfg +0 -31
  21. git_getpkg-0.1.0/src/git_getpkg/installer.py +0 -162
  22. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/LICENSE +0 -0
  23. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/__main__.py +0 -0
  24. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/github.py +0 -0
  25. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/trust.py +0 -0
  26. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/dependency_links.txt +0 -0
  27. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/entry_points.txt +0 -0
  28. {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-getpkg
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Discover and easily install packages from Git repositories
5
5
  Author: mergefriends
6
6
  License: MIT License
@@ -37,6 +37,7 @@ Description-Content-Type: text/markdown
37
37
  License-File: LICENSE
38
38
  Requires-Dist: bandit[toml]>=1.8
39
39
  Requires-Dist: pip-audit>=2.10
40
+ Requires-Dist: pipx<2,>=1.7
40
41
  Requires-Dist: rich<14,>=13.5.2
41
42
  Requires-Dist: tomli>=2.0; python_version < "3.11"
42
43
  Dynamic: license-file
@@ -49,7 +50,7 @@ then installs selected Python packages into isolated managed environments.
49
50
  ## Install
50
51
 
51
52
  ```bash
52
- brew install pipx && pipx ensurepath && pipx install git+https://github.com/mergefriends/git-getpkg.git
53
+ brew install pipx && pipx ensurepath && pipx install git-getpkg
53
54
  ```
54
55
 
55
56
  ## Usage
@@ -70,9 +71,12 @@ Install a single package from a repo
70
71
  git getpkg https://github.com/psf/black black
71
72
  ```
72
73
 
73
- ## Development
74
+ ## Develop, build, and test locally
75
+
76
+ Requires Python 3.10+ and pipx. From the repository root:
74
77
 
75
78
  ```bash
76
- python3 -m unittest discover -s tests -v
77
- PYTHONPATH=src python3 -m git_getpkg list .
78
- ```
79
+ pipx install --editable . --force
80
+ pipx run pytest -q
81
+ git getpkg list .
82
+ g```
@@ -6,7 +6,7 @@ then installs selected Python packages into isolated managed environments.
6
6
  ## Install
7
7
 
8
8
  ```bash
9
- brew install pipx && pipx ensurepath && pipx install git+https://github.com/mergefriends/git-getpkg.git
9
+ brew install pipx && pipx ensurepath && pipx install git-getpkg
10
10
  ```
11
11
 
12
12
  ## Usage
@@ -27,9 +27,12 @@ Install a single package from a repo
27
27
  git getpkg https://github.com/psf/black black
28
28
  ```
29
29
 
30
- ## Development
30
+ ## Develop, build, and test locally
31
+
32
+ Requires Python 3.10+ and pipx. From the repository root:
31
33
 
32
34
  ```bash
33
- python3 -m unittest discover -s tests -v
34
- PYTHONPATH=src python3 -m git_getpkg list .
35
- ```
35
+ pipx install --editable . --force
36
+ pipx run pytest -q
37
+ git getpkg list .
38
+ g```
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-getpkg"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Discover and easily install packages from Git repositories"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
12
  "bandit[toml]>=1.8",
13
13
  "pip-audit>=2.10",
14
+ "pipx>=1.7,<2",
14
15
  "rich>=13.5.2,<14",
15
16
  "tomli>=2.0; python_version < '3.11'",
16
17
  ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -1,3 +1,3 @@
1
1
  """The git-getpkg package."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -4,39 +4,77 @@ import argparse
4
4
  import json
5
5
  import os
6
6
  import shlex
7
+ import shutil
7
8
  import sys
8
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
10
  from contextlib import ExitStack, nullcontext
11
+ from io import StringIO
10
12
  from urllib.parse import urlparse
11
13
 
12
14
  from rich.console import Console
15
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
16
+ from rich.table import Table
17
+ from rich.text import Text
13
18
 
14
19
  from git_getpkg.discovery import discover
15
20
  from git_getpkg.github import Repository
16
21
  from git_getpkg.github import repositories as github_repositories
17
22
  from git_getpkg.github import signals as github_signals
18
23
  from git_getpkg.installer import (
19
- bin_directory,
20
- create_command_shims,
21
- ensure_bin_on_path,
24
+ audit_python,
25
+ audit_python_application,
26
+ ensure_pipx_path,
27
+ expose_python_application,
22
28
  high_risk_pip_signals,
23
29
  install_python,
30
+ install_python_application,
31
+ remove_environment,
32
+ remove_python_application,
24
33
  validate_python,
34
+ validate_python_application,
25
35
  )
26
36
  from git_getpkg.models import PackageReport, SourceInfo
27
37
  from git_getpkg.security import findings as security_findings
28
38
  from git_getpkg.security import scan_python
29
- from git_getpkg.source import open_source
39
+ from git_getpkg.source import cleanup_stale_remote_checkouts, open_source
30
40
  from git_getpkg.trust import assess, source_signals
31
41
 
32
42
  MAX_PARALLEL_REPOSITORY_SCANS = 8
33
43
  OWNER_DISCOVERY_CLONE_TIMEOUT_SECONDS = 90
34
44
 
35
45
 
36
- def _link(label: str, url: str | None, enabled: bool) -> str:
37
- if not url or not enabled:
38
- return label
39
- return f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
46
+ def _link(label: str, url: str | None, enabled: bool) -> Text:
47
+ return Text(label, style=f"link {url}") if url and enabled else Text(label)
48
+
49
+
50
+ def _terminal_width() -> int:
51
+ return shutil.get_terminal_size(fallback=(100, 24)).columns
52
+
53
+
54
+ def _render_table(headers: list[str], rows: list[list[str | Text]], *, links: bool = False) -> str:
55
+ """Render a compact table that adapts to the active terminal width."""
56
+ width = _terminal_width()
57
+ stream = StringIO()
58
+ console = Console(
59
+ file=stream,
60
+ width=width,
61
+ force_terminal=links,
62
+ color_system="standard" if links else None,
63
+ highlight=False,
64
+ )
65
+ table = Table(box=None, pad_edge=False, collapse_padding=True, show_edge=False, header_style="bold")
66
+ for header in headers:
67
+ table.add_column(
68
+ header,
69
+ overflow="ellipsis",
70
+ max_width=max(24, width // 2) if header in {"Signals", "Finding"} else None,
71
+ no_wrap=header not in {"Signals", "Finding"},
72
+ ratio=1 if header in {"Signals", "Finding"} else None,
73
+ )
74
+ for row in rows:
75
+ table.add_row(*row)
76
+ console.print(table)
77
+ return stream.getvalue().rstrip()
40
78
 
41
79
 
42
80
  def _display_date(value: str | None) -> str:
@@ -63,47 +101,39 @@ def _compact_signals(signals: list[str], *, limit: int = 2) -> str:
63
101
 
64
102
 
65
103
  def render_reports(reports: list[PackageReport], source: SourceInfo, *, links: bool) -> str:
66
- headers = ["Package", "Type", "Repository", "Namespace", "Path", "Last touched", "Signals"]
67
- raw_rows: list[list[str]] = []
104
+ width = _terminal_width()
105
+ if width < 80:
106
+ headers = ["Package", "Last touched", "Signals"]
107
+ elif width < 110:
108
+ headers = ["Package", "Type", "Last touched", "Signals"]
109
+ else:
110
+ headers = ["Package", "Type", "Repository", "Namespace", "Path", "Last touched", "Signals"]
111
+ rows: list[list[str | Text]] = []
68
112
  for report in reports:
69
113
  touched = (
70
114
  "—" if not report.last_touched_by else f"{report.last_touched_by}, {_display_date(report.last_touched_at)}"
71
115
  )
72
- raw_rows.append(
73
- [
74
- report.package.name + (f" {report.package.version}" if report.package.version else ""),
75
- report.package.ecosystem,
76
- source.repository_name,
77
- source.namespace or "",
78
- report.package.relative_path,
79
- touched,
80
- _compact_signals(report.signals),
81
- ]
82
- )
83
- widths = [len(header) for header in headers]
84
- for row in raw_rows:
85
- for index, cell in enumerate(row):
86
- widths[index] = max(widths[index], len(cell))
87
- output = [" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))]
88
- output.append(" ".join("-" * width for width in widths))
89
- for row in raw_rows:
90
- rendered = list(row)
91
- rendered[2] = _link(row[2], source.repository_url, links)
92
- rendered[3] = _link(row[3], source.namespace_url, links) if row[3] != "—" else row[3]
93
- output.append(
94
- " ".join(cell + " " * max(0, widths[index] - len(row[index])) for index, cell in enumerate(rendered))
95
- )
96
- return "\n".join(output)
116
+ values: dict[str, str | Text] = {
117
+ "Package": report.package.name + (f" {report.package.version}" if report.package.version else ""),
118
+ "Type": report.package.ecosystem,
119
+ "Repository": _link(source.repository_name, source.repository_url, links),
120
+ "Namespace": _link(source.namespace, source.namespace_url, links) if source.namespace else "—",
121
+ "Path": report.package.relative_path,
122
+ "Last touched": touched,
123
+ "Signals": _compact_signals(report.signals),
124
+ }
125
+ rows.append([values[header] for header in headers])
126
+ return _render_table(headers, rows, links=links)
97
127
 
98
128
 
99
129
  def render_repositories(repositories: list[Repository], *, links: bool) -> str:
100
130
  headers = ["Repository", "Language", "Updated", "Visibility", "Status", "Package scan"]
101
- rows: list[list[str]] = []
131
+ rows: list[list[str | Text]] = []
102
132
  for repository in repositories:
103
133
  status = "archived" if repository.archived else "fork" if repository.fork else "active"
104
134
  rows.append(
105
135
  [
106
- repository.name,
136
+ _link(repository.name, repository.url, links),
107
137
  repository.language or "—",
108
138
  _display_date(repository.updated_at),
109
139
  "private" if repository.private else "public",
@@ -111,19 +141,7 @@ def render_repositories(repositories: list[Repository], *, links: bool) -> str:
111
141
  "eligible" if repository.is_package_candidate else "skipped",
112
142
  ]
113
143
  )
114
- widths = [len(header) for header in headers]
115
- for row in rows:
116
- for index, cell in enumerate(row):
117
- widths[index] = max(widths[index], len(cell))
118
- output = [" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))]
119
- output.append(" ".join("-" * width for width in widths))
120
- for repository, row in zip(repositories, rows):
121
- rendered = list(row)
122
- rendered[0] = _link(row[0], repository.url, links)
123
- output.append(
124
- " ".join(cell + " " * max(0, widths[index] - len(row[index])) for index, cell in enumerate(rendered))
125
- )
126
- return "\n".join(output)
144
+ return _render_table(headers, rows, links=links)
127
145
 
128
146
 
129
147
  def render_discovery_size_footer(repositories: list[Repository], table: str) -> str:
@@ -138,43 +156,39 @@ def render_discovery_size_footer(repositories: list[Repository], table: str) ->
138
156
 
139
157
 
140
158
  def render_owner_reports(reports: list[tuple[SourceInfo, PackageReport]], *, links: bool) -> str:
141
- headers = ["Repository source", "Package", "Install", "Last touched", "Signals"]
142
- rows: list[list[str]] = []
159
+ width = _terminal_width()
160
+ headers = (
161
+ ["Repository source", "Package", "Signals"]
162
+ if width < 100
163
+ else ["Repository source", "Package", "Install", "Last touched", "Signals"]
164
+ )
165
+ rows: list[list[str | Text]] = []
143
166
  for source, report in reports:
144
167
  touched = (
145
168
  "—" if not report.last_touched_by else f"{report.last_touched_by}, {_display_date(report.last_touched_at)}"
146
169
  )
147
- rows.append(
148
- [
149
- source.repository_url or source.original,
150
- report.package.name + (f" {report.package.version}" if report.package.version else ""),
151
- "Python ready" if report.package.ecosystem == "Python" and report.package.installable else "list only",
152
- touched,
153
- _compact_signals(report.signals),
154
- ]
155
- )
156
- widths = [len(header) for header in headers]
157
- for row in rows:
158
- for index, cell in enumerate(row):
159
- widths[index] = max(widths[index], len(cell))
160
- output = [" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))]
161
- output.append(" ".join("-" * width for width in widths))
162
- for (source, _), row in zip(reports, rows):
163
- rendered = list(row)
164
- rendered[0] = _link(row[0], source.repository_url, links)
165
- output.append(
166
- " ".join(cell + " " * max(0, widths[index] - len(row[index])) for index, cell in enumerate(rendered))
167
- )
168
- return "\n".join(output)
169
-
170
-
171
- def _reports(source: SourceInfo, *, enrich: bool) -> list[PackageReport]:
170
+ values: dict[str, str | Text] = {
171
+ "Repository source": _link(source.repository_url or source.original, source.repository_url, links),
172
+ "Package": report.package.name + (f" {report.package.version}" if report.package.version else ""),
173
+ "Install": (
174
+ "Python ready" if report.package.ecosystem == "Python" and report.package.installable else "list only"
175
+ ),
176
+ "Last touched": touched,
177
+ "Signals": _compact_signals(report.signals),
178
+ }
179
+ rows.append([values[header] for header in headers])
180
+ return _render_table(headers, rows, links=links)
181
+
182
+
183
+ def _reports(source: SourceInfo, *, enrich: bool, security_package: str | None = None) -> list[PackageReport]:
172
184
  packages = discover(source.root)
173
185
  context = github_signals(source) if enrich else []
174
186
  base = source_signals(source)
175
187
  reports = [assess(source, package, context, base) for package in packages]
176
188
  for report in reports:
177
- if report.package.ecosystem == "Python":
189
+ if report.package.ecosystem == "Python" and (
190
+ security_package is None or report.package.name == security_package
191
+ ):
178
192
  report.signals.append(scan_python(report.package.directory))
179
193
  return reports
180
194
 
@@ -199,6 +213,26 @@ def _select(reports: list[PackageReport], name: str | None) -> list[PackageRepor
199
213
  return matches
200
214
 
201
215
 
216
+ def _print_source_risk_findings(reports: list[PackageReport]) -> None:
217
+ """Print detailed non-executing Bandit findings for selected Python packages."""
218
+ rows: list[list[str]] = []
219
+ for report in reports:
220
+ for severity, rule, filename, line, message in security_findings(report.package.directory):
221
+ rows.append([severity, rule, filename, str(line), message])
222
+ if rows:
223
+ print(_render_table(["Severity", "Rule", "File", "Line", "Finding"], rows))
224
+ else:
225
+ print("No high or medium source-risk findings.")
226
+
227
+
228
+ def _source_risk_command(source: str, package: str | None) -> str:
229
+ """Return a copy-pasteable command for detailed source-risk findings."""
230
+ values = ["git", "getpkg", "scan", source]
231
+ if package:
232
+ values.append(package)
233
+ return shlex.join(values)
234
+
235
+
202
236
  def _scan_github_repository(repository: Repository) -> list[tuple[SourceInfo, PackageReport]]:
203
237
  """Clone, scan, and clean up one approved repository in a worker thread."""
204
238
  with open_source(repository.clone_url, clone_timeout=OWNER_DISCOVERY_CLONE_TIMEOUT_SECONDS) as source:
@@ -330,22 +364,11 @@ def _install(
330
364
  if unsupported:
331
365
  raise RuntimeError(f"v1 can install only installable Python projects: {', '.join(unsupported)}")
332
366
  source_identity = source.repository_url or str(source.root)
333
- plans = [install_python(item.package, source.commit, source_identity, dry_run=True) for item in selected]
334
- if show_review:
335
- for target, commands in plans:
336
- print(f"\nTarget environment: {target}")
337
- for command in commands:
338
- print(" $ " + shlex.join(command))
339
- managed_bin = bin_directory()
340
- if str(managed_bin) not in os.environ.get("PATH", "").split(os.pathsep) and show_review:
341
- print(f"\nAfter installation, git getpkg will add {managed_bin} to your shell PATH for future terminals.")
342
367
  if args.dry_run:
343
368
  return 0
344
- if not args.yes and sys.stdin.isatty() and _confirm("Show Live security scan findings?", False):
345
- print("\nSeverity Rule File Line Finding")
346
- for report in selected:
347
- for severity, rule, filename, line, message in security_findings(report.package.directory):
348
- print(f"{severity:<8} {rule:<4} {filename} {line:<4} {message}")
369
+ console.print(
370
+ f"[yellow]Source-risk details:[/] [dim]{_source_risk_command(args.source, args.package)}[/]"
371
+ )
349
372
  if not _confirm(
350
373
  f"Install {len(selected)} package(s)? These commands may execute package build/install code. Continue?",
351
374
  args.yes,
@@ -353,67 +376,99 @@ def _install(
353
376
  print("Cancelled.")
354
377
  return 0
355
378
  failures = 0
356
- for report in selected:
357
- try:
358
- activity = (
359
- console.status(f"Installing {report.package.name}", spinner="dots") if show_progress else nullcontext()
360
- )
361
- with activity:
362
- target, _ = install_python(report.package, source.commit, source_identity, dry_run=False)
363
- print(f"Installed {report.package.name} into {target}")
364
- validation_error = validate_python(target)
365
- if validation_error:
379
+ progress = (
380
+ Progress(
381
+ SpinnerColumn(),
382
+ TextColumn("[progress.description]{task.description}"),
383
+ BarColumn(),
384
+ console=console,
385
+ transient=True,
386
+ )
387
+ if show_progress
388
+ else None
389
+ )
390
+ with progress or nullcontext():
391
+ for report in selected:
392
+ task = progress.add_task(f"Installing {report.package.name}", total=4) if progress else None
393
+ try:
394
+ if report.package.has_console_scripts:
395
+ install_python_application(report.package, dry_run=False)
396
+ target = None
397
+ else:
398
+ target, _ = install_python(report.package, source.commit, source_identity, dry_run=False)
399
+ if progress:
400
+ progress.update(task, description=f"Validating {report.package.name}", completed=1)
401
+ validation_error = (
402
+ validate_python_application(report.package) if target is None else validate_python(target)
403
+ )
404
+ if progress:
405
+ progress.update(task, description=f"Auditing dependencies for {report.package.name}", completed=2)
406
+ audit = audit_python_application(report.package) if target is None else audit_python(target)
407
+ if validation_error or audit.error or audit.findings:
408
+ failures += 1
409
+ if report.package.has_console_scripts:
410
+ remove_python_application(report.package)
411
+ elif target:
412
+ remove_environment(target)
413
+ console.print(
414
+ f"[bold red]✗ Installation failed for {report.package.name}; staged environment removed.[/]"
415
+ )
416
+ if validation_error:
417
+ print(f"Dependency validation failed: {validation_error}", file=sys.stderr)
418
+ if audit.error:
419
+ print(f"Dependency audit unavailable: {audit.error}", file=sys.stderr)
420
+ for package, version, advisory, fixes in audit.findings:
421
+ fixed = ", ".join(fixes) or "no fix published"
422
+ print(f"Dependency audit: {package} {version} · {advisory} · fixed in {fixed}", file=sys.stderr)
423
+ continue
424
+ if target:
425
+ print(f"Installed {report.package.name} into {target}")
426
+ else:
427
+ if progress:
428
+ progress.update(task, description=f"Exposing commands for {report.package.name}", completed=3)
429
+ expose_python_application(report.package)
430
+ ensure_pipx_path()
431
+ print("Pipx manages this package's commands. Open a new terminal if they are not yet on PATH.")
432
+ if progress:
433
+ progress.update(task, description=f"Installed {report.package.name}", completed=4)
434
+ console.print(f"[bold green]✓ Installed {report.package.name} · dependency audit clean (OSV).[/]")
435
+ except Exception as error: # Continue to report every requested package.
366
436
  failures += 1
367
- print(f"Dependency validation failed for {report.package.name}: {validation_error}", file=sys.stderr)
368
- shims = create_command_shims(report.package, target)
369
- if shims.created:
370
- print(f"Added command(s) to {shims.directory}: {', '.join(shims.created)}")
371
- path_result = ensure_bin_on_path(directory=shims.directory)
372
- print(path_result.message)
373
- if path_result.configured:
374
- print("Open a new terminal to use these commands by name.")
375
- if shims.conflicts:
376
- print(f"Did not replace existing command(s): {', '.join(shims.conflicts)}", file=sys.stderr)
377
- except Exception as error: # Continue to report every requested package.
378
- failures += 1
379
- print(f"Failed to install {report.package.name}: {error}", file=sys.stderr)
437
+ console.print(f"[bold red]✗ Failed to install {report.package.name}: {error}[/]")
380
438
  return 1 if failures else 0
381
439
 
382
440
 
383
441
  def _print_install_review(source: SourceInfo, selected: list[PackageReport]) -> None:
384
442
  """Show the opt-in pre-install review in a compact table."""
385
- headers = ["Package", "Commit", "Live security scan", "Signals"]
443
+ headers = ["Package", "Source risk scan", "Signals"] if _terminal_width() < 90 else [
444
+ "Package",
445
+ "Commit",
446
+ "Source risk scan",
447
+ "Signals",
448
+ ]
386
449
  rows: list[list[str]] = []
387
450
  for report in selected:
388
451
  security = next(
389
- (item for item in report.signals if item.startswith("Live security scan:")),
390
- "Live security scan: unavailable",
452
+ (item for item in report.signals if item.startswith("Source risk scan (Bandit):")),
453
+ "Source risk scan (Bandit): unavailable",
391
454
  )
392
455
  other = [item for item in report.signals if item != security]
393
- rows.append(
394
- [
395
- report.package.name,
396
- (source.commit or "unresolved")[:12],
397
- security.removeprefix("Live security scan: "),
398
- _compact_signals(other, limit=4),
399
- ]
400
- )
401
- widths = [len(header) for header in headers]
402
- for row in rows:
403
- for index, cell in enumerate(row):
404
- widths[index] = max(widths[index], len(cell))
456
+ values = {
457
+ "Package": report.package.name,
458
+ "Commit": (source.commit or "unresolved")[:12],
459
+ "Source risk scan": security.removeprefix("Source risk scan (Bandit): "),
460
+ "Signals": _compact_signals(other, limit=4),
461
+ }
462
+ rows.append([values[header] for header in headers])
405
463
  print()
406
- print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
407
- print(" ".join("-" * width for width in widths))
408
- for row in rows:
409
- print(" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)))
464
+ print(_render_table(headers, rows))
410
465
 
411
466
 
412
467
  def parser() -> argparse.ArgumentParser:
413
468
  command = argparse.ArgumentParser(
414
469
  prog="git getpkg", description="Safely discover and install packages from Git sources."
415
470
  )
416
- command.add_argument("--version", action="version", version="git-getpkg 0.1.0")
471
+ command.add_argument("--version", action="version", version="git-getpkg 0.2.0")
417
472
  subcommands = command.add_subparsers(dest="command", required=False)
418
473
  list_command = subcommands.add_parser("list", help="Discover packages without executing repository code")
419
474
  list_command.add_argument("source")
@@ -429,13 +484,16 @@ def parser() -> argparse.ArgumentParser:
429
484
  install.add_argument("package", nargs="?")
430
485
  install.add_argument("--dry-run", action="store_true")
431
486
  install.add_argument("--yes", action="store_true")
487
+ scan = subcommands.add_parser("scan", help="Show detailed non-executing source-risk findings")
488
+ scan.add_argument("source")
489
+ scan.add_argument("package", nargs="?")
432
490
  return command
433
491
 
434
492
 
435
493
  def main(argv: list[str] | None = None) -> int:
436
494
  values = list(argv if argv is not None else sys.argv[1:])
437
495
  # `git getpkg <source>` is the friendly default form.
438
- if values and values[0] not in {"list", "install", "--help", "-h", "--version"}:
496
+ if values and values[0] not in {"list", "install", "scan", "--help", "-h", "--version"}:
439
497
  values.insert(0, "install")
440
498
  args = parser().parse_args(values)
441
499
  if args.command is None:
@@ -443,6 +501,7 @@ def main(argv: list[str] | None = None) -> int:
443
501
  return 0
444
502
  try:
445
503
  console = Console(stderr=True)
504
+ cleanup_stale_remote_checkouts()
446
505
  show_progress = sys.stderr.isatty() and not (args.command == "list" and args.as_json)
447
506
  github_owner = github_owner_from_source(args.source) if args.command == "list" else None
448
507
  if github_owner:
@@ -454,8 +513,12 @@ def main(argv: list[str] | None = None) -> int:
454
513
  with activity as status:
455
514
  source = stack.enter_context(open_source(args.source))
456
515
  if status:
457
- status.update("Scanning package manifests and running Security scan…")
458
- reports = _reports(source, enrich=args.command == "install")
516
+ status.update("Scanning package manifests and running source risk scan…")
517
+ reports = _reports(
518
+ source,
519
+ enrich=args.command == "install",
520
+ security_package=args.package if args.command in {"install", "scan"} else None,
521
+ )
459
522
  if status and args.command == "install":
460
523
  status.update("Preparing installation review…")
461
524
  if args.command == "list":
@@ -467,6 +530,13 @@ def main(argv: list[str] | None = None) -> int:
467
530
  else:
468
531
  print("No supported package manifests found.")
469
532
  return 0
533
+ if args.command == "scan":
534
+ selected = _select(reports, args.package)
535
+ if not selected:
536
+ print("No packages discovered.")
537
+ return 0
538
+ _print_source_risk_findings(selected)
539
+ return 0
470
540
  return _install(source, reports, args, console=console, show_progress=show_progress)
471
541
  except (ValueError, RuntimeError, OSError) as error:
472
542
  print(f"git getpkg: {error}", file=sys.stderr)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import subprocess
4
5
  from pathlib import Path
5
6
 
@@ -9,10 +10,23 @@ class CommandError(RuntimeError):
9
10
 
10
11
 
11
12
  def run(
12
- args: list[str], *, cwd: Path | None = None, check: bool = True, timeout: float | None = None
13
+ args: list[str],
14
+ *,
15
+ cwd: Path | None = None,
16
+ check: bool = True,
17
+ timeout: float | None = None,
18
+ env: dict[str, str] | None = None,
13
19
  ) -> subprocess.CompletedProcess[str]:
14
20
  try:
15
- result = subprocess.run(args, cwd=cwd, text=True, capture_output=True, check=False, timeout=timeout)
21
+ result = subprocess.run(
22
+ args,
23
+ cwd=cwd,
24
+ text=True,
25
+ capture_output=True,
26
+ check=False,
27
+ timeout=timeout,
28
+ env=None if env is None else os.environ | env,
29
+ )
16
30
  except subprocess.TimeoutExpired as error:
17
31
  raise CommandError(f"{' '.join(args[:3])}: timed out after {error.timeout:g} seconds") from error
18
32
  if check and result.returncode: