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:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 10:39:52 +00:00
1 parent 54f0cbb37b
commit 1b31b10df6
20 files changed
+1949 -2

No files matched your search

+2 -1
View File
@@ -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"]
+7
View File
@@ -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"]
+112
View File
@@ -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), "")
+153
View File
@@ -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,
)
+154
View File
@@ -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
+115
View File
@@ -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"))
+136
View File
@@ -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)
+86
View File
@@ -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
+74
View File
@@ -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
+347
View File
@@ -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">&minus;{{ 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"> &rarr; </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 &#8599;</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">&hellip; {{ 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") }} &middot; {{ 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 &middot; {{ 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>
+67
View File
@@ -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)
)
+2
View File
@@ -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",
] ]
+23
View File
@@ -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())
+5
View File
@@ -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",
+2
View File
@@ -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)
+47
View File
@@ -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.",
)
+3 -1
View File
@@ -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.
""" """
+237
View File
@@ -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"> &rarr; </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 &lt;t@example.org&gt;", 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("&lt;script&gt;alert(&#39;file&#39;)&lt;/script&gt;", html)
self.assertIn("feat: &lt;script&gt;alert(&#34;subject&#34;)", html)
self.assertIn("Body &lt;script&gt;alert(&#34;body&#34;)", html)
self.assertIn("<figcaption>score.txt</figcaption>", html)
self.assertIn("&lt;b&gt;score&lt;/b&gt; 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"&lt;U+{ord(char):04X}&gt;" 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(&#39;evil&#39;)", html)
self.assertNotIn("print(&#39;staged&#39;)", 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()
+229
View File
@@ -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(&#39;unstaged&#39;)", 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()