Merge pull request #2 from akadeepesh/feat/exclude-include-subcommands

feat: add `exclude` and `include` subcommands for multi-file filtering
This commit is contained in:
Deepesh Kumar
2026-05-16 02:25:38 +05:30
committed by GitHub
2 changed files with 275 additions and 59 deletions
+20 -3
View File
@@ -83,7 +83,9 @@ contextzip [OPTIONS]
| Option | Description |
|---|---|
| `-i`, `--include PATH` | Only include files under this path. Repeatable. |
| `-e`, `--exclude PATTERN` | Extra exclusion patterns (gitignore syntax). Space-separated or repeatable: `-e '*.log' file1 file2` or `-e '*.log' -e file1` |
| `-e`, `--exclude PATTERN` | Extra exclusion patterns (gitignore syntax). Repeatable. |
| `exclude` | Subcommand: exclude specific files/patterns. `contextzip exclude CHANGELOG.md LICENSE .github/` |
| `include` | Subcommand: include only specific paths. `contextzip include src/ app/` |
| `--git-changes` | Only include files reported by git as modified, staged, or untracked. |
| `-n`, `--dry-run` | Preview what would be included, no ZIP created. |
| `-o`, `--output FILE` | Custom output path for the ZIP file. |
@@ -109,7 +111,22 @@ contextzip --include src --include app
**Exclude additional patterns beyond the auto-rules:**
```bash
contextzip -e "*.log" "*.sqlite" "tests/"
contextzip --exclude "*.log" --exclude "*.sqlite" --exclude "tests/"
```
**Exclude files using the subcommand (space-separated, no repetition):**
```bash
contextzip exclude CHANGELOG.md CONTRIBUTING.md LICENSE .github/
```
**Exclude with flags:**
```bash
contextzip exclude CHANGELOG.md --dry-run --verbose
```
**Include only specific directories using the subcommand:**
```bash
contextzip include src/ app/
```
**Only package files changed in git:**
@@ -206,7 +223,7 @@ contextzip surfaces issues before they waste your time:
contextzip/
├── contextzip/
│ ├── __init__.py # version string
│ ├── cli.py # Click entry point, all flags, rich output
│ ├── cli.py # Click entry point, all flags, subcommands (exclude, include), rich output
│ ├── detector.py # framework/language detection engine
│ ├── filters.py # pathspec-based file filtering + ResolveResult
│ ├── git.py # git status parsing + changed-file detection
+255 -56
View File
@@ -1,5 +1,27 @@
"""
cli.py — contextzip entry point. Phases 1–5 complete.
Command surface
───────────────
Main command (unchanged, fully backwards-compatible):
contextzip [OPTIONS]
-i / --include PATH only include files under these paths (repeatable)
-e / --exclude PATTERN extra exclusion patterns (repeatable)
-n / --dry-run preview without creating ZIP
-o / --output FILE custom output path
--no-clipboard skip clipboard / folder-open step
--no-gitignore ignore project .gitignore
--git-changes only package files changed in git
-v / --verbose show every file decision
Subcommands (new — all modifier flags available on each):
contextzip exclude PATTERN… [OPTIONS]
contextzip include PATH… [OPTIONS]
Both subcommands accept the same modifier flags as the main command so that
the flags naturally follow the verb, matching the git / docker / cargo UX:
contextzip exclude CHANGELOG.md --dry-run --verbose
contextzip include src/ app/ --output ~/Desktop/out.zip
"""
from __future__ import annotations
@@ -30,55 +52,88 @@ console = Console()
# ---------------------------------------------------------------------------
# CLI
# Shared modifier-flag decorator
# ---------------------------------------------------------------------------
# Defined once so the main command and every subcommand declare exactly the
# same flags without duplicating help strings.
def _modifier_options(f):
"""
Attach all run-modifier flags to a command.
Applied to both the main command and every subcommand so that flags
always follow the verb — matching the git / docker / cargo convention:
contextzip exclude CHANGELOG.md --dry-run --verbose
"""
decorators = [
click.option(
"--dry-run", "-n",
is_flag=True, default=False,
help="Show what would be included without creating the ZIP.",
),
click.option(
"--output", "-o",
default=None, metavar="FILE",
help="Output ZIP path. Defaults to <project>_context_<timestamp>.zip in temp dir.",
),
click.option(
"--no-clipboard",
is_flag=True, default=False,
help="Skip clipboard / folder-open step after creating the ZIP.",
),
click.option(
"--no-gitignore",
is_flag=True, default=False,
help="Ignore the project's .gitignore file (use only built-in rules).",
),
click.option(
"--git-changes",
is_flag=True, default=False,
help=(
"Only include files that git reports as modified, added, or untracked. "
"Requires the project to be inside a git repository."
),
),
click.option(
"--verbose", "-v",
is_flag=True, default=False,
help="Show every included and excluded file.",
),
]
for dec in reversed(decorators):
f = dec(f)
return f
# ---------------------------------------------------------------------------
# CLI group
# ---------------------------------------------------------------------------
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
@click.group(
invoke_without_command=True,
context_settings={"help_option_names": ["-h", "--help"]},
)
@click.option(
"--include", "-i",
multiple=True, metavar="PATH",
help="Only include files under these paths (relative to project root). "
"Repeatable: --include src --include app",
help=(
"Only include files under these paths (relative to project root). "
"Repeatable: --include src --include app | or use: contextzip include src app"
),
)
@click.option(
"--exclude", "-e",
multiple=True, metavar="PATTERN...",
help="Extra exclusion patterns on top of auto-rules (gitignore syntax). "
"Space-separated or repeatable: -e '*.log' file1 file2 OR -e '*.log' -e file1",
)
@click.option(
"--dry-run", "-n",
is_flag=True, default=False,
help="Show what would be included without creating the ZIP.",
)
@click.option(
"--output", "-o",
default=None, metavar="FILE",
help="Output ZIP path. Defaults to <project>_context_<timestamp>.zip in temp dir.",
)
@click.option(
"--no-clipboard",
is_flag=True, default=False,
help="Skip clipboard / folder-open step after creating the ZIP.",
)
@click.option(
"--no-gitignore",
is_flag=True, default=False,
help="Ignore the project's .gitignore file (use only built-in rules).",
)
@click.option(
"--git-changes",
is_flag=True, default=False,
help="Only include files that git reports as modified, added, or untracked. "
"Requires the project to be inside a git repository.",
)
@click.option(
"--verbose", "-v",
is_flag=True, default=False,
help="Show every included and excluded file.",
multiple=True, metavar="PATTERN",
help=(
"Extra exclusion patterns on top of auto-rules (gitignore syntax). "
"Repeatable: -e '*.log' -e CHANGELOG.md | or use: contextzip exclude CHANGELOG.md *.log"
),
)
@_modifier_options
@click.version_option(version=__version__, prog_name="contextzip")
@click.pass_context
def main(
ctx: click.Context,
include: tuple[str, ...],
exclude: tuple[str, ...],
dry_run: bool,
@@ -94,8 +149,132 @@ def main(
Run from your project root to produce a smart, lightweight ZIP
ready to paste directly into Claude, ChatGPT, or any AI interface.
"""
\b
SUBCOMMANDS
contextzip exclude CHANGELOG.md LICENSE .github/
contextzip include src/ app/
Both subcommands accept the same flags as the main command:
contextzip exclude CHANGELOG.md --dry-run --verbose
"""
# A subcommand was invoked — let it handle everything.
if ctx.invoked_subcommand is not None:
return
_run(
extra_exclude=list(exclude),
include_only=list(include),
dry_run=dry_run,
output=output,
no_clipboard=no_clipboard,
no_gitignore=no_gitignore,
git_changes=git_changes,
verbose=verbose,
)
# ---------------------------------------------------------------------------
# Subcommand: exclude
# ---------------------------------------------------------------------------
@main.command("exclude")
@click.argument("patterns", nargs=-1, required=True, metavar="PATTERN…")
@_modifier_options
def cmd_exclude(
patterns: tuple[str, ...],
dry_run: bool,
output: str | None,
no_clipboard: bool,
no_gitignore: bool,
git_changes: bool,
verbose: bool,
) -> None:
"""
Exclude specific files or patterns and package everything else.
\b
EXAMPLES
contextzip exclude CHANGELOG.md CONTRIBUTING.md LICENSE
contextzip exclude .github/ tests/ '*.log'
contextzip exclude CHANGELOG.md --dry-run --verbose
contextzip exclude .github/ CHANGELOG.md --output ~/Desktop/out.zip
Patterns follow gitignore syntax. Folders are matched with or without
a trailing slash: both '.github' and '.github/' work.
"""
_run(
extra_exclude=list(patterns),
include_only=None,
dry_run=dry_run,
output=output,
no_clipboard=no_clipboard,
no_gitignore=no_gitignore,
git_changes=git_changes,
verbose=verbose,
)
# ---------------------------------------------------------------------------
# Subcommand: include
# ---------------------------------------------------------------------------
@main.command("include")
@click.argument("paths", nargs=-1, required=True, metavar="PATH…")
@_modifier_options
def cmd_include(
paths: tuple[str, ...],
dry_run: bool,
output: str | None,
no_clipboard: bool,
no_gitignore: bool,
git_changes: bool,
verbose: bool,
) -> None:
"""
Package only the specified paths and skip everything else.
\b
EXAMPLES
contextzip include src/ app/
contextzip include src/ app/ --dry-run
contextzip include src/ --output ~/Desktop/out.zip --verbose
Paths are matched as exact prefixes at directory boundaries:
'src' matches 'src/index.ts' but not 'src2/index.ts'.
"""
_run(
extra_exclude=None,
include_only=list(paths),
dry_run=dry_run,
output=output,
no_clipboard=no_clipboard,
no_gitignore=no_gitignore,
git_changes=git_changes,
verbose=verbose,
)
# ---------------------------------------------------------------------------
# Core execution logic (shared by main command + all subcommands)
# ---------------------------------------------------------------------------
def _run(
*,
extra_exclude: list[str] | None,
include_only: list[str] | None,
dry_run: bool,
output: str | None,
no_clipboard: bool,
no_gitignore: bool,
git_changes: bool,
verbose: bool,
) -> None:
"""
All actual work lives here. The main command and every subcommand
delegate to this function after collecting their arguments/flags,
keeping the CLI surface thin and the logic testable in isolation.
"""
project_dir = Path(os.getcwd()).resolve()
# ── Header ───────────────────────────────────────────────────────────────
@@ -153,20 +332,12 @@ def main(
and gitignore_path.is_file()
)
normalized_exclude = (
[_normalize_pattern(p) for p in extra_exclude]
if extra_exclude else []
)
with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"):
# Flatten: each -e invocation may itself contain space-separated tokens
raw_exclude = exclude
flat_exclude: list[str] = []
for token in raw_exclude:
flat_exclude.extend(token.split())
# Normalize Windows-style paths (.\foo\bar → foo/bar)
def _normalize_pattern(p: str) -> str:
p = p.lstrip(".\\/") # strip leading .\ or ./
p = p.replace("\\", "/") # backslash → forward slash
return p
normalized_exclude = [_normalize_pattern(p) for p in flat_exclude]
spec = build_spec(
rule_modules=detection.rule_modules,
extra_exclude=normalized_exclude if normalized_exclude else None,
@@ -182,7 +353,7 @@ def main(
resolved = resolve_files(
project_dir=project_dir,
spec=spec,
include_only=list(include) if include else None,
include_only=include_only if include_only else None,
)
# ── File scan summary ────────────────────────────────────────────────────
@@ -202,7 +373,8 @@ def main(
if len(resolved.large_files) > 5:
console.print(f" [dim]… and {len(resolved.large_files) - 5} more[/]")
console.print(
f" [dim] Use --exclude to drop them if unneeded.[/]"
" [dim] Use [cyan]-e PATTERN[/] or [cyan]contextzip exclude PATTERN[/] "
"to drop them if unneeded.[/]"
)
# ── Warnings: binary files ───────────────────────────────────────────────
@@ -245,7 +417,7 @@ def main(
if not resolved.included:
console.print(
"\n[red]Nothing to package.[/] All files were excluded — "
"try [cyan]--include[/] to override."
"try [cyan]contextzip include PATH[/] or [cyan]-i PATH[/] to override."
)
return
@@ -285,6 +457,33 @@ def main(
_print_clipboard_result(cb)
# ---------------------------------------------------------------------------
# Normalisation
# ---------------------------------------------------------------------------
def _normalize_pattern(p: str) -> str:
"""
Canonicalise a user-supplied exclusion pattern.
Rules applied in order:
1. Strip a leading ``./`` or ``.\\`` so that ``./CHANGELOG.md``
and ``CHANGELOG.md`` are treated identically.
2. Replace every backslash with a forward slash for cross-platform
consistency (Windows paths entered on the CLI).
3. Collapse ``folder/*`` → ``folder/`` so that gitignore-style
directory globs work as expected.
"""
# 1. Strip leading ./ or .\
if p.startswith("./") or p.startswith(".\\"):
p = p[2:]
# 2. Normalise path separators
p = p.replace("\\", "/")
# 3. folder/* → folder/
if p.endswith("/*"):
p = p[:-1]
return p
# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------
@@ -449,4 +648,4 @@ def _human_size(n: int) -> str:
if n < 1024:
return f"{n:.0f} {unit}"
n /= 1024
return f"{n:.1f} TB"
return f"{n:.1f} TB"