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.
- {contextzip-0.4.3 → contextzip-0.4.5}/PKG-INFO +7 -3
- {contextzip-0.4.3 → contextzip-0.4.5}/README.md +6 -2
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/__init__.py +1 -1
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/api.py +53 -8
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/applier.py +193 -54
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cleanup.py +44 -25
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cli.py +23 -16
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cli_ai.py +1 -28
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cli_display.py +5 -3
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/error_parser.py +12 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/filters.py +116 -8
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/packager.py +18 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/project_config.py +13 -12
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/report.py +17 -4
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/watcher.py +8 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/PKG-INFO +7 -3
- {contextzip-0.4.3 → contextzip-0.4.5}/pyproject.toml +1 -1
- {contextzip-0.4.3 → contextzip-0.4.5}/LICENSE +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/ai/selector.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/clipboard.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/config.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/detector.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/git.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/redact.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/base.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/go.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/node.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/python.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/rules/rust.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/updater.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/assets.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/persist.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/server.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/SOURCES.txt +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.4.3 → contextzip-0.4.5}/contextzip.egg-info/top_level.txt +0 -0
- {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
|
+
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
|
-
|
|
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`,
|
|
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
|
-
|
|
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`,
|
|
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
|
|
|
@@ -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=
|
|
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)
|
|
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
|
-
"
|
|
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
|
|