mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
feat(include): bring back excluded paths named explicitly with --include
`contextzip include .env` or `include dist/` now includes paths the rules exclude instead of matching nothing. Nested excluded folders still apply, and naming an ordinary folder doesn't pull in secret files inside it. Include paths also accept a leading ./ or .\. The CLI and report list files brought back this way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8052318063
commit
87561e6315
@@ -165,6 +165,15 @@ def print_scan_summary(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_secret(path: Path, project_dir: Path) -> bool:
|
||||
from contextzip.redact import is_sensitive_path
|
||||
|
||||
try:
|
||||
return is_sensitive_path(path.relative_to(project_dir))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def print_file_warnings(
|
||||
resolved,
|
||||
project_dir: Path,
|
||||
@@ -173,6 +182,21 @@ def print_file_warnings(
|
||||
con: Console = console,
|
||||
) -> None:
|
||||
"""One line per warning category — no file listings inline (see report)."""
|
||||
explicit = getattr(resolved, "explicitly_included", None)
|
||||
if explicit:
|
||||
n = len(explicit)
|
||||
secret = sum(1 for p in explicit if _is_secret(p, project_dir))
|
||||
detail = (
|
||||
f" — {secret} secret file{'s' if secret != 1 else ''}, values will be redacted"
|
||||
if secret
|
||||
else ""
|
||||
)
|
||||
warn(
|
||||
f"{n} normally excluded file{'s' if n != 1 else ''} included "
|
||||
f"because you named {'them' if n != 1 else 'it'}{detail}",
|
||||
con=con,
|
||||
)
|
||||
|
||||
if resolved.large_files:
|
||||
n = len(resolved.large_files)
|
||||
warn(
|
||||
|
||||
+72
-11
@@ -103,6 +103,10 @@ class ResolveResult:
|
||||
skipped: list[tuple[Path, str]] = field(default_factory=list) # (path, reason)
|
||||
large_files: list[tuple[Path, int]] = field(default_factory=list) # (path, bytes)
|
||||
binary_files: list[Path] = field(default_factory=list)
|
||||
# Files the rules would have excluded, included anyway because an
|
||||
# explicit --include / `contextzip include` path named them (see
|
||||
# _explicit_include_override). Also present in `included`.
|
||||
explicitly_included: list[Path] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -307,6 +311,11 @@ def resolve_files(
|
||||
an explicit `contextzip include PATH` for this run still has the final
|
||||
say on what actually gets included.
|
||||
|
||||
*include_only* narrows the run to those paths. Naming a path the rules
|
||||
exclude (`contextzip include .env`, `include dist/`) brings it back —
|
||||
see :func:`_explicit_include_override` — and those files are also
|
||||
listed in ``explicitly_included``.
|
||||
|
||||
*large_file_warn_bytes* overrides the module default (1 MB) — typically
|
||||
a project's `limits.max_file_size_mb` preference (.contextzip/config.json).
|
||||
|
||||
@@ -352,13 +361,13 @@ def resolve_files(
|
||||
)
|
||||
|
||||
if not forced:
|
||||
# ── Check directory-level exclusion ──────────────────────────────
|
||||
if _any_parent_excluded(rel, spec):
|
||||
result.excluded.append(abs_path)
|
||||
continue
|
||||
|
||||
# ── Check file-level exclusion ─────────────────────────────────
|
||||
if spec.match_file(rel_str):
|
||||
excluded = _any_parent_excluded(rel, spec) or spec.match_file(rel_str)
|
||||
if excluded and include_only:
|
||||
# ── Explicitly named on --include: brings it back ────────────
|
||||
if _explicit_include_override(rel, include_only, spec):
|
||||
result.explicitly_included.append(abs_path)
|
||||
excluded = False
|
||||
if excluded:
|
||||
result.excluded.append(abs_path)
|
||||
continue
|
||||
|
||||
@@ -414,7 +423,9 @@ def resolve_files_from_git(
|
||||
2. Base safety floor + *extra_exclude* (CLI ``--exclude`` plus config
|
||||
``always_exclude``) — a match excludes the file.
|
||||
3. *include_only* (CLI ``--include``) — the file must live under one of
|
||||
these paths, even if it was force-included.
|
||||
these paths, even if it was force-included. A path that is itself
|
||||
excluded brings its files back when named here (see
|
||||
:func:`_explicit_include_override`).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -475,8 +486,12 @@ def resolve_files_from_git(
|
||||
if not forced and (
|
||||
_any_parent_excluded(rel, base_spec) or base_spec.match_file(rel_str)
|
||||
):
|
||||
result.excluded.append(abs_path)
|
||||
continue
|
||||
# ── ...unless explicitly named on --include ──────────────────────
|
||||
if include_only and _explicit_include_override(rel, include_only, base_spec):
|
||||
result.explicitly_included.append(abs_path)
|
||||
else:
|
||||
result.excluded.append(abs_path)
|
||||
continue
|
||||
|
||||
# ── Apply --include filter (exact prefix, not substring) ─────────────
|
||||
if include_only and not _matches_any_prefix(rel_str, include_only):
|
||||
@@ -579,6 +594,52 @@ def _any_parent_excluded(rel: Path, spec: pathspec.PathSpec) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _clean_include_path(p: str) -> str:
|
||||
"""`./src/`, `.\\src\\` and `src` all become `src`."""
|
||||
p = p.replace("\\", "/")
|
||||
while p.startswith("./"):
|
||||
p = p[2:]
|
||||
return p.rstrip("/")
|
||||
|
||||
|
||||
def _explicit_include_override(
|
||||
rel: Path, include_only: list[str], spec: pathspec.PathSpec
|
||||
) -> bool:
|
||||
"""
|
||||
True if an excluded file should be included anyway because the user
|
||||
named it — or a folder holding it — on --include / `contextzip include`.
|
||||
|
||||
That only counts when the named path is *itself* excluded by the rules:
|
||||
`contextzip include .env` or `include dist/` brings those back, but
|
||||
`include src/` doesn't drag in `src/.env`, since src/ was never excluded
|
||||
and the user just meant "scope to src/". Under a named folder, excluded
|
||||
folders *nested inside it* still apply (`include dist/` skips
|
||||
`dist/node_modules/`), matched relative to the named folder. Secret
|
||||
files brought back this way are still redacted by the packager.
|
||||
"""
|
||||
rel_str = rel.as_posix()
|
||||
for raw in include_only:
|
||||
named = _clean_include_path(raw)
|
||||
if not named or not (rel_str == named or rel_str.startswith(named + "/")):
|
||||
continue
|
||||
|
||||
named_path = Path(named)
|
||||
named_is_excluded = (
|
||||
spec.match_file(named)
|
||||
or spec.match_file(named + "/")
|
||||
or _any_parent_excluded(named_path, spec)
|
||||
)
|
||||
if not named_is_excluded:
|
||||
continue
|
||||
if rel_str == named:
|
||||
return True
|
||||
|
||||
inner = rel.relative_to(named_path)
|
||||
if not _any_parent_excluded(inner, spec):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _matches_any_prefix(rel_str: str, prefixes: list[str]) -> bool:
|
||||
"""
|
||||
True if *rel_str* starts with one of *prefixes* at a path boundary.
|
||||
@@ -588,7 +649,7 @@ def _matches_any_prefix(rel_str: str, prefixes: list[str]) -> bool:
|
||||
``src`` does NOT match ``src2/...`` ✗
|
||||
"""
|
||||
for prefix in prefixes:
|
||||
p = prefix.rstrip("/")
|
||||
p = _clean_include_path(prefix)
|
||||
if rel_str == p:
|
||||
return True
|
||||
if rel_str.startswith(p + "/"):
|
||||
|
||||
@@ -125,6 +125,13 @@ def write_scan_report(
|
||||
w(f" {rel.as_posix():<60} {summary}")
|
||||
w("")
|
||||
|
||||
explicit = getattr(resolved, "explicitly_included", None)
|
||||
if explicit:
|
||||
w("Included because you named them (normally excluded)")
|
||||
for p in explicit:
|
||||
w(f" {p.relative_to(project_dir).as_posix()}")
|
||||
w("")
|
||||
|
||||
if skipped_in_zip:
|
||||
w("Left out of the ZIP")
|
||||
for p, reason in skipped_in_zip:
|
||||
|
||||
Reference in New Issue
Block a user