|
@@ -19,14 +19,13 @@ Usage:
|
|
|
patch_artifacts.py --remove-scratch <workspace>
|
|
patch_artifacts.py --remove-scratch <workspace>
|
|
|
|
|
|
|
|
Exits 0 on success (declined findings included), 1 on a refusal naming what is
|
|
Exits 0 on success (declined findings included), 1 on a refusal naming what is
|
|
|
-wrong, 2 on a usage error. Python 3.9-compatible, stdlib only.
|
|
|
|
|
|
|
+wrong, 2 on a usage error.
|
|
|
|
|
+Python 3.9-compatible, stdlib only.
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import argparse
|
|
import argparse
|
|
|
-import contextlib
|
|
|
|
|
-import json
|
|
|
|
|
import os
|
|
import os
|
|
|
import pathlib
|
|
import pathlib
|
|
|
import re
|
|
import re
|
|
@@ -35,11 +34,13 @@ import shutil
|
|
|
import stat
|
|
import stat
|
|
|
import subprocess
|
|
import subprocess
|
|
|
import sys
|
|
import sys
|
|
|
-import tempfile
|
|
|
|
|
-from typing import TYPE_CHECKING, TypedDict, cast
|
|
|
|
|
|
|
+from typing import TYPE_CHECKING, TypedDict
|
|
|
|
|
|
|
|
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
-from render_report import HEX_RE, RenderError, as_map, atomic_write
|
|
|
|
|
|
|
+# The lib/ package lives next to this script. Python normally adds a script's own
|
|
|
|
|
+# directory to the import path, but not under -P or PYTHONSAFEPATH, so we add it here.
|
|
|
|
|
+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
|
|
|
+from lib import console, plugin, strictjson
|
|
|
|
|
+from lib.strictjson import JsonMap, is_list, is_map, is_str
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
if TYPE_CHECKING:
|
|
|
from collections.abc import Callable
|
|
from collections.abc import Callable
|
|
@@ -48,14 +49,12 @@ if TYPE_CHECKING:
|
|
|
|
|
|
|
|
FINDING_ID_PATTERN = "F[0-9]{1,9}"
|
|
FINDING_ID_PATTERN = "F[0-9]{1,9}"
|
|
|
FINDING_ID_RE = re.compile(rf"^{FINDING_ID_PATTERN}\Z")
|
|
FINDING_ID_RE = re.compile(rf"^{FINDING_ID_PATTERN}\Z")
|
|
|
-SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
|
|
|
|
|
REGULAR_FILE_MODE = "100644"
|
|
REGULAR_FILE_MODE = "100644"
|
|
|
# \Z, not $: `$` also matches before a trailing newline, and this is a fence.
|
|
# \Z, not $: `$` also matches before a trailing newline, and this is a fence.
|
|
|
-REPORT_DIR_RE = re.compile(r"^CLAUDE-SECURITY-[0-9][0-9-]*\Z")
|
|
|
|
|
|
|
+REPORT_DIR_RE = re.compile(rf"^{re.escape(plugin.REPORT_DIR_PREFIX)}[0-9][0-9-]*\Z")
|
|
|
PATCHES_DIR_NAME = "patches"
|
|
PATCHES_DIR_NAME = "patches"
|
|
|
SCRATCH_NAME_RE = re.compile(rf"^scratch-{FINDING_ID_PATTERN}\Z")
|
|
SCRATCH_NAME_RE = re.compile(rf"^scratch-{FINDING_ID_PATTERN}\Z")
|
|
|
PATCH_DIR_RE = re.compile(r"^patch-[0-9][0-9-]*\Z")
|
|
PATCH_DIR_RE = re.compile(r"^patch-[0-9][0-9-]*\Z")
|
|
|
-RUN_DIR_NAME = ".claude-security-run"
|
|
|
|
|
DIFF_HEADER = "diff --git "
|
|
DIFF_HEADER = "diff --git "
|
|
|
CLAIM_KEYS = ("targeted", "no_new_vulnerability", "behaviour_unchanged")
|
|
CLAIM_KEYS = ("targeted", "no_new_vulnerability", "behaviour_unchanged")
|
|
|
CLAIM_LABELS = {
|
|
CLAIM_LABELS = {
|
|
@@ -101,6 +100,15 @@ class Unit(TypedDict):
|
|
|
recommendation: str
|
|
recommendation: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class Args(argparse.Namespace):
|
|
|
|
|
+ """The parsed command line."""
|
|
|
|
|
+
|
|
|
|
|
+ patch_dir: str = ""
|
|
|
|
|
+ patches_dir: str = ""
|
|
|
|
|
+ scan_root: str = ""
|
|
|
|
|
+ base: str = ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class PatchError(Exception):
|
|
class PatchError(Exception):
|
|
|
"""The run record or a raw diff is malformed; the caller must correct it."""
|
|
"""The run record or a raw diff is malformed; the caller must correct it."""
|
|
|
|
|
|
|
@@ -121,12 +129,11 @@ def field(value: object, what: str) -> str:
|
|
|
"""A record field as text; None reads as empty."""
|
|
"""A record field as text; None reads as empty."""
|
|
|
if value is None:
|
|
if value is None:
|
|
|
return ""
|
|
return ""
|
|
|
- if not isinstance(value, str):
|
|
|
|
|
|
|
+ if not is_str(value):
|
|
|
msg = f"{what} must be a string"
|
|
msg = f"{what} must be a string"
|
|
|
raise PatchError(msg)
|
|
raise PatchError(msg)
|
|
|
- lone = SURROGATE_RE.search(value)
|
|
|
|
|
- if lone:
|
|
|
|
|
- msg = f"{what} contains an unpaired surrogate ({lone.group(0)!r}); it is not valid text"
|
|
|
|
|
|
|
+ if strictjson.has_lone_surrogate(value):
|
|
|
|
|
+ msg = f"{what} contains an unpaired surrogate; it is not valid text"
|
|
|
raise PatchError(msg)
|
|
raise PatchError(msg)
|
|
|
return value
|
|
return value
|
|
|
|
|
|
|
@@ -140,20 +147,24 @@ def field_list(value: object, what: str) -> list[str]:
|
|
|
"""A list-of-strings record field."""
|
|
"""A list-of-strings record field."""
|
|
|
if value is None:
|
|
if value is None:
|
|
|
return []
|
|
return []
|
|
|
- if not isinstance(value, list):
|
|
|
|
|
|
|
+ if not is_list(value):
|
|
|
msg = f"{what} must be a list of strings"
|
|
msg = f"{what} must be a list of strings"
|
|
|
raise PatchError(msg)
|
|
raise PatchError(msg)
|
|
|
- items = cast("list[object]", value)
|
|
|
|
|
- return [field(item, f"{what}[{index}]") for index, item in enumerate(items)]
|
|
|
|
|
|
|
+ return [field(item, f"{what}[{index}]") for index, item in enumerate(value)]
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_claims(raw: object, unit_id: str, status: str) -> dict[str, Claim]:
|
|
def build_claims(raw: object, unit_id: str, status: str) -> dict[str, Claim]:
|
|
|
"""Validate the three named claims. A written patch needs all three CONFIDENT."""
|
|
"""Validate the three named claims. A written patch needs all three CONFIDENT."""
|
|
|
- claims_map = as_map(raw) or {}
|
|
|
|
|
|
|
+ claims_map: JsonMap = {}
|
|
|
|
|
+ if raw is not None:
|
|
|
|
|
+ if not is_map(raw):
|
|
|
|
|
+ msg = f"{unit_id}: claims must be an object keyed by claim name"
|
|
|
|
|
+ raise PatchError(msg)
|
|
|
|
|
+ claims_map = raw
|
|
|
out: dict[str, Claim] = {}
|
|
out: dict[str, Claim] = {}
|
|
|
for key in CLAIM_KEYS:
|
|
for key in CLAIM_KEYS:
|
|
|
- claim = as_map(claims_map.get(key))
|
|
|
|
|
- if claim is None:
|
|
|
|
|
|
|
+ claim = claims_map.get(key)
|
|
|
|
|
+ if not is_map(claim):
|
|
|
if status == "patch_written":
|
|
if status == "patch_written":
|
|
|
msg = f"{unit_id}: status is patch_written but claim {key!r} is missing"
|
|
msg = f"{unit_id}: status is patch_written but claim {key!r} is missing"
|
|
|
raise PatchError(msg)
|
|
raise PatchError(msg)
|
|
@@ -179,10 +190,9 @@ def build_claims(raw: object, unit_id: str, status: str) -> dict[str, Claim]:
|
|
|
return out
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
-def build_unit(raw: object, index: int) -> Unit:
|
|
|
|
|
|
|
+def build_unit(item: object, index: int) -> Unit:
|
|
|
"""Validate one unit from patches.json into the shape the writers use."""
|
|
"""Validate one unit from patches.json into the shape the writers use."""
|
|
|
- item = as_map(raw)
|
|
|
|
|
- if item is None:
|
|
|
|
|
|
|
+ if not is_map(item):
|
|
|
msg = f"patches.json unit {index} is not an object"
|
|
msg = f"patches.json unit {index} is not an object"
|
|
|
raise PatchError(msg)
|
|
raise PatchError(msg)
|
|
|
unit_id = field(item.get("id"), f"unit {index} id")
|
|
unit_id = field(item.get("id"), f"unit {index} id")
|
|
@@ -227,20 +237,18 @@ def load_units(patch_dir: str) -> list[Unit]:
|
|
|
"""Read and validate patches.json (an object with a `units` array)."""
|
|
"""Read and validate patches.json (an object with a `units` array)."""
|
|
|
path = os.path.join(patch_dir, "patches.json")
|
|
path = os.path.join(patch_dir, "patches.json")
|
|
|
try:
|
|
try:
|
|
|
- with open(path, encoding="utf-8") as handle:
|
|
|
|
|
- raw = cast("object", json.load(handle))
|
|
|
|
|
|
|
+ raw = strictjson.load(path)
|
|
|
except OSError as error:
|
|
except OSError as error:
|
|
|
msg = "patches.json is missing from the patch directory. Write it before running this."
|
|
msg = "patches.json is missing from the patch directory. Write it before running this."
|
|
|
raise PatchError(msg) from error
|
|
raise PatchError(msg) from error
|
|
|
except ValueError as error:
|
|
except ValueError as error:
|
|
|
msg = f"patches.json is not valid JSON: {error}"
|
|
msg = f"patches.json is not valid JSON: {error}"
|
|
|
raise PatchError(msg) from error
|
|
raise PatchError(msg) from error
|
|
|
- record = as_map(raw)
|
|
|
|
|
- units_raw: object = record.get("units") if record is not None else raw
|
|
|
|
|
- if not isinstance(units_raw, list):
|
|
|
|
|
|
|
+ units_raw = raw.get("units") if is_map(raw) else raw
|
|
|
|
|
+ if not is_list(units_raw):
|
|
|
msg = 'patches.json must be an object with a "units" array'
|
|
msg = 'patches.json must be an object with a "units" array'
|
|
|
raise PatchError(msg)
|
|
raise PatchError(msg)
|
|
|
- units = [build_unit(item, i) for i, item in enumerate(cast("list[object]", units_raw))]
|
|
|
|
|
|
|
+ units = [build_unit(item, i) for i, item in enumerate(units_raw)]
|
|
|
seen: set[str] = set()
|
|
seen: set[str] = set()
|
|
|
for unit in units:
|
|
for unit in units:
|
|
|
if unit["id"] in seen:
|
|
if unit["id"] in seen:
|
|
@@ -273,21 +281,6 @@ def read_diff(patch_dir: str, unit_id: str, required: bool) -> bytes | None:
|
|
|
return data
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
-def atomic_write_bytes(path: str, data: bytes) -> None:
|
|
|
|
|
- """Byte-faithful counterpart of render_report.atomic_write."""
|
|
|
|
|
- handle, temp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".render.")
|
|
|
|
|
- try:
|
|
|
|
|
- with os.fdopen(handle, "wb") as out:
|
|
|
|
|
- out.write(data)
|
|
|
|
|
- out.flush()
|
|
|
|
|
- os.fsync(out.fileno())
|
|
|
|
|
- os.replace(temp, path)
|
|
|
|
|
- except BaseException:
|
|
|
|
|
- with contextlib.suppress(OSError):
|
|
|
|
|
- os.unlink(temp)
|
|
|
|
|
- raise
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
def display_name(name: str | None) -> str | None:
|
|
def display_name(name: str | None) -> str | None:
|
|
|
"""A `--- `/`+++ ` line's file name for display: a/ or b/ dropped, None for /dev/null."""
|
|
"""A `--- `/`+++ ` line's file name for display: a/ or b/ dropped, None for /dev/null."""
|
|
|
if name is None:
|
|
if name is None:
|
|
@@ -398,7 +391,7 @@ def apply_check(top: str | None, patch_path: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
def diffstat_lines(stats: list[DiffStat] | None) -> list[str]:
|
|
def diffstat_lines(stats: list[DiffStat] | None) -> list[str]:
|
|
|
- """Diffstat as markdown bullets, or a one-line fallback when git was unavailable."""
|
|
|
|
|
|
|
+ """Diffstat as markdown bullets, or a one-line note when there is no diff to size."""
|
|
|
if stats is None:
|
|
if stats is None:
|
|
|
return ["- _(no attempt diff was saved)_"]
|
|
return ["- _(no attempt diff was saved)_"]
|
|
|
if not stats:
|
|
if not stats:
|
|
@@ -592,7 +585,7 @@ def jsonl(
|
|
|
"apply_check": checks.get(unit["id"]),
|
|
"apply_check": checks.get(unit["id"]),
|
|
|
"decline_reason": unit["decline_reason"] or None,
|
|
"decline_reason": unit["decline_reason"] or None,
|
|
|
}
|
|
}
|
|
|
- rows.append(json.dumps(record, ensure_ascii=False, sort_keys=False))
|
|
|
|
|
|
|
+ rows.append(strictjson.text(record))
|
|
|
return "\n".join(rows) + ("\n" if rows else "")
|
|
return "\n".join(rows) + ("\n" if rows else "")
|
|
|
|
|
|
|
|
|
|
|
|
@@ -631,7 +624,8 @@ def ensure_gitignore(report_dir: str) -> str:
|
|
|
except OSError:
|
|
except OSError:
|
|
|
return "open"
|
|
return "open"
|
|
|
return "present" if "*" in (line.strip() for line in existing.splitlines()) else "open"
|
|
return "present" if "*" in (line.strip() for line in existing.splitlines()) else "open"
|
|
|
- atomic_write(path, "*\n")
|
|
|
|
|
|
|
+ with open(path, "w", encoding="utf-8", newline="\n") as out:
|
|
|
|
|
+ out.write("*\n")
|
|
|
return "written"
|
|
return "written"
|
|
|
|
|
|
|
|
|
|
|
|
@@ -640,7 +634,7 @@ def contained_relpath(target: str, root: str) -> str | None:
|
|
|
rel = os.path.relpath(os.path.realpath(target), os.path.realpath(root))
|
|
rel = os.path.relpath(os.path.realpath(target), os.path.realpath(root))
|
|
|
if rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel):
|
|
if rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel):
|
|
|
return None
|
|
return None
|
|
|
- return rel
|
|
|
|
|
|
|
+ return rel.replace(os.sep, "/")
|
|
|
|
|
|
|
|
|
|
|
|
|
def report_path_from_root(report_dir: str, top: str | None, fallback: str) -> str:
|
|
def report_path_from_root(report_dir: str, top: str | None, fallback: str) -> str:
|
|
@@ -691,7 +685,7 @@ def run(patch_dir: str, patches_dir: str, scan_root: str, base: str) -> int:
|
|
|
if written and diff is not None:
|
|
if written and diff is not None:
|
|
|
patch_path = os.path.join(patches_dir, f"{unit['id']}.patch")
|
|
patch_path = os.path.join(patches_dir, f"{unit['id']}.patch")
|
|
|
header = header_comment(unit, base, report_ref)
|
|
header = header_comment(unit, base, report_ref)
|
|
|
- atomic_write_bytes(patch_path, header.encode("utf-8") + diff)
|
|
|
|
|
|
|
+ pathlib.Path(patch_path).write_bytes(header.encode("utf-8") + diff)
|
|
|
check = apply_check(top, patch_path)
|
|
check = apply_check(top, patch_path)
|
|
|
checks[unit["id"]] = check
|
|
checks[unit["id"]] = check
|
|
|
note = note_written(unit, stats, check, report_ref)
|
|
note = note_written(unit, stats, check, report_ref)
|
|
@@ -700,13 +694,16 @@ def run(patch_dir: str, patches_dir: str, scan_root: str, base: str) -> int:
|
|
|
else:
|
|
else:
|
|
|
note = note_declined(unit, stats)
|
|
note = note_declined(unit, stats)
|
|
|
print(f"{unit['id']}: no patch ({unit['status']}) -> {unit['id']}.md")
|
|
print(f"{unit['id']}: no patch ({unit['status']}) -> {unit['id']}.md")
|
|
|
- atomic_write(os.path.join(patches_dir, f"{unit['id']}.md"), note)
|
|
|
|
|
|
|
+ note_path = os.path.join(patches_dir, f"{unit['id']}.md")
|
|
|
|
|
+ with open(note_path, "w", encoding="utf-8", newline="\n") as out:
|
|
|
|
|
+ out.write(note)
|
|
|
produced.add(f"{unit['id']}.md")
|
|
produced.add(f"{unit['id']}.md")
|
|
|
- index_text = index_markdown(units, base, report_dir_name, report_ref)
|
|
|
|
|
- atomic_write(os.path.join(patches_dir, "PATCHES.md"), index_text)
|
|
|
|
|
- atomic_write(
|
|
|
|
|
- os.path.join(patches_dir, "patches.jsonl"), jsonl(units, base, stats_by_id, checks)
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ index_path = os.path.join(patches_dir, "PATCHES.md")
|
|
|
|
|
+ with open(index_path, "w", encoding="utf-8", newline="\n") as out:
|
|
|
|
|
+ out.write(index_markdown(units, base, report_dir_name, report_ref))
|
|
|
|
|
+ jsonl_path = os.path.join(patches_dir, "patches.jsonl")
|
|
|
|
|
+ with open(jsonl_path, "w", encoding="utf-8", newline="\n") as out:
|
|
|
|
|
+ out.write(jsonl(units, base, stats_by_id, checks))
|
|
|
for name in clear_stale_products(patches_dir, produced):
|
|
for name in clear_stale_products(patches_dir, produced):
|
|
|
print(f"removed stale {name} (not produced by this run)")
|
|
print(f"removed stale {name} (not produced by this run)")
|
|
|
swept, warnings = remove_workspaces_in(patch_dir)
|
|
swept, warnings = remove_workspaces_in(patch_dir)
|
|
@@ -749,8 +746,8 @@ def refuse_reason(path: str) -> str | None:
|
|
|
top = os.path.dirname(run)
|
|
top = os.path.dirname(run)
|
|
|
if not PATCH_DIR_RE.match(os.path.basename(run)):
|
|
if not PATCH_DIR_RE.match(os.path.basename(run)):
|
|
|
return "it is not inside a patch-<timestamp> run directory"
|
|
return "it is not inside a patch-<timestamp> run directory"
|
|
|
- if os.path.basename(top) != RUN_DIR_NAME:
|
|
|
|
|
- return f"its run directory is not inside {RUN_DIR_NAME}/"
|
|
|
|
|
|
|
+ if os.path.basename(top) != plugin.RUN_DIR_NAME:
|
|
|
|
|
+ return f"its run directory is not inside {plugin.RUN_DIR_NAME}/"
|
|
|
if not os.path.isdir(os.path.join(leaf, ".git")):
|
|
if not os.path.isdir(os.path.join(leaf, ".git")):
|
|
|
return "it holds no .git directory of its own"
|
|
return "it holds no .git directory of its own"
|
|
|
return None
|
|
return None
|
|
@@ -779,7 +776,7 @@ def remove_workspace(path: str) -> None:
|
|
|
try:
|
|
try:
|
|
|
shutil.rmtree(target, onerror=clear_readonly)
|
|
shutil.rmtree(target, onerror=clear_readonly)
|
|
|
except OSError as error:
|
|
except OSError as error:
|
|
|
- detail = error.args[0] if error.args else error
|
|
|
|
|
|
|
+ detail = console.removal_failure_detail(error)
|
|
|
msg = f"could not remove {path!r}: {detail}"
|
|
msg = f"could not remove {path!r}: {detail}"
|
|
|
raise PatchError(msg) from error
|
|
raise PatchError(msg) from error
|
|
|
|
|
|
|
@@ -820,12 +817,12 @@ def remove_patch_run(patch_dir: str) -> tuple[list[str], list[str]]:
|
|
|
run_dir = os.path.dirname(target)
|
|
run_dir = os.path.dirname(target)
|
|
|
if not PATCH_DIR_RE.match(os.path.basename(target)):
|
|
if not PATCH_DIR_RE.match(os.path.basename(target)):
|
|
|
return removed, [f"left {patch_dir!r} in place: its name is not patch-<timestamp>"]
|
|
return removed, [f"left {patch_dir!r} in place: its name is not patch-<timestamp>"]
|
|
|
- if os.path.basename(run_dir) != RUN_DIR_NAME:
|
|
|
|
|
- return removed, [f"left {patch_dir!r} in place: it is not inside {RUN_DIR_NAME}/"]
|
|
|
|
|
|
|
+ if os.path.basename(run_dir) != plugin.RUN_DIR_NAME:
|
|
|
|
|
+ return removed, [f"left {patch_dir!r} in place: it is not inside {plugin.RUN_DIR_NAME}/"]
|
|
|
try:
|
|
try:
|
|
|
shutil.rmtree(target, onerror=clear_readonly)
|
|
shutil.rmtree(target, onerror=clear_readonly)
|
|
|
except OSError as error:
|
|
except OSError as error:
|
|
|
- detail = error.args[0] if error.args else error
|
|
|
|
|
|
|
+ detail = console.removal_failure_detail(error)
|
|
|
return removed, [f"could not remove {patch_dir!r}: {detail}"]
|
|
return removed, [f"could not remove {patch_dir!r}: {detail}"]
|
|
|
removed.append(target)
|
|
removed.append(target)
|
|
|
try:
|
|
try:
|
|
@@ -855,23 +852,20 @@ def main(argv: list[str]) -> int:
|
|
|
parser.add_argument("patches_dir", help="the report's patches/ directory to write into")
|
|
parser.add_argument("patches_dir", help="the report's patches/ directory to write into")
|
|
|
parser.add_argument("scan_root", help="the user's repository root (for git apply --check)")
|
|
parser.add_argument("scan_root", help="the user's repository root (for git apply --check)")
|
|
|
parser.add_argument("--base", required=True, help="the revision every patch applies to")
|
|
parser.add_argument("--base", required=True, help="the revision every patch applies to")
|
|
|
- args = parser.parse_args(argv)
|
|
|
|
|
- patch_dir = str(cast("object", args.patch_dir))
|
|
|
|
|
- patches_dir = str(cast("object", args.patches_dir))
|
|
|
|
|
- scan_root = str(cast("object", args.scan_root))
|
|
|
|
|
- base = str(cast("object", args.base))
|
|
|
|
|
- for label, path in (("patch dir", patch_dir), ("patches dir", patches_dir)):
|
|
|
|
|
|
|
+ args = parser.parse_args(argv, namespace=Args())
|
|
|
|
|
+ for label, path in (("patch dir", args.patch_dir), ("patches dir", args.patches_dir)):
|
|
|
if not os.path.isdir(path):
|
|
if not os.path.isdir(path):
|
|
|
die_usage(f"{label} is not a directory: {path}")
|
|
die_usage(f"{label} is not a directory: {path}")
|
|
|
- if not HEX_RE.match(base):
|
|
|
|
|
- die_usage(f"--base {base!r} is not a hex revision id")
|
|
|
|
|
|
|
+ if not plugin.SHA_RE.match(args.base):
|
|
|
|
|
+ die_usage(f"--base {args.base!r} is not a hex revision id")
|
|
|
try:
|
|
try:
|
|
|
- return run(patch_dir, patches_dir, scan_root, base)
|
|
|
|
|
- except (PatchError, RenderError) as error:
|
|
|
|
|
|
|
+ return run(args.patch_dir, args.patches_dir, args.scan_root, args.base)
|
|
|
|
|
+ except PatchError as error:
|
|
|
die(str(error))
|
|
die(str(error))
|
|
|
except OSError as error:
|
|
except OSError as error:
|
|
|
die(f"could not read or write the report's files: {error}")
|
|
die(f"could not read or write the report's files: {error}")
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if __name__ == "__main__":
|
|
|
|
|
+ console.tolerate_undecodable_names()
|
|
|
sys.exit(main(sys.argv[1:]))
|
|
sys.exit(main(sys.argv[1:]))
|