contextzip 0.4.3__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.3 → contextzip-0.4.5}/PKG-INFO +7 -3
  2. {contextzip-0.4.3 → contextzip-0.4.5}/README.md +6 -2
  3. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/__init__.py +1 -1
  4. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/api.py +53 -8
  5. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/applier.py +193 -54
  6. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cleanup.py +44 -25
  7. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cli.py +23 -16
  8. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cli_ai.py +1 -28
  9. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cli_display.py +5 -3
  10. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/error_parser.py +12 -0
  11. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/filters.py +116 -8
  12. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/packager.py +18 -0
  13. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/project_config.py +13 -12
  14. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/report.py +17 -4
  15. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/watcher.py +8 -0
  16. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/PKG-INFO +7 -3
  17. {contextzip-0.4.3 → contextzip-0.4.5}/pyproject.toml +1 -1
  18. {contextzip-0.4.3 → contextzip-0.4.5}/LICENSE +0 -0
  19. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/ai/__init__.py +0 -0
  20. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/ai/gemini.py +0 -0
  21. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/ai/heuristic.py +0 -0
  22. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/ai/selector.py +0 -0
  23. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cli_onboard.py +0 -0
  24. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/clipboard.py +0 -0
  25. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/config.py +0 -0
  26. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/detector.py +0 -0
  27. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/git.py +0 -0
  28. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/redact.py +0 -0
  29. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/__init__.py +0 -0
  30. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/base.py +0 -0
  31. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/errors/__init__.py +0 -0
  32. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/errors/node.py +0 -0
  33. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/errors/python.py +0 -0
  34. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/go.py +0 -0
  35. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/node.py +0 -0
  36. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/python.py +0 -0
  37. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/ruby.py +0 -0
  38. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/rust.py +0 -0
  39. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/updater.py +0 -0
  40. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/__init__.py +0 -0
  41. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/assets.py +0 -0
  42. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/persist.py +0 -0
  43. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/server.py +0 -0
  44. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/suggestions.py +0 -0
  45. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/SOURCES.txt +0 -0
  46. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/dependency_links.txt +0 -0
  47. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/entry_points.txt +0 -0
  48. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/requires.txt +0 -0
  49. {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/top_level.txt +0 -0
  50. {contextzip-0.4.3 → 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.3
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
 
@@ -87,7 +91,7 @@ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextz
87
91
  - [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
88
92
  - [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
89
93
  - [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
90
- - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
94
+ - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, manifest diffing, safety
91
95
  - [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
92
96
  - [CLI & Python API reference](https://contextzip.vercel.app/cli)
93
97
 
@@ -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
 
@@ -58,7 +62,7 @@ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextz
58
62
  - [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
59
63
  - [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
60
64
  - [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
61
- - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
65
+ - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, manifest diffing, safety
62
66
  - [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
63
67
  - [CLI & Python API reference](https://contextzip.vercel.app/cli)
64
68
 
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.4.3"
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)
@@ -354,8 +379,6 @@ def apply_zip(
354
379
  classify every file as new, modified, unchanged, or one with no safe
355
380
  baseline (locally edited since zipping, or never part of the original
356
381
  manifest). Only adds and modifies files — nothing is ever deleted.
357
- Every overwritten file is backed up first, under
358
- ``.contextzip/backups/<timestamp>/``.
359
382
 
360
383
  Unlike the CLI, this applies immediately and does not prompt — check
361
384
  ``contextzip.applier.build_plan()`` yourself first if you want to
@@ -375,8 +398,7 @@ def apply_zip(
375
398
  Returns
376
399
  -------
377
400
  ApplyResult
378
- ``written`` (list of relative paths written), ``backup_dir``
379
- (``Path`` or ``None`` if nothing needed backing up), and
401
+ ``written`` (list of relative paths written) and
380
402
  ``applied_zip_path`` (where the consumed zip ended up).
381
403
 
382
404
  Raises
@@ -393,8 +415,6 @@ def apply_zip(
393
415
 
394
416
  result = apply_zip() # picks up .contextzip/inbox/*.zip
395
417
  print(f"Wrote {len(result.written)} files")
396
- if result.backup_dir:
397
- print(f"Backup at {result.backup_dir}")
398
418
  """
399
419
  from contextzip.applier import (
400
420
  ApplyError,
@@ -452,6 +472,31 @@ def detect_ecosystem(
452
472
  # ---------------------------------------------------------------------------
453
473
 
454
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
+
455
500
  def _resolve_dir(path: str | Path | None) -> Path:
456
501
  if path is None:
457
502
  return Path(os.getcwd()).resolve()
@@ -48,8 +48,86 @@ from pathlib import Path
48
48
  _MANIFEST_SUFFIX = ".manifest.json"
49
49
  _INBOX_DIRNAME = "inbox"
50
50
  _APPLIED_DIRNAME = "applied"
51
- _BACKUPS_DIRNAME = "backups"
52
51
  _OUTPUT_DIRNAME = "output" # must match packager._OUTPUT_DIRNAME
52
+ _INBOX_README_NAME = "README.txt"
53
+
54
+ _INBOX_README_CONTENTS = """\
55
+ Drop your zip here which you want to apply.
56
+ ============================================
57
+
58
+ This folder is contextzip's inbox — the one place `apply-zip` looks for
59
+ a zip to bring back into your project.
60
+
61
+ HOW TO USE IT
62
+ 1. Take the zip your AI tool (Claude, ChatGPT, etc.) gave you back,
63
+ after you sent it a codebase package contextzip made for you.
64
+ 2. Drop that zip file directly into this folder — no need to rename
65
+ it or unzip it first.
66
+ 3. Run: contextzip apply-zip (short alias: cz apply-zip)
67
+ 4. contextzip finds it automatically, compares every file inside it
68
+ against your project's manifest, and shows you exactly what's
69
+ new, modified, or unchanged before writing anything to disk.
70
+
71
+ WHAT HAPPENS TO THE ZIP AFTERWARDS
72
+ Once applied, contextzip moves it into inbox/applied/ instead of
73
+ deleting it, so there's always a record of exactly what was applied
74
+ and when. Old entries there are pruned automatically over time — see
75
+ the `cleanup` and `applied_zip_retention` settings in
76
+ .contextzip/config.json if you'd like to keep more of them.
77
+
78
+ A FEW THINGS WORTH KNOWING
79
+ - Only .zip files placed directly in this folder are picked up —
80
+ not ones inside a subfolder.
81
+ - If more than one zip ends up here at once, contextzip will ask you
82
+ to say which one: contextzip apply-zip <name>.zip
83
+ - You can also point apply-zip at a zip anywhere else on disk instead
84
+ of using this folder: contextzip apply-zip /path/to/file.zip
85
+ - This whole .contextzip/ folder is local to your machine and is
86
+ never committed to git (see .contextzip/.gitignore) — nothing here
87
+ leaves your computer unless you explicitly share it yourself.
88
+ - This file is just documentation — it's fine to edit it, and it
89
+ won't affect anything if you do. If it ever goes missing, though,
90
+ contextzip will put a fresh copy back the next time it touches this
91
+ folder; it never touches an actual pending zip, only this file.
92
+
93
+ Full docs: https://contextzip.vercel.app/apply-zip
94
+ """
95
+
96
+
97
+ def ensure_inbox_scaffolding(workspace: Path) -> None:
98
+ """
99
+ Make sure <workspace>/inbox/ exists, with its README.txt explaining
100
+ what the folder is for.
101
+
102
+ Called at the same moment the rest of the .contextzip/ workspace gets
103
+ set up (see packager.py's `_workspace_output_path[_silent]`), so the
104
+ inbox is ready and self-explanatory from the very first run — rather
105
+ than a bare, unexplained folder someone only discovers by reading the
106
+ docs, or worse, one that doesn't exist yet the first time `apply-zip`
107
+ goes looking for it.
108
+
109
+ Idempotent, same rule as the workspace .gitignore (see
110
+ `_ensure_workspace_gitignore` in packager.py): leaves an
111
+ already-correct README.txt untouched, and (re)writes it whenever it's
112
+ missing or doesn't match, so it stays reliable documentation rather
113
+ than something that quietly rots or disappears. Never touches
114
+ anything else already in the inbox — pending zips, `applied/`, past
115
+ reports.
116
+ """
117
+ inbox = workspace / _INBOX_DIRNAME
118
+ inbox.mkdir(parents=True, exist_ok=True)
119
+
120
+ readme_path = inbox / _INBOX_README_NAME
121
+ if readme_path.is_file():
122
+ try:
123
+ if readme_path.read_text(encoding="utf-8", errors="replace") == _INBOX_README_CONTENTS:
124
+ return
125
+ except OSError:
126
+ return
127
+ try:
128
+ readme_path.write_text(_INBOX_README_CONTENTS, encoding="utf-8")
129
+ except OSError:
130
+ pass
53
131
 
54
132
  # Files contextzip itself writes into outgoing ZIPs that should never be
55
133
  # written back into the project even if an AI tool echoes them back.
@@ -146,7 +224,6 @@ class ApplyPlan:
146
224
  @dataclass
147
225
  class ApplyResult:
148
226
  written: list[str]
149
- backup_dir: Path | None
150
227
  applied_zip_path: Path
151
228
 
152
229
 
@@ -266,10 +343,17 @@ def find_zip_to_apply(
266
343
 
267
344
  inbox = inbox_dir(project_dir)
268
345
  if not inbox.is_dir():
346
+ # Normally created alongside the rest of .contextzip/ the first
347
+ # time any contextzip command runs (see
348
+ # ensure_inbox_scaffolding), so this only fires if apply-zip is
349
+ # somehow the very first command run in a project. Create it now
350
+ # rather than just describing where it should be — so the folder
351
+ # (and its README) are actually there for the person to drop a
352
+ # zip into right after reading this message.
353
+ ensure_inbox_scaffolding(_workspace_dir(project_dir))
269
354
  raise NoZipFoundError(
270
- f"No zip found. Drop the AI-returned zip into {inbox} "
271
- "(created the first time you run contextzip), or pass a path "
272
- "directly: contextzip apply-zip <path>"
355
+ f"No zip found. Drop the AI-returned zip into {inbox}, "
356
+ "or pass a path directly: contextzip apply-zip <path>"
273
357
  )
274
358
 
275
359
  candidates = sorted(p for p in inbox.glob("*.zip") if p.is_file())
@@ -417,6 +501,31 @@ def _match_rate(names: list[str], manifest_files: dict) -> float:
417
501
  # ---------------------------------------------------------------------------
418
502
 
419
503
 
504
+ def _top_level_dir_match_rate(names: list[str], project_dir: Path) -> float:
505
+ """
506
+ Fraction of *names*' unique top-level path segments that exist as a
507
+ real file or directory directly under *project_dir* right now.
508
+
509
+ Used as a fallback signal for wrapper-stripping when the manifest
510
+ can't settle it — e.g. the freshest manifest on disk is a narrow,
511
+ git-diff-scoped one (`.contextzip/output/git-changes/...`) that only
512
+ ever listed a handful of touched files, so a zip full of brand-new
513
+ files matches it at ~0% whether or not the wrapper is stripped. The
514
+ manifest is silent either way, but the project's actual top-level
515
+ layout — `app/`, `components/`, `lib/`, and so on — usually isn't:
516
+ a genuine wrapper's contents line up with it once stripped, while
517
+ the raw, unstripped paths (starting with the wrapper's own name)
518
+ essentially never do.
519
+ """
520
+ if not names:
521
+ return 0.0
522
+ tops = {n.split("/", 1)[0] for n in names}
523
+ if not tops:
524
+ return 0.0
525
+ existing = sum(1 for t in tops if t and (project_dir / t).exists())
526
+ return existing / len(tops)
527
+
528
+
420
529
  def build_plan(
421
530
  zip_path: Path,
422
531
  project_dir: Path,
@@ -447,6 +556,9 @@ def build_plan(
447
556
  wrapper_note: str | None = None
448
557
  candidate = _detect_common_wrapper(raw_names)
449
558
  if candidate:
559
+ stripped_names = [n[len(candidate) + 1:] for n in raw_names]
560
+ candidate_is_real_dir = (project_dir / candidate).is_dir()
561
+
450
562
  if manifest_files:
451
563
  # Decide using the manifest — actual ground truth about what
452
564
  # paths this project has — rather than whether a directory of
@@ -465,7 +577,6 @@ def build_plan(
465
577
  # better *unstripped*, since those paths are already in the
466
578
  # manifest as-is.
467
579
  unstripped_rate = _match_rate(raw_names, manifest_files)
468
- stripped_names = [n[len(candidate) + 1:] for n in raw_names]
469
580
  stripped_rate = _match_rate(stripped_names, manifest_files)
470
581
  # Only strip when it clearly helps — meaningfully better match
471
582
  # against paths we know this project actually has, not just a
@@ -478,18 +589,53 @@ def build_plan(
478
589
  f"project manifest after stripping vs {unstripped_rate:.0%} "
479
590
  "before."
480
591
  )
481
- elif not (project_dir / candidate).is_dir():
592
+ elif stripped_rate == unstripped_rate and not candidate_is_real_dir:
593
+ # The manifest didn't help decide either way — most often
594
+ # because the manifest in use is scoped to a small subset
595
+ # of the project (e.g. a git-diff manifest from
596
+ # output/git-changes/, which only ever tracked the files
597
+ # touched in one change) rather than the whole tree, so a
598
+ # zip of brand-new files matches it at 0% regardless of
599
+ # stripping. Fall back to checking whether the paths line
600
+ # up with the project's real, current top-level layout
601
+ # instead of giving up and leaving the wrapper in place.
602
+ dir_unstripped_rate = _top_level_dir_match_rate(raw_names, project_dir)
603
+ dir_stripped_rate = _top_level_dir_match_rate(stripped_names, project_dir)
604
+ if dir_stripped_rate > dir_unstripped_rate and dir_stripped_rate >= 0.5:
605
+ strip_prefix = candidate
606
+ wrapper_note = (
607
+ f"Removed wrapping folder '{candidate}/' present in "
608
+ "every zip entry — the project manifest in use didn't "
609
+ "cover enough of these paths to tell either way "
610
+ "(likely scoped to a specific change rather than the "
611
+ "whole project), but "
612
+ f"{dir_stripped_rate:.0%} of the stripped paths' "
613
+ "top-level folders match this project's real layout "
614
+ f"vs {dir_unstripped_rate:.0%} before — double-check "
615
+ "the result before trusting it."
616
+ )
617
+ elif not candidate_is_real_dir:
482
618
  # No manifest to confirm against, so this is a softer call —
483
619
  # still strip (matches how `tar` and GitHub's own zip downloads
484
620
  # behave) as long as the project doesn't already have a real
485
621
  # folder of that name, but say so plainly since it's not
486
- # manifest-verified.
622
+ # manifest-verified. Where the on-disk layout backs it up, say
623
+ # that too rather than leaving the caveat maximally vague.
487
624
  strip_prefix = candidate
488
- wrapper_note = (
489
- f"Removed wrapping folder '{candidate}/' present in every zip "
490
- "entry (no manifest available to confirm — double-check the "
491
- "result before trusting it)."
492
- )
625
+ dir_stripped_rate = _top_level_dir_match_rate(stripped_names, project_dir)
626
+ if dir_stripped_rate >= 0.5:
627
+ wrapper_note = (
628
+ f"Removed wrapping folder '{candidate}/' present in every "
629
+ f"zip entry — {dir_stripped_rate:.0%} of the stripped "
630
+ "paths' top-level folders match this project's real "
631
+ "layout (no manifest available to confirm further)."
632
+ )
633
+ else:
634
+ wrapper_note = (
635
+ f"Removed wrapping folder '{candidate}/' present in every zip "
636
+ "entry (no manifest available to confirm — double-check the "
637
+ "result before trusting it)."
638
+ )
493
639
 
494
640
  extraction_dir = Path(tempfile.mkdtemp(prefix="contextzip-apply-"))
495
641
  _safe_extract(zip_path, extraction_dir, strip_prefix=strip_prefix)
@@ -538,16 +684,31 @@ def build_plan(
538
684
  known = sum(1 for e in entries if e.rel_path in manifest_files)
539
685
  rate = known / len(entries)
540
686
  if rate < 0.1:
541
- structure_warning = (
542
- f"Only {known} of {len(entries)} files in this zip match paths "
543
- "from the project manifest, even after checking for a wrapping "
544
- "folder. That usually means the zip's internal structure "
545
- "doesn't line up with this project — the wrong zip, or one "
546
- "built with an unexpected layout. Applying it as-is will "
547
- "likely create a pile of unrelated new files rather than "
548
- "update the ones you meant to change. Double-check the zip "
549
- "before proceeding."
550
- )
687
+ if strip_prefix and "top-level folders match" in (wrapper_note or ""):
688
+ # The wrapper was already resolved via the on-disk layout
689
+ # fallback above, so don't re-cast that as if the wrapper
690
+ # check itself came up empty — it didn't, the manifest just
691
+ # doesn't happen to cover these particular files.
692
+ structure_warning = (
693
+ f"Only {known} of {len(entries)} files in this zip match "
694
+ "paths from the project manifest. The wrapper folder was "
695
+ "still resolved using the project's real directory "
696
+ "layout (see above), but none of these exact files were "
697
+ "in the manifest, so this may be new work the manifest "
698
+ "predates rather than a mismatch — worth a quick look "
699
+ "before applying."
700
+ )
701
+ else:
702
+ structure_warning = (
703
+ f"Only {known} of {len(entries)} files in this zip match paths "
704
+ "from the project manifest, even after checking for a wrapping "
705
+ "folder. That usually means the zip's internal structure "
706
+ "doesn't line up with this project — the wrong zip, or one "
707
+ "built with an unexpected layout. Applying it as-is will "
708
+ "likely create a pile of unrelated new files rather than "
709
+ "update the ones you meant to change. Double-check the zip "
710
+ "before proceeding."
711
+ )
551
712
 
552
713
  return ApplyPlan(
553
714
  zip_path=zip_path,
@@ -570,29 +731,6 @@ def discard_plan(plan: ApplyPlan) -> None:
570
731
  # ---------------------------------------------------------------------------
571
732
 
572
733
 
573
- def _backup_entries(
574
- entries: list[ApplyEntry], project_dir: Path, workspace_root: Path
575
- ) -> Path | None:
576
- """
577
- Copy the current, pre-apply version of every existing file about to be
578
- touched into .contextzip/backups/<timestamp>/, preserving relative
579
- paths. Files that don't exist yet locally (new files) have nothing to
580
- back up. Returns the backup directory, or None if nothing needed one.
581
- """
582
- to_backup = [e for e in entries if (project_dir / e.rel_path).is_file()]
583
- if not to_backup:
584
- return None
585
-
586
- stamp = time.strftime("%Y%m%d-%H%M%S")
587
- backup_dir = workspace_root / _BACKUPS_DIRNAME / stamp
588
- for entry in to_backup:
589
- src = project_dir / entry.rel_path
590
- dst = backup_dir / entry.rel_path
591
- dst.parent.mkdir(parents=True, exist_ok=True)
592
- shutil.copy2(src, dst)
593
- return backup_dir
594
-
595
-
596
734
  def _move_zip_to_applied(
597
735
  zip_path: Path, project_dir: Path, workspace_root: Path, retain: int = 1
598
736
  ) -> Path:
@@ -639,9 +777,14 @@ def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyRe
639
777
  """
640
778
  Write every non-unchanged entry from *plan* into the project.
641
779
 
642
- Backs up whatever's about to be overwritten first. Assumes the caller
643
- has already decided to proceed — dry-run and confirmation prompts are
644
- handled upstream (see cli.py's `apply-zip` command).
780
+ Assumes the caller has already decided to proceed — dry-run and
781
+ confirmation prompts are handled upstream (see cli.py's `apply-zip`
782
+ command). Files are overwritten directly, with no backup taken first:
783
+ `build_plan`'s manifest diff and the confirmation prompt it drives
784
+ (see `ApplyPlan.is_risky`) are the safety net here, and the zip you
785
+ just applied is itself always recoverable — it's archived to
786
+ `.contextzip/inbox/applied/` (or left wherever you pointed at it)
787
+ rather than deleted.
645
788
 
646
789
  *retain* caps how many applied zips are kept in
647
790
  .contextzip/inbox/applied/ — typically a project's
@@ -650,8 +793,6 @@ def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyRe
650
793
  workspace_root = _workspace_dir(project_dir)
651
794
  to_write = plan.writable_entries
652
795
 
653
- backup_dir = _backup_entries(to_write, project_dir, workspace_root)
654
-
655
796
  written: list[str] = []
656
797
  for entry in to_write:
657
798
  dest = project_dir / entry.rel_path
@@ -664,6 +805,4 @@ def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyRe
664
805
  )
665
806
  shutil.rmtree(plan.extraction_dir, ignore_errors=True)
666
807
 
667
- return ApplyResult(
668
- written=written, backup_dir=backup_dir, applied_zip_path=applied_zip_path
669
- )
808
+ return ApplyResult(written=written, applied_zip_path=applied_zip_path)
@@ -2,21 +2,16 @@
2
2
  cleanup.py — Automatic workspace housekeeping for contextzip.
3
3
 
4
4
  .contextzip/ accumulates disposable artifacts over time: a zip+manifest+report
5
- set every run, a timestamped backup folder every risky `apply-zip`, and (until
6
- pruned) old archived applied-zips. None of it is precious — every zip is
7
- trivially reproducible by just re-running contextzip, and a backup only
8
- matters until the apply that made it is confirmed good. So cleanup here is
9
- deliberately brutal rather than cautious: it runs automatically after every
10
- successful command (see `_auto_cleanup` in cli.py), with no prompt and no
11
- separate command to remember, and keeps only the most recent
12
- `cleanup.keep_recent` item(s) in each category — everything else is deleted
13
- immediately, every time.
14
-
15
- The heavier caution used elsewhere in contextzip (backing up before overwrite
16
- in apply-zip, never inferring deletions) is reserved for the project's own
17
- files, where losing something can mean losing real work. Nothing here ever
18
- touches the project — only disposable metadata under .contextzip/ that costs
19
- nothing to regenerate.
5
+ set every run, and (until pruned) old archived applied-zips and loose apply
6
+ reports. None of it is precious — every zip is trivially reproducible by just
7
+ re-running contextzip. So cleanup here is deliberately brutal rather than
8
+ cautious: it runs automatically after every successful command (see
9
+ `_auto_cleanup` in cli.py), with no prompt and no separate command to
10
+ remember, and keeps only the most recent `cleanup.keep_recent` item(s) in
11
+ each category — everything else is deleted immediately, every time.
12
+
13
+ Nothing here ever touches the project — only disposable metadata under
14
+ .contextzip/ that costs nothing to regenerate.
20
15
 
21
16
  Never touched, under any settings:
22
17
  - .contextzip/config.json (project preferences)
@@ -28,11 +23,22 @@ Never touched, under any settings:
28
23
  Kept only-the-most-recent-N, everything older deleted immediately:
29
24
  - zip/manifest/report sets under .contextzip/output/<mode>/, per mode
30
25
  folder
31
- - timestamped folders under .contextzip/backups/
32
26
  - archived zips under .contextzip/inbox/applied/ (this is also governed
33
27
  by `applied_zip_retention`, already enforced on every apply-zip run —
34
28
  scanning it here too just catches the case where zips piled up before
35
29
  that setting existed, or the setting was since lowered)
30
+ - loose `*.apply-report.txt` files sitting directly in .contextzip/inbox/
31
+ (as opposed to inbox/applied/) — these are written next to wherever
32
+ apply-zip resolved its zip from, decoupled from the zip's own
33
+ lifecycle (a zip gets archived into applied/ or left where the user
34
+ put it; its report always lands in inbox/ itself), so nothing else
35
+ here would ever catch them piling up run after run
36
+
37
+ Removed outright, no matter how recent:
38
+ - .contextzip/backups/ in its entirety. apply-zip no longer creates
39
+ per-run backups there at all — this only ever catches a leftover
40
+ folder from a project that used an older contextzip version, and it's
41
+ dead weight the moment that version is gone.
36
42
 
37
43
  Scanning only ever touches .contextzip/ directory listings and file mtimes —
38
44
  no hashing, no reading file contents — so this stays fast even called on
@@ -47,7 +53,7 @@ from pathlib import Path
47
53
 
48
54
 
49
55
  _OUTPUT_DIRNAME = "output"
50
- _BACKUPS_DIRNAME = "backups"
56
+ _LEGACY_BACKUPS_DIRNAME = "backups"
51
57
  _INBOX_DIRNAME = "inbox"
52
58
  _APPLIED_DIRNAME = "applied"
53
59
 
@@ -60,7 +66,7 @@ _APPLIED_DIRNAME = "applied"
60
66
  @dataclass
61
67
  class CleanupItem:
62
68
  path: Path
63
- kind: str # "zip-set" | "backup" | "applied-zip"
69
+ kind: str # "zip-set" | "applied-zip" | "apply-report" | "legacy-backups"
64
70
  size_bytes: int
65
71
 
66
72
 
@@ -160,14 +166,14 @@ def scan(
160
166
  for f in _zip_set_files(zp):
161
167
  items.append(CleanupItem(path=f, kind="zip-set", size_bytes=_size_of(f)))
162
168
 
163
- # ── backups/<timestamp>/ ─────────────────────────────────────────────
164
- backups_root = workspace / _BACKUPS_DIRNAME
169
+ # ── backups/ — the feature that created these is gone entirely; wipe
170
+ # the whole folder outright rather than pruning it, so upgrading
171
+ # from an older contextzip version doesn't leave it behind forever ──
172
+ backups_root = workspace / _LEGACY_BACKUPS_DIRNAME
165
173
  if backups_root.is_dir():
166
- backups = _newest_first([p for p in backups_root.iterdir() if p.is_dir()])
167
- for backup_dir in backups[keep:]:
168
- items.append(
169
- CleanupItem(path=backup_dir, kind="backup", size_bytes=_size_of(backup_dir))
170
- )
174
+ items.append(
175
+ CleanupItem(path=backups_root, kind="legacy-backups", size_bytes=_size_of(backups_root))
176
+ )
171
177
 
172
178
  # ── inbox/applied/ ───────────────────────────────────────────────────
173
179
  applied_root = workspace / _INBOX_DIRNAME / _APPLIED_DIRNAME
@@ -177,6 +183,19 @@ def scan(
177
183
  for zp in applied_zips[retain:]:
178
184
  items.append(CleanupItem(path=zp, kind="applied-zip", size_bytes=_size_of(zp)))
179
185
 
186
+ # ── inbox/*.apply-report.txt ─────────────────────────────────────────
187
+ # Loose report files written directly in the inbox root (see
188
+ # report.py's write_apply_report) — decoupled from any single zip's
189
+ # own path, so nothing above ever catches these. Every apply-zip run
190
+ # writes one, so left alone they'd accumulate forever.
191
+ inbox_root = workspace / _INBOX_DIRNAME
192
+ if inbox_root.is_dir():
193
+ reports = _newest_first(
194
+ [p for p in inbox_root.glob("*.apply-report.txt") if p.is_file()]
195
+ )
196
+ for rp in reports[keep:]:
197
+ items.append(CleanupItem(path=rp, kind="apply-report", size_bytes=_size_of(rp)))
198
+
180
199
  return CleanupPlan(workspace=workspace, items=items)
181
200
 
182
201