feat(review): render a branch review as a local HTML page
pkgmgr review writes one self-contained HTML page for a repository: commits ahead of the base, the staged, unstaged and untracked diff per file, and the checks of the change set. Repository text reaches the packaged Jinja template only as autoescaped data, git runs in scan mode (now also without replace objects), and the page is written atomically through mkstemp and os.replace. 🎯 Fix: 92 % 💥 Break: 3 % 💸 Cost: new ⏱️ Time: new 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: cost new, time new: cleared by the operator's commit word for C (2026-11-07) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54f0cbb37b
commit
1b31b10df6
20 files changed
+1949
-2
No files matched your search
+2
-1
@@ -21,7 +21,7 @@ authors = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
"tomli; python_version < \"3.11\"",
|
"tomli; python_version < \"3.11\"",
|
||||||
"jinja2>=3.1"
|
"jinja2>=3.1.6"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -52,3 +52,4 @@ include = ["pkgmgr*"]
|
|||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
||||||
|
"pkgmgr.actions.review" = ["*.j2"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .collect import collect_review
|
||||||
|
from .model import ReviewError
|
||||||
|
from .render import render_review, write_review
|
||||||
|
|
||||||
|
__all__ = ["ReviewError", "collect_review", "render_review", "write_review"]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import GITLINK, _unsafe_config
|
||||||
|
from pkgmgr.core.git.errors import GitBaseError, GitRunError
|
||||||
|
from pkgmgr.core.git.run import run
|
||||||
|
|
||||||
|
from .model import FileView, ReviewError
|
||||||
|
from .repo import git_text
|
||||||
|
|
||||||
|
ATTRIBUTE_BINARY = (
|
||||||
|
"{} is shown as binary because {}, not because of its content: "
|
||||||
|
"run git diff --text to see it."
|
||||||
|
)
|
||||||
|
UNSET = "a gitattributes entry unsets its diff attribute"
|
||||||
|
DRIVER = "its gitattributes diff driver {0} sets diff.{0}.binary"
|
||||||
|
RUNS = "a command git would run on this host"
|
||||||
|
|
||||||
|
|
||||||
|
def hidden_entries(top: str) -> list[str]:
|
||||||
|
listing = git_text(["ls-files", "-v", "-z"], top)
|
||||||
|
return [
|
||||||
|
f"Index entry {entry[2:]} is marked "
|
||||||
|
f"{'skip-worktree' if entry[0] == 'S' else 'assume-unchanged'}: "
|
||||||
|
"its worktree changes are not shown."
|
||||||
|
for entry in listing.split("\0")
|
||||||
|
if entry[:1] == "S" or entry[:1].islower()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _binary_drivers(top: str) -> set[str]:
|
||||||
|
try:
|
||||||
|
out = run(
|
||||||
|
["config", "-z", "--type=bool", "--get-regexp", r"^diff\..*\.binary$"],
|
||||||
|
cwd=top,
|
||||||
|
scan=True,
|
||||||
|
)
|
||||||
|
except GitRunError as exc:
|
||||||
|
if exc.returncode == 1:
|
||||||
|
return set()
|
||||||
|
raise ReviewError("git config failed reading diff.<driver>.binary") from None
|
||||||
|
last: dict[str, str] = {}
|
||||||
|
for record in filter(None, out.split("\0")):
|
||||||
|
key, _, value = record.partition("\n")
|
||||||
|
last[key.removeprefix("diff.").removesuffix(".binary")] = value
|
||||||
|
return {driver for driver, value in last.items() if value == "true"}
|
||||||
|
|
||||||
|
|
||||||
|
def attribute_binary(files: list[FileView], top: str) -> list[str]:
|
||||||
|
paths = list(dict.fromkeys(view.diff.path for view in files if view.diff.binary))
|
||||||
|
if not paths:
|
||||||
|
return []
|
||||||
|
fields = git_text(["check-attr", "-z", "diff", "--", *paths], top).split("\0")
|
||||||
|
reasons = {
|
||||||
|
**{driver: DRIVER.format(driver) for driver in _binary_drivers(top)},
|
||||||
|
"unset": UNSET,
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
ATTRIBUTE_BINARY.format(path, reasons[value])
|
||||||
|
for path, value in zip(fields[0::3], fields[2::3])
|
||||||
|
if value in reasons
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _submodule_configs(top: str, prefix: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
top: Real path of the top-level working tree.
|
||||||
|
prefix: Submodule path below ``top`` whose gitlinks are walked, or ""
|
||||||
|
for the top level itself.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One reason per populated submodule, nested ones included, whose
|
||||||
|
config defines a filter or cannot be read. A gitlink behind a symbolic
|
||||||
|
link or without a ``.git`` is skipped: git runs nothing there either.
|
||||||
|
"""
|
||||||
|
listing = git_text(["ls-files", "--stage", "-z"], os.path.join(top, prefix))
|
||||||
|
gitlinks = dict.fromkeys(
|
||||||
|
path
|
||||||
|
for meta, _, path in (entry.partition("\t") for entry in listing.split("\0"))
|
||||||
|
if meta.startswith(f"{GITLINK} ")
|
||||||
|
)
|
||||||
|
reasons: list[str] = []
|
||||||
|
for path in gitlinks:
|
||||||
|
relative = os.path.join(prefix, path)
|
||||||
|
full = os.path.join(top, relative)
|
||||||
|
if os.path.realpath(full) != full or not os.path.lexists(
|
||||||
|
os.path.join(full, ".git")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
keys = _unsafe_config(full)
|
||||||
|
nested = _submodule_configs(top, relative)
|
||||||
|
except (GitBaseError, ReviewError):
|
||||||
|
reasons.append(
|
||||||
|
f"the config of submodule {relative} cannot be read, so its "
|
||||||
|
"filters cannot be ruled out"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if keys:
|
||||||
|
reasons.append(
|
||||||
|
f"the config of submodule {relative} defines {', '.join(keys)}, {RUNS}"
|
||||||
|
)
|
||||||
|
reasons.extend(nested)
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def unsafe_configs(top: str) -> list[str]:
|
||||||
|
keys = _unsafe_config(top)
|
||||||
|
own = [f"the repository config defines {', '.join(keys)}, {RUNS}"] if keys else []
|
||||||
|
return own + _submodule_configs(os.path.realpath(top), "")
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import END_OF_OPTIONS
|
||||||
|
|
||||||
|
from .checks import attribute_binary, hidden_entries, unsafe_configs
|
||||||
|
from .diff import parse_patch
|
||||||
|
from .files import read_note, untracked_views
|
||||||
|
from .model import Commit, Review, Section
|
||||||
|
from .repo import base_ref, git_optional, git_text, top_level
|
||||||
|
from .views import file_views
|
||||||
|
|
||||||
|
DIFF_FLAGS = [
|
||||||
|
"--no-color",
|
||||||
|
"--no-ext-diff",
|
||||||
|
"--no-textconv",
|
||||||
|
"-M",
|
||||||
|
"--submodule=short",
|
||||||
|
"--ignore-submodules=none",
|
||||||
|
"--src-prefix=a/",
|
||||||
|
"--dst-prefix=b/",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(sha: str, anchor: str, top: str, limit: int) -> Commit:
|
||||||
|
meta = git_text(
|
||||||
|
[
|
||||||
|
"show",
|
||||||
|
"-s",
|
||||||
|
"--no-color",
|
||||||
|
"--no-show-signature",
|
||||||
|
"--format=%h%x00%an <%ae>%x00%s%x00%b",
|
||||||
|
END_OF_OPTIONS,
|
||||||
|
sha,
|
||||||
|
],
|
||||||
|
top,
|
||||||
|
)
|
||||||
|
short, author, subject, body = meta.split("\0", 3)
|
||||||
|
patch = git_text(
|
||||||
|
[
|
||||||
|
"show",
|
||||||
|
"--format=",
|
||||||
|
"--no-show-signature",
|
||||||
|
"--diff-merges=first-parent",
|
||||||
|
*DIFF_FLAGS,
|
||||||
|
END_OF_OPTIONS,
|
||||||
|
sha,
|
||||||
|
],
|
||||||
|
top,
|
||||||
|
)
|
||||||
|
return Commit(
|
||||||
|
anchor=anchor,
|
||||||
|
short=short,
|
||||||
|
author=author,
|
||||||
|
subject=subject,
|
||||||
|
body=body.rstrip("\n"),
|
||||||
|
files=file_views(parse_patch(patch), anchor, top, limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_review(
|
||||||
|
cwd: str, max_file_lines: int, base: str | None, notes: list[str]
|
||||||
|
) -> Review:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
cwd: Directory inside the git working copy to review.
|
||||||
|
max_file_lines: Diff lines shown per file before the rest is summarised.
|
||||||
|
base: Branch, under refs/heads/ or refs/remotes/, whose ``REF..HEAD``
|
||||||
|
commits are listed, or None for no Commits section.
|
||||||
|
notes: Regular text files shown verbatim at the top, relative to
|
||||||
|
``cwd``; a symbolic link anywhere in the path is refused.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The collected Review model.
|
||||||
|
"""
|
||||||
|
top = top_level(cwd)
|
||||||
|
base_name, base_sha = (None, None) if base is None else base_ref(base, top)
|
||||||
|
note_items = tuple(read_note(path, cwd) for path in notes)
|
||||||
|
unsafe = unsafe_configs(top)
|
||||||
|
skipped = (
|
||||||
|
f"Unstaged changes and index tree not computed: {'; '.join(unsafe)}."
|
||||||
|
if unsafe
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
staged = Section(
|
||||||
|
key="staged",
|
||||||
|
title="Staged",
|
||||||
|
files=file_views(
|
||||||
|
parse_patch(git_text(["diff", "--cached", *DIFF_FLAGS], top)),
|
||||||
|
"staged",
|
||||||
|
top,
|
||||||
|
max_file_lines,
|
||||||
|
),
|
||||||
|
skipped=None,
|
||||||
|
)
|
||||||
|
unstaged = Section(
|
||||||
|
key="unstaged",
|
||||||
|
title="Unstaged",
|
||||||
|
files=()
|
||||||
|
if skipped
|
||||||
|
else file_views(
|
||||||
|
parse_patch(git_text(["diff", *DIFF_FLAGS], top)),
|
||||||
|
"unstaged",
|
||||||
|
top,
|
||||||
|
max_file_lines,
|
||||||
|
),
|
||||||
|
skipped=skipped,
|
||||||
|
)
|
||||||
|
untracked = Section(
|
||||||
|
key="untracked",
|
||||||
|
title="Untracked",
|
||||||
|
files=untracked_views(top, max_file_lines),
|
||||||
|
skipped=None,
|
||||||
|
)
|
||||||
|
commits: tuple[Commit, ...] = ()
|
||||||
|
if base_sha is not None:
|
||||||
|
shas = git_text(["rev-list", END_OF_OPTIONS, f"{base_sha}..HEAD"], top)
|
||||||
|
commits = tuple(
|
||||||
|
_commit(sha, f"commit-{index}", top, max_file_lines)
|
||||||
|
for index, sha in enumerate(shas.split(), 1)
|
||||||
|
)
|
||||||
|
tracked = [
|
||||||
|
*staged.files,
|
||||||
|
*unstaged.files,
|
||||||
|
*(view for commit in commits for view in commit.files),
|
||||||
|
]
|
||||||
|
return Review(
|
||||||
|
top_level=top,
|
||||||
|
name=os.path.basename(top),
|
||||||
|
branch=git_optional(
|
||||||
|
["symbolic-ref", "--short", "-q", END_OF_OPTIONS, "HEAD"], top
|
||||||
|
),
|
||||||
|
head=git_optional(
|
||||||
|
["rev-parse", "--verify", "--quiet", "--short", END_OF_OPTIONS, "HEAD"],
|
||||||
|
top,
|
||||||
|
),
|
||||||
|
index_tree="(not computed, see the warnings)"
|
||||||
|
if skipped
|
||||||
|
else git_optional(["write-tree"], top) or "(index has unmerged entries)",
|
||||||
|
base=base_name,
|
||||||
|
base_short=None
|
||||||
|
if base_sha is None
|
||||||
|
else git_text(["rev-parse", "--short", END_OF_OPTIONS, base_sha], top).strip(),
|
||||||
|
warnings=tuple(
|
||||||
|
([skipped] if skipped else [])
|
||||||
|
+ hidden_entries(top)
|
||||||
|
+ attribute_binary(tracked, top)
|
||||||
|
),
|
||||||
|
notes=note_items,
|
||||||
|
sections=(staged, unstaged, untracked),
|
||||||
|
commits=commits,
|
||||||
|
)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .model import FileDiff, Hunk, Line
|
||||||
|
|
||||||
|
HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
||||||
|
GIT_HEADER = "diff --git "
|
||||||
|
ESCAPES = {
|
||||||
|
"a": 7,
|
||||||
|
"b": 8,
|
||||||
|
"t": 9,
|
||||||
|
"n": 10,
|
||||||
|
"v": 11,
|
||||||
|
"f": 12,
|
||||||
|
"r": 13,
|
||||||
|
'"': 34,
|
||||||
|
"\\": 92,
|
||||||
|
}
|
||||||
|
HEADER_FIELDS = (
|
||||||
|
"rename from ",
|
||||||
|
"rename to ",
|
||||||
|
"copy from ",
|
||||||
|
"copy to ",
|
||||||
|
"new file mode ",
|
||||||
|
"deleted file mode ",
|
||||||
|
"old mode ",
|
||||||
|
"new mode ",
|
||||||
|
"--- ",
|
||||||
|
"+++ ",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unquote(text: str) -> str:
|
||||||
|
if len(text) < 2 or not (text.startswith('"') and text.endswith('"')):
|
||||||
|
return text
|
||||||
|
body = text[1:-1]
|
||||||
|
raw = bytearray()
|
||||||
|
index = 0
|
||||||
|
while index < len(body):
|
||||||
|
char = body[index]
|
||||||
|
escaped = body[index + 1 : index + 2]
|
||||||
|
octal = body[index + 1 : index + 4]
|
||||||
|
if char == "\\" and escaped in ESCAPES:
|
||||||
|
raw.append(ESCAPES[escaped])
|
||||||
|
index += 2
|
||||||
|
elif char == "\\" and len(octal) == 3 and set(octal) <= set("01234567"):
|
||||||
|
raw.append(int(octal, 8) & 0xFF)
|
||||||
|
index += 4
|
||||||
|
else:
|
||||||
|
raw.extend(char.encode("utf-8"))
|
||||||
|
index += 1
|
||||||
|
return raw.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _side(value: str, prefix: str) -> str | None:
|
||||||
|
value = value.removesuffix("\t")
|
||||||
|
if value == "/dev/null":
|
||||||
|
return None
|
||||||
|
return unquote(value).removeprefix(prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def _file(
|
||||||
|
git_line: str, headers: list[str], pending: list[tuple[str, int, list[Line]]]
|
||||||
|
) -> FileDiff:
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
for header in headers:
|
||||||
|
for key in HEADER_FIELDS:
|
||||||
|
if header.startswith(key):
|
||||||
|
fields[key.strip()] = header[len(key) :]
|
||||||
|
break
|
||||||
|
half = (len(git_line) - 1) // 2
|
||||||
|
old_path = _side(git_line[:half], "a/")
|
||||||
|
new_path = _side(git_line[half + 1 :], "b/")
|
||||||
|
if "---" in fields:
|
||||||
|
old_path = _side(fields["---"], "a/")
|
||||||
|
if "+++" in fields:
|
||||||
|
new_path = _side(fields["+++"], "b/")
|
||||||
|
status = "modified"
|
||||||
|
for kind, moved in (("rename", "renamed"), ("copy", "copied")):
|
||||||
|
if f"{kind} from" in fields and f"{kind} to" in fields:
|
||||||
|
old_path = unquote(fields[f"{kind} from"])
|
||||||
|
new_path = unquote(fields[f"{kind} to"])
|
||||||
|
status = moved
|
||||||
|
if "new file mode" in fields:
|
||||||
|
old_path, status = None, "added"
|
||||||
|
if "deleted file mode" in fields:
|
||||||
|
new_path, status = None, "deleted"
|
||||||
|
mode_change = None
|
||||||
|
if "old mode" in fields and "new mode" in fields:
|
||||||
|
mode_change = f"{fields['old mode']} → {fields['new mode']}"
|
||||||
|
return FileDiff(
|
||||||
|
old_path=old_path,
|
||||||
|
new_path=new_path,
|
||||||
|
status=status,
|
||||||
|
binary=any(header.startswith("Binary files ") for header in headers),
|
||||||
|
mode_change=mode_change,
|
||||||
|
hunks=tuple(
|
||||||
|
Hunk(header, start, tuple(lines)) for header, start, lines in pending
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_patch(text: str) -> list[FileDiff]:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
text: Output of ``git diff`` or ``git show`` produced with ``a/`` and
|
||||||
|
``b/`` prefixes and decoded without newline translation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One FileDiff per ``diff --git`` block, in git's order.
|
||||||
|
"""
|
||||||
|
files: list[FileDiff] = []
|
||||||
|
git_line: str | None = None
|
||||||
|
headers: list[str] = []
|
||||||
|
pending: list[tuple[str, int, list[Line]]] = []
|
||||||
|
old_no = new_no = old_left = new_left = 0
|
||||||
|
for raw in text.split("\n"):
|
||||||
|
if pending and (old_left > 0 or new_left > 0):
|
||||||
|
lines = pending[-1][2]
|
||||||
|
sign, body = raw[:1], raw[1:]
|
||||||
|
if sign == "+":
|
||||||
|
lines.append(Line("add", None, new_no, body))
|
||||||
|
new_no, new_left = new_no + 1, new_left - 1
|
||||||
|
elif sign == "-":
|
||||||
|
lines.append(Line("del", old_no, None, body))
|
||||||
|
old_no, old_left = old_no + 1, old_left - 1
|
||||||
|
elif sign == "\\":
|
||||||
|
lines.append(Line("meta", None, None, raw))
|
||||||
|
else:
|
||||||
|
lines.append(Line("ctx", old_no, new_no, body))
|
||||||
|
old_no, new_no = old_no + 1, new_no + 1
|
||||||
|
old_left, new_left = old_left - 1, new_left - 1
|
||||||
|
continue
|
||||||
|
if raw.startswith(GIT_HEADER):
|
||||||
|
if git_line is not None:
|
||||||
|
files.append(_file(git_line, headers, pending))
|
||||||
|
git_line, headers, pending = raw[len(GIT_HEADER) :], [], []
|
||||||
|
continue
|
||||||
|
if git_line is None:
|
||||||
|
continue
|
||||||
|
match = HUNK_HEADER.match(raw)
|
||||||
|
if match:
|
||||||
|
old_no, new_no = int(match[1]), int(match[3])
|
||||||
|
old_left = 1 if match[2] is None else int(match[2])
|
||||||
|
new_left = 1 if match[4] is None else int(match[4])
|
||||||
|
pending.append((raw, new_no, []))
|
||||||
|
elif pending and raw.startswith("\\"):
|
||||||
|
pending[-1][2].append(Line("meta", None, None, raw))
|
||||||
|
elif not pending:
|
||||||
|
headers.append(raw)
|
||||||
|
if git_line is not None:
|
||||||
|
files.append(_file(git_line, headers, pending))
|
||||||
|
return files
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
|
||||||
|
from .model import FileDiff, FileView, Hunk, Line, Note, ReviewError
|
||||||
|
from .repo import git_text
|
||||||
|
from .views import BINARY_DETAIL, file_view
|
||||||
|
|
||||||
|
READ_MAX_BYTES = 1024 * 1024
|
||||||
|
BINARY_SNIFF_BYTES = 8000
|
||||||
|
|
||||||
|
|
||||||
|
def _listed(path: str, binary: bool) -> FileDiff:
|
||||||
|
return FileDiff(
|
||||||
|
old_path=None,
|
||||||
|
new_path=path,
|
||||||
|
status="untracked",
|
||||||
|
binary=binary,
|
||||||
|
mode_change=None,
|
||||||
|
hunks=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_regular(path: str, limit: int) -> tuple[bytes, int] | None:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
path: Absolute path, opened one component at a time without following
|
||||||
|
a symbolic link anywhere along it.
|
||||||
|
limit: Most bytes to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The bytes read and the file size, or None when a component is a
|
||||||
|
symbolic link or the target is not a regular file.
|
||||||
|
"""
|
||||||
|
names = [name for name in path.split(os.sep) if name not in ("", ".")]
|
||||||
|
fd = os.open(os.sep, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
for index, name in enumerate(names, 1):
|
||||||
|
kind = os.O_NONBLOCK if index == len(names) else os.O_DIRECTORY
|
||||||
|
child = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | kind, dir_fd=fd)
|
||||||
|
os.close(fd)
|
||||||
|
fd = child
|
||||||
|
info = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
return None
|
||||||
|
with os.fdopen(os.dup(fd), "rb") as handle:
|
||||||
|
return handle.read(limit), info.st_size
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _untracked_file(path: str, top: str) -> tuple[FileDiff, str | None]:
|
||||||
|
try:
|
||||||
|
read = _read_regular(os.path.join(top, path), READ_MAX_BYTES + 1)
|
||||||
|
except OSError as exc:
|
||||||
|
return _listed(path, False), f"Unreadable: {exc.strerror}."
|
||||||
|
if read is None:
|
||||||
|
return _listed(path, False), "Not a regular file, contents not shown."
|
||||||
|
data, size = read
|
||||||
|
if len(data) > READ_MAX_BYTES:
|
||||||
|
return _listed(path, False), f"Large file ({size} bytes), contents not shown."
|
||||||
|
if b"\0" in data[:BINARY_SNIFF_BYTES]:
|
||||||
|
return _listed(path, True), BINARY_DETAIL
|
||||||
|
lines = data.decode("utf-8", "replace").split("\n")
|
||||||
|
if lines[-1] == "":
|
||||||
|
lines.pop()
|
||||||
|
added = tuple(
|
||||||
|
Line("add", None, number, text) for number, text in enumerate(lines, 1)
|
||||||
|
)
|
||||||
|
hunks = (Hunk(f"@@ -0,0 +1,{len(added)} @@", 1, added),) if added else ()
|
||||||
|
return (
|
||||||
|
FileDiff(
|
||||||
|
old_path=None,
|
||||||
|
new_path=path,
|
||||||
|
status="untracked",
|
||||||
|
binary=False,
|
||||||
|
mode_change=None,
|
||||||
|
hunks=hunks,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def untracked_views(top: str, limit: int) -> tuple[FileView, ...]:
|
||||||
|
listing = git_text(["ls-files", "--others", "--exclude-standard", "-z"], top)
|
||||||
|
views = []
|
||||||
|
for index, path in enumerate(sorted(p for p in listing.split("\0") if p), 1):
|
||||||
|
diff, detail = _untracked_file(path, top)
|
||||||
|
views.append(file_view(diff, f"untracked-{index}", top, limit, detail))
|
||||||
|
return tuple(views)
|
||||||
|
|
||||||
|
|
||||||
|
def read_note(path: str, cwd: str) -> Note:
|
||||||
|
try:
|
||||||
|
read = _read_regular(
|
||||||
|
os.path.abspath(os.path.join(cwd, path)), READ_MAX_BYTES + 1
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ReviewError(f"cannot read note {path!r}: {exc.strerror}") from None
|
||||||
|
if read is None:
|
||||||
|
raise ReviewError(
|
||||||
|
f"cannot read note {path!r}: a symbolic link or not a regular file"
|
||||||
|
)
|
||||||
|
data, size = read
|
||||||
|
if len(data) > READ_MAX_BYTES:
|
||||||
|
raise ReviewError(
|
||||||
|
f"cannot read note {path!r}: {size} bytes, more than {READ_MAX_BYTES}"
|
||||||
|
)
|
||||||
|
return Note(title=os.path.basename(path), text=data.decode("utf-8", "replace"))
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Line:
|
||||||
|
kind: str
|
||||||
|
old: int | None
|
||||||
|
new: int | None
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Hunk:
|
||||||
|
header: str
|
||||||
|
new_start: int
|
||||||
|
lines: tuple[Line, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileDiff:
|
||||||
|
old_path: str | None
|
||||||
|
new_path: str | None
|
||||||
|
status: str
|
||||||
|
binary: bool
|
||||||
|
mode_change: str | None
|
||||||
|
hunks: tuple[Hunk, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> str:
|
||||||
|
return self.new_path if self.new_path is not None else str(self.old_path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def moved(self) -> bool:
|
||||||
|
return self.status in ("renamed", "copied")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def added(self) -> int:
|
||||||
|
return sum(
|
||||||
|
1 for hunk in self.hunks for line in hunk.lines if line.kind == "add"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def removed(self) -> int:
|
||||||
|
return sum(
|
||||||
|
1 for hunk in self.hunks for line in hunk.lines if line.kind == "del"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileView:
|
||||||
|
anchor: str
|
||||||
|
diff: FileDiff
|
||||||
|
detail: str | None
|
||||||
|
link_base: str | None
|
||||||
|
link: str | None
|
||||||
|
hunks: tuple[Hunk, ...]
|
||||||
|
hidden: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def search(self) -> str:
|
||||||
|
if self.diff.moved:
|
||||||
|
return f"{self.diff.old_path} {self.diff.new_path}"
|
||||||
|
return self.diff.path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Section:
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
files: tuple[FileView, ...]
|
||||||
|
skipped: str | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def added(self) -> int:
|
||||||
|
return sum(view.diff.added for view in self.files)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def removed(self) -> int:
|
||||||
|
return sum(view.diff.removed for view in self.files)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Commit:
|
||||||
|
anchor: str
|
||||||
|
short: str
|
||||||
|
author: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
files: tuple[FileView, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def added(self) -> int:
|
||||||
|
return sum(view.diff.added for view in self.files)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def removed(self) -> int:
|
||||||
|
return sum(view.diff.removed for view in self.files)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Note:
|
||||||
|
title: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Review:
|
||||||
|
top_level: str
|
||||||
|
name: str
|
||||||
|
branch: str | None
|
||||||
|
head: str | None
|
||||||
|
index_tree: str
|
||||||
|
base: str | None
|
||||||
|
base_short: str | None
|
||||||
|
warnings: tuple[str, ...]
|
||||||
|
notes: tuple[Note, ...]
|
||||||
|
sections: tuple[Section, ...]
|
||||||
|
commits: tuple[Commit, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commit_files(self) -> int:
|
||||||
|
return sum(len(commit.files) for commit in self.commits)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commit_added(self) -> int:
|
||||||
|
return sum(commit.added for commit in self.commits)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commit_removed(self) -> int:
|
||||||
|
return sum(commit.removed for commit in self.commits)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
||||||
|
|
||||||
|
from .collect import collect_review
|
||||||
|
from .model import Review, ReviewError
|
||||||
|
|
||||||
|
TEMPLATE = "review.html.j2"
|
||||||
|
INVISIBLE = {
|
||||||
|
code: f"<U+{code:04X}>"
|
||||||
|
for code in (
|
||||||
|
*range(0x200B, 0x2010),
|
||||||
|
*range(0x202A, 0x202F),
|
||||||
|
*range(0x2066, 0x206A),
|
||||||
|
0x061C,
|
||||||
|
0xFEFF,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _csp_hash(text: str) -> str:
|
||||||
|
digest = hashlib.sha256(str(text).encode("utf-8")).digest()
|
||||||
|
return "sha256-" + base64.b64encode(digest).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _visible(value: object) -> object:
|
||||||
|
# not isinstance: Markup.translate() would splice the marks in unescaped,
|
||||||
|
# and macro output is Markup whose text was already marked when built
|
||||||
|
return value.translate(INVISIBLE) if type(value) is str else value
|
||||||
|
|
||||||
|
|
||||||
|
def render_review(review: Review) -> str:
|
||||||
|
environment = Environment(
|
||||||
|
loader=FileSystemLoader(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
autoescape=True,
|
||||||
|
undefined=StrictUndefined,
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
keep_trailing_newline=True,
|
||||||
|
finalize=_visible,
|
||||||
|
)
|
||||||
|
environment.filters["csp_hash"] = _csp_hash
|
||||||
|
return environment.get_template(TEMPLATE).render(review=review)
|
||||||
|
|
||||||
|
|
||||||
|
def write_review(
|
||||||
|
cwd: str, out: str, max_file_lines: int, base: str | None, notes: list[str]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
cwd: Directory inside the git working copy to review.
|
||||||
|
out: Output HTML path, relative to ``cwd`` unless absolute; written as
|
||||||
|
a new mode 0600 file renamed onto it, so a symbolic link there is
|
||||||
|
replaced, never followed.
|
||||||
|
max_file_lines: Diff lines shown per file before the rest is summarised.
|
||||||
|
base: Branch whose ``REF..HEAD`` commits are listed, or None.
|
||||||
|
notes: Text files shown verbatim at the top of the page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The absolute path of the written page.
|
||||||
|
"""
|
||||||
|
html = render_review(
|
||||||
|
collect_review(cwd=cwd, max_file_lines=max_file_lines, base=base, notes=notes)
|
||||||
|
)
|
||||||
|
path = os.path.abspath(os.path.join(cwd, out))
|
||||||
|
directory = os.path.dirname(path)
|
||||||
|
try:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, temp = tempfile.mkstemp(prefix=".review-", suffix=".tmp", dir=directory)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
handle.write(html)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temp, path)
|
||||||
|
except OSError:
|
||||||
|
os.unlink(temp)
|
||||||
|
raise
|
||||||
|
except OSError as exc:
|
||||||
|
raise ReviewError(f"cannot write {path!r}: {exc.strerror}") from None
|
||||||
|
return path
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch.gc import END_OF_OPTIONS
|
||||||
|
from pkgmgr.core.git.errors import GitBaseError
|
||||||
|
from pkgmgr.core.git.run import SCAN_FLAGS, SCAN_TIMEOUT, _scan_env, run
|
||||||
|
|
||||||
|
from .model import ReviewError
|
||||||
|
|
||||||
|
BRANCH_NAMESPACES = ("refs/heads/", "refs/remotes/")
|
||||||
|
|
||||||
|
|
||||||
|
def git_text(args: list[str], cwd: str) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *SCAN_FLAGS, "-c", "core.quotepath=false", *args],
|
||||||
|
cwd=cwd,
|
||||||
|
env=_scan_env(),
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=SCAN_TIMEOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise ReviewError(f"git {args[0]} timed out") from None
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.decode("utf-8", "replace").strip().splitlines()
|
||||||
|
reason = stderr[0] if stderr else f"exit code {result.returncode}"
|
||||||
|
raise ReviewError(f"git {args[0]} failed: {reason}")
|
||||||
|
return result.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def git_optional(args: list[str], cwd: str) -> str | None:
|
||||||
|
try:
|
||||||
|
return run(args, cwd=cwd, scan=True) or None
|
||||||
|
except GitBaseError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def top_level(cwd: str) -> str:
|
||||||
|
top = git_optional(["rev-parse", "--show-toplevel"], cwd)
|
||||||
|
if top is None:
|
||||||
|
raise ReviewError(f"not inside a git working copy: {cwd!r}")
|
||||||
|
return top
|
||||||
|
|
||||||
|
|
||||||
|
def base_ref(base: str, top: str) -> tuple[str, str]:
|
||||||
|
names = (
|
||||||
|
[base]
|
||||||
|
if base.startswith(BRANCH_NAMESPACES)
|
||||||
|
else [namespace + base for namespace in BRANCH_NAMESPACES]
|
||||||
|
)
|
||||||
|
refs: dict[str, str] = {}
|
||||||
|
for name in names:
|
||||||
|
sha = git_optional(
|
||||||
|
["show-ref", "--verify", "--hash", END_OF_OPTIONS, name], top
|
||||||
|
)
|
||||||
|
if sha is not None:
|
||||||
|
refs[name] = sha
|
||||||
|
if len(refs) > 1:
|
||||||
|
raise ReviewError(f"ambiguous base ref: {base!r} names {' and '.join(refs)}")
|
||||||
|
if not refs:
|
||||||
|
raise ReviewError(
|
||||||
|
f"unknown base ref: {base!r}, expected a branch under "
|
||||||
|
f"{' or '.join(BRANCH_NAMESPACES)}"
|
||||||
|
)
|
||||||
|
[(name, sha)] = refs.items()
|
||||||
|
commit = git_optional(
|
||||||
|
["rev-parse", "--verify", "--quiet", END_OF_OPTIONS, f"{sha}^{{commit}}"], top
|
||||||
|
)
|
||||||
|
if commit is None:
|
||||||
|
raise ReviewError(f"base ref {name!r} does not point to a commit")
|
||||||
|
return name, commit
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
{% set status_letters = {"added": "A", "deleted": "D", "modified": "M", "renamed": "R", "copied": "C", "untracked": "U"} %}
|
||||||
|
{% set signs = {"add": "+", "del": "-", "ctx": "", "meta": ""} %}
|
||||||
|
{% set style %}
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #f6f8fa;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--subtle: #f6f8fa;
|
||||||
|
--border: #d1d9e0;
|
||||||
|
--border-muted: #e1e6eb;
|
||||||
|
--text: #1f2328;
|
||||||
|
--muted: #59636e;
|
||||||
|
--accent: #0969da;
|
||||||
|
--accent-bg: #ddf4ff;
|
||||||
|
--add-fg: #1a7f37;
|
||||||
|
--add-bg: #dafbe1;
|
||||||
|
--add-line: #e6ffec;
|
||||||
|
--add-num: #ccffd8;
|
||||||
|
--del-fg: #cf222e;
|
||||||
|
--del-bg: #ffebe9;
|
||||||
|
--del-line: #ffebe9;
|
||||||
|
--del-num: #ffd7d5;
|
||||||
|
--hunk-bg: #ddf4ff;
|
||||||
|
--hunk-fg: #59636e;
|
||||||
|
--warn-fg: #9a6700;
|
||||||
|
--warn-bg: #fff8c5;
|
||||||
|
--shadow: 0 1px 2px rgba(31, 35, 40, 0.06);
|
||||||
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
--sans: system-ui, -apple-system, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--panel: #151b23;
|
||||||
|
--subtle: #1b222c;
|
||||||
|
--border: #3d444d;
|
||||||
|
--border-muted: #2a313c;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #9198a1;
|
||||||
|
--accent: #4493f8;
|
||||||
|
--accent-bg: rgba(56, 139, 253, 0.15);
|
||||||
|
--add-fg: #3fb950;
|
||||||
|
--add-bg: rgba(46, 160, 67, 0.2);
|
||||||
|
--add-line: rgba(46, 160, 67, 0.15);
|
||||||
|
--add-num: rgba(63, 185, 80, 0.3);
|
||||||
|
--del-fg: #f85149;
|
||||||
|
--del-bg: rgba(248, 81, 73, 0.2);
|
||||||
|
--del-line: rgba(248, 81, 73, 0.1);
|
||||||
|
--del-num: rgba(248, 81, 73, 0.3);
|
||||||
|
--hunk-bg: rgba(56, 139, 253, 0.1);
|
||||||
|
--hunk-fg: #9198a1;
|
||||||
|
--warn-fg: #d29922;
|
||||||
|
--warn-bg: rgba(187, 128, 9, 0.15);
|
||||||
|
--shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
html { scroll-padding-top: 64px; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
pre, code { font-family: var(--mono); }
|
||||||
|
.muted { color: var(--muted); font-weight: 400; }
|
||||||
|
.masthead { background: var(--panel); border-bottom: 1px solid var(--border); }
|
||||||
|
.masthead-inner, .toolbar-inner, main { max-width: 1280px; margin: 0 auto; padding-left: 32px; padding-right: 32px; }
|
||||||
|
.masthead-inner { padding-top: 24px; padding-bottom: 20px; }
|
||||||
|
.eyebrow { margin: 0 0 4px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
h1 { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; font-size: 22px; font-weight: 600; }
|
||||||
|
.pill { padding: 2px 10px; border-radius: 999px; background: var(--accent-bg); color: var(--accent); font: 500 12px/20px var(--mono); }
|
||||||
|
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 20px; margin: 16px 0 0; font-size: 13px; }
|
||||||
|
.facts dt { color: var(--muted); }
|
||||||
|
.facts dd { margin: 0; font-family: var(--mono); overflow-wrap: anywhere; }
|
||||||
|
.warnings, .skipped { border: 1px solid var(--warn-fg); border-radius: 8px; background: var(--warn-bg); color: var(--warn-fg); font-size: 13px; overflow-wrap: anywhere; }
|
||||||
|
.warnings { margin: 16px 0 0; padding: 10px 14px 10px 34px; }
|
||||||
|
.skipped { margin: 0; padding: 14px 16px; border-style: dashed; }
|
||||||
|
.totals { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
|
||||||
|
.total { display: flex; flex-direction: column; gap: 2px; min-width: 160px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--subtle); color: var(--text); }
|
||||||
|
.total:hover { border-color: var(--accent); text-decoration: none; }
|
||||||
|
.total .label { color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||||
|
.total .files { font-size: 15px; font-weight: 600; }
|
||||||
|
.stat { display: inline-flex; gap: 6px; font: 600 12px var(--mono); white-space: nowrap; }
|
||||||
|
.stat .add { color: var(--add-fg); }
|
||||||
|
.stat .del { color: var(--del-fg); }
|
||||||
|
.toolbar { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--border); background: var(--panel); background: color-mix(in srgb, var(--panel) 86%, transparent); backdrop-filter: saturate(180%) blur(10px); box-shadow: var(--shadow); }
|
||||||
|
.toolbar-inner { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding-top: 8px; padding-bottom: 8px; }
|
||||||
|
.jumps { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||||
|
.jumps a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px; color: var(--text); font-weight: 500; }
|
||||||
|
.jumps a:hover { background: var(--accent-bg); text-decoration: none; }
|
||||||
|
.count { min-width: 20px; padding: 0 6px; border: 1px solid var(--border-muted); border-radius: 999px; background: var(--subtle); color: var(--muted); font-size: 12px; line-height: 18px; text-align: center; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
input[type="search"] { width: 260px; max-width: 100%; padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font: 13px var(--mono); }
|
||||||
|
input[type="search"]:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||||
|
button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--subtle); color: var(--text); font: 500 13px var(--sans); cursor: pointer; }
|
||||||
|
button:hover { border-color: var(--muted); }
|
||||||
|
main { padding-top: 24px; padding-bottom: 80px; }
|
||||||
|
.group { margin-bottom: 36px; }
|
||||||
|
h2 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; margin: 0 0 12px; font-size: 17px; font-weight: 600; }
|
||||||
|
.note { margin: 0 0 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); overflow: hidden; }
|
||||||
|
.note figcaption { padding: 8px 14px; border-bottom: 1px solid var(--border); background: var(--subtle); font: 600 13px var(--mono); }
|
||||||
|
.note pre, .commit-body { margin: 0; padding: 12px 14px; font-size: 13px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
details.file, details.commit { margin-bottom: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
summary { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; list-style: none; }
|
||||||
|
summary::-webkit-details-marker { display: none; }
|
||||||
|
summary::before { content: ""; flex: none; width: 6px; height: 6px; margin: 0 4px 0 2px; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); transition: transform 0.12s ease; }
|
||||||
|
details[open] > summary::before { transform: rotate(45deg); }
|
||||||
|
details.file > summary { background: var(--subtle); font: 13px var(--mono); }
|
||||||
|
details.file[open] > summary, details.commit[open] > summary { border-bottom: 1px solid var(--border); }
|
||||||
|
summary:hover { background: var(--accent-bg); }
|
||||||
|
.badge { flex: none; width: 20px; height: 20px; border-radius: 5px; font: 700 11px/20px var(--sans); text-align: center; }
|
||||||
|
.badge.added, .badge.untracked { background: var(--add-bg); color: var(--add-fg); }
|
||||||
|
.badge.deleted { background: var(--del-bg); color: var(--del-fg); }
|
||||||
|
.badge.modified { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.badge.renamed, .badge.copied { background: var(--warn-bg); color: var(--warn-fg); }
|
||||||
|
.path { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||||
|
.path .old, .path .arrow { color: var(--muted); font-weight: 400; }
|
||||||
|
.tag { flex: none; padding: 0 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font: 500 11px/18px var(--sans); }
|
||||||
|
.open { flex: none; font: 500 12px var(--sans); }
|
||||||
|
.placeholder, .empty { margin: 0; padding: 14px 16px; color: var(--muted); font-style: italic; }
|
||||||
|
.empty { border: 1px dashed var(--border); border-radius: 8px; }
|
||||||
|
table.diff { width: 100%; border-collapse: collapse; font: 12.5px/20px var(--mono); tab-size: 4; }
|
||||||
|
.diff td { height: 20px; padding: 0; vertical-align: top; }
|
||||||
|
td.num { width: 1%; min-width: 50px; padding: 0 10px; color: var(--muted); text-align: right; white-space: nowrap; user-select: none; }
|
||||||
|
td.num a { color: inherit; }
|
||||||
|
td.num a:hover { color: var(--accent); }
|
||||||
|
td.sign { width: 20px; padding-left: 10px; color: var(--muted); user-select: none; }
|
||||||
|
td.code { padding-right: 16px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
tr.add td { background: var(--add-line); }
|
||||||
|
tr.add td.num { background: var(--add-num); }
|
||||||
|
tr.add td.sign { color: var(--add-fg); }
|
||||||
|
tr.del td { background: var(--del-line); }
|
||||||
|
tr.del td.num { background: var(--del-num); }
|
||||||
|
tr.del td.sign { color: var(--del-fg); }
|
||||||
|
tr.hunk td { padding-top: 4px; padding-bottom: 4px; background: var(--hunk-bg); color: var(--hunk-fg); }
|
||||||
|
tr.meta td.code { color: var(--muted); font-style: italic; }
|
||||||
|
tr.more td { padding: 8px 16px; border-top: 1px dashed var(--border); background: var(--subtle); color: var(--muted); font: italic 12px var(--sans); text-align: center; }
|
||||||
|
details.commit > summary { padding: 10px 14px; }
|
||||||
|
.sha { flex: none; padding: 1px 8px; border: 1px solid var(--border-muted); border-radius: 6px; background: var(--subtle); font-size: 12px; }
|
||||||
|
.subject { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||||
|
.commit-meta { padding: 10px 14px 0; color: var(--muted); font-size: 13px; }
|
||||||
|
.commit-files { padding: 12px 14px 2px; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.masthead-inner, .toolbar-inner, main { padding-left: 14px; padding-right: 14px; }
|
||||||
|
input[type="search"] { width: 100%; }
|
||||||
|
.spacer { display: none; }
|
||||||
|
}
|
||||||
|
{% endset %}
|
||||||
|
{% set script %}
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var files = Array.prototype.slice.call(document.querySelectorAll("details.file"));
|
||||||
|
var filter = document.getElementById("filter");
|
||||||
|
var counter = document.getElementById("filter-count");
|
||||||
|
function setOpen(open) {
|
||||||
|
document.querySelectorAll("details").forEach(function (item) {
|
||||||
|
item.open = open;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function applyFilter() {
|
||||||
|
var query = filter.value.trim().toLowerCase();
|
||||||
|
var shown = 0;
|
||||||
|
files.forEach(function (item) {
|
||||||
|
var match = query === "" || item.dataset.path.toLowerCase().indexOf(query) !== -1;
|
||||||
|
item.hidden = !match;
|
||||||
|
shown += match ? 1 : 0;
|
||||||
|
});
|
||||||
|
counter.textContent = query === "" ? "" : shown + " of " + files.length + " files";
|
||||||
|
}
|
||||||
|
document.getElementById("expand-all").addEventListener("click", function () {
|
||||||
|
setOpen(true);
|
||||||
|
});
|
||||||
|
document.getElementById("collapse-all").addEventListener("click", function () {
|
||||||
|
setOpen(false);
|
||||||
|
});
|
||||||
|
filter.addEventListener("input", applyFilter);
|
||||||
|
document.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key === "/" && document.activeElement !== filter) {
|
||||||
|
event.preventDefault();
|
||||||
|
filter.focus();
|
||||||
|
} else if (event.key === "Escape" && document.activeElement === filter) {
|
||||||
|
filter.value = "";
|
||||||
|
applyFilter();
|
||||||
|
filter.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
applyFilter();
|
||||||
|
})();
|
||||||
|
{% endset %}
|
||||||
|
{% macro stat(added, removed) %}<span class="stat"><span class="add">+{{ added }}</span><span class="del">−{{ removed }}</span></span>{% endmacro %}
|
||||||
|
{% macro plural(count, word) %}{{ count }} {{ word }}{{ "" if count == 1 else "s" }}{% endmacro %}
|
||||||
|
{% macro file_block(file, open) %}
|
||||||
|
<details class="file" id="{{ file.anchor }}" data-path="{{ file.search }}"{% if open %} open{% endif %}>
|
||||||
|
<summary>
|
||||||
|
<span class="badge {{ file.diff.status }}" title="{{ file.diff.status }}">{{ status_letters[file.diff.status] }}</span>
|
||||||
|
<span class="path">{% if file.diff.moved %}<span class="old">{{ file.diff.old_path }}</span><span class="arrow"> → </span>{% endif %}{{ file.diff.path }}</span>
|
||||||
|
{% if file.diff.mode_change %}
|
||||||
|
<span class="tag">mode {{ file.diff.mode_change }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if file.diff.binary %}
|
||||||
|
<span class="tag">binary</span>
|
||||||
|
{% endif %}
|
||||||
|
{{ stat(file.diff.added, file.diff.removed) }}
|
||||||
|
{% if file.link %}
|
||||||
|
<a class="open" href="{{ file.link }}" title="Open in VS Code">Open ↗</a>
|
||||||
|
{% endif %}
|
||||||
|
</summary>
|
||||||
|
{% if file.detail %}
|
||||||
|
<p class="placeholder">{{ file.detail }}</p>
|
||||||
|
{% elif not file.hunks %}
|
||||||
|
<p class="placeholder">No content changes.</p>
|
||||||
|
{% else %}
|
||||||
|
<table class="diff">
|
||||||
|
{% for hunk in file.hunks %}
|
||||||
|
<tbody>
|
||||||
|
<tr class="hunk"><td class="num" colspan="2"></td><td class="code" colspan="2">{{ hunk.header }}</td></tr>
|
||||||
|
{% for line in hunk.lines %}
|
||||||
|
<tr class="{{ line.kind }}"><td class="num">{{ "" if line.old is none else line.old }}</td><td class="num">{% if line.new is not none and file.link_base %}<a href="{{ file.link_base }}:{{ line.new }}">{{ line.new }}</a>{% elif line.new is not none %}{{ line.new }}{% endif %}</td><td class="sign">{{ signs[line.kind] }}</td><td class="code">{{ line.text }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
{% if file.hidden %}
|
||||||
|
<tbody><tr class="more"><td colspan="4">… {{ plural(file.hidden, "more line") }} not shown</td></tr></tbody>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</details>
|
||||||
|
{% endmacro %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src '{{ style|csp_hash }}'; script-src '{{ script|csp_hash }}'; base-uri 'none'; form-action 'none'">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>Review: {{ review.name }}{% if review.branch %} ({{ review.branch }}){% endif %}</title>
|
||||||
|
<style>{{ style }}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="masthead-inner">
|
||||||
|
<p class="eyebrow">Working copy review</p>
|
||||||
|
<h1>{{ review.name }} <span class="pill">{{ review.branch or "detached HEAD" }}</span></h1>
|
||||||
|
<dl class="facts">
|
||||||
|
<dt>Top level</dt><dd>{{ review.top_level }}</dd>
|
||||||
|
<dt>Branch</dt><dd>{{ review.branch or "(detached HEAD)" }}</dd>
|
||||||
|
<dt>HEAD</dt><dd>{{ review.head or "(no commits yet)" }}</dd>
|
||||||
|
<dt>Index tree</dt><dd>{{ review.index_tree }}</dd>
|
||||||
|
{% if review.base is not none %}
|
||||||
|
<dt>Base</dt><dd>{{ review.base }} @ {{ review.base_short }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
{% if review.warnings %}
|
||||||
|
<ul class="warnings">
|
||||||
|
{% for warning in review.warnings %}
|
||||||
|
<li>{{ warning }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<div class="totals">
|
||||||
|
{% for section in review.sections %}
|
||||||
|
<a class="total" href="#{{ section.key }}"><span class="label">{{ section.title }}</span><span class="files">{{ plural(section.files|length, "file") }}</span>{{ stat(section.added, section.removed) }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if review.base is not none %}
|
||||||
|
<a class="total" href="#commits"><span class="label">Commits</span><span class="files">{{ plural(review.commits|length, "commit") }} · {{ plural(review.commit_files, "file") }}</span>{{ stat(review.commit_added, review.commit_removed) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<nav class="toolbar">
|
||||||
|
<div class="toolbar-inner">
|
||||||
|
<div class="jumps">
|
||||||
|
{% if review.notes %}
|
||||||
|
<a href="#notes">Notes <span class="count">{{ review.notes|length }}</span></a>
|
||||||
|
{% endif %}
|
||||||
|
{% for section in review.sections %}
|
||||||
|
<a href="#{{ section.key }}">{{ section.title }} <span class="count">{{ section.files|length }}</span></a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if review.base is not none %}
|
||||||
|
<a href="#commits">Commits <span class="count">{{ review.commits|length }}</span></a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<input id="filter" type="search" placeholder="Filter files by path ( / )" autocomplete="off" spellcheck="false" aria-label="Filter files by path">
|
||||||
|
<span id="filter-count" class="muted"></span>
|
||||||
|
<button type="button" id="expand-all">Expand all</button>
|
||||||
|
<button type="button" id="collapse-all">Collapse all</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{% if review.notes %}
|
||||||
|
<section class="group" id="notes">
|
||||||
|
<h2>Notes</h2>
|
||||||
|
{% for note in review.notes %}
|
||||||
|
<figure class="note">
|
||||||
|
<figcaption>{{ note.title }}</figcaption>
|
||||||
|
<pre>
|
||||||
|
{{ note.text }}</pre>
|
||||||
|
</figure>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% for section in review.sections %}
|
||||||
|
<section class="group" id="{{ section.key }}" data-files="{{ section.files|length }}" data-added="{{ section.added }}" data-removed="{{ section.removed }}">
|
||||||
|
<h2>{{ section.title }} <span class="muted">{{ plural(section.files|length, "file") }}</span>{{ stat(section.added, section.removed) }}</h2>
|
||||||
|
{% if section.skipped %}
|
||||||
|
<p class="skipped">{{ section.skipped }}</p>
|
||||||
|
{% else %}
|
||||||
|
{% for file in section.files %}
|
||||||
|
{{ file_block(file, true) }}
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">No changes.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% if review.base is not none %}
|
||||||
|
<section class="group" id="commits" data-commits="{{ review.commits|length }}" data-files="{{ review.commit_files }}" data-added="{{ review.commit_added }}" data-removed="{{ review.commit_removed }}">
|
||||||
|
<h2>Commits <span class="muted">{{ review.base }}..HEAD · {{ plural(review.commits|length, "commit") }}</span>{{ stat(review.commit_added, review.commit_removed) }}</h2>
|
||||||
|
{% for commit in review.commits %}
|
||||||
|
<details class="commit" id="{{ commit.anchor }}" open>
|
||||||
|
<summary>
|
||||||
|
<code class="sha">{{ commit.short }}</code>
|
||||||
|
<span class="subject">{{ commit.subject }}</span>
|
||||||
|
{{ stat(commit.added, commit.removed) }}
|
||||||
|
</summary>
|
||||||
|
<div class="commit-meta">{{ commit.author }}</div>
|
||||||
|
{% if commit.body %}
|
||||||
|
<pre class="commit-body">
|
||||||
|
{{ commit.body }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
<div class="commit-files">
|
||||||
|
{% for file in commit.files %}
|
||||||
|
{{ file_block(file, false) }}
|
||||||
|
{% else %}
|
||||||
|
<p class="placeholder">No file changes.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">No commits between {{ review.base }} and HEAD.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
<script>{{ script }}</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from .model import FileDiff, FileView, Hunk
|
||||||
|
|
||||||
|
BINARY_DETAIL = "Binary file, contents not shown."
|
||||||
|
|
||||||
|
|
||||||
|
def _first_changed_line(hunks: tuple[Hunk, ...]) -> int:
|
||||||
|
for hunk in hunks:
|
||||||
|
position = hunk.new_start
|
||||||
|
for line in hunk.lines:
|
||||||
|
if line.kind in ("add", "del"):
|
||||||
|
return max(position, 1)
|
||||||
|
if line.new is not None:
|
||||||
|
position = line.new + 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(hunks: tuple[Hunk, ...], limit: int) -> tuple[tuple[Hunk, ...], int]:
|
||||||
|
shown: list[Hunk] = []
|
||||||
|
budget = limit
|
||||||
|
for hunk in hunks:
|
||||||
|
if budget <= 0:
|
||||||
|
break
|
||||||
|
lines = hunk.lines[:budget]
|
||||||
|
shown.append(Hunk(hunk.header, hunk.new_start, lines))
|
||||||
|
budget -= len(lines)
|
||||||
|
total = sum(len(hunk.lines) for hunk in hunks)
|
||||||
|
return tuple(shown), total - (limit - budget)
|
||||||
|
|
||||||
|
|
||||||
|
def file_view(
|
||||||
|
diff: FileDiff, anchor: str, top: str, limit: int, detail: str | None
|
||||||
|
) -> FileView:
|
||||||
|
link_base = None
|
||||||
|
if diff.new_path is not None:
|
||||||
|
link_base = "vscode://file" + quote(os.path.join(top, diff.new_path))
|
||||||
|
hunks, hidden = _truncate(diff.hunks, limit)
|
||||||
|
return FileView(
|
||||||
|
anchor=anchor,
|
||||||
|
diff=diff,
|
||||||
|
detail=detail,
|
||||||
|
link_base=link_base,
|
||||||
|
link=None
|
||||||
|
if link_base is None
|
||||||
|
else f"{link_base}:{_first_changed_line(diff.hunks)}",
|
||||||
|
hunks=hunks,
|
||||||
|
hidden=hidden,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def file_views(
|
||||||
|
diffs: list[FileDiff], prefix: str, top: str, limit: int
|
||||||
|
) -> tuple[FileView, ...]:
|
||||||
|
return tuple(
|
||||||
|
file_view(
|
||||||
|
diff,
|
||||||
|
f"{prefix}-{index}",
|
||||||
|
top,
|
||||||
|
limit,
|
||||||
|
BINARY_DETAIL if diff.binary else None,
|
||||||
|
)
|
||||||
|
for index, diff in enumerate(diffs, 1)
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ from .mirror import handle_mirror_command
|
|||||||
from .publish import handle_publish
|
from .publish import handle_publish
|
||||||
from .release import handle_release
|
from .release import handle_release
|
||||||
from .repos import handle_repos_command
|
from .repos import handle_repos_command
|
||||||
|
from .review import handle_review
|
||||||
from .tools import handle_tools_command
|
from .tools import handle_tools_command
|
||||||
from .version import handle_version
|
from .version import handle_version
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ __all__ = [
|
|||||||
"handle_publish",
|
"handle_publish",
|
||||||
"handle_release",
|
"handle_release",
|
||||||
"handle_repos_command",
|
"handle_repos_command",
|
||||||
|
"handle_review",
|
||||||
"handle_tools_command",
|
"handle_tools_command",
|
||||||
"handle_version",
|
"handle_version",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.review import ReviewError, write_review
|
||||||
|
from pkgmgr.cli.context import CLIContext
|
||||||
|
|
||||||
|
|
||||||
|
def handle_review(args, ctx: CLIContext) -> None:
|
||||||
|
try:
|
||||||
|
path = write_review(
|
||||||
|
cwd=os.getcwd(),
|
||||||
|
out=args.out,
|
||||||
|
max_file_lines=args.max_file_lines,
|
||||||
|
base=args.base,
|
||||||
|
notes=args.note,
|
||||||
|
)
|
||||||
|
except ReviewError as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
print(Path(path).as_uri())
|
||||||
@@ -15,6 +15,7 @@ from pkgmgr.cli.commands import (
|
|||||||
handle_publish,
|
handle_publish,
|
||||||
handle_release,
|
handle_release,
|
||||||
handle_repos_command,
|
handle_repos_command,
|
||||||
|
handle_review,
|
||||||
handle_tools_command,
|
handle_tools_command,
|
||||||
handle_version,
|
handle_version,
|
||||||
)
|
)
|
||||||
@@ -69,6 +70,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
|||||||
handle_code_scanning(args, ctx)
|
handle_code_scanning(args, ctx)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.command == "review":
|
||||||
|
handle_review(args, ctx)
|
||||||
|
return
|
||||||
|
|
||||||
commands_with_selection = {
|
commands_with_selection = {
|
||||||
"install",
|
"install",
|
||||||
"update",
|
"update",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .mirror_cmd import add_mirror_subparsers
|
|||||||
from .navigation_cmd import add_navigation_subparsers
|
from .navigation_cmd import add_navigation_subparsers
|
||||||
from .publish_cmd import add_publish_subparser
|
from .publish_cmd import add_publish_subparser
|
||||||
from .release_cmd import add_release_subparser
|
from .release_cmd import add_release_subparser
|
||||||
|
from .review_cmd import add_review_subparser
|
||||||
from .version_cmd import add_version_subparser
|
from .version_cmd import add_version_subparser
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
|
|||||||
add_navigation_subparsers(subparsers)
|
add_navigation_subparsers(subparsers)
|
||||||
|
|
||||||
add_branch_subparsers(subparsers)
|
add_branch_subparsers(subparsers)
|
||||||
|
add_review_subparser(subparsers)
|
||||||
add_release_subparser(subparsers)
|
add_release_subparser(subparsers)
|
||||||
add_publish_subparser(subparsers)
|
add_publish_subparser(subparsers)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(value: str) -> int:
|
||||||
|
number = int(value)
|
||||||
|
if number < 1:
|
||||||
|
raise argparse.ArgumentTypeError(f"must be a positive integer, got {value}")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def add_review_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
"review",
|
||||||
|
help="Render the working copy's changes as a local HTML review page.",
|
||||||
|
description=(
|
||||||
|
"Write a self-contained HTML page showing the staged, unstaged and "
|
||||||
|
"untracked changes of the current git working copy (plus the commits "
|
||||||
|
"of REF..HEAD with --base) and print its file:// URL."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out",
|
||||||
|
required=True,
|
||||||
|
metavar="FILE",
|
||||||
|
help="Write the HTML page to FILE; parent directories are created.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-file-lines",
|
||||||
|
required=True,
|
||||||
|
type=_positive_int,
|
||||||
|
metavar="N",
|
||||||
|
help="Show at most N diff lines per file and summarise the rest.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--base",
|
||||||
|
metavar="REF",
|
||||||
|
help="Add a Commits section for REF..HEAD; omitted means no Commits section.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--note",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="FILE",
|
||||||
|
help="Show FILE verbatim in a panel at the top; repeatable.",
|
||||||
|
)
|
||||||
@@ -22,6 +22,7 @@ SCAN_FLAGS = (
|
|||||||
SCAN_ENV = {
|
SCAN_ENV = {
|
||||||
"GIT_CONFIG_NOSYSTEM": "1",
|
"GIT_CONFIG_NOSYSTEM": "1",
|
||||||
"GIT_NO_LAZY_FETCH": "1",
|
"GIT_NO_LAZY_FETCH": "1",
|
||||||
|
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||||
"GIT_OPTIONAL_LOCKS": "0",
|
"GIT_OPTIONAL_LOCKS": "0",
|
||||||
"GIT_TERMINAL_PROMPT": "0",
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
"GIT_ASKPASS": "/bin/false",
|
"GIT_ASKPASS": "/bin/false",
|
||||||
@@ -63,7 +64,8 @@ def run(
|
|||||||
cwd: directory to run in.
|
cwd: directory to run in.
|
||||||
preview: print the command instead of running it.
|
preview: print the command instead of running it.
|
||||||
scan: read a repository an agent may have written: no fsmonitor, hook,
|
scan: read a repository an agent may have written: no fsmonitor, hook,
|
||||||
pager, lazy fetch, system config or prompt, stdin closed, a timeout.
|
pager, lazy fetch, replace object, system config or prompt, stdin
|
||||||
|
closed, a timeout.
|
||||||
|
|
||||||
Raises GitRunError (or a subclass) if execution fails or times out.
|
Raises GitRunError (or a subclass) if execution fails or times out.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from pkgmgr.actions.review import ReviewError, write_review
|
||||||
|
from pkgmgr.cli.commands.review import handle_review
|
||||||
|
from pkgmgr.cli.parser import create_parser
|
||||||
|
|
||||||
|
GIT_ENV = {
|
||||||
|
**os.environ,
|
||||||
|
"GIT_AUTHOR_NAME": "t",
|
||||||
|
"GIT_AUTHOR_EMAIL": "t@example.org",
|
||||||
|
"GIT_COMMITTER_NAME": "t",
|
||||||
|
"GIT_COMMITTER_EMAIL": "t@example.org",
|
||||||
|
}
|
||||||
|
COMMIT_MESSAGE = (
|
||||||
|
'feat: <script>alert("subject")</script>\n\nBody <script>alert("body")</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def git(cwd: str, *args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-c", "commit.gpgsign=false", *args],
|
||||||
|
cwd=cwd,
|
||||||
|
env=GIT_ENV,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def write(path: str, content: bytes) -> None:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "wb") as handle:
|
||||||
|
handle.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReview(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
|
self.repo = os.path.join(self.root, "my repo")
|
||||||
|
git(self.root, "init", "-b", "main", self.repo)
|
||||||
|
app = "".join(f"line {n}\n" for n in range(1, 21))
|
||||||
|
write(self._path("app.py"), app.encode())
|
||||||
|
write(self._path("notes.md"), b"# Notes\n")
|
||||||
|
git(self.repo, "add", "-A")
|
||||||
|
git(self.repo, "commit", "-m", "init")
|
||||||
|
|
||||||
|
git(self.repo, "checkout", "-b", "feature")
|
||||||
|
write(self._path("notes.md"), b"# Notes\nfeature\n")
|
||||||
|
git(self.repo, "commit", "-am", COMMIT_MESSAGE)
|
||||||
|
|
||||||
|
write(self._path("app.py"), app.replace("line 5\n", "line five\n").encode())
|
||||||
|
big = "".join(f"big {n}\n" for n in range(1, 31))
|
||||||
|
write(self._path("dir with space", "big.txt"), big.encode())
|
||||||
|
write(self._path("image.png"), b"\x89PNG\x00\x01\x02")
|
||||||
|
git(self.repo, "mv", "notes.md", "renamed.md")
|
||||||
|
git(self.repo, "add", "-A")
|
||||||
|
|
||||||
|
unstaged = app.replace("line 5\n", "line five\n")
|
||||||
|
write(
|
||||||
|
self._path("app.py"), unstaged.replace("line 15\n", "line 15!\n").encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
write(
|
||||||
|
self._path("todo.txt"),
|
||||||
|
b"first\n<script>alert('file')</script>\nlast\n",
|
||||||
|
)
|
||||||
|
write(self._path("blob.bin"), b"\x00\xffdata")
|
||||||
|
|
||||||
|
self.note = os.path.join(self.root, "score.txt")
|
||||||
|
write(self.note, b"<b>score</b> 90/5\n")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _path(self, *parts: str) -> str:
|
||||||
|
return os.path.join(self.repo, *parts)
|
||||||
|
|
||||||
|
def _render(self, out: str, base: str | None, notes: list[str]) -> str:
|
||||||
|
path = write_review(
|
||||||
|
cwd=self.repo,
|
||||||
|
out=os.path.join(self.root, "out", out),
|
||||||
|
max_file_lines=10,
|
||||||
|
base=base,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
return Path(path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def _link(self, rel: str, line: int) -> str:
|
||||||
|
return f"vscode://file{quote(os.path.join(self.repo, rel))}:{line}"
|
||||||
|
|
||||||
|
def test_sections_and_counts(self) -> None:
|
||||||
|
html = self._render("page.html", None, [])
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
'id="staged" data-files="4" data-added="31" data-removed="1"', html
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'id="unstaged" data-files="1" data-added="1" data-removed="1"', html
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'id="untracked" data-files="2" data-added="3" data-removed="0"', html
|
||||||
|
)
|
||||||
|
self.assertNotIn('id="commits"', html)
|
||||||
|
self.assertIn(
|
||||||
|
'notes.md</span><span class="arrow"> → </span>renamed.md', html
|
||||||
|
)
|
||||||
|
self.assertIn('<span class="tag">binary</span>', html)
|
||||||
|
self.assertIn('data-path="blob.bin"', html)
|
||||||
|
self.assertIn("Binary file, contents not shown.", html)
|
||||||
|
self.assertIn(git(self.repo, "write-tree"), html)
|
||||||
|
self.assertIn(git(self.repo, "rev-parse", "--short", "HEAD"), html)
|
||||||
|
|
||||||
|
def test_base_adds_commits_section(self) -> None:
|
||||||
|
html = self._render("page.html", "main", [])
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
'id="commits" data-commits="1" data-files="1" data-added="1" '
|
||||||
|
'data-removed="0"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertIn("t <t@example.org>", html)
|
||||||
|
self.assertIn(git(self.repo, "rev-parse", "--short", "feature"), html)
|
||||||
|
|
||||||
|
def test_untrusted_text_is_escaped(self) -> None:
|
||||||
|
html = self._render("page.html", "main", [self.note])
|
||||||
|
|
||||||
|
self.assertEqual(html.count("<script"), 1)
|
||||||
|
self.assertNotIn("<script>alert", html)
|
||||||
|
self.assertIn("<script>alert('file')</script>", html)
|
||||||
|
self.assertIn("feat: <script>alert("subject")", html)
|
||||||
|
self.assertIn("Body <script>alert("body")", html)
|
||||||
|
self.assertIn("<figcaption>score.txt</figcaption>", html)
|
||||||
|
self.assertIn("<b>score</b> 90/5", html)
|
||||||
|
|
||||||
|
def test_rendering_is_deterministic(self) -> None:
|
||||||
|
first = self._render("one.html", "main", [self.note])
|
||||||
|
second = self._render("two.html", "main", [self.note])
|
||||||
|
|
||||||
|
self.assertEqual(first.encode("utf-8"), second.encode("utf-8"))
|
||||||
|
|
||||||
|
def test_vscode_links_and_truncation(self) -> None:
|
||||||
|
html = self._render("page.html", None, [])
|
||||||
|
|
||||||
|
self.assertIn(f'class="open" href="{self._link("app.py", 5)}"', html)
|
||||||
|
self.assertIn(f'class="open" href="{self._link("app.py", 15)}"', html)
|
||||||
|
self.assertIn(f'<a href="{self._link("app.py", 5)}">5</a>', html)
|
||||||
|
self.assertIn("my%20repo/dir%20with%20space/big.txt:1", html)
|
||||||
|
self.assertIn("big 10</td>", html)
|
||||||
|
self.assertNotIn("big 11</td>", html)
|
||||||
|
self.assertIn("20 more lines not shown", html)
|
||||||
|
|
||||||
|
def test_errors_exit_two(self) -> None:
|
||||||
|
plain = os.path.join(self.root, "plain")
|
||||||
|
os.makedirs(plain)
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"GIT_CEILING_DIRECTORIES": self.root}),
|
||||||
|
self.assertRaisesRegex(ReviewError, "not inside a git working copy"),
|
||||||
|
):
|
||||||
|
write_review(cwd=plain, out="x.html", max_file_lines=5, base=None, notes=[])
|
||||||
|
with self.assertRaisesRegex(ReviewError, "unknown base ref"):
|
||||||
|
write_review(
|
||||||
|
cwd=self.repo,
|
||||||
|
out="x.html",
|
||||||
|
max_file_lines=5,
|
||||||
|
base="no-such-ref",
|
||||||
|
notes=[],
|
||||||
|
)
|
||||||
|
args = create_parser("test").parse_args(
|
||||||
|
["review", "--out", "x.html", "--max-file-lines", "5", "--base", "nope"]
|
||||||
|
)
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with (
|
||||||
|
patch("pkgmgr.cli.commands.review.os.getcwd", return_value=self.repo),
|
||||||
|
redirect_stderr(stderr),
|
||||||
|
self.assertRaises(SystemExit) as exit_ctx,
|
||||||
|
):
|
||||||
|
handle_review(args, ctx=None)
|
||||||
|
self.assertEqual(exit_ctx.exception.code, 2)
|
||||||
|
self.assertEqual(len(stderr.getvalue().splitlines()), 1)
|
||||||
|
self.assertFalse(os.path.exists(self._path("x.html")))
|
||||||
|
|
||||||
|
def test_cli_forwards_every_option(self) -> None:
|
||||||
|
args = create_parser("test").parse_args(
|
||||||
|
[
|
||||||
|
"review",
|
||||||
|
"--out",
|
||||||
|
"page.html",
|
||||||
|
"--max-file-lines",
|
||||||
|
"50",
|
||||||
|
"--base",
|
||||||
|
"origin/main",
|
||||||
|
"--note",
|
||||||
|
"score.txt",
|
||||||
|
"--note",
|
||||||
|
"message.txt",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
target = os.path.join(self.root, "page.html")
|
||||||
|
stdout = io.StringIO()
|
||||||
|
with (
|
||||||
|
patch("pkgmgr.cli.commands.review.os.getcwd", return_value=self.repo),
|
||||||
|
patch(
|
||||||
|
"pkgmgr.cli.commands.review.write_review", return_value=target
|
||||||
|
) as run_review,
|
||||||
|
redirect_stdout(stdout),
|
||||||
|
):
|
||||||
|
handle_review(args, ctx=None)
|
||||||
|
|
||||||
|
run_review.assert_called_once_with(
|
||||||
|
cwd=self.repo,
|
||||||
|
out="page.html",
|
||||||
|
max_file_lines=50,
|
||||||
|
base="origin/main",
|
||||||
|
notes=["score.txt", "message.txt"],
|
||||||
|
)
|
||||||
|
self.assertEqual(stdout.getvalue(), f"file://{target}\n")
|
||||||
|
|
||||||
|
bare = create_parser("test").parse_args(
|
||||||
|
["review", "--out", "p.html", "--max-file-lines", "3"]
|
||||||
|
)
|
||||||
|
self.assertIsNone(bare.base)
|
||||||
|
self.assertEqual(bare.note, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pkgmgr.actions.review import ReviewError
|
||||||
|
from tests.integration.test_review import git, write
|
||||||
|
from tests.integration.test_review_hardening import MODULE, ReviewRepoCase
|
||||||
|
|
||||||
|
INVISIBLE = "".join(
|
||||||
|
map(
|
||||||
|
chr,
|
||||||
|
(
|
||||||
|
*range(0x202A, 0x202F),
|
||||||
|
*range(0x2066, 0x206A),
|
||||||
|
*range(0x200B, 0x2010),
|
||||||
|
0x061C,
|
||||||
|
0xFEFF,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
RLO, LRI, ZWSP, BOM = map(chr, (0x202E, 0x2066, 0x200B, 0xFEFF))
|
||||||
|
HIDDEN = "<li>Index entry {} is marked {}: its worktree changes are not shown.</li>"
|
||||||
|
ATTRIBUTE_BINARY = (
|
||||||
|
"{} is shown as binary because {}, not because of its content: "
|
||||||
|
"run git diff --text to see it."
|
||||||
|
)
|
||||||
|
UNSET = "a gitattributes entry unsets its diff attribute"
|
||||||
|
DRIVER = "its gitattributes diff driver {0} sets diff.{0}.binary"
|
||||||
|
|
||||||
|
|
||||||
|
def marks(text: str) -> str:
|
||||||
|
return "".join(f"<U+{ord(char):04X}>" for char in text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewConcealment(ReviewRepoCase):
|
||||||
|
def test_replace_objects_cannot_mask_the_index(self) -> None:
|
||||||
|
evil = self._object("blob", "print('evil')\n")
|
||||||
|
benign = self._object("blob", "print('staged')\n")
|
||||||
|
git(self.repo, "update-index", "--cacheinfo", f"100644,{evil},app.py")
|
||||||
|
git(self.repo, "replace", evil, benign)
|
||||||
|
git(self.repo, "update-index", "--skip-worktree", "app.py")
|
||||||
|
git(self.repo, "update-index", "--assume-unchanged", "lib.py")
|
||||||
|
write(self._path("lib.py"), b"print('hidden')\n")
|
||||||
|
|
||||||
|
html = self._render(None, [])
|
||||||
|
|
||||||
|
self.assertIn("print('evil')", html)
|
||||||
|
self.assertNotIn("print('staged')", html)
|
||||||
|
self.assertIn(HIDDEN.format("app.py", "skip-worktree"), html)
|
||||||
|
self.assertIn(HIDDEN.format("lib.py", "assume-unchanged"), html)
|
||||||
|
|
||||||
|
def test_ignored_submodule_change_is_shown(self) -> None:
|
||||||
|
sub = self._submodule(self.repo, "sub", "\tignore = all\n")
|
||||||
|
git(sub, "commit", "--allow-empty", "-m", "two")
|
||||||
|
new = git(sub, "rev-parse", "HEAD")
|
||||||
|
git(self.repo, "update-index", "--cacheinfo", f"160000,{new},sub")
|
||||||
|
|
||||||
|
gitmodules = self._render(None, [])
|
||||||
|
write(self._path(".gitmodules"), MODULE.format("sub", "").encode())
|
||||||
|
git(self.repo, "config", "diff.ignoreSubmodules", "all")
|
||||||
|
config = self._render(None, [])
|
||||||
|
|
||||||
|
for source, html in (("gitmodules", gitmodules), ("config", config)):
|
||||||
|
with self.subTest(source=source):
|
||||||
|
self.assertIn(f"Subproject commit {new}", html)
|
||||||
|
|
||||||
|
def test_attribute_binary_is_warned(self) -> None:
|
||||||
|
write(self._path(".gitattributes"), b"*.txt -diff\n")
|
||||||
|
write(self._path(".git", "info", "attributes"), b"*.md binary\n")
|
||||||
|
write(self._path("plain.txt"), b"plain text\n")
|
||||||
|
write(self._path("readme.md"), b"# readme\n")
|
||||||
|
write(self._path("image.bin"), b"\x00\x01binary\n")
|
||||||
|
git(self.repo, "add", ".gitattributes", "plain.txt", "readme.md", "image.bin")
|
||||||
|
|
||||||
|
html = self._render(None, [])
|
||||||
|
|
||||||
|
self.assertIn('data-path="image.bin"', html)
|
||||||
|
for path in ("plain.txt", "readme.md"):
|
||||||
|
self.assertIn(f"<li>{ATTRIBUTE_BINARY.format(path, UNSET)}</li>", html)
|
||||||
|
for path in ("image.bin", "new.txt"):
|
||||||
|
self.assertNotIn(f"{path} is shown as binary", html)
|
||||||
|
|
||||||
|
def test_binary_diff_driver_is_warned(self) -> None:
|
||||||
|
write(self._path(".gitattributes"), b"*.dat diff=opaque\n*.bin diff=plain\n")
|
||||||
|
git(self.repo, "config", "diff.opaque.binary", "yes")
|
||||||
|
write(self._path("data.dat"), b"text data\n")
|
||||||
|
write(self._path("image.bin"), b"\x00\x01binary\n")
|
||||||
|
git(self.repo, "add", ".gitattributes", "data.dat", "image.bin")
|
||||||
|
|
||||||
|
html = self._render(None, [])
|
||||||
|
|
||||||
|
self.assertIn('data-path="image.bin"', html)
|
||||||
|
warning = ATTRIBUTE_BINARY.format("data.dat", DRIVER.format("opaque"))
|
||||||
|
self.assertIn(f"<li>{warning}</li>", html)
|
||||||
|
self.assertNotIn("image.bin is shown as binary", html)
|
||||||
|
|
||||||
|
def test_base_resolves_the_branch_not_a_shadow(self) -> None:
|
||||||
|
head = git(self.repo, "rev-parse", "HEAD")
|
||||||
|
git(self.repo, "update-ref", "refs/tags/main", head)
|
||||||
|
tag_shadow = self._render("main", [])
|
||||||
|
write(self._path(".git", "main"), f"{head}\n".encode())
|
||||||
|
dir_shadow = self._render("main", [])
|
||||||
|
|
||||||
|
for html in (tag_shadow, dir_shadow):
|
||||||
|
self.assertIn('id="commits" data-commits="1"', html)
|
||||||
|
self.assertIn("<dt>Base</dt><dd>refs/heads/main @ ", html)
|
||||||
|
|
||||||
|
def test_base_refuses_ambiguous_and_other_refs(self) -> None:
|
||||||
|
head = git(self.repo, "rev-parse", "HEAD")
|
||||||
|
git(self.repo, "update-ref", "refs/remotes/main", head)
|
||||||
|
git(self.repo, "update-ref", "refs/tags/v1", head)
|
||||||
|
cases = {
|
||||||
|
"main": "ambiguous base ref",
|
||||||
|
"refs/tags/v1": "unknown base ref",
|
||||||
|
"HEAD~1": "unknown base ref",
|
||||||
|
head: "unknown base ref",
|
||||||
|
}
|
||||||
|
|
||||||
|
for base, message in cases.items():
|
||||||
|
with (
|
||||||
|
self.subTest(base=base),
|
||||||
|
self.assertRaisesRegex(ReviewError, message),
|
||||||
|
):
|
||||||
|
self._render(base, [])
|
||||||
|
|
||||||
|
def test_invisible_characters_are_marked(self) -> None:
|
||||||
|
tree = git(self.repo, "rev-parse", "HEAD^{tree}")
|
||||||
|
message = f"fix{RLO}: subject\n\nbody{LRI}text"
|
||||||
|
commit = git(self.repo, "commit-tree", tree, "-p", "HEAD", "-m", message)
|
||||||
|
git(self.repo, "update-ref", "HEAD", commit)
|
||||||
|
write(self._path(f"evil{RLO}txt.py"), f"code{ZWSP}line{BOM}\n".encode())
|
||||||
|
note = os.path.join(self.root, "note.txt")
|
||||||
|
write(note, f"note{INVISIBLE}\n".encode())
|
||||||
|
|
||||||
|
html = self._render("main", [note])
|
||||||
|
|
||||||
|
for char in INVISIBLE:
|
||||||
|
self.assertNotIn(char, html, f"U+{ord(char):04X}")
|
||||||
|
self.assertIn(f"fix{marks(RLO)}: subject", html)
|
||||||
|
self.assertIn(f"body{marks(LRI)}text", html)
|
||||||
|
self.assertIn(f"evil{marks(RLO)}txt.py", html)
|
||||||
|
self.assertIn(f"code{marks(ZWSP)}line{marks(BOM)}", html)
|
||||||
|
self.assertIn(f"note{marks(INVISIBLE)}", html)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pkgmgr.actions.review import ReviewError, write_review
|
||||||
|
from tests.integration.test_review import GIT_ENV, git, write
|
||||||
|
|
||||||
|
SECRET = b"SECRET-KEY-MATERIAL\n"
|
||||||
|
SIGNATURE = (
|
||||||
|
"gpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEE\n"
|
||||||
|
" -----END PGP SIGNATURE-----"
|
||||||
|
)
|
||||||
|
MODULE = '[submodule "{0}"]\n\tpath = {0}\n\turl = ./{0}\n{1}'
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewRepoCase(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
|
self.repo = os.path.join(self.root, "repo")
|
||||||
|
self.marker = os.path.join(self.root, "marker")
|
||||||
|
self.secret = os.path.join(self.root, "secret", "id_ed25519")
|
||||||
|
write(self.secret, SECRET)
|
||||||
|
git(self.root, "init", "-b", "main", self.repo)
|
||||||
|
write(self._path("app.py"), b"print('base')\n")
|
||||||
|
write(self._path("lib.py"), b"print('lib')\n")
|
||||||
|
git(self.repo, "add", "-A")
|
||||||
|
git(self.repo, "commit", "-m", "init")
|
||||||
|
git(self.repo, "checkout", "-b", "feature")
|
||||||
|
write(self._path("app.py"), b"print('feature')\n")
|
||||||
|
git(self.repo, "commit", "-am", "feature")
|
||||||
|
write(self._path("app.py"), b"print('staged')\n")
|
||||||
|
git(self.repo, "add", "app.py")
|
||||||
|
write(self._path("app.py"), b"print('unstaged')\n")
|
||||||
|
write(self._path("new.txt"), b"untracked\n")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _path(self, *parts: str) -> str:
|
||||||
|
return os.path.join(self.repo, *parts)
|
||||||
|
|
||||||
|
def _render(self, base: str | None, notes: list[str]) -> str:
|
||||||
|
path = write_review(
|
||||||
|
cwd=self.repo,
|
||||||
|
out=os.path.join(self.root, "out", "page.html"),
|
||||||
|
max_file_lines=50,
|
||||||
|
base=base,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
return Path(path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def _trap(self, path: str, tail: str) -> str:
|
||||||
|
write(path, f"#!/bin/sh\ntouch '{self.marker}'\n{tail}\n".encode())
|
||||||
|
os.chmod(path, 0o755)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _assert_fires(self, *args: str) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=self.repo,
|
||||||
|
env=GIT_ENV,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(os.path.exists(self.marker), f"plain git {args[0]} fires")
|
||||||
|
os.remove(self.marker)
|
||||||
|
|
||||||
|
def _submodule(self, parent: str, name: str, extra: str = "") -> str:
|
||||||
|
path = os.path.join(parent, name)
|
||||||
|
git(parent, "init", "-b", "main", path)
|
||||||
|
write(os.path.join(path, "f.txt"), b"one\n")
|
||||||
|
git(path, "add", "-A")
|
||||||
|
git(path, "commit", "-m", "one")
|
||||||
|
head = git(path, "rev-parse", "HEAD")
|
||||||
|
write(os.path.join(parent, ".gitmodules"), MODULE.format(name, extra).encode())
|
||||||
|
git(parent, "update-index", "--add", "--cacheinfo", f"160000,{head},{name}")
|
||||||
|
git(parent, "add", ".gitmodules")
|
||||||
|
git(parent, "commit", "-m", name)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _object(self, kind: str, content: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "hash-object", "-t", kind, "-w", "--stdin"],
|
||||||
|
cwd=self.repo,
|
||||||
|
env=GIT_ENV,
|
||||||
|
input=content,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewHardening(ReviewRepoCase):
|
||||||
|
def test_fsmonitor_never_runs(self) -> None:
|
||||||
|
monitor = self._trap(os.path.join(self.root, "fsmonitor"), "exit 1")
|
||||||
|
git(self.repo, "config", "core.fsmonitor", monitor)
|
||||||
|
self._assert_fires("diff")
|
||||||
|
|
||||||
|
self._render("main", [])
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(self.marker))
|
||||||
|
|
||||||
|
def test_repository_filter_never_runs(self) -> None:
|
||||||
|
write(self._path(".git", "info", "attributes"), b"* filter=trap\n")
|
||||||
|
clean = self._trap(os.path.join(self.root, "clean"), "exec cat")
|
||||||
|
git(self.repo, "config", "filter.trap.clean", clean)
|
||||||
|
self._assert_fires("diff")
|
||||||
|
|
||||||
|
html = self._render("main", [])
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(self.marker))
|
||||||
|
self.assertIn('id="unstaged" data-files="0"', html)
|
||||||
|
self.assertIn("filter.trap.clean", html)
|
||||||
|
self.assertNotIn("print('unstaged')", html)
|
||||||
|
self.assertIn("<dd>(not computed, see the warnings)</dd>", html)
|
||||||
|
|
||||||
|
def test_submodule_filter_never_runs(self) -> None:
|
||||||
|
sub = self._submodule(self.repo, "sub")
|
||||||
|
inner = self._submodule(sub, "inner")
|
||||||
|
clean = self._trap(os.path.join(self.root, "clean"), "exec cat")
|
||||||
|
for path in (sub, inner):
|
||||||
|
write(os.path.join(path, ".git", "info", "attributes"), b"* filter=x\n")
|
||||||
|
write(os.path.join(path, "f.txt"), b"two\n")
|
||||||
|
git(path, "config", "filter.x.clean", clean)
|
||||||
|
self._assert_fires("diff")
|
||||||
|
|
||||||
|
html = self._render(None, [])
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(self.marker))
|
||||||
|
self.assertIn('id="unstaged" data-files="0"', html)
|
||||||
|
self.assertIn("the config of submodule sub defines filter.x.clean", html)
|
||||||
|
self.assertIn("the config of submodule sub/inner defines filter.x.clean", html)
|
||||||
|
self.assertIn("<dd>(not computed, see the warnings)</dd>", html)
|
||||||
|
|
||||||
|
def test_signature_program_never_runs(self) -> None:
|
||||||
|
header, message = git(self.repo, "cat-file", "commit", "HEAD").split("\n\n", 1)
|
||||||
|
signed = self._object("commit", f"{header}\n{SIGNATURE}\n\n{message}\n")
|
||||||
|
git(self.repo, "update-ref", "HEAD", signed)
|
||||||
|
program = self._trap(os.path.join(self.root, "gpg"), "exit 1")
|
||||||
|
git(self.repo, "config", "gpg.program", program)
|
||||||
|
self._assert_fires("log", "-1", "--format=%G?")
|
||||||
|
|
||||||
|
html = self._render("main", [])
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(self.marker))
|
||||||
|
self.assertIn('id="commits" data-commits="1"', html)
|
||||||
|
self.assertNotIn('<span class="sig', html)
|
||||||
|
|
||||||
|
def test_hooks_never_run(self) -> None:
|
||||||
|
hooks = os.path.join(self.root, "hooks")
|
||||||
|
self._trap(os.path.join(hooks, "post-index-change"), "exit 0")
|
||||||
|
git(self.repo, "config", "core.hooksPath", hooks)
|
||||||
|
self._assert_fires("update-index", "--force-write-index")
|
||||||
|
|
||||||
|
self._render("main", [])
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(self.marker))
|
||||||
|
|
||||||
|
def test_note_symlinks_are_refused(self) -> None:
|
||||||
|
link = os.path.join(self.root, "score.txt")
|
||||||
|
os.symlink(self.secret, link)
|
||||||
|
parent = os.path.join(self.root, "notes")
|
||||||
|
os.symlink(os.path.dirname(self.secret), parent)
|
||||||
|
|
||||||
|
for note in (link, os.path.join(parent, "id_ed25519")):
|
||||||
|
with (
|
||||||
|
self.subTest(note=note),
|
||||||
|
self.assertRaisesRegex(ReviewError, "cannot read note"),
|
||||||
|
):
|
||||||
|
self._render(None, [note])
|
||||||
|
|
||||||
|
def test_untracked_parent_swap_is_not_followed(self) -> None:
|
||||||
|
write(self._path("sub", "id_ed25519"), b"harmless\n")
|
||||||
|
real_run = subprocess.run
|
||||||
|
swapped: list[list[str]] = []
|
||||||
|
|
||||||
|
def swap(cmd, *args, **kwargs):
|
||||||
|
result = real_run(cmd, *args, **kwargs)
|
||||||
|
if "--others" in cmd and not swapped:
|
||||||
|
os.rename(self._path("sub"), os.path.join(self.root, "sub-real"))
|
||||||
|
os.symlink(os.path.dirname(self.secret), self._path("sub"))
|
||||||
|
swapped.append(cmd)
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch("subprocess.run", swap):
|
||||||
|
html = self._render(None, [])
|
||||||
|
|
||||||
|
self.assertTrue(swapped)
|
||||||
|
self.assertNotIn(SECRET.decode().strip(), html)
|
||||||
|
self.assertIn("Not a regular file, contents not shown.", html)
|
||||||
|
|
||||||
|
def test_out_symlink_is_replaced_not_followed(self) -> None:
|
||||||
|
keys = b"ssh-ed25519 AAAA operator\n"
|
||||||
|
victim = os.path.join(self.root, "authorized_keys")
|
||||||
|
write(victim, keys)
|
||||||
|
missing = os.path.join(self.root, "missing_keys")
|
||||||
|
out = os.path.join(self.root, "out")
|
||||||
|
os.makedirs(out)
|
||||||
|
os.symlink(victim, os.path.join(out, "page.html"))
|
||||||
|
os.symlink(missing, os.path.join(out, "dangling.html"))
|
||||||
|
|
||||||
|
for name in ("page.html", "dangling.html"):
|
||||||
|
with self.subTest(out=name):
|
||||||
|
path = write_review(
|
||||||
|
cwd=self.repo,
|
||||||
|
out=os.path.join(out, name),
|
||||||
|
max_file_lines=50,
|
||||||
|
base=None,
|
||||||
|
notes=[],
|
||||||
|
)
|
||||||
|
info = os.lstat(path)
|
||||||
|
self.assertTrue(stat.S_ISREG(info.st_mode))
|
||||||
|
self.assertEqual(stat.S_IMODE(info.st_mode), 0o600)
|
||||||
|
|
||||||
|
self.assertEqual(Path(victim).read_bytes(), keys)
|
||||||
|
self.assertFalse(os.path.lexists(missing))
|
||||||
|
self.assertEqual(sorted(os.listdir(out)), ["dangling.html", "page.html"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user