contextzip 0.4.4__tar.gz → 0.4.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {contextzip-0.4.4 → contextzip-0.4.5}/PKG-INFO +6 -2
  2. {contextzip-0.4.4 → contextzip-0.4.5}/README.md +5 -1
  3. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/__init__.py +1 -1
  4. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/api.py +52 -2
  5. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/cli.py +17 -11
  6. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/cli_ai.py +1 -28
  7. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/cli_display.py +4 -0
  8. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/error_parser.py +12 -0
  9. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/filters.py +116 -8
  10. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/watcher.py +8 -0
  11. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip.egg-info/PKG-INFO +6 -2
  12. {contextzip-0.4.4 → contextzip-0.4.5}/pyproject.toml +1 -1
  13. {contextzip-0.4.4 → contextzip-0.4.5}/LICENSE +0 -0
  14. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/ai/__init__.py +0 -0
  15. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/ai/gemini.py +0 -0
  16. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/ai/heuristic.py +0 -0
  17. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/ai/selector.py +0 -0
  18. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/applier.py +0 -0
  19. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/cleanup.py +0 -0
  20. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/cli_onboard.py +0 -0
  21. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/clipboard.py +0 -0
  22. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/config.py +0 -0
  23. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/detector.py +0 -0
  24. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/git.py +0 -0
  25. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/packager.py +0 -0
  26. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/project_config.py +0 -0
  27. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/redact.py +0 -0
  28. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/report.py +0 -0
  29. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/__init__.py +0 -0
  30. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/base.py +0 -0
  31. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/errors/__init__.py +0 -0
  32. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/errors/node.py +0 -0
  33. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/errors/python.py +0 -0
  34. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/go.py +0 -0
  35. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/node.py +0 -0
  36. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/python.py +0 -0
  37. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/ruby.py +0 -0
  38. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/rules/rust.py +0 -0
  39. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/updater.py +0 -0
  40. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/webui/__init__.py +0 -0
  41. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/webui/assets.py +0 -0
  42. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/webui/persist.py +0 -0
  43. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/webui/server.py +0 -0
  44. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip/webui/suggestions.py +0 -0
  45. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip.egg-info/SOURCES.txt +0 -0
  46. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip.egg-info/dependency_links.txt +0 -0
  47. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip.egg-info/entry_points.txt +0 -0
  48. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip.egg-info/requires.txt +0 -0
  49. {contextzip-0.4.4 → contextzip-0.4.5}/contextzip.egg-info/top_level.txt +0 -0
  50. {contextzip-0.4.4 → contextzip-0.4.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -27,7 +27,11 @@ Requires-Dist: pathspec>=0.12
27
27
  Requires-Dist: httpx>=0.27
28
28
  Dynamic: license-file
29
29
 
30
- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" />
30
+
31
+
32
+ https://github.com/user-attachments/assets/91913ad6-6161-430d-9075-b0fa76498021
33
+
34
+ <!-- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" /> -->
31
35
 
32
36
  # contextzip
33
37
 
@@ -1,4 +1,8 @@
1
- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" />
1
+
2
+
3
+ https://github.com/user-attachments/assets/91913ad6-6161-430d-9075-b0fa76498021
4
+
5
+ <!-- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" /> -->
2
6
 
3
7
  # contextzip
4
8
 
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.4.4"
3
+ __version__ = "0.4.5"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -43,12 +43,15 @@ from contextzip.applier import ApplyResult
43
43
  from contextzip.detector import DetectionResult, detect
44
44
  from contextzip.filters import (
45
45
  ResolveResult,
46
+ build_force_include_spec,
46
47
  build_spec,
48
+ normalize_pattern,
47
49
  resolve_files,
48
50
  resolve_files_from_git,
49
51
  )
50
52
  from contextzip.git import GitError, GitErrorKind, get_changed_files
51
53
  from contextzip.packager import PackageResult, create_zip_silent
54
+ from contextzip.project_config import load_project_config
52
55
 
53
56
 
54
57
  # ---------------------------------------------------------------------------
@@ -139,19 +142,27 @@ class ZipNotFoundError(ContextzipError):
139
142
 
140
143
  def get_git_changes(
141
144
  path: str | Path | None = None,
145
+ *,
146
+ use_project_config: bool = True,
142
147
  ) -> FileCollection:
143
148
  """
144
149
  Return the files that git reports as modified, added, or untracked.
145
150
 
146
151
  The git root is found automatically by walking up from *path* (or the
147
152
  current working directory). Files are filtered through contextzip's base
148
- safety rules so secrets, binaries, and similar files are always excluded.
153
+ safety rules so secrets, binaries, and similar files are always excluded,
154
+ and through the project's ``.contextzip/config.json``
155
+ (``always_include`` / ``always_exclude`` / ``limits``) — the same as the
156
+ CLI's ``--git-changes``.
149
157
 
150
158
  Parameters
151
159
  ----------
152
160
  path:
153
161
  Directory to start from. Defaults to ``Path.cwd()``. The actual
154
162
  git root may be a parent of this directory.
163
+ use_project_config:
164
+ Apply the project's ``.contextzip/config.json``. Defaults to
165
+ ``True``; pass ``False`` to use only the built-in rules.
155
166
 
156
167
  Returns
157
168
  -------
@@ -192,9 +203,12 @@ def get_git_changes(
192
203
  if git_result.is_empty:
193
204
  return FileCollection(project_dir=project_dir)
194
205
 
206
+ cfg = load_project_config(project_dir) if use_project_config else None
207
+
195
208
  resolved = resolve_files_from_git(
196
209
  git_files=git_result.files,
197
210
  project_dir=project_dir,
211
+ **_config_kwargs(cfg),
198
212
  )
199
213
 
200
214
  detection = detect(project_dir)
@@ -212,6 +226,7 @@ def get_files(
212
226
  include: list[str] | None = None,
213
227
  exclude: list[str] | None = None,
214
228
  use_gitignore: bool = True,
229
+ use_project_config: bool = True,
215
230
  ) -> FileCollection:
216
231
  """
217
232
  Return all project files after applying contextzip's standard exclusion rules.
@@ -229,6 +244,11 @@ def get_files(
229
244
  use_gitignore:
230
245
  Whether to apply the project's ``.gitignore`` file.
231
246
  Defaults to ``True``.
247
+ use_project_config:
248
+ Apply the project's ``.contextzip/config.json`` (``always_include``,
249
+ ``always_exclude``, ``limits``), the same as the CLI. Defaults to
250
+ ``True``; pass ``False`` to use only the built-in rules and the
251
+ arguments above.
232
252
 
233
253
  Returns
234
254
  -------
@@ -256,9 +276,12 @@ def get_files(
256
276
 
257
277
  gitignore_path = (project_dir / ".gitignore") if use_gitignore else None
258
278
 
279
+ cfg = load_project_config(project_dir) if use_project_config else None
280
+ cfg_kwargs = _config_kwargs(cfg, extra_exclude=exclude)
281
+
259
282
  spec = build_spec(
260
283
  rule_modules=detection.rule_modules,
261
- extra_exclude=exclude or None,
284
+ extra_exclude=cfg_kwargs["extra_exclude"],
262
285
  gitignore_path=gitignore_path,
263
286
  )
264
287
 
@@ -266,6 +289,8 @@ def get_files(
266
289
  project_dir=project_dir,
267
290
  spec=spec,
268
291
  include_only=include or None,
292
+ force_include=cfg_kwargs["force_include"],
293
+ large_file_warn_bytes=cfg_kwargs["large_file_warn_bytes"],
269
294
  )
270
295
 
271
296
  return _resolve_result_to_collection(resolved, project_dir, detection)
@@ -447,6 +472,31 @@ def detect_ecosystem(
447
472
  # ---------------------------------------------------------------------------
448
473
 
449
474
 
475
+ def _config_kwargs(cfg, extra_exclude: list[str] | None = None) -> dict:
476
+ """
477
+ Translate a ProjectConfig (or None) into the shared filter inputs:
478
+ extra_exclude (caller patterns + always_exclude), force_include
479
+ (always_include) and large_file_warn_bytes (limits.max_file_size_mb).
480
+ """
481
+ from contextzip.filters import LARGE_FILE_WARN_BYTES
482
+
483
+ patterns = [normalize_pattern(p) for p in extra_exclude or []]
484
+ if cfg is None:
485
+ return {
486
+ "extra_exclude": patterns or None,
487
+ "force_include": None,
488
+ "large_file_warn_bytes": LARGE_FILE_WARN_BYTES,
489
+ }
490
+ patterns += [normalize_pattern(p) for p in cfg.always_exclude]
491
+ return {
492
+ "extra_exclude": patterns or None,
493
+ "force_include": build_force_include_spec(
494
+ [normalize_pattern(p) for p in cfg.always_include]
495
+ ),
496
+ "large_file_warn_bytes": int(cfg.limits.max_file_size_mb * 1024 * 1024),
497
+ }
498
+
499
+
450
500
  def _resolve_dir(path: str | Path | None) -> Path:
451
501
  if path is None:
452
502
  return Path(os.getcwd()).resolve()
@@ -927,6 +927,18 @@ def _run(
927
927
  if saved:
928
928
  project_cfg = load_project_config(project_dir)
929
929
 
930
+ # ── Include/exclude inputs shared by every scan mode ─────────────────────
931
+ # CLI --exclude/-e patterns plus any standing always_exclude patterns from
932
+ # project config — both are additive, persistent behavior comes from
933
+ # config.json so it doesn't need to be re-typed every run. always_include
934
+ # is a standing negation. Built once here so standard and --git-changes
935
+ # runs honour project config identically.
936
+ normalized_exclude = [normalize_pattern(p) for p in extra_exclude or []]
937
+ normalized_exclude += [normalize_pattern(p) for p in project_cfg.always_exclude]
938
+ force_include = build_force_include_spec(
939
+ [normalize_pattern(p) for p in project_cfg.always_include]
940
+ )
941
+
930
942
  # ── Git-changes mode ─────────────────────────────────────────────────────
931
943
  if git_changes:
932
944
  with console.status("[cyan]Querying git for changed files…[/]", spinner="dots"):
@@ -939,7 +951,7 @@ def _run(
939
951
  print_git_deleted_and_submodules(git_result)
940
952
 
941
953
  if git_result.is_empty:
942
- info("Nothing to package — working tree is clean.")
954
+ info("Nothing to compress in this one — working tree is clean.")
943
955
  return
944
956
 
945
957
  scan_label, scan_detail = git_scan_label_and_detail(git_result)
@@ -953,27 +965,21 @@ def _run(
953
965
  large_file_warn_bytes=int(
954
966
  project_cfg.limits.max_file_size_mb * 1024 * 1024
955
967
  ),
968
+ extra_exclude=normalized_exclude if normalized_exclude else None,
969
+ include_only=include_only if include_only else None,
970
+ force_include=force_include,
956
971
  )
957
972
 
958
973
  else:
959
974
  # ── Build exclusion spec ─────────────────────────────────────────────
960
975
  gitignore_path = None if no_gitignore else (project_dir / ".gitignore")
961
976
 
962
- # CLI --exclude/-e patterns plus any standing always_exclude patterns
963
- # from project config — both are additive, persistent behavior comes
964
- # from config.json so it doesn't need to be re-typed every run.
965
- normalized_exclude = [normalize_pattern(p) for p in extra_exclude or []]
966
- normalized_exclude += [normalize_pattern(p) for p in project_cfg.always_exclude]
967
-
968
977
  with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"):
969
978
  spec = build_spec(
970
979
  rule_modules=detection.rule_modules,
971
980
  extra_exclude=normalized_exclude if normalized_exclude else None,
972
981
  gitignore_path=gitignore_path,
973
982
  )
974
- force_include = build_force_include_spec(
975
- [normalize_pattern(p) for p in project_cfg.always_include]
976
- )
977
983
 
978
984
  # ── Resolve files ─────────────────────────────────────────────────────
979
985
  with console.status("[cyan]Scanning project files…[/]", spinner="dots"):
@@ -1027,7 +1033,7 @@ def _run(
1027
1033
  return
1028
1034
 
1029
1035
  if not resolved.included:
1030
- err("Nothing to package — all files were excluded.")
1036
+ err("Nothing to compress in this one — all files were excluded.")
1031
1037
  info("Try contextzip include PATH or -i PATH to override.")
1032
1038
  return
1033
1039
 
@@ -15,38 +15,11 @@ from pathlib import Path
15
15
  from rich.console import Console
16
16
 
17
17
  from contextzip.cli_display import print_ai_selection, err, warn, info
18
+ from contextzip.filters import normalize_pattern # noqa: F401 (re-exported for cli.py)
18
19
 
19
20
  console = Console()
20
21
 
21
22
 
22
- # ---------------------------------------------------------------------------
23
- # Pattern normalisation
24
- # ---------------------------------------------------------------------------
25
-
26
-
27
- def normalize_pattern(p: str) -> str:
28
- """
29
- Canonicalise a user-supplied exclusion pattern.
30
-
31
- Rules applied in order:
32
- 1. Strip a leading ``./`` or ``.\\`` so that ``./CHANGELOG.md``
33
- and ``CHANGELOG.md`` are treated identically.
34
- 2. Replace every backslash with a forward slash for cross-platform
35
- consistency (Windows paths entered on the CLI).
36
- 3. Collapse ``folder/*`` → ``folder/`` so that gitignore-style
37
- directory globs work as expected.
38
- """
39
- # 1. Strip leading ./ or .\\
40
- if p.startswith("./") or p.startswith(".\\"):
41
- p = p[2:]
42
- # 2. Normalise path separators
43
- p = p.replace("\\", "/")
44
- # 3. folder/* → folder/
45
- if p.endswith("/*"):
46
- p = p[:-1]
47
- return p
48
-
49
-
50
23
  # ---------------------------------------------------------------------------
51
24
  # AI selection (full run — produces ZIP)
52
25
  # ---------------------------------------------------------------------------
@@ -64,6 +64,10 @@ def print_detection(detection, *, con: Console = console) -> None:
64
64
  """One line: what was detected and how confident contextzip is."""
65
65
  if detection.is_unknown:
66
66
  ok("Detected", "unknown ecosystem, base rules only", con=con)
67
+ info(
68
+ "Don't see your language? Add it here: https://example.com",
69
+ con=con,
70
+ )
67
71
  return
68
72
 
69
73
  parts = []
@@ -22,6 +22,8 @@ from __future__ import annotations
22
22
  import re
23
23
  from pathlib import Path
24
24
 
25
+ from contextzip.filters import filter_paths_by_config
26
+
25
27
  # ---------------------------------------------------------------------------
26
28
  # ANSI escape code stripping
27
29
  # ---------------------------------------------------------------------------
@@ -453,6 +455,8 @@ def process_buffer(
453
455
  project_dir: Path,
454
456
  ecosystems: list[str],
455
457
  ecosystem_display: str,
458
+ always_exclude: list[str] | None = None,
459
+ always_include: list[str] | None = None,
456
460
  ) -> tuple[str, str, list[Path]] | None:
457
461
  """
458
462
  Run the full pipeline on *raw_buffer*.
@@ -472,6 +476,11 @@ def process_buffer(
472
476
  List of detected ecosystem names from DetectionResult.ecosystems.
473
477
  ecosystem_display:
474
478
  Human-readable display string, e.g. "Next.js + Node.js".
479
+ always_exclude / always_include:
480
+ A project config's standing patterns. Files named in the stack trace
481
+ that match always_exclude are left out of the referenced-file list
482
+ (and therefore out of source-files.zip and prompt.txt), unless they
483
+ also match always_include.
475
484
  """
476
485
  # 1. Strip ANSI
477
486
  clean_text = strip_ansi(raw_buffer)
@@ -493,6 +502,9 @@ def process_buffer(
493
502
 
494
503
  # 4. Extract file paths
495
504
  referenced_paths = extract_paths(error_block_raw, project_dir, ecosystems)
505
+ referenced_paths = filter_paths_by_config(
506
+ referenced_paths, project_dir, always_exclude, always_include
507
+ )
496
508
 
497
509
  # 5. Build prompt.txt
498
510
  prompt_txt = build_prompt_txt(
@@ -12,14 +12,18 @@ Phase 5 hardening:
12
12
  Git mode:
13
13
  - resolve_files_from_git() accepts a GitChanges result and builds a
14
14
  ResolveResult from only the modified/added/untracked files reported
15
- by git, while still running all size and binary checks.
15
+ by git, while still running all size and binary checks. It honours the
16
+ same extra-exclude / include-only / force-include inputs as
17
+ resolve_files(), so project config (always_include / always_exclude)
18
+ and CLI --include/--exclude behave identically with and without
19
+ --git-changes.
16
20
 
17
21
  Force-include (Phase 7):
18
22
  - build_force_include_spec() turns a project config's "always_include"
19
- patterns into a PathSpec. resolve_files() accepts it as force_include
20
- and, when a file (or one of its parent directories) matches, treats
21
- the file as not excluded — a standing negation on top of auto-rules
22
- and .gitignore. It does not apply in git-changes mode, and it does not
23
+ patterns into a PathSpec. resolve_files() and resolve_files_from_git()
24
+ accept it as force_include and, when a file (or one of its parent
25
+ directories) matches, treat the file as not excluded — a standing
26
+ negation on top of auto-rules, .gitignore and --exclude. It does not
23
27
  override an explicit --include/-i for the current run.
24
28
  """
25
29
 
@@ -106,6 +110,27 @@ class ResolveResult:
106
110
  # ---------------------------------------------------------------------------
107
111
 
108
112
 
113
+ def normalize_pattern(p: str) -> str:
114
+ """
115
+ Canonicalise a user-supplied exclusion/inclusion pattern (CLI flag or
116
+ project config entry).
117
+
118
+ Rules applied in order:
119
+ 1. Strip a leading ``./`` or ``.\\`` so that ``./CHANGELOG.md``
120
+ and ``CHANGELOG.md`` are treated identically.
121
+ 2. Replace every backslash with a forward slash for cross-platform
122
+ consistency (Windows paths entered on the CLI).
123
+ 3. Collapse ``folder/*`` → ``folder/`` so that gitignore-style
124
+ directory globs work as expected.
125
+ """
126
+ if p.startswith("./") or p.startswith(".\\"):
127
+ p = p[2:]
128
+ p = p.replace("\\", "/")
129
+ if p.endswith("/*"):
130
+ p = p[:-1]
131
+ return p
132
+
133
+
109
134
  def build_spec(
110
135
  rule_modules: list[str],
111
136
  extra_exclude: list[str] | None = None,
@@ -368,6 +393,9 @@ def resolve_files_from_git(
368
393
  git_files: list[Path],
369
394
  project_dir: Path,
370
395
  large_file_warn_bytes: int = LARGE_FILE_WARN_BYTES,
396
+ extra_exclude: list[str] | None = None,
397
+ include_only: list[str] | None = None,
398
+ force_include: pathspec.PathSpec | None = None,
371
399
  ) -> ResolveResult:
372
400
  """
373
401
  Build a :class:`ResolveResult` from a pre-selected list of files reported
@@ -378,6 +406,16 @@ def resolve_files_from_git(
378
406
  Size and binary checks are also performed so that the usual warnings appear
379
407
  in the CLI output.
380
408
 
409
+ Precedence mirrors :func:`resolve_files` exactly, so a project's config
410
+ behaves the same with and without ``--git-changes``:
411
+
412
+ 1. *force_include* (config ``always_include``) — a match short-circuits
413
+ every exclusion check below, including the base safety floor.
414
+ 2. Base safety floor + *extra_exclude* (CLI ``--exclude`` plus config
415
+ ``always_exclude``) — a match excludes the file.
416
+ 3. *include_only* (CLI ``--include``) — the file must live under one of
417
+ these paths, even if it was force-included.
418
+
381
419
  Parameters
382
420
  ----------
383
421
  git_files:
@@ -388,10 +426,18 @@ def resolve_files_from_git(
388
426
  large_file_warn_bytes:
389
427
  Overrides the module default (1 MB) — typically a project's
390
428
  `limits.max_file_size_mb` preference (.contextzip/config.json).
429
+ extra_exclude:
430
+ Additional gitignore-style exclusion patterns, layered on top of the
431
+ base safety floor.
432
+ include_only:
433
+ If given, only files under these path prefixes are kept.
434
+ force_include:
435
+ Spec from :func:`build_force_include_spec`; matching files skip all
436
+ exclusion checks.
391
437
  """
392
438
  # Build the base spec once — this is our safety floor regardless of what
393
439
  # git reports as changed. It blocks .env, secrets, binaries, etc.
394
- base_spec = build_spec(rule_modules=["base"])
440
+ base_spec = build_spec(rule_modules=["base"], extra_exclude=extra_exclude or None)
395
441
 
396
442
  result = ResolveResult()
397
443
 
@@ -417,9 +463,23 @@ def resolve_files_from_git(
417
463
  result.skipped.append((abs_path, "outside project tree"))
418
464
  continue
419
465
 
420
- # ── Base safety floor — block secrets / binaries even if git-tracked ─
421
466
  rel_str = rel.as_posix()
422
- if _any_parent_excluded(rel, base_spec) or base_spec.match_file(rel_str):
467
+
468
+ # ── force_include (config always_include) short-circuits exclusion ───
469
+ forced = force_include is not None and (
470
+ force_include.match_file(rel_str)
471
+ or _any_parent_excluded(rel, force_include)
472
+ )
473
+
474
+ # ── Base safety floor + extra excludes (CLI --exclude / always_exclude)
475
+ if not forced and (
476
+ _any_parent_excluded(rel, base_spec) or base_spec.match_file(rel_str)
477
+ ):
478
+ result.excluded.append(abs_path)
479
+ continue
480
+
481
+ # ── Apply --include filter (exact prefix, not substring) ─────────────
482
+ if include_only and not _matches_any_prefix(rel_str, include_only):
423
483
  result.excluded.append(abs_path)
424
484
  continue
425
485
 
@@ -443,6 +503,54 @@ def resolve_files_from_git(
443
503
  return result
444
504
 
445
505
 
506
+ def filter_paths_by_config(
507
+ paths: list[Path],
508
+ project_dir: Path,
509
+ always_exclude: list[str] | None = None,
510
+ always_include: list[str] | None = None,
511
+ ) -> list[Path]:
512
+ """
513
+ Drop any of *paths* matched by a project config's ``always_exclude``,
514
+ unless it's also matched by ``always_include`` (same precedence as
515
+ :func:`resolve_files`: force-include wins).
516
+
517
+ For callers that pick files by some other means than a directory scan
518
+ (e.g. ``contextzip watch`` collects files named in a stack trace) but
519
+ must still respect the user's manual include/exclude choices. Paths
520
+ outside *project_dir* are left untouched.
521
+ """
522
+ if not always_exclude:
523
+ return list(paths)
524
+
525
+ exclude_spec = build_spec(
526
+ rule_modules=[],
527
+ extra_exclude=[normalize_pattern(p) for p in always_exclude],
528
+ )
529
+ force_spec = build_force_include_spec(
530
+ [normalize_pattern(p) for p in always_include or []]
531
+ )
532
+
533
+ kept: list[Path] = []
534
+ for path in paths:
535
+ try:
536
+ rel = path.relative_to(project_dir)
537
+ except ValueError:
538
+ kept.append(path)
539
+ continue
540
+ rel_str = rel.as_posix()
541
+
542
+ forced = force_spec is not None and (
543
+ force_spec.match_file(rel_str) or _any_parent_excluded(rel, force_spec)
544
+ )
545
+ excluded = _any_parent_excluded(rel, exclude_spec) or exclude_spec.match_file(
546
+ rel_str
547
+ )
548
+ if excluded and not forced:
549
+ continue
550
+ kept.append(path)
551
+ return kept
552
+
553
+
446
554
  def summarise_exclusions(excluded: list[Path], project_dir: Path) -> dict[str, int]:
447
555
  """Group excluded files by top-level directory / filename, sorted by count."""
448
556
  buckets: dict[str, int] = {}
@@ -513,11 +513,19 @@ def _package_debug_context(
513
513
  )
514
514
  referenced_paths: list[Path] = []
515
515
  else:
516
+ # Honour the project's manual include/exclude choices — a file
517
+ # the user put in always_exclude must not leak into the debug
518
+ # zip just because a stack trace happened to mention it.
519
+ from contextzip.project_config import load_project_config
520
+
521
+ project_cfg = load_project_config(project_dir)
516
522
  result = process_buffer(
517
523
  raw_buffer=raw_text,
518
524
  project_dir=project_dir,
519
525
  ecosystems=ecosystems,
520
526
  ecosystem_display=ecosystem_display,
527
+ always_exclude=project_cfg.always_exclude,
528
+ always_include=project_cfg.always_include,
521
529
  )
522
530
 
523
531
  if result is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -27,7 +27,11 @@ Requires-Dist: pathspec>=0.12
27
27
  Requires-Dist: httpx>=0.27
28
28
  Dynamic: license-file
29
29
 
30
- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" />
30
+
31
+
32
+ https://github.com/user-attachments/assets/91913ad6-6161-430d-9075-b0fa76498021
33
+
34
+ <!-- <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" /> -->
31
35
 
32
36
  # contextzip
33
37
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.4.4"
7
+ version = "0.4.5"
8
8
  description = "Intelligently package your codebase for AI tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes