contextzip 0.3.9__tar.gz → 0.4.1__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.1/PKG-INFO +98 -0
- contextzip-0.4.1/README.md +69 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/__init__.py +1 -1
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/ai/selector.py +33 -11
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/applier.py +30 -14
- contextzip-0.4.1/contextzip/cleanup.py +207 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/cli.py +179 -227
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/cli_ai.py +11 -35
- contextzip-0.4.1/contextzip/cli_display.py +409 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/cli_onboard.py +13 -35
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/filters.py +10 -2
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/packager.py +77 -16
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/project_config.py +196 -9
- contextzip-0.4.1/contextzip/report.py +167 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/watcher.py +30 -65
- contextzip-0.4.1/contextzip/webui/assets.py +1185 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/webui/persist.py +32 -4
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/webui/server.py +127 -24
- contextzip-0.4.1/contextzip.egg-info/PKG-INFO +98 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/SOURCES.txt +2 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/pyproject.toml +1 -1
- contextzip-0.3.9/PKG-INFO +0 -350
- contextzip-0.3.9/README.md +0 -321
- contextzip-0.3.9/contextzip/cli_display.py +0 -443
- contextzip-0.3.9/contextzip/webui/assets.py +0 -1012
- contextzip-0.3.9/contextzip.egg-info/PKG-INFO +0 -350
- {contextzip-0.3.9 → contextzip-0.4.1}/LICENSE +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/api.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/clipboard.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/config.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/detector.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/error_parser.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/git.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/base.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/go.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/node.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/python.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/rust.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.3.9 → contextzip-0.4.1}/setup.cfg +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contextzip
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Intelligently package your codebase for AI tools
|
|
5
|
+
Author-email: Deepesh <akadeepesh@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/akadeepesh/contextzip
|
|
8
|
+
Project-URL: Repository, https://github.com/akadeepesh/contextzip
|
|
9
|
+
Project-URL: Issues, https://github.com/akadeepesh/contextzip/issues
|
|
10
|
+
Keywords: ai,cli,developer-tools,context,zip,llm,claude,chatgpt,code-packaging
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: click>=8.1
|
|
25
|
+
Requires-Dist: rich>=13.0
|
|
26
|
+
Requires-Dist: pathspec>=0.12
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# contextzip
|
|
31
|
+
|
|
32
|
+
> Package exactly the right parts of your codebase and paste it into any AI tool — in one command.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install contextzip
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**📖 Full docs, every flag, every config option: [contextzip.vercel.app](https://contextzip.vercel.app)**
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Why contextzip
|
|
43
|
+
|
|
44
|
+
Every AI session starts the same way: hunt down the relevant files, skip `node_modules` and build artifacts by hand, zip it, find the zip, upload it — then do it all again next session.
|
|
45
|
+
|
|
46
|
+
contextzip eliminates that. Run it from your project root — it detects your stack, applies smart exclusions, and produces a lean ZIP ready to paste into Claude, ChatGPT, or any AI tool. When the AI hands changes back, `contextzip apply-zip` writes them into your project safely.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
cd ~/projects/my-app
|
|
52
|
+
contextzip
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That's it — no flags required. contextzip detects your framework, excludes the noise, and writes a ZIP to `.contextzip/`.
|
|
56
|
+
|
|
57
|
+
## The essentials
|
|
58
|
+
|
|
59
|
+
| Command | What it does |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `contextzip` | Detect stack, exclude noise, package the project |
|
|
62
|
+
| `contextzip --prompt "task"` | Let Gemini pick only the files relevant to a task |
|
|
63
|
+
| `contextzip --git-changes` | Package only modified/staged/untracked files |
|
|
64
|
+
| `contextzip apply-zip` | Write an AI-returned ZIP back into your project, safely |
|
|
65
|
+
| `contextzip watch -- npm run dev` | Auto-package debug context the moment an error appears |
|
|
66
|
+
| `contextzip config --ui` | Set include/exclude rules visually in a local browser tab |
|
|
67
|
+
|
|
68
|
+
Every command supports `--dry-run`, `--verbose`, `--include`, `--exclude`, and more — see the [full CLI reference](https://contextzip.vercel.app/cli).
|
|
69
|
+
|
|
70
|
+
contextzip respects your `.gitignore`, detects Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby (including in monorepos), and never packages secrets — SSH keys, cloud credentials, and Terraform state are always excluded. Details: [contextzip.vercel.app/features](https://contextzip.vercel.app/features).
|
|
71
|
+
|
|
72
|
+
## Also a Python library
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from contextzip import get_git_changes, create_zip
|
|
76
|
+
|
|
77
|
+
collection = get_git_changes()
|
|
78
|
+
pkg = create_zip(collection, output="/tmp/changes.zip")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Full function reference: [contextzip.vercel.app/cli#python-api](https://contextzip.vercel.app/cli#python-api)
|
|
82
|
+
|
|
83
|
+
## Learn more
|
|
84
|
+
|
|
85
|
+
- [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
|
|
86
|
+
- [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
|
|
87
|
+
- [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
|
|
88
|
+
- [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
|
|
89
|
+
- [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
|
|
90
|
+
- [CLI & Python API reference](https://contextzip.vercel.app/cli)
|
|
91
|
+
|
|
92
|
+
## Contributing
|
|
93
|
+
|
|
94
|
+
Contributions are welcome — especially new framework rule sets and edge-case fixes. See [CONTRIBUTING.md](https://github.com/akadeepesh/contextzip/blob/main/CONTRIBUTING.md). Please open an issue before starting a large PR.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT — see [LICENSE](https://github.com/akadeepesh/contextzip/blob/main/LICENSE) for details.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# contextzip
|
|
2
|
+
|
|
3
|
+
> Package exactly the right parts of your codebase and paste it into any AI tool — in one command.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install contextzip
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**📖 Full docs, every flag, every config option: [contextzip.vercel.app](https://contextzip.vercel.app)**
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Why contextzip
|
|
14
|
+
|
|
15
|
+
Every AI session starts the same way: hunt down the relevant files, skip `node_modules` and build artifacts by hand, zip it, find the zip, upload it — then do it all again next session.
|
|
16
|
+
|
|
17
|
+
contextzip eliminates that. Run it from your project root — it detects your stack, applies smart exclusions, and produces a lean ZIP ready to paste into Claude, ChatGPT, or any AI tool. When the AI hands changes back, `contextzip apply-zip` writes them into your project safely.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd ~/projects/my-app
|
|
23
|
+
contextzip
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That's it — no flags required. contextzip detects your framework, excludes the noise, and writes a ZIP to `.contextzip/`.
|
|
27
|
+
|
|
28
|
+
## The essentials
|
|
29
|
+
|
|
30
|
+
| Command | What it does |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `contextzip` | Detect stack, exclude noise, package the project |
|
|
33
|
+
| `contextzip --prompt "task"` | Let Gemini pick only the files relevant to a task |
|
|
34
|
+
| `contextzip --git-changes` | Package only modified/staged/untracked files |
|
|
35
|
+
| `contextzip apply-zip` | Write an AI-returned ZIP back into your project, safely |
|
|
36
|
+
| `contextzip watch -- npm run dev` | Auto-package debug context the moment an error appears |
|
|
37
|
+
| `contextzip config --ui` | Set include/exclude rules visually in a local browser tab |
|
|
38
|
+
|
|
39
|
+
Every command supports `--dry-run`, `--verbose`, `--include`, `--exclude`, and more — see the [full CLI reference](https://contextzip.vercel.app/cli).
|
|
40
|
+
|
|
41
|
+
contextzip respects your `.gitignore`, detects Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby (including in monorepos), and never packages secrets — SSH keys, cloud credentials, and Terraform state are always excluded. Details: [contextzip.vercel.app/features](https://contextzip.vercel.app/features).
|
|
42
|
+
|
|
43
|
+
## Also a Python library
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from contextzip import get_git_changes, create_zip
|
|
47
|
+
|
|
48
|
+
collection = get_git_changes()
|
|
49
|
+
pkg = create_zip(collection, output="/tmp/changes.zip")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Full function reference: [contextzip.vercel.app/cli#python-api](https://contextzip.vercel.app/cli#python-api)
|
|
53
|
+
|
|
54
|
+
## Learn more
|
|
55
|
+
|
|
56
|
+
- [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
|
|
57
|
+
- [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
|
|
58
|
+
- [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
|
|
59
|
+
- [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
|
|
60
|
+
- [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
|
|
61
|
+
- [CLI & Python API reference](https://contextzip.vercel.app/cli)
|
|
62
|
+
|
|
63
|
+
## Contributing
|
|
64
|
+
|
|
65
|
+
Contributions are welcome — especially new framework rule sets and edge-case fixes. See [CONTRIBUTING.md](https://github.com/akadeepesh/contextzip/blob/main/CONTRIBUTING.md). Please open an issue before starting a large PR.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT — see [LICENSE](https://github.com/akadeepesh/contextzip/blob/main/LICENSE) for details.
|
|
@@ -33,6 +33,7 @@ def ai_select(
|
|
|
33
33
|
ecosystem: str,
|
|
34
34
|
api_key: str,
|
|
35
35
|
max_files: int | None = None,
|
|
36
|
+
prompt_template: str = "",
|
|
36
37
|
) -> tuple[list[Path], str, str]:
|
|
37
38
|
"""
|
|
38
39
|
Select the minimum relevant files for *prompt* from *resolved.included*.
|
|
@@ -41,6 +42,10 @@ def ai_select(
|
|
|
41
42
|
project's `ai.max_files` preference (.contextzip/config.json). None
|
|
42
43
|
falls back to each backend's own built-in default cap.
|
|
43
44
|
|
|
45
|
+
*prompt_template*, if non-empty, is a project's `ai.prompt_template`
|
|
46
|
+
preference — house conventions prepended to the generated prompt.txt
|
|
47
|
+
ahead of the task description.
|
|
48
|
+
|
|
44
49
|
Returns
|
|
45
50
|
-------
|
|
46
51
|
(selected_paths, prompt_txt, method)
|
|
@@ -78,14 +83,20 @@ def ai_select(
|
|
|
78
83
|
}
|
|
79
84
|
selected_paths = [rel_to_abs[rel] for rel in selected_rel if rel in rel_to_abs]
|
|
80
85
|
|
|
81
|
-
prompt_txt = _build_prompt_txt(
|
|
86
|
+
prompt_txt = _build_prompt_txt(
|
|
87
|
+
prompt, selected_rel, ecosystem, method, prompt_template=prompt_template
|
|
88
|
+
)
|
|
82
89
|
|
|
83
90
|
return selected_paths, prompt_txt, method
|
|
84
91
|
|
|
85
92
|
|
|
86
|
-
def build_prompt_only_txt(
|
|
93
|
+
def build_prompt_only_txt(
|
|
94
|
+
prompt: str, ecosystem: str, prompt_template: str = ""
|
|
95
|
+
) -> str:
|
|
87
96
|
"""Build a minimal prompt.txt when no AI selection was performed."""
|
|
88
|
-
return _build_prompt_txt(
|
|
97
|
+
return _build_prompt_txt(
|
|
98
|
+
prompt, [], ecosystem, USED_GEMINI, prompt_template=prompt_template
|
|
99
|
+
)
|
|
89
100
|
|
|
90
101
|
|
|
91
102
|
# ---------------------------------------------------------------------------
|
|
@@ -125,12 +136,14 @@ def _build_prompt_txt(
|
|
|
125
136
|
selected_rel: list[str],
|
|
126
137
|
ecosystem: str,
|
|
127
138
|
method: str,
|
|
139
|
+
prompt_template: str = "",
|
|
128
140
|
) -> str:
|
|
129
141
|
"""
|
|
130
142
|
Build the prompt.txt to include inside the ZIP.
|
|
131
143
|
|
|
132
|
-
Any AI tool that receives the ZIP immediately sees the
|
|
133
|
-
|
|
144
|
+
Any AI tool that receives the ZIP immediately sees the project's own
|
|
145
|
+
conventions (if any are configured), the task description, the
|
|
146
|
+
framework, and exactly which files were selected and why.
|
|
134
147
|
"""
|
|
135
148
|
|
|
136
149
|
selector_label = (
|
|
@@ -139,12 +152,21 @@ def _build_prompt_txt(
|
|
|
139
152
|
else "contextzip (keyword heuristic — Gemini was rate limited)"
|
|
140
153
|
)
|
|
141
154
|
|
|
142
|
-
lines: list[str] = [
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
lines: list[str] = []
|
|
156
|
+
|
|
157
|
+
if prompt_template:
|
|
158
|
+
lines.append("Project conventions:")
|
|
159
|
+
lines.append(prompt_template)
|
|
160
|
+
lines.append("")
|
|
161
|
+
|
|
162
|
+
lines.extend(
|
|
163
|
+
[
|
|
164
|
+
f"Task: {prompt}",
|
|
165
|
+
"",
|
|
166
|
+
f"Framework: {ecosystem}",
|
|
167
|
+
"",
|
|
168
|
+
]
|
|
169
|
+
)
|
|
148
170
|
|
|
149
171
|
if selected_rel:
|
|
150
172
|
lines.append(f"Files selected by {selector_label}:")
|
|
@@ -291,10 +291,12 @@ def find_latest_manifest(
|
|
|
291
291
|
Resolve which manifest to diff against.
|
|
292
292
|
|
|
293
293
|
An explicit path always wins (if it exists). Otherwise picks the most
|
|
294
|
-
recently modified *.manifest.json under .contextzip/output/
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
294
|
+
recently modified *.manifest.json anywhere under .contextzip/output/
|
|
295
|
+
— including its per-mode subfolders (output/codebase/, output/git-changes/,
|
|
296
|
+
output/prompt/) — since in the normal one-round-trip-at-a-time flow,
|
|
297
|
+
that's the zip you most recently generated regardless of which mode
|
|
298
|
+
produced it, which is almost always the right baseline. Returns None
|
|
299
|
+
if nothing is found (apply-zip still works, just more conservatively).
|
|
298
300
|
"""
|
|
299
301
|
if explicit_manifest is not None:
|
|
300
302
|
path = Path(explicit_manifest).expanduser().resolve()
|
|
@@ -304,7 +306,7 @@ def find_latest_manifest(
|
|
|
304
306
|
if not out_dir.is_dir():
|
|
305
307
|
return None
|
|
306
308
|
|
|
307
|
-
candidates = [p for p in out_dir.
|
|
309
|
+
candidates = [p for p in out_dir.rglob(f"*{_MANIFEST_SUFFIX}") if p.is_file()]
|
|
308
310
|
if not candidates:
|
|
309
311
|
return None
|
|
310
312
|
return max(candidates, key=lambda p: p.stat().st_mtime)
|
|
@@ -573,18 +575,21 @@ def _backup_entries(
|
|
|
573
575
|
return backup_dir
|
|
574
576
|
|
|
575
577
|
|
|
576
|
-
def _move_zip_to_applied(
|
|
578
|
+
def _move_zip_to_applied(
|
|
579
|
+
zip_path: Path, project_dir: Path, workspace_root: Path, retain: int = 1
|
|
580
|
+
) -> Path:
|
|
577
581
|
"""
|
|
578
582
|
Move a consumed inbox zip into .contextzip/inbox/applied/, timestamped,
|
|
579
583
|
so it can't be accidentally re-applied. Zips passed by an explicit path
|
|
580
584
|
outside the inbox are left where the user put them rather than being
|
|
581
585
|
moved unexpectedly.
|
|
582
586
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
587
|
+
Keeps at most *retain* applied zips (default 1 — only the most recent).
|
|
588
|
+
Filenames are timestamp-prefixed, so a lexicographic sort is also a
|
|
589
|
+
chronological one; the oldest are pruned first, before this run's zip
|
|
590
|
+
is moved in, so the folder never holds more than *retain* at once.
|
|
591
|
+
Typically a project's `applied_zip_retention` preference
|
|
592
|
+
(.contextzip/config.json) — raise it to keep a longer audit trail.
|
|
588
593
|
"""
|
|
589
594
|
if zip_path.parent.resolve() != inbox_dir(project_dir).resolve():
|
|
590
595
|
return zip_path
|
|
@@ -592,7 +597,12 @@ def _move_zip_to_applied(zip_path: Path, project_dir: Path, workspace_root: Path
|
|
|
592
597
|
applied_dir = workspace_root / _INBOX_DIRNAME / _APPLIED_DIRNAME
|
|
593
598
|
applied_dir.mkdir(parents=True, exist_ok=True)
|
|
594
599
|
|
|
595
|
-
|
|
600
|
+
retain = max(1, retain)
|
|
601
|
+
existing = sorted(applied_dir.glob("*.zip"))
|
|
602
|
+
# Keep room for the zip we're about to add: prune down to retain - 1
|
|
603
|
+
# existing entries before moving the new one in.
|
|
604
|
+
overflow = len(existing) - (retain - 1)
|
|
605
|
+
for old in existing[: max(0, overflow)]:
|
|
596
606
|
try:
|
|
597
607
|
old.unlink()
|
|
598
608
|
except OSError:
|
|
@@ -607,13 +617,17 @@ def _move_zip_to_applied(zip_path: Path, project_dir: Path, workspace_root: Path
|
|
|
607
617
|
return dest
|
|
608
618
|
|
|
609
619
|
|
|
610
|
-
def execute_plan(plan: ApplyPlan, project_dir: Path) -> ApplyResult:
|
|
620
|
+
def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyResult:
|
|
611
621
|
"""
|
|
612
622
|
Write every non-unchanged entry from *plan* into the project.
|
|
613
623
|
|
|
614
624
|
Backs up whatever's about to be overwritten first. Assumes the caller
|
|
615
625
|
has already decided to proceed — dry-run and confirmation prompts are
|
|
616
626
|
handled upstream (see cli.py's `apply-zip` command).
|
|
627
|
+
|
|
628
|
+
*retain* caps how many applied zips are kept in
|
|
629
|
+
.contextzip/inbox/applied/ — typically a project's
|
|
630
|
+
`applied_zip_retention` preference (.contextzip/config.json).
|
|
617
631
|
"""
|
|
618
632
|
workspace_root = _workspace_dir(project_dir)
|
|
619
633
|
to_write = plan.writable_entries
|
|
@@ -627,7 +641,9 @@ def execute_plan(plan: ApplyPlan, project_dir: Path) -> ApplyResult:
|
|
|
627
641
|
shutil.copy2(entry.extracted_path, dest)
|
|
628
642
|
written.append(entry.rel_path)
|
|
629
643
|
|
|
630
|
-
applied_zip_path = _move_zip_to_applied(
|
|
644
|
+
applied_zip_path = _move_zip_to_applied(
|
|
645
|
+
plan.zip_path, project_dir, workspace_root, retain=retain
|
|
646
|
+
)
|
|
631
647
|
shutil.rmtree(plan.extraction_dir, ignore_errors=True)
|
|
632
648
|
|
|
633
649
|
return ApplyResult(
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cleanup.py — Automatic workspace housekeeping for contextzip.
|
|
3
|
+
|
|
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.
|
|
20
|
+
|
|
21
|
+
Never touched, under any settings:
|
|
22
|
+
- .contextzip/config.json (project preferences)
|
|
23
|
+
- .contextzip/inbox/*.zip (zips waiting to be applied — never assumed
|
|
24
|
+
stale just because they've been sitting there; only inbox/applied/,
|
|
25
|
+
the archive of zips already consumed, is ever pruned)
|
|
26
|
+
- anything outside .contextzip/
|
|
27
|
+
|
|
28
|
+
Kept only-the-most-recent-N, everything older deleted immediately:
|
|
29
|
+
- zip/manifest/report sets under .contextzip/output/<mode>/, per mode
|
|
30
|
+
folder
|
|
31
|
+
- timestamped folders under .contextzip/backups/
|
|
32
|
+
- archived zips under .contextzip/inbox/applied/ (this is also governed
|
|
33
|
+
by `applied_zip_retention`, already enforced on every apply-zip run —
|
|
34
|
+
scanning it here too just catches the case where zips piled up before
|
|
35
|
+
that setting existed, or the setting was since lowered)
|
|
36
|
+
|
|
37
|
+
Scanning only ever touches .contextzip/ directory listings and file mtimes —
|
|
38
|
+
no hashing, no reading file contents — so this stays fast even called on
|
|
39
|
+
every command, on a large project.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import shutil
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_OUTPUT_DIRNAME = "output"
|
|
50
|
+
_BACKUPS_DIRNAME = "backups"
|
|
51
|
+
_INBOX_DIRNAME = "inbox"
|
|
52
|
+
_APPLIED_DIRNAME = "applied"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Data model
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class CleanupItem:
|
|
62
|
+
path: Path
|
|
63
|
+
kind: str # "zip-set" | "backup" | "applied-zip"
|
|
64
|
+
size_bytes: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class CleanupPlan:
|
|
69
|
+
workspace: Path
|
|
70
|
+
items: list[CleanupItem] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def total_bytes(self) -> int:
|
|
74
|
+
return sum(i.size_bytes for i in self.items)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_empty(self) -> bool:
|
|
78
|
+
return not self.items
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class CleanupResult:
|
|
83
|
+
removed: list[Path] = field(default_factory=list)
|
|
84
|
+
failed: list[tuple[Path, str]] = field(default_factory=list)
|
|
85
|
+
freed_bytes: int = 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Helpers
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _size_of(path: Path) -> int:
|
|
94
|
+
"""Size of a file, or total size of everything under a directory."""
|
|
95
|
+
if path.is_file():
|
|
96
|
+
try:
|
|
97
|
+
return path.stat().st_size
|
|
98
|
+
except OSError:
|
|
99
|
+
return 0
|
|
100
|
+
if not path.is_dir():
|
|
101
|
+
return 0
|
|
102
|
+
total = 0
|
|
103
|
+
for p in path.rglob("*"):
|
|
104
|
+
if p.is_file():
|
|
105
|
+
try:
|
|
106
|
+
total += p.stat().st_size
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
return total
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _zip_set_files(zip_path: Path) -> list[Path]:
|
|
113
|
+
"""The zip and any sidecar manifest/report files that sit next to it."""
|
|
114
|
+
files = [zip_path]
|
|
115
|
+
for suffix in (".manifest.json", ".report.txt", ".apply-report.txt"):
|
|
116
|
+
sidecar = zip_path.with_name(zip_path.stem + suffix)
|
|
117
|
+
if sidecar.is_file():
|
|
118
|
+
files.append(sidecar)
|
|
119
|
+
return files
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _newest_first(paths: list[Path]) -> list[Path]:
|
|
123
|
+
return sorted(paths, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Scan
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def scan(
|
|
132
|
+
project_dir: Path,
|
|
133
|
+
*,
|
|
134
|
+
keep_recent: int = 1,
|
|
135
|
+
applied_zip_retention: int = 1,
|
|
136
|
+
) -> CleanupPlan:
|
|
137
|
+
"""
|
|
138
|
+
Build a plan of everything beyond the most recent `keep_recent` item(s)
|
|
139
|
+
per category. Read-only — never deletes anything itself; pass the
|
|
140
|
+
result to `execute()` to actually do so.
|
|
141
|
+
"""
|
|
142
|
+
from contextzip.packager import _workspace_dir
|
|
143
|
+
|
|
144
|
+
workspace, _is_git_repo = _workspace_dir(project_dir)
|
|
145
|
+
items: list[CleanupItem] = []
|
|
146
|
+
keep = max(0, keep_recent)
|
|
147
|
+
|
|
148
|
+
# ── output/ — per-mode subfolders, plus any flat legacy files left
|
|
149
|
+
# behind by a contextzip version predating the per-mode split ──────
|
|
150
|
+
output_root = workspace / _OUTPUT_DIRNAME
|
|
151
|
+
if output_root.is_dir():
|
|
152
|
+
for mode_dir in sorted(p for p in output_root.iterdir() if p.is_dir()):
|
|
153
|
+
zips = _newest_first([p for p in mode_dir.glob("*.zip") if p.is_file()])
|
|
154
|
+
for zp in zips[keep:]:
|
|
155
|
+
for f in _zip_set_files(zp):
|
|
156
|
+
items.append(CleanupItem(path=f, kind="zip-set", size_bytes=_size_of(f)))
|
|
157
|
+
|
|
158
|
+
legacy_zips = _newest_first([p for p in output_root.glob("*.zip") if p.is_file()])
|
|
159
|
+
for zp in legacy_zips[keep:]:
|
|
160
|
+
for f in _zip_set_files(zp):
|
|
161
|
+
items.append(CleanupItem(path=f, kind="zip-set", size_bytes=_size_of(f)))
|
|
162
|
+
|
|
163
|
+
# ── backups/<timestamp>/ ─────────────────────────────────────────────
|
|
164
|
+
backups_root = workspace / _BACKUPS_DIRNAME
|
|
165
|
+
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
|
+
)
|
|
171
|
+
|
|
172
|
+
# ── inbox/applied/ ───────────────────────────────────────────────────
|
|
173
|
+
applied_root = workspace / _INBOX_DIRNAME / _APPLIED_DIRNAME
|
|
174
|
+
if applied_root.is_dir():
|
|
175
|
+
applied_zips = _newest_first([p for p in applied_root.glob("*.zip") if p.is_file()])
|
|
176
|
+
retain = max(1, applied_zip_retention)
|
|
177
|
+
for zp in applied_zips[retain:]:
|
|
178
|
+
items.append(CleanupItem(path=zp, kind="applied-zip", size_bytes=_size_of(zp)))
|
|
179
|
+
|
|
180
|
+
return CleanupPlan(workspace=workspace, items=items)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Execute
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def execute(plan: CleanupPlan) -> CleanupResult:
|
|
189
|
+
"""
|
|
190
|
+
Delete everything in *plan*, immediately, no confirmation. Best-effort:
|
|
191
|
+
one failure doesn't stop the rest, and every failure is reported rather
|
|
192
|
+
than silently swallowed.
|
|
193
|
+
"""
|
|
194
|
+
result = CleanupResult()
|
|
195
|
+
for item in plan.items:
|
|
196
|
+
try:
|
|
197
|
+
if item.path.is_dir():
|
|
198
|
+
shutil.rmtree(item.path)
|
|
199
|
+
elif item.path.is_file():
|
|
200
|
+
item.path.unlink()
|
|
201
|
+
else:
|
|
202
|
+
continue # already gone — nothing to do, not a failure
|
|
203
|
+
result.removed.append(item.path)
|
|
204
|
+
result.freed_bytes += item.size_bytes
|
|
205
|
+
except OSError as exc:
|
|
206
|
+
result.failed.append((item.path, str(exc)))
|
|
207
|
+
return result
|