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.
- {contextzip-0.4.3 → contextzip-0.4.4}/PKG-INFO +2 -2
- {contextzip-0.4.3 → contextzip-0.4.4}/README.md +1 -1
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/__init__.py +1 -1
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/api.py +1 -6
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/applier.py +193 -54
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cleanup.py +44 -25
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cli.py +6 -5
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cli_display.py +1 -3
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/packager.py +18 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/project_config.py +13 -12
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/report.py +17 -4
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/PKG-INFO +2 -2
- {contextzip-0.4.3 → contextzip-0.4.4}/pyproject.toml +1 -1
- {contextzip-0.4.3 → contextzip-0.4.4}/LICENSE +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/ai/selector.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cli_ai.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/clipboard.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/config.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/detector.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/error_parser.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/filters.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/git.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/redact.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/base.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/go.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/node.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/python.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/rules/rust.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/updater.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/watcher.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/assets.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/persist.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/server.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/SOURCES.txt +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.4.3 → contextzip-0.4.4}/contextzip.egg-info/top_level.txt +0 -0
- {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
|
+
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`,
|
|
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`,
|
|
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
|
|
|
@@ -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)
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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" | "
|
|
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
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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`
|
|
861
|
-
|
|
862
|
-
|
|
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
|
|
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,
|
|
114
|
-
|
|
115
|
-
every successful contextzip command, the
|
|
116
|
-
to only the most recent `keep_recent`
|
|
117
|
-
confirmation, no separate command needed.
|
|
118
|
-
brutal rather than cautious: every zip is
|
|
119
|
-
by re-running contextzip, so there's little
|
|
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/),
|
|
128
|
-
|
|
129
|
-
inbox
|
|
130
|
-
of each, delete everything
|
|
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)
|
|
144
|
-
|
|
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 =
|
|
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
|
+
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`,
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|