worktree-shield 0.1.0__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.
- worktree_shield/__init__.py +8 -0
- worktree_shield/__main__.py +6 -0
- worktree_shield/cli.py +377 -0
- worktree_shield/collectors/__init__.py +1 -0
- worktree_shield/collectors/base.py +63 -0
- worktree_shield/collectors/compose.py +315 -0
- worktree_shield/collectors/envfiles.py +130 -0
- worktree_shield/collectors/layout.py +106 -0
- worktree_shield/collectors/node.py +87 -0
- worktree_shield/collectors/procfile.py +134 -0
- worktree_shield/collectors/python.py +128 -0
- worktree_shield/collectors/runtime.py +167 -0
- worktree_shield/collectors/static.py +25 -0
- worktree_shield/collectors/vite.py +133 -0
- worktree_shield/collisions.py +115 -0
- worktree_shield/config.py +188 -0
- worktree_shield/exit_codes.py +22 -0
- worktree_shield/files.py +70 -0
- worktree_shield/git/__init__.py +1 -0
- worktree_shield/git/discovery.py +85 -0
- worktree_shield/git/porcelain.py +48 -0
- worktree_shield/models.py +356 -0
- worktree_shield/names.py +83 -0
- worktree_shield/orchestrator.py +121 -0
- worktree_shield/overlap.py +175 -0
- worktree_shield/parsers/__init__.py +1 -0
- worktree_shield/parsers/commands.py +158 -0
- worktree_shield/parsers/compose.py +148 -0
- worktree_shield/parsers/dburl.py +156 -0
- worktree_shield/parsers/dotenv.py +91 -0
- worktree_shield/parsers/interpolate.py +142 -0
- worktree_shield/parsers/jsonc.py +48 -0
- worktree_shield/parsers/jstext.py +142 -0
- worktree_shield/plan.py +175 -0
- worktree_shield/privacy.py +237 -0
- worktree_shield/reports/__init__.py +1 -0
- worktree_shield/reports/findings.py +176 -0
- worktree_shield/reports/json_report.py +61 -0
- worktree_shield/reports/markdown.py +103 -0
- worktree_shield/reports/sarif.py +116 -0
- worktree_shield/reports/terminal.py +327 -0
- worktree_shield/rules/__init__.py +1 -0
- worktree_shield/rules/catalog.py +28 -0
- worktree_shield/rules/common.py +61 -0
- worktree_shield/rules/context.py +201 -0
- worktree_shield/rules/engine.py +60 -0
- worktree_shield/rules/explain.py +27 -0
- worktree_shield/rules/rules.md +112 -0
- worktree_shield/rules/runtime.py +164 -0
- worktree_shield/rules/shared.py +216 -0
- worktree_shield/runner.py +273 -0
- worktree_shield-0.1.0.dist-info/METADATA +151 -0
- worktree_shield-0.1.0.dist-info/RECORD +56 -0
- worktree_shield-0.1.0.dist-info/WHEEL +4 -0
- worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
- worktree_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Worktree Shield: audit parallel Git worktrees for shared resources."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("worktree-shield")
|
|
7
|
+
except PackageNotFoundError: # imported from a source tree that was never installed
|
|
8
|
+
__version__ = "0+unknown"
|
worktree_shield/cli.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Command-line interface: parses arguments and wires the layers together.
|
|
2
|
+
|
|
3
|
+
No diagnosis logic lives here; commands call collectors, rules and renderers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from worktree_shield import __version__
|
|
19
|
+
from worktree_shield.config import PolicyError, policy_of
|
|
20
|
+
from worktree_shield.exit_codes import ExitCode
|
|
21
|
+
from worktree_shield.git.discovery import NotARepositoryError
|
|
22
|
+
from worktree_shield.models import Snapshot, Status
|
|
23
|
+
from worktree_shield.orchestrator import diagnose, inspect_resources, inspect_worktrees
|
|
24
|
+
from worktree_shield.plan import make_plan
|
|
25
|
+
from worktree_shield.privacy import RedactionContext, Redactor, redact_snapshot
|
|
26
|
+
from worktree_shield.reports.findings import render_plan, render_scan
|
|
27
|
+
from worktree_shield.reports.json_report import render_json
|
|
28
|
+
from worktree_shield.reports.markdown import render_markdown
|
|
29
|
+
from worktree_shield.reports.sarif import render_sarif
|
|
30
|
+
from worktree_shield.reports.terminal import render_policy, render_resources, render_worktrees
|
|
31
|
+
from worktree_shield.rules.catalog import builtin_rules
|
|
32
|
+
from worktree_shield.rules.explain import explanation
|
|
33
|
+
from worktree_shield.runner import SubprocessRunner
|
|
34
|
+
|
|
35
|
+
HELP = (
|
|
36
|
+
"Audit the worktrees of a Git repository for shared ports, databases and Docker "
|
|
37
|
+
"resources before your agents collide.\n\n"
|
|
38
|
+
"Read-only: nothing in the repository is executed, changed or locked (.git included), "
|
|
39
|
+
"no process is stopped, no container is touched, and secrets are never printed."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(
|
|
43
|
+
name="worktree-shield",
|
|
44
|
+
help=HELP,
|
|
45
|
+
no_args_is_help=True,
|
|
46
|
+
add_completion=False,
|
|
47
|
+
# Rich tracebacks print local variables, which can hold environment values.
|
|
48
|
+
pretty_exceptions_enable=False,
|
|
49
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _show_version(value: bool) -> None:
|
|
54
|
+
if value:
|
|
55
|
+
typer.echo(f"worktree-shield {__version__}")
|
|
56
|
+
raise typer.Exit()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.callback()
|
|
60
|
+
def cli(
|
|
61
|
+
version: Annotated[
|
|
62
|
+
bool,
|
|
63
|
+
typer.Option(
|
|
64
|
+
"--version", callback=_show_version, is_eager=True, help="Show the version and exit."
|
|
65
|
+
),
|
|
66
|
+
] = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Global options (the help text comes from HELP)."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Scope(StrEnum):
|
|
72
|
+
ALL = "all"
|
|
73
|
+
STATIC = "static"
|
|
74
|
+
RUNTIME = "runtime"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_RUNTIME_RULES = frozenset({"WTS-PORT-002", "WTS-RUNTIME-001"})
|
|
78
|
+
_REPO_OPTION = typer.Option(
|
|
79
|
+
"--repo", metavar="PATH", help="A directory inside the repository (default: here)."
|
|
80
|
+
)
|
|
81
|
+
_ENV_OPTION = typer.Option(
|
|
82
|
+
"--include-env",
|
|
83
|
+
help="Also read .env and .env.local; only ports, database servers and paths are kept.",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Format(StrEnum):
|
|
88
|
+
TERMINAL = "terminal"
|
|
89
|
+
JSON = "json"
|
|
90
|
+
MARKDOWN = "markdown"
|
|
91
|
+
SARIF = "sarif"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ReportFormat(StrEnum):
|
|
95
|
+
MARKDOWN = "markdown"
|
|
96
|
+
JSON = "json"
|
|
97
|
+
SARIF = "sarif"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_STRICT_OPTION = typer.Option("--strict", help="Exit with 1 when a finding fails under the policy.")
|
|
101
|
+
_SCOPE_OPTION = typer.Option(
|
|
102
|
+
"--scope",
|
|
103
|
+
help="all: configuration and running processes; static: configuration only; "
|
|
104
|
+
"runtime: only what running processes show.",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _diagnosed(repo: Path, scope: Scope, include_env: bool) -> Snapshot:
|
|
109
|
+
"""Scan and diagnose, or exit with 3 (not a repository) or 2 (unusable policy)."""
|
|
110
|
+
try:
|
|
111
|
+
snapshot = inspect_resources(
|
|
112
|
+
SubprocessRunner(),
|
|
113
|
+
repo,
|
|
114
|
+
include_env_files=True if include_env else None,
|
|
115
|
+
include_runtime=False if scope is Scope.STATIC else None,
|
|
116
|
+
)
|
|
117
|
+
except NotARepositoryError:
|
|
118
|
+
typer.echo(f"worktree-shield: {repo} is not inside a Git repository", err=True)
|
|
119
|
+
raise typer.Exit(ExitCode.CONTEXT_UNAVAILABLE) from None
|
|
120
|
+
except PolicyError as error:
|
|
121
|
+
message = Redactor(RedactionContext.current()).text(str(error))
|
|
122
|
+
typer.echo(f"worktree-shield: {message}", err=True)
|
|
123
|
+
raise typer.Exit(ExitCode.ERROR) from None
|
|
124
|
+
diagnosed = diagnose(snapshot)
|
|
125
|
+
if scope is Scope.RUNTIME:
|
|
126
|
+
kept = tuple(f for f in diagnosed.findings if f.id in _RUNTIME_RULES)
|
|
127
|
+
diagnosed = dataclasses.replace(diagnosed, findings=kept)
|
|
128
|
+
return diagnosed
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _rendered(snapshot: Snapshot, output_format: str) -> str:
|
|
132
|
+
if output_format == Format.JSON:
|
|
133
|
+
return render_json(snapshot)
|
|
134
|
+
if output_format == Format.SARIF:
|
|
135
|
+
return render_sarif(snapshot)
|
|
136
|
+
return render_markdown(snapshot)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _finish(diagnosed: Snapshot, strict: bool) -> None:
|
|
140
|
+
if diagnosed.repository is None:
|
|
141
|
+
raise typer.Exit(ExitCode.ERROR)
|
|
142
|
+
if strict and any(f.status is Status.FAIL for f in diagnosed.findings):
|
|
143
|
+
raise typer.Exit(ExitCode.STRICT_FAILURE)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def scan(
|
|
148
|
+
repo: Annotated[Path, _REPO_OPTION] = Path(),
|
|
149
|
+
strict: Annotated[bool, _STRICT_OPTION] = False,
|
|
150
|
+
scope: Annotated[Scope, _SCOPE_OPTION] = Scope.ALL,
|
|
151
|
+
include_env: Annotated[bool, _ENV_OPTION] = False,
|
|
152
|
+
output_format: Annotated[
|
|
153
|
+
Format, typer.Option("--format", help="terminal, or json/markdown/sarif on stdout.")
|
|
154
|
+
] = Format.TERMINAL,
|
|
155
|
+
show_all: Annotated[
|
|
156
|
+
bool, typer.Option("--all", help="Also list the checks that passed or did not apply.")
|
|
157
|
+
] = False,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Find ports, databases, Compose names and paths the worktrees share.
|
|
160
|
+
|
|
161
|
+
Reads configuration as data and, unless --scope static, looks at listening
|
|
162
|
+
processes; nothing is run, changed or locked. Exit codes: 0 when the scan ran, 1
|
|
163
|
+
with --strict when a finding fails under the policy, 2 for errors, 3 outside a Git
|
|
164
|
+
repository.
|
|
165
|
+
"""
|
|
166
|
+
diagnosed = _diagnosed(repo, scope, include_env)
|
|
167
|
+
redacted = redact_snapshot(diagnosed)
|
|
168
|
+
if output_format is Format.TERMINAL:
|
|
169
|
+
render_scan(redacted, Console(highlight=False), show_all=show_all)
|
|
170
|
+
else:
|
|
171
|
+
sys.stdout.write(_rendered(redacted, output_format))
|
|
172
|
+
_finish(diagnosed, strict)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def report(
|
|
177
|
+
repo: Annotated[Path, _REPO_OPTION] = Path(),
|
|
178
|
+
output_format: Annotated[
|
|
179
|
+
ReportFormat, typer.Option("--format", "-f", help="markdown, json or sarif.")
|
|
180
|
+
] = ReportFormat.MARKDOWN,
|
|
181
|
+
output: Annotated[
|
|
182
|
+
Path | None,
|
|
183
|
+
typer.Option("--output", "-o", metavar="FILE", help="Write to FILE (default: stdout)."),
|
|
184
|
+
] = None,
|
|
185
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite FILE if it exists.")] = False,
|
|
186
|
+
strict: Annotated[bool, _STRICT_OPTION] = False,
|
|
187
|
+
scope: Annotated[Scope, _SCOPE_OPTION] = Scope.ALL,
|
|
188
|
+
include_env: Annotated[bool, _ENV_OPTION] = False,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Write the scan as a shareable report: Markdown for an issue or PR, JSON for
|
|
191
|
+
scripts, SARIF for code scanning.
|
|
192
|
+
|
|
193
|
+
The report is redacted (home paths, user names, secrets). The file named with
|
|
194
|
+
--output is the only thing worktree-shield ever writes.
|
|
195
|
+
"""
|
|
196
|
+
to_file = output is not None and str(output) != "-"
|
|
197
|
+
if to_file and output is not None and output.exists() and not force:
|
|
198
|
+
typer.echo(f"worktree-shield: {output} exists; use --force to overwrite it.", err=True)
|
|
199
|
+
raise typer.Exit(ExitCode.ERROR)
|
|
200
|
+
diagnosed = _diagnosed(repo, scope, include_env)
|
|
201
|
+
redacted = redact_snapshot(diagnosed)
|
|
202
|
+
rendered = _rendered(redacted, output_format)
|
|
203
|
+
if to_file and output is not None:
|
|
204
|
+
output.write_text(rendered, encoding="utf-8")
|
|
205
|
+
typer.echo(f"Wrote {output}. Read it before you share it.", err=True)
|
|
206
|
+
else:
|
|
207
|
+
sys.stdout.write(rendered)
|
|
208
|
+
_finish(diagnosed, strict)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command()
|
|
212
|
+
def plan(
|
|
213
|
+
repo: Annotated[Path, _REPO_OPTION] = Path(),
|
|
214
|
+
scope: Annotated[Scope, _SCOPE_OPTION] = Scope.ALL,
|
|
215
|
+
include_env: Annotated[bool, _ENV_OPTION] = False,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Suggest a change per worktree that would resolve each collision; apply none.
|
|
218
|
+
|
|
219
|
+
The current worktree keeps its values; every other one gets a free port, its own
|
|
220
|
+
database and Compose project name. Values from .env templates are overridden in
|
|
221
|
+
the worktree's .env. Nothing is written.
|
|
222
|
+
"""
|
|
223
|
+
diagnosed = _diagnosed(repo, scope, include_env)
|
|
224
|
+
redacted = redact_snapshot(diagnosed)
|
|
225
|
+
render_plan(redacted, make_plan(redacted), Console(highlight=False))
|
|
226
|
+
if diagnosed.repository is None:
|
|
227
|
+
raise typer.Exit(ExitCode.ERROR)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.command()
|
|
231
|
+
def explain(
|
|
232
|
+
rule_id: Annotated[str, typer.Argument(metavar="RULE", help="A rule ID such as WTS-PORT-001.")],
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Explain a rule: what it finds, which policy key decides it, how to fix it."""
|
|
235
|
+
text = explanation(rule_id)
|
|
236
|
+
if text is None:
|
|
237
|
+
typer.echo(
|
|
238
|
+
f"worktree-shield: no rule {rule_id}; worktree-shield rules lists them", err=True
|
|
239
|
+
)
|
|
240
|
+
raise typer.Exit(ExitCode.ERROR)
|
|
241
|
+
sys.stdout.write(text)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.command(name="rules")
|
|
245
|
+
def list_rules() -> None:
|
|
246
|
+
"""List every rule with its ID; worktree-shield explain RULE says more."""
|
|
247
|
+
for rule in builtin_rules():
|
|
248
|
+
typer.echo(f"{rule.id:<16} {rule.title}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.command()
|
|
252
|
+
def worktrees(
|
|
253
|
+
repo: Annotated[
|
|
254
|
+
Path,
|
|
255
|
+
typer.Option(
|
|
256
|
+
"--repo", metavar="PATH", help="A directory inside the repository (default: here)."
|
|
257
|
+
),
|
|
258
|
+
] = Path(),
|
|
259
|
+
) -> None:
|
|
260
|
+
"""List the worktrees of the repository, as Git records them.
|
|
261
|
+
|
|
262
|
+
Runs only git rev-parse and git worktree list, with optional locks off: nothing in
|
|
263
|
+
.git is written. Exits with code 3 when PATH is not inside a Git repository.
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
snapshot = inspect_worktrees(SubprocessRunner(), repo)
|
|
267
|
+
except NotARepositoryError:
|
|
268
|
+
typer.echo(f"worktree-shield: {repo} is not inside a Git repository", err=True)
|
|
269
|
+
raise typer.Exit(ExitCode.CONTEXT_UNAVAILABLE) from None
|
|
270
|
+
render_worktrees(redact_snapshot(snapshot), Console(highlight=False))
|
|
271
|
+
if snapshot.repository is None:
|
|
272
|
+
raise typer.Exit(ExitCode.ERROR)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.command()
|
|
276
|
+
def policy(
|
|
277
|
+
repo: Annotated[
|
|
278
|
+
Path,
|
|
279
|
+
typer.Option(
|
|
280
|
+
"--repo", metavar="PATH", help="A directory inside the repository (default: here)."
|
|
281
|
+
),
|
|
282
|
+
] = Path(),
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Show the policy that applies: .worktree-shield.toml in this worktree, or the defaults.
|
|
285
|
+
|
|
286
|
+
The file is read as data, never run. A file that cannot be used is reported with the
|
|
287
|
+
key at fault and exits with code 2; outside a Git repository the exit code is 3.
|
|
288
|
+
"""
|
|
289
|
+
redactor = Redactor(RedactionContext.current())
|
|
290
|
+
try:
|
|
291
|
+
snapshot = inspect_worktrees(SubprocessRunner(), repo)
|
|
292
|
+
if snapshot.repository is None:
|
|
293
|
+
details = "; ".join(f.detail for f in snapshot.probe_failures)
|
|
294
|
+
raise PolicyError(f"the worktrees could not be listed: {details}")
|
|
295
|
+
effective = policy_of(snapshot.repository)
|
|
296
|
+
except NotARepositoryError:
|
|
297
|
+
typer.echo(f"worktree-shield: {repo} is not inside a Git repository", err=True)
|
|
298
|
+
raise typer.Exit(ExitCode.CONTEXT_UNAVAILABLE) from None
|
|
299
|
+
except PolicyError as error:
|
|
300
|
+
typer.echo(f"worktree-shield: {redactor.text(str(error))}", err=True)
|
|
301
|
+
raise typer.Exit(ExitCode.ERROR) from None
|
|
302
|
+
current = snapshot.repository.current
|
|
303
|
+
render_policy(
|
|
304
|
+
redactor.redact(effective),
|
|
305
|
+
redactor.text(current) if current is not None else None,
|
|
306
|
+
Console(highlight=False),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.command()
|
|
311
|
+
def resources(
|
|
312
|
+
repo: Annotated[
|
|
313
|
+
Path,
|
|
314
|
+
typer.Option(
|
|
315
|
+
"--repo", metavar="PATH", help="A directory inside the repository (default: here)."
|
|
316
|
+
),
|
|
317
|
+
] = Path(),
|
|
318
|
+
include_env: Annotated[
|
|
319
|
+
bool,
|
|
320
|
+
typer.Option(
|
|
321
|
+
"--include-env",
|
|
322
|
+
help="Also read .env and .env.local; only ports, database servers and paths are kept.",
|
|
323
|
+
),
|
|
324
|
+
] = False,
|
|
325
|
+
no_runtime: Annotated[
|
|
326
|
+
bool,
|
|
327
|
+
typer.Option(
|
|
328
|
+
"--no-runtime",
|
|
329
|
+
help="Do not look at running processes (their sockets and working directories).",
|
|
330
|
+
),
|
|
331
|
+
] = False,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""List the ports, databases, Compose names and paths each worktree declares.
|
|
334
|
+
|
|
335
|
+
Reads .env templates, package.json, vite.config.*, pyproject.toml and Compose files
|
|
336
|
+
as data; nothing is run and Docker is not contacted. Listening processes count for
|
|
337
|
+
the worktree they run in. Resources used by more than one worktree are marked.
|
|
338
|
+
Exits with 2 for a policy file that cannot be used and 3 outside a Git repository.
|
|
339
|
+
"""
|
|
340
|
+
redactor = Redactor(RedactionContext.current())
|
|
341
|
+
try:
|
|
342
|
+
snapshot = inspect_resources(
|
|
343
|
+
SubprocessRunner(),
|
|
344
|
+
repo,
|
|
345
|
+
include_env_files=True if include_env else None,
|
|
346
|
+
include_runtime=False if no_runtime else None,
|
|
347
|
+
)
|
|
348
|
+
except NotARepositoryError:
|
|
349
|
+
typer.echo(f"worktree-shield: {repo} is not inside a Git repository", err=True)
|
|
350
|
+
raise typer.Exit(ExitCode.CONTEXT_UNAVAILABLE) from None
|
|
351
|
+
except PolicyError as error:
|
|
352
|
+
typer.echo(f"worktree-shield: {redactor.text(str(error))}", err=True)
|
|
353
|
+
raise typer.Exit(ExitCode.ERROR) from None
|
|
354
|
+
render_resources(redact_snapshot(snapshot), Console(highlight=False))
|
|
355
|
+
if snapshot.repository is None:
|
|
356
|
+
raise typer.Exit(ExitCode.ERROR)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def main() -> None:
|
|
360
|
+
"""Console entry point.
|
|
361
|
+
|
|
362
|
+
Typer reports usage errors itself (exit code 2) and turns Ctrl+C into 130. Anything
|
|
363
|
+
else that escapes is a bug: print one redacted line instead of a traceback, which
|
|
364
|
+
could contain local values, and exit with 2. ``WTS_DEBUG=1`` shows the traceback.
|
|
365
|
+
"""
|
|
366
|
+
try:
|
|
367
|
+
app()
|
|
368
|
+
except Exception as exc: # SystemExit (normal exits) is not an Exception
|
|
369
|
+
if os.environ.get("WTS_DEBUG"):
|
|
370
|
+
raise
|
|
371
|
+
message = Redactor(RedactionContext.current()).text(str(exc)) or type(exc).__name__
|
|
372
|
+
typer.echo(
|
|
373
|
+
f"worktree-shield: internal error: {message}\n"
|
|
374
|
+
"Run it again with WTS_DEBUG=1 to see the traceback, and please report it.",
|
|
375
|
+
err=True,
|
|
376
|
+
)
|
|
377
|
+
sys.exit(ExitCode.ERROR)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Collectors: read one kind of configuration from a worktree and record claims."""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""What every collector shares: where it reads from and where its results go."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
7
|
+
|
|
8
|
+
from worktree_shield.files import RefusedError, read_text_inside
|
|
9
|
+
from worktree_shield.models import ResourceClaim, SkippedFile, SourceRef, UnreadableValue
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Collected:
|
|
14
|
+
"""The results of the collectors of one scan, in the order they were found."""
|
|
15
|
+
|
|
16
|
+
claims: list[ResourceClaim] = field(default_factory=list)
|
|
17
|
+
skipped: list[SkippedFile] = field(default_factory=list)
|
|
18
|
+
unreadable: list[UnreadableValue] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Scope:
|
|
23
|
+
"""One worktree: ``root`` is read from, ``worktree`` is how reports name it (the
|
|
24
|
+
path Git lists). Files are named by their POSIX path relative to the root."""
|
|
25
|
+
|
|
26
|
+
root: Path
|
|
27
|
+
worktree: str
|
|
28
|
+
max_bytes: int
|
|
29
|
+
|
|
30
|
+
def source(self, relative: PurePosixPath, line: int | None = None) -> SourceRef:
|
|
31
|
+
return SourceRef(self.worktree, relative.as_posix(), line)
|
|
32
|
+
|
|
33
|
+
def read(self, relative: PurePosixPath, out: Collected) -> str | None:
|
|
34
|
+
"""The text of a file, ``None`` if it does not exist; a refused file is recorded
|
|
35
|
+
in ``out.skipped`` and also gives ``None``."""
|
|
36
|
+
try:
|
|
37
|
+
return read_text_inside(self.root, Path(*relative.parts), self.max_bytes)
|
|
38
|
+
except RefusedError as error:
|
|
39
|
+
out.skipped.append(SkippedFile(self.source(relative), str(error)))
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_absolute_path(value: str) -> bool:
|
|
44
|
+
"""An absolute POSIX or Windows path below the root (``/`` and ``C:\\`` are not)."""
|
|
45
|
+
if "://" in value:
|
|
46
|
+
return False
|
|
47
|
+
for flavour in (PurePosixPath, PureWindowsPath):
|
|
48
|
+
path = flavour(value)
|
|
49
|
+
if path.is_absolute():
|
|
50
|
+
return len(path.parts) > 1
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def path_key(value: str) -> str:
|
|
55
|
+
"""``path:///srv/cache`` for an absolute path, trailing separators removed."""
|
|
56
|
+
return "path://" + value.rstrip("/\\")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def port_key(host: str | None, port: int, default: str = "*", protocol: str = "tcp") -> str:
|
|
60
|
+
"""``tcp://127.0.0.1:5173``; ``default`` stands for a host the config leaves open."""
|
|
61
|
+
server = host or default
|
|
62
|
+
shown = f"[{server}]" if ":" in server else server
|
|
63
|
+
return f"{protocol}://{shown}:{port}"
|