whycode-cli 0.2.3__tar.gz → 0.2.4__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.
- {whycode_cli-0.2.3/src/whycode_cli.egg-info → whycode_cli-0.2.4}/PKG-INFO +1 -1
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/pyproject.toml +1 -1
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/__init__.py +1 -1
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/cli.py +38 -45
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/git_facts.py +50 -2
- {whycode_cli-0.2.3 → whycode_cli-0.2.4/src/whycode_cli.egg-info}/PKG-INFO +1 -1
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/LICENSE +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/README.md +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/setup.cfg +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/__main__.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/ignore.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/mcp_server.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/risk_card.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/scorer.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/signals.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/suppressions.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/templates/__init__.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/templates/github-workflow.yml +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/templates/pre-commit +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/SOURCES.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/entry_points.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/requires.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/top_level.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_cli.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_git_facts.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_ignore.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_scorer.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_signals.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_suppressions.py +0 -0
|
@@ -71,12 +71,29 @@ def _path_is_known_to_git(repo_root: Path, rel: str) -> bool:
|
|
|
71
71
|
if gf.is_tracked(repo_root, rel):
|
|
72
72
|
return True
|
|
73
73
|
try:
|
|
74
|
-
out = gf.
|
|
74
|
+
out = gf.run_git(repo_root, "log", "--oneline", "-1", "--all", "--", rel)
|
|
75
75
|
except gf.GitError:
|
|
76
76
|
return False
|
|
77
77
|
return bool(out.strip())
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def _require_tracked(path_arg: str) -> tuple[Path, str]:
|
|
81
|
+
"""Resolve ``path_arg`` to ``(repo_root, rel)`` or exit with a friendly warning.
|
|
82
|
+
|
|
83
|
+
Used by every command that takes a path argument and needs git history
|
|
84
|
+
to be useful (``why``, ``timeline``, ``honest``). Combines the two earlier
|
|
85
|
+
helpers so callers don't repeat the warn-and-exit boilerplate.
|
|
86
|
+
"""
|
|
87
|
+
repo_root, rel = _resolve_repo_and_path(path_arg)
|
|
88
|
+
if not _path_is_known_to_git(repo_root, rel):
|
|
89
|
+
err.print(
|
|
90
|
+
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
91
|
+
f"and has no history in this repo. Nothing to learn from."
|
|
92
|
+
)
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
return repo_root, rel
|
|
95
|
+
|
|
96
|
+
|
|
80
97
|
# --- shared: band threshold parsing ----------------------------------------
|
|
81
98
|
|
|
82
99
|
_BAND_THRESHOLDS_BY_KEY: dict[str, int] = {
|
|
@@ -142,17 +159,11 @@ def why(
|
|
|
142
159
|
),
|
|
143
160
|
) -> None:
|
|
144
161
|
"""Print the Risk Card for ``path``."""
|
|
145
|
-
repo_root, rel =
|
|
146
|
-
if not _path_is_known_to_git(repo_root, rel):
|
|
147
|
-
err.print(
|
|
148
|
-
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
149
|
-
f"and has no history in this repo. Nothing to learn from."
|
|
150
|
-
)
|
|
151
|
-
raise typer.Exit(1)
|
|
162
|
+
repo_root, rel = _require_tracked(path)
|
|
152
163
|
resolved_ref: str | None = None
|
|
153
164
|
if at is not None:
|
|
154
165
|
try:
|
|
155
|
-
resolved_ref = gf.
|
|
166
|
+
resolved_ref = gf.run_git(
|
|
156
167
|
repo_root, "rev-parse", "--verify", f"{at}^{{commit}}"
|
|
157
168
|
).strip()
|
|
158
169
|
except gf.GitError:
|
|
@@ -202,7 +213,7 @@ def _resolve_base_ref(repo_root: Path, requested: str | None) -> str:
|
|
|
202
213
|
candidates = ("origin/main", "origin/master", "main", "master", "HEAD~1")
|
|
203
214
|
for ref in candidates:
|
|
204
215
|
try:
|
|
205
|
-
gf.
|
|
216
|
+
gf.run_git(repo_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
|
206
217
|
return ref
|
|
207
218
|
except gf.GitError:
|
|
208
219
|
continue
|
|
@@ -241,13 +252,13 @@ def diff(
|
|
|
241
252
|
try:
|
|
242
253
|
repo_root = gf.discover_repo_root(repo.resolve())
|
|
243
254
|
if staged:
|
|
244
|
-
raw = gf.
|
|
255
|
+
raw = gf.run_git(
|
|
245
256
|
repo_root, "diff", "--cached", "--name-only", "--diff-filter=ACMR"
|
|
246
257
|
)
|
|
247
258
|
actual_base = "(staged changes)"
|
|
248
259
|
else:
|
|
249
260
|
actual_base = _resolve_base_ref(repo_root, base)
|
|
250
|
-
raw = gf.
|
|
261
|
+
raw = gf.run_git(repo_root, "diff", "--name-only", f"{actual_base}...HEAD")
|
|
251
262
|
except gf.GitError as exc:
|
|
252
263
|
err.print(f"[red]error:[/red] {exc}")
|
|
253
264
|
raise typer.Exit(2) from exc
|
|
@@ -483,13 +494,7 @@ def timeline(
|
|
|
483
494
|
),
|
|
484
495
|
) -> None:
|
|
485
496
|
"""Show how this file's risk score evolved over its history."""
|
|
486
|
-
repo_root, rel =
|
|
487
|
-
if not _path_is_known_to_git(repo_root, rel):
|
|
488
|
-
err.print(
|
|
489
|
-
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
490
|
-
f"and has no history in this repo."
|
|
491
|
-
)
|
|
492
|
-
raise typer.Exit(1)
|
|
497
|
+
repo_root, rel = _require_tracked(path)
|
|
493
498
|
|
|
494
499
|
commits = gf.commits_for_path(repo_root, rel)
|
|
495
500
|
if not commits:
|
|
@@ -588,7 +593,7 @@ def scan(
|
|
|
588
593
|
err.print(f"[red]error:[/red] {exc}")
|
|
589
594
|
raise typer.Exit(2) from exc
|
|
590
595
|
|
|
591
|
-
raw = gf.
|
|
596
|
+
raw = gf.run_git(repo_root, "ls-files")
|
|
592
597
|
all_paths = [line for line in raw.splitlines() if line.strip()]
|
|
593
598
|
patterns = () if no_ignore else ign.effective_patterns(repo_root)
|
|
594
599
|
paths = [p for p in all_paths if not ign.is_ignored(p, patterns)][:sample]
|
|
@@ -648,13 +653,7 @@ def honest(
|
|
|
648
653
|
Use when the Risk Card's first-sentence truncation is hiding important
|
|
649
654
|
context — e.g., a commit whose constraint is stated across two lines.
|
|
650
655
|
"""
|
|
651
|
-
repo_root, rel =
|
|
652
|
-
if not _path_is_known_to_git(repo_root, rel):
|
|
653
|
-
err.print(
|
|
654
|
-
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
655
|
-
f"and has no history in this repo."
|
|
656
|
-
)
|
|
657
|
-
raise typer.Exit(1)
|
|
656
|
+
repo_root, rel = _require_tracked(path)
|
|
658
657
|
facts = gf.gather(repo_root, rel)
|
|
659
658
|
if not facts.invariant_quotes:
|
|
660
659
|
if json_out:
|
|
@@ -716,24 +715,18 @@ def show(
|
|
|
716
715
|
"""Risk-flavored summary for a single commit: classification + per-file risk."""
|
|
717
716
|
try:
|
|
718
717
|
repo_root = gf.discover_repo_root(repo.resolve())
|
|
719
|
-
full_sha = gf._run_git(repo_root, "rev-parse", "--verify", f"{sha}^{{commit}}").strip()
|
|
720
718
|
except gf.GitError as exc:
|
|
721
719
|
err.print(f"[red]error:[/red] {exc}")
|
|
722
720
|
raise typer.Exit(2) from exc
|
|
723
721
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
commits = gf._parse_log_records(raw)
|
|
728
|
-
if not commits:
|
|
729
|
-
err.print(f"[red]error:[/red] could not read commit {full_sha}")
|
|
722
|
+
commit = gf.read_commit(repo_root, sha)
|
|
723
|
+
if commit is None:
|
|
724
|
+
err.print(f"[red]error:[/red] could not read commit {sha!r}")
|
|
730
725
|
raise typer.Exit(2)
|
|
731
|
-
|
|
726
|
+
full_sha = commit.sha
|
|
732
727
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
or gf._BREAKING_CC_RE.search(commit.subject)
|
|
736
|
-
)
|
|
728
|
+
classification = gf.classify_commit(commit)
|
|
729
|
+
is_incident = classification.incident_flavoured
|
|
737
730
|
invariants = gf.extract_invariant_quotes([commit])
|
|
738
731
|
file_changes = gf.files_changed_in(repo_root, full_sha)
|
|
739
732
|
|
|
@@ -768,14 +761,14 @@ def show(
|
|
|
768
761
|
)
|
|
769
762
|
console.print(f" {commit.subject}")
|
|
770
763
|
console.print()
|
|
771
|
-
|
|
764
|
+
badges: list[str] = []
|
|
772
765
|
if is_incident:
|
|
773
|
-
|
|
766
|
+
badges.append("[bold red]incident-flavored[/bold red]")
|
|
774
767
|
if invariants:
|
|
775
|
-
|
|
776
|
-
if not
|
|
777
|
-
|
|
778
|
-
console.print(" " + " ".join(
|
|
768
|
+
badges.append(f"[yellow]states {len(invariants)} invariant(s)[/yellow]")
|
|
769
|
+
if not badges:
|
|
770
|
+
badges.append("[dim]no special classification[/dim]")
|
|
771
|
+
console.print(" " + " ".join(badges))
|
|
779
772
|
console.print(f" [dim]{len(file_changes)} files changed[/dim]")
|
|
780
773
|
|
|
781
774
|
if not cards:
|
|
@@ -129,8 +129,13 @@ class GitError(RuntimeError):
|
|
|
129
129
|
"""Raised when a git invocation fails or produces unexpected output."""
|
|
130
130
|
|
|
131
131
|
|
|
132
|
-
def
|
|
133
|
-
"""Invoke git
|
|
132
|
+
def run_git(repo_root: Path, *args: str) -> str:
|
|
133
|
+
"""Invoke ``git -C <repo_root> <args>`` and return stdout.
|
|
134
|
+
|
|
135
|
+
Public API: callers (CLI, MCP server) use this to run git commands
|
|
136
|
+
that aren't already wrapped in a higher-level helper here. Raises
|
|
137
|
+
:class:`GitError` on non-zero exit or when ``git`` itself is missing.
|
|
138
|
+
"""
|
|
134
139
|
cmd = ["git", "-C", str(repo_root), *args]
|
|
135
140
|
try:
|
|
136
141
|
proc = subprocess.run(
|
|
@@ -150,6 +155,10 @@ def _run_git(repo_root: Path, *args: str) -> str:
|
|
|
150
155
|
return proc.stdout
|
|
151
156
|
|
|
152
157
|
|
|
158
|
+
# Back-compat alias. Prefer ``run_git`` in new code.
|
|
159
|
+
_run_git = run_git
|
|
160
|
+
|
|
161
|
+
|
|
153
162
|
def discover_repo_root(start: Path) -> Path:
|
|
154
163
|
"""Find the enclosing git repo root for ``start``."""
|
|
155
164
|
out = _run_git(start, "rev-parse", "--show-toplevel").strip()
|
|
@@ -240,6 +249,25 @@ def all_commits(repo_root: Path, *, max_count: int | None = None) -> list[Commit
|
|
|
240
249
|
return _parse_log_records(raw)
|
|
241
250
|
|
|
242
251
|
|
|
252
|
+
def read_commit(repo_root: Path, ref: str) -> Commit | None:
|
|
253
|
+
"""Resolve ``ref`` (SHA, tag, branch, ``HEAD~3`` …) to a single ``Commit``.
|
|
254
|
+
|
|
255
|
+
Returns ``None`` when the ref doesn't exist or doesn't resolve to a
|
|
256
|
+
commit. Used by ``whycode show <sha>`` and similar single-commit views.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
full_sha = run_git(
|
|
260
|
+
repo_root, "rev-parse", "--verify", f"{ref}^{{commit}}"
|
|
261
|
+
).strip()
|
|
262
|
+
except GitError:
|
|
263
|
+
return None
|
|
264
|
+
raw = run_git(
|
|
265
|
+
repo_root, "log", "-1", "--no-merges", f"--pretty=format:{_log_format()}", full_sha
|
|
266
|
+
)
|
|
267
|
+
parsed = _parse_log_records(raw)
|
|
268
|
+
return parsed[0] if parsed else None
|
|
269
|
+
|
|
270
|
+
|
|
243
271
|
def files_changed_in(repo_root: Path, sha: str) -> list[FileChange]:
|
|
244
272
|
"""Return the list of files (with diffstat) changed in ``sha``."""
|
|
245
273
|
raw = _run_git(
|
|
@@ -359,6 +387,26 @@ def find_incidents(commits: Sequence[Commit]) -> list[Commit]:
|
|
|
359
387
|
return out
|
|
360
388
|
|
|
361
389
|
|
|
390
|
+
@dataclass(frozen=True)
|
|
391
|
+
class CommitClassification:
|
|
392
|
+
"""Light-weight summary of what kind of work a single commit represents."""
|
|
393
|
+
|
|
394
|
+
incident_flavoured: bool
|
|
395
|
+
invariant_count: int
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def classify_commit(commit: Commit) -> CommitClassification:
|
|
399
|
+
"""Classify a single commit by reusing the same rules ``find_incidents`` and
|
|
400
|
+
``extract_invariant_quotes`` apply to a list. Public API for ``whycode show``
|
|
401
|
+
and any other surface that wants a single-commit verdict without
|
|
402
|
+
re-implementing the regex ladder.
|
|
403
|
+
"""
|
|
404
|
+
return CommitClassification(
|
|
405
|
+
incident_flavoured=bool(find_incidents([commit])),
|
|
406
|
+
invariant_count=len(extract_invariant_quotes([commit])),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
362
410
|
# Straight, backtick, and the four common Unicode "smart" quote code points.
|
|
363
411
|
# We build the string from chr() calls because ruff's RUF001 ambiguous-char
|
|
364
412
|
# check rejects the literal Unicode quotes inline.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|