contextzip 0.4.3__tar.gz → 0.4.4__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.4}/PKG-INFO +2 -2
  2. {contextzip-0.4.3 → contextzip-0.4.4}/README.md +1 -1
  3. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/__init__.py +1 -1
  4. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/api.py +1 -6
  5. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/applier.py +193 -54
  6. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cleanup.py +44 -25
  7. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cli.py +6 -5
  8. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cli_display.py +1 -3
  9. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/packager.py +18 -0
  10. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/project_config.py +13 -12
  11. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/report.py +17 -4
  12. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/PKG-INFO +2 -2
  13. {contextzip-0.4.3 → contextzip-0.4.4}/pyproject.toml +1 -1
  14. {contextzip-0.4.3 → contextzip-0.4.4}/LICENSE +0 -0
  15. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/ai/__init__.py +0 -0
  16. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/ai/gemini.py +0 -0
  17. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/ai/heuristic.py +0 -0
  18. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/ai/selector.py +0 -0
  19. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cli_ai.py +0 -0
  20. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cli_onboard.py +0 -0
  21. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/clipboard.py +0 -0
  22. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/config.py +0 -0
  23. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/detector.py +0 -0
  24. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/error_parser.py +0 -0
  25. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/filters.py +0 -0
  26. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/git.py +0 -0
  27. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/redact.py +0 -0
  28. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/__init__.py +0 -0
  29. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/base.py +0 -0
  30. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/errors/__init__.py +0 -0
  31. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/errors/node.py +0 -0
  32. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/errors/python.py +0 -0
  33. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/go.py +0 -0
  34. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/node.py +0 -0
  35. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/python.py +0 -0
  36. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/ruby.py +0 -0
  37. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/rust.py +0 -0
  38. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/updater.py +0 -0
  39. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/watcher.py +0 -0
  40. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/__init__.py +0 -0
  41. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/assets.py +0 -0
  42. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/persist.py +0 -0
  43. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/server.py +0 -0
  44. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/suggestions.py +0 -0
  45. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/SOURCES.txt +0 -0
  46. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/dependency_links.txt +0 -0
  47. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/entry_points.txt +0 -0
  48. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/requires.txt +0 -0
  49. {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/top_level.txt +0 -0
  50. {contextzip-0.4.3 → contextzip-0.4.4}/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.4
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -87,7 +87,7 @@ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextz
87
87
  - [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
88
88
  - [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
89
89
  - [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
90
- - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
90
+ - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, manifest diffing, safety
91
91
  - [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
92
92
  - [CLI & Python API reference](https://contextzip.vercel.app/cli)
93
93
 
@@ -58,7 +58,7 @@ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextz
58
58
  - [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
59
59
  - [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
60
60
  - [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
61
- - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
61
+ - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, manifest diffing, safety
62
62
  - [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
63
63
  - [CLI & Python API reference](https://contextzip.vercel.app/cli)
64
64
 
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.4.3"
3
+ __version__ = "0.4.4"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -354,8 +354,6 @@ def apply_zip(
354
354
  classify every file as new, modified, unchanged, or one with no safe
355
355
  baseline (locally edited since zipping, or never part of the original
356
356
  manifest). Only adds and modifies files — nothing is ever deleted.
357
- Every overwritten file is backed up first, under
358
- ``.contextzip/backups/<timestamp>/``.
359
357
 
360
358
  Unlike the CLI, this applies immediately and does not prompt — check
361
359
  ``contextzip.applier.build_plan()`` yourself first if you want to
@@ -375,8 +373,7 @@ def apply_zip(
375
373
  Returns
376
374
  -------
377
375
  ApplyResult
378
- ``written`` (list of relative paths written), ``backup_dir``
379
- (``Path`` or ``None`` if nothing needed backing up), and
376
+ ``written`` (list of relative paths written) and
380
377
  ``applied_zip_path`` (where the consumed zip ended up).
381
378
 
382
379
  Raises
@@ -393,8 +390,6 @@ def apply_zip(
393
390
 
394
391
  result = apply_zip() # picks up .contextzip/inbox/*.zip
395
392
  print(f"Wrote {len(result.written)} files")
396
- if result.backup_dir:
397
- print(f"Backup at {result.backup_dir}")
398
393
  """
399
394
  from contextzip.applier import (
400
395
  ApplyError,
@@ -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
 
@@ -382,8 +382,7 @@ def cmd_apply_zip(
382
382
  every file as new, modified, unchanged, or one needing a closer look
383
383
  (edited locally since zipping, or with no baseline at all) before
384
384
  writing anything. Only adds and modifies files — nothing is ever
385
- deleted. Every overwritten file is backed up first, under
386
- .contextzip/backups/<timestamp>/.
385
+ deleted.
387
386
  """
388
387
  project_dir = Path(os.getcwd()).resolve()
389
388
  project_cfg = load_project_config(project_dir)
@@ -857,9 +856,11 @@ def _auto_cleanup(project_dir: Path, project_cfg) -> None:
857
856
  to remember. Every zip is trivially reproducible by re-running
858
857
  contextzip, so this is deliberately brutal rather than cautious: it
859
858
  keeps only the `cleanup.keep_recent` most recent zip/manifest/report
860
- set per mode folder, the most recent `cleanup.keep_recent` backup
861
- folder(s), and the most recent `cleanup.keep_recent` archived
862
- applied-zip(s) — everything else is deleted immediately, every run.
859
+ set per mode folder, the most recent `cleanup.keep_recent` archived
860
+ applied-zip(s), and the most recent `cleanup.keep_recent` loose
861
+ apply-report(s) in inbox/ — everything else is deleted immediately,
862
+ every run. Also wipes .contextzip/backups/ outright if a leftover one
863
+ still exists from an older contextzip version.
863
864
 
864
865
  Gated on `cleanup.enabled` (default True) — set to False in
865
866
  .contextzip/config.json to turn this off entirely.
@@ -380,10 +380,8 @@ def print_apply_plan(
380
380
 
381
381
 
382
382
  def print_apply_result(result, *, con: Console = console) -> None:
383
- """Two or three lines: what was written, backed up, and archived."""
383
+ """Two lines: what was written and archived."""
384
384
  ok(f"Applied {len(result.written)} files", con=con)
385
- if result.backup_dir:
386
- ok("Backed up to", str(result.backup_dir), con=con)
387
385
  ok("Archived zip to", str(result.applied_zip_path), con=con)
388
386
 
389
387
 
@@ -663,6 +663,15 @@ def _workspace_output_path_silent(
663
663
  except OSError:
664
664
  return Path(tempfile.gettempdir()) / filename
665
665
 
666
+ # Lazy import: applier.py resolves the workspace dir via this module,
667
+ # so importing it at module load time would be circular.
668
+ from contextzip.applier import ensure_inbox_scaffolding
669
+
670
+ try:
671
+ ensure_inbox_scaffolding(workspace)
672
+ except OSError:
673
+ pass
674
+
666
675
  if is_git_repo:
667
676
  try:
668
677
  _ensure_workspace_gitignore(workspace)
@@ -704,6 +713,15 @@ def _workspace_output_path(
704
713
  )
705
714
  return Path(tempfile.gettempdir()) / filename
706
715
 
716
+ # Lazy import: applier.py resolves the workspace dir via this module,
717
+ # so importing it at module load time would be circular.
718
+ from contextzip.applier import ensure_inbox_scaffolding
719
+
720
+ try:
721
+ ensure_inbox_scaffolding(workspace)
722
+ except OSError:
723
+ pass
724
+
707
725
  # Handle .gitignore only when we're inside a git repo and the workspace
708
726
  # actually lives inside it (a custom absolute path outside the repo has
709
727
  # nothing for .gitignore to protect)
@@ -110,24 +110,25 @@ built-in default for the newer preference fields.
110
110
 
111
111
  cleanup
112
112
  Automatic housekeeping for `.contextzip/`, which otherwise only
113
- grows over time (a zip+manifest+report set per run, a timestamped
114
- folder under backups/ per risky apply-zip). See cleanup.py — after
115
- every successful contextzip command, the workspace is pruned down
116
- to only the most recent `keep_recent` item(s) per category, no
117
- confirmation, no separate command needed. This is deliberately
118
- brutal rather than cautious: every zip is trivially reproducible
119
- by re-running contextzip, so there's little reason to let old ones
120
- pile up.
113
+ grows over time (a zip+manifest+report set per run, archived zips
114
+ under inbox/applied/, loose apply-report.txt files in inbox/). See
115
+ cleanup.py — after every successful contextzip command, the
116
+ workspace is pruned down to only the most recent `keep_recent`
117
+ item(s) per category, no confirmation, no separate command needed.
118
+ This is deliberately brutal rather than cautious: every zip is
119
+ trivially reproducible by re-running contextzip, so there's little
120
+ reason to let old ones pile up.
121
121
  enabled — if true (default), auto-cleanup runs after every
122
122
  successful command. Set to false to keep
123
123
  everything contextzip has ever generated.
124
124
  keep_recent — how many most-recent item(s) are kept per
125
125
  category before the rest are deleted: zip+manifest
126
126
  +report sets per mode folder (output/codebase/,
127
- output/git-changes/, output/prompt/), timestamped
128
- folders under backups/, and archived zips under
129
- inbox/applied/. Default 1 — keep only the latest
130
- of each, delete everything older immediately.
127
+ output/git-changes/, output/prompt/), archived
128
+ zips under inbox/applied/, and loose
129
+ apply-report.txt files in inbox/. Default 1 —
130
+ keep only the latest of each, delete everything
131
+ older immediately.
131
132
 
132
133
  The schema is intentionally a flat, easily-extended dict so future
133
134
  preferences can be added without another migration.
@@ -16,6 +16,7 @@ from pathlib import Path
16
16
 
17
17
  from contextzip.filters import summarise_exclusions
18
18
  from contextzip.cli_display import human_size
19
+ from contextzip.applier import inbox_dir
19
20
 
20
21
 
21
22
  def report_path_for(zip_path: Path) -> Path:
@@ -140,8 +141,21 @@ def write_apply_report(
140
141
  result=None,
141
142
  ) -> Path | None:
142
143
  """
143
- Write the full apply-zip report (every file + status) next to the
144
- zip that was applied. Returns the report path, or None on failure.
144
+ Write the full apply-zip report (every file + status) into
145
+ .contextzip/inbox/ — always there, regardless of where the zip that
146
+ was applied actually lives. Returns the report path, or None on
147
+ failure.
148
+
149
+ `apply-zip <path>` accepts an explicit path anywhere on disk (not
150
+ just the inbox), and that path is meant to be left alone afterwards
151
+ rather than moved — see `archive_applied_zip`. The report used to
152
+ follow the same "leave it where the zip is" rule, which meant
153
+ pointing apply-zip at a zip sitting in the project root (or anywhere
154
+ else outside .contextzip/inbox/) dropped a stray
155
+ `<name>.apply-report.txt` right there, cluttering the repo as an
156
+ untracked file. The report itself doesn't need to travel with the
157
+ zip the way an archived copy would, so it always goes to the one
158
+ place these reports are expected to live.
145
159
  """
146
160
  lines: list[str] = []
147
161
  w = lines.append
@@ -161,11 +175,10 @@ def write_apply_report(
161
175
  if result is not None:
162
176
  w("Result")
163
177
  w(f" written : {len(result.written)}")
164
- w(f" backup : {result.backup_dir or 'none needed'}")
165
178
  w(f" archived: {result.applied_zip_path}")
166
179
  w("")
167
180
 
168
- report_path = plan.zip_path.with_name(plan.zip_path.stem + ".apply-report.txt")
181
+ report_path = inbox_dir(project_dir) / (plan.zip_path.stem + ".apply-report.txt")
169
182
  try:
170
183
  report_path.parent.mkdir(parents=True, exist_ok=True)
171
184
  report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -87,7 +87,7 @@ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextz
87
87
  - [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
88
88
  - [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
89
89
  - [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
90
- - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
90
+ - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, manifest diffing, safety
91
91
  - [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
92
92
  - [CLI & Python API reference](https://contextzip.vercel.app/cli)
93
93
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.4.3"
7
+ version = "0.4.4"
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