codecrate 0.1.1__tar.gz → 0.1.2__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.

Potentially problematic release.


This version of codecrate might be problematic. Click here for more details.

Files changed (65) hide show
  1. {codecrate-0.1.1/codecrate.egg-info → codecrate-0.1.2}/PKG-INFO +1 -1
  2. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/_version.py +3 -3
  3. codecrate-0.1.2/codecrate/cli.py +433 -0
  4. {codecrate-0.1.1 → codecrate-0.1.2/codecrate.egg-info}/PKG-INFO +1 -1
  5. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate.egg-info/SOURCES.txt +1 -0
  6. {codecrate-0.1.1 → codecrate-0.1.2}/docs/cli.rst +15 -6
  7. {codecrate-0.1.1 → codecrate-0.1.2}/docs/quickstart.rst +6 -0
  8. codecrate-0.1.2/tests/test_cli_pack_multi.py +39 -0
  9. codecrate-0.1.1/codecrate/cli.py +0 -251
  10. {codecrate-0.1.1 → codecrate-0.1.2}/.github/pytest.ini +0 -0
  11. {codecrate-0.1.1 → codecrate-0.1.2}/.github/workflows/codecov.yml +0 -0
  12. {codecrate-0.1.1 → codecrate-0.1.2}/.github/workflows/pre-commit.yml +0 -0
  13. {codecrate-0.1.1 → codecrate-0.1.2}/.github/workflows/python-publish.yml +0 -0
  14. {codecrate-0.1.1 → codecrate-0.1.2}/.github/workflows/tests.yml +0 -0
  15. {codecrate-0.1.1 → codecrate-0.1.2}/.gitignore +0 -0
  16. {codecrate-0.1.1 → codecrate-0.1.2}/.pre-commit-config.yaml +0 -0
  17. {codecrate-0.1.1 → codecrate-0.1.2}/.readthedocs.yaml +0 -0
  18. {codecrate-0.1.1 → codecrate-0.1.2}/.ruff.toml +0 -0
  19. {codecrate-0.1.1 → codecrate-0.1.2}/AGENTS.md +0 -0
  20. {codecrate-0.1.1 → codecrate-0.1.2}/LICENSE +0 -0
  21. {codecrate-0.1.1 → codecrate-0.1.2}/README.md +0 -0
  22. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/__init__.py +0 -0
  23. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/config.py +0 -0
  24. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/diffgen.py +0 -0
  25. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/discover.py +0 -0
  26. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/ids.py +0 -0
  27. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/manifest.py +0 -0
  28. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/markdown.py +0 -0
  29. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/mdparse.py +0 -0
  30. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/model.py +0 -0
  31. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/packer.py +0 -0
  32. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/parse.py +0 -0
  33. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/stubber.py +0 -0
  34. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/token_budget.py +0 -0
  35. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/udiff.py +0 -0
  36. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/unpacker.py +0 -0
  37. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate/validate.py +0 -0
  38. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate.egg-info/dependency_links.txt +0 -0
  39. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate.egg-info/entry_points.txt +0 -0
  40. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate.egg-info/requires.txt +0 -0
  41. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate.egg-info/top_level.txt +0 -0
  42. {codecrate-0.1.1 → codecrate-0.1.2}/codecrate.toml +0 -0
  43. {codecrate-0.1.1 → codecrate-0.1.2}/docs/api.rst +0 -0
  44. {codecrate-0.1.1 → codecrate-0.1.2}/docs/conf.py +0 -0
  45. {codecrate-0.1.1 → codecrate-0.1.2}/docs/format.rst +0 -0
  46. {codecrate-0.1.1 → codecrate-0.1.2}/docs/index.rst +0 -0
  47. {codecrate-0.1.1 → codecrate-0.1.2}/docs/make.bat +0 -0
  48. {codecrate-0.1.1 → codecrate-0.1.2}/docs/make.py +0 -0
  49. {codecrate-0.1.1 → codecrate-0.1.2}/docs/requirements.txt +0 -0
  50. {codecrate-0.1.1 → codecrate-0.1.2}/pyproject.toml +0 -0
  51. {codecrate-0.1.1 → codecrate-0.1.2}/requirements-test.txt +0 -0
  52. {codecrate-0.1.1 → codecrate-0.1.2}/setup.cfg +0 -0
  53. {codecrate-0.1.1 → codecrate-0.1.2}/setup.py +0 -0
  54. {codecrate-0.1.1 → codecrate-0.1.2}/tests/__init__.py +0 -0
  55. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_config.py +0 -0
  56. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_discover.py +0 -0
  57. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_ids.py +0 -0
  58. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_markdown_line_numbers.py +0 -0
  59. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_model.py +0 -0
  60. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_pack_unpack_roundtrip.py +0 -0
  61. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_parse.py +0 -0
  62. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_patch_apply_roundtrip.py +0 -0
  63. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_smoke.py +0 -0
  64. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_split_codecrate_pack.py +0 -0
  65. {codecrate-0.1.1 → codecrate-0.1.2}/tests/test_token_budget.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecrate
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Pack Python codebases into Markdown optimized for LLM context delivery (pack/unpack/patch/apply)
5
5
  Author-email: Holger Nahrstaedt <nahrstaedt@gmail.com>
6
6
  License: MIT License
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.1'
32
- __version_tuple__ = version_tuple = (0, 1, 1)
31
+ __version__ = version = '0.1.2'
32
+ __version_tuple__ = version_tuple = (0, 1, 2)
33
33
 
34
- __commit_id__ = commit_id = 'g4591edb4c'
34
+ __commit_id__ = commit_id = 'g3ede7bcd7'
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .config import Config, load_config
8
+ from .diffgen import generate_patch_markdown
9
+ from .discover import discover_files
10
+ from .markdown import render_markdown
11
+ from .packer import pack_repo
12
+ from .token_budget import split_by_max_chars
13
+ from .udiff import apply_file_diffs, parse_unified_diff
14
+ from .unpacker import unpack_to_dir
15
+ from .validate import validate_pack_markdown
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ p = argparse.ArgumentParser(
20
+ prog="codecrate",
21
+ description="Pack/unpack/patch/apply for repositories (Python + text files).",
22
+ )
23
+ sub = p.add_subparsers(dest="cmd", required=True)
24
+
25
+ # pack
26
+ pack = sub.add_parser(
27
+ "pack", help="Pack one or more repositories/directories into Markdown."
28
+ )
29
+ pack.add_argument(
30
+ "root",
31
+ type=Path,
32
+ nargs="?",
33
+ help="Root directory to scan (omit when using --repo)",
34
+ )
35
+ pack.add_argument(
36
+ "--repo",
37
+ action="append",
38
+ default=None,
39
+ type=Path,
40
+ help="Additional repo root to pack (repeatable; use instead of ROOT)",
41
+ )
42
+ pack.add_argument(
43
+ "-o",
44
+ "--output",
45
+ type=Path,
46
+ default=None,
47
+ help="Output markdown path (default: config 'output' or context.md)",
48
+ )
49
+ pack.add_argument(
50
+ "--dedupe", action="store_true", help="Deduplicate identical function bodies"
51
+ )
52
+ pack.add_argument(
53
+ "--layout",
54
+ choices=["auto", "stubs", "full"],
55
+ default=None,
56
+ help="Output layout: auto|stubs|full (default: auto via config)",
57
+ )
58
+ pack.add_argument(
59
+ "--keep-docstrings",
60
+ action=argparse.BooleanOptionalAction,
61
+ default=None,
62
+ help="Keep docstrings in stubbed file view (default: true via config)",
63
+ )
64
+ pack.add_argument(
65
+ "--respect-gitignore",
66
+ action=argparse.BooleanOptionalAction,
67
+ default=None,
68
+ help="Respect .gitignore (default: true via config)",
69
+ )
70
+ pack.add_argument(
71
+ "--manifest",
72
+ action=argparse.BooleanOptionalAction,
73
+ default=None,
74
+ help="Include Manifest section (default: true via config)",
75
+ )
76
+ pack.add_argument(
77
+ "--include", action="append", default=None, help="Include glob (repeatable)"
78
+ )
79
+ pack.add_argument(
80
+ "--exclude", action="append", default=None, help="Exclude glob (repeatable)"
81
+ )
82
+ pack.add_argument(
83
+ "--split-max-chars",
84
+ type=int,
85
+ default=None,
86
+ help="Split output into .partN.md files",
87
+ )
88
+
89
+ # unpack
90
+ unpack = sub.add_parser(
91
+ "unpack", help="Reconstruct files from a packed context Markdown."
92
+ )
93
+ unpack.add_argument("markdown", type=Path, help="Packed Markdown file from `pack`")
94
+ unpack.add_argument(
95
+ "-o",
96
+ "--out-dir",
97
+ type=Path,
98
+ required=True,
99
+ help="Output directory for reconstructed files",
100
+ )
101
+
102
+ # patch
103
+ patch = sub.add_parser(
104
+ "patch",
105
+ help="Generate a diff-only patch Markdown from old pack + current repo.",
106
+ )
107
+ patch.add_argument(
108
+ "old_markdown", type=Path, help="Older packed Markdown (baseline)"
109
+ )
110
+ patch.add_argument("root", type=Path, help="Current repo root to compare against")
111
+ patch.add_argument(
112
+ "-o",
113
+ "--output",
114
+ type=Path,
115
+ default=Path("patch.md"),
116
+ help="Output patch markdown",
117
+ )
118
+
119
+ # apply
120
+ apply = sub.add_parser("apply", help="Apply a diff-only patch Markdown to a repo.")
121
+ apply.add_argument(
122
+ "patch_markdown", type=Path, help="Patch Markdown containing ```diff blocks"
123
+ )
124
+ apply.add_argument("root", type=Path, help="Repo root to apply patch to")
125
+ # validate-pack
126
+ vpack = sub.add_parser(
127
+ "validate-pack",
128
+ help="Validate a packed context Markdown (sha/markers/canonical consistency).",
129
+ )
130
+ vpack.add_argument("markdown", type=Path, help="Packed Markdown to validate")
131
+ vpack.add_argument(
132
+ "--root",
133
+ type=Path,
134
+ default=None,
135
+ help="Optional repo root to compare reconstructed files against",
136
+ )
137
+
138
+ return p
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class PackOptions:
143
+ include: list[str] | None
144
+ exclude: list[str] | None
145
+ keep_docstrings: bool
146
+ include_manifest: bool
147
+ respect_gitignore: bool
148
+ dedupe: bool
149
+ split_max_chars: int
150
+ layout: str
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class PackRun:
155
+ root: Path
156
+ label: str
157
+ slug: str
158
+ markdown: str
159
+ options: PackOptions
160
+ default_output: Path
161
+
162
+
163
+ def _resolve_pack_options(cfg: Config, args: argparse.Namespace) -> PackOptions:
164
+ include = args.include if args.include is not None else cfg.include
165
+ exclude = args.exclude if args.exclude is not None else cfg.exclude
166
+ keep_docstrings = (
167
+ cfg.keep_docstrings
168
+ if args.keep_docstrings is None
169
+ else bool(args.keep_docstrings)
170
+ )
171
+ include_manifest = cfg.manifest if args.manifest is None else bool(args.manifest)
172
+ respect_gitignore = (
173
+ cfg.respect_gitignore
174
+ if args.respect_gitignore is None
175
+ else bool(args.respect_gitignore)
176
+ )
177
+ dedupe = bool(args.dedupe) or bool(cfg.dedupe)
178
+ split_max_chars = (
179
+ cfg.split_max_chars
180
+ if args.split_max_chars is None
181
+ else int(args.split_max_chars or 0)
182
+ )
183
+ layout = (
184
+ str(args.layout).strip().lower()
185
+ if args.layout is not None
186
+ else str(getattr(cfg, "layout", "auto")).strip().lower()
187
+ )
188
+ return PackOptions(
189
+ include=include,
190
+ exclude=exclude,
191
+ keep_docstrings=keep_docstrings,
192
+ include_manifest=include_manifest,
193
+ respect_gitignore=respect_gitignore,
194
+ dedupe=dedupe,
195
+ split_max_chars=split_max_chars,
196
+ layout=layout,
197
+ )
198
+
199
+
200
+ def _resolve_output_path(cfg: Config, args: argparse.Namespace, root: Path) -> Path:
201
+ if args.output is not None:
202
+ return args.output
203
+ out_path = Path(getattr(cfg, "output", "context.md"))
204
+ if not out_path.is_absolute():
205
+ out_path = root / out_path
206
+ return out_path
207
+
208
+
209
+ def _default_repo_label(root: Path) -> str:
210
+ cwd = Path.cwd().resolve()
211
+ resolved = root.resolve()
212
+ try:
213
+ rel = resolved.relative_to(cwd).as_posix()
214
+ return rel or resolved.name or resolved.as_posix()
215
+ except ValueError:
216
+ return root.name or resolved.name or resolved.as_posix()
217
+
218
+
219
+ def _unique_label(root: Path, used: set[str]) -> str:
220
+ base = _default_repo_label(root)
221
+ label = base
222
+ idx = 2
223
+ while label in used:
224
+ label = f"{base}-{idx}"
225
+ idx += 1
226
+ used.add(label)
227
+ return label
228
+
229
+
230
+ def _slugify(label: str) -> str:
231
+ safe: list[str] = []
232
+ for ch in label:
233
+ if ch.isalnum() or ch in {"-", "_"}:
234
+ safe.append(ch)
235
+ else:
236
+ safe.append("-")
237
+ slug = "".join(safe).strip("-")
238
+ while "--" in slug:
239
+ slug = slug.replace("--", "-")
240
+ return slug or "repo"
241
+
242
+
243
+ def _unique_slug(label: str, used: set[str]) -> str:
244
+ base = _slugify(label)
245
+ slug = base
246
+ idx = 2
247
+ while slug in used:
248
+ slug = f"{base}-{idx}"
249
+ idx += 1
250
+ used.add(slug)
251
+ return slug
252
+
253
+
254
+ def _prefix_repo_header(text: str, label: str) -> str:
255
+ header = f"# Repository: {label}\n\n"
256
+ if text.startswith(header):
257
+ return text
258
+ return header + text
259
+
260
+
261
+ def _combine_pack_markdown(packs: list[PackRun]) -> str:
262
+ out: list[str] = []
263
+ for i, pack in enumerate(packs):
264
+ if i:
265
+ out.append("\n\n")
266
+ out.append(_prefix_repo_header(pack.markdown.rstrip() + "\n", pack.label))
267
+ return "".join(out).rstrip() + "\n"
268
+
269
+
270
+ def _extract_diff_blocks(md_text: str) -> str:
271
+ """
272
+ Extract only diff fences from markdown and concatenate to a unified diff string.
273
+ """
274
+ lines = md_text.splitlines()
275
+ out: list[str] = []
276
+ i = 0
277
+ while i < len(lines):
278
+ if lines[i].strip() == "```diff":
279
+ i += 1
280
+ while i < len(lines) and lines[i].strip() != "```":
281
+ out.append(lines[i])
282
+ i += 1
283
+ i += 1
284
+ return "\n".join(out) + "\n"
285
+
286
+
287
+ def main(argv: list[str] | None = None) -> None: # noqa: C901
288
+ parser = build_parser()
289
+ args = parser.parse_args(argv)
290
+
291
+ if args.cmd == "pack":
292
+ if args.repo:
293
+ if args.root is not None:
294
+ parser.error(
295
+ "pack: specify either ROOT or --repo (repeatable), not both"
296
+ )
297
+ roots = [r.resolve() for r in args.repo]
298
+ else:
299
+ if args.root is None:
300
+ parser.error("pack: ROOT is required when --repo is not used")
301
+ roots = [args.root.resolve()]
302
+
303
+ used_labels: set[str] = set()
304
+ used_slugs: set[str] = set()
305
+ pack_runs: list[PackRun] = []
306
+
307
+ for root in roots:
308
+ cfg = load_config(root)
309
+ options = _resolve_pack_options(cfg, args)
310
+ label = _unique_label(root, used_labels)
311
+ slug = _unique_slug(label, used_slugs)
312
+
313
+ disc = discover_files(
314
+ root=root,
315
+ include=options.include,
316
+ exclude=options.exclude,
317
+ respect_gitignore=options.respect_gitignore,
318
+ )
319
+ pack, canonical = pack_repo(
320
+ disc.root,
321
+ disc.files,
322
+ keep_docstrings=options.keep_docstrings,
323
+ dedupe=options.dedupe,
324
+ )
325
+ md = render_markdown(
326
+ pack,
327
+ canonical,
328
+ layout=options.layout,
329
+ include_manifest=options.include_manifest,
330
+ )
331
+ default_output = _resolve_output_path(cfg, args, root)
332
+ pack_runs.append(
333
+ PackRun(
334
+ root=root,
335
+ label=label,
336
+ slug=slug,
337
+ markdown=md,
338
+ options=options,
339
+ default_output=default_output,
340
+ )
341
+ )
342
+
343
+ out_path = (
344
+ args.output if args.output is not None else pack_runs[0].default_output
345
+ )
346
+ if len(pack_runs) == 1:
347
+ md = pack_runs[0].markdown
348
+ else:
349
+ md = _combine_pack_markdown(pack_runs)
350
+
351
+ # Always write the canonical, unsplit pack
352
+ # for machine parsing (unpack/validate).
353
+ out_path.write_text(md, encoding="utf-8")
354
+
355
+ extra_count = 0
356
+ if len(pack_runs) == 1:
357
+ split_max_chars = pack_runs[0].options.split_max_chars
358
+ parts = split_by_max_chars(md, out_path, split_max_chars)
359
+ extra = [p for p in parts if p.path != out_path]
360
+ for part in extra:
361
+ part.path.write_text(part.content, encoding="utf-8")
362
+ extra_count += len(extra)
363
+ else:
364
+ for pack in pack_runs:
365
+ if pack.options.split_max_chars <= 0:
366
+ continue
367
+ repo_base = out_path.with_name(
368
+ f"{out_path.stem}.{pack.slug}{out_path.suffix}"
369
+ )
370
+ parts = split_by_max_chars(
371
+ pack.markdown, repo_base, pack.options.split_max_chars
372
+ )
373
+ extra = [p for p in parts if p.path != repo_base]
374
+ for part in extra:
375
+ content = _prefix_repo_header(part.content, pack.label)
376
+ part.path.write_text(content, encoding="utf-8")
377
+ extra_count += len(extra)
378
+
379
+ if extra_count:
380
+ if len(pack_runs) == 1:
381
+ print(f"Wrote {out_path} and {extra_count} split part file(s).")
382
+ else:
383
+ print(
384
+ f"Wrote {out_path} and {extra_count} split part file(s) for "
385
+ f"{len(pack_runs)} repos."
386
+ )
387
+ else:
388
+ if len(pack_runs) == 1:
389
+ print(f"Wrote {out_path}.")
390
+ else:
391
+ print(f"Wrote {out_path} for {len(pack_runs)} repos.")
392
+ elif args.cmd == "unpack":
393
+ md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
394
+ unpack_to_dir(md_text, args.out_dir)
395
+ print(f"Unpacked into {args.out_dir}")
396
+
397
+ elif args.cmd == "patch":
398
+ old_md = args.old_markdown.read_text(encoding="utf-8", errors="replace")
399
+ cfg = load_config(args.root)
400
+ patch_md = generate_patch_markdown(
401
+ old_md,
402
+ args.root,
403
+ include=cfg.include,
404
+ exclude=cfg.exclude,
405
+ respect_gitignore=cfg.respect_gitignore,
406
+ )
407
+ args.output.write_text(patch_md, encoding="utf-8")
408
+ print(f"Wrote {args.output}")
409
+
410
+ elif args.cmd == "validate-pack":
411
+ md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
412
+ report = validate_pack_markdown(md_text, root=args.root)
413
+ if report.warnings:
414
+ print("Warnings:")
415
+ for w in report.warnings:
416
+ print(f"- {w}")
417
+ if report.errors:
418
+ print("Errors:")
419
+ for e in report.errors:
420
+ print(f"- {e}")
421
+ raise SystemExit(1)
422
+ print("OK: pack is internally consistent.")
423
+
424
+ elif args.cmd == "apply":
425
+ md_text = args.patch_markdown.read_text(encoding="utf-8", errors="replace")
426
+ diff_text = _extract_diff_blocks(md_text)
427
+ diffs = parse_unified_diff(diff_text)
428
+ changed = apply_file_diffs(diffs, args.root)
429
+ print(f"Applied patch to {len(changed)} file(s).")
430
+
431
+
432
+ if __name__ == "__main__":
433
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecrate
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Pack Python codebases into Markdown optimized for LLM context delivery (pack/unpack/patch/apply)
5
5
  Author-email: Holger Nahrstaedt <nahrstaedt@gmail.com>
6
6
  License: MIT License
@@ -48,6 +48,7 @@ docs/make.py
48
48
  docs/quickstart.rst
49
49
  docs/requirements.txt
50
50
  tests/__init__.py
51
+ tests/test_cli_pack_multi.py
51
52
  tests/test_config.py
52
53
  tests/test_discover.py
53
54
  tests/test_ids.py
@@ -26,7 +26,7 @@ Overview
26
26
 
27
27
  .. code-block:: console
28
28
 
29
- codecrate pack ROOT [options]
29
+ codecrate pack [ROOT] [--repo REPO ...] [options]
30
30
  codecrate unpack PACK.md -o OUT_DIR
31
31
  codecrate patch OLD_PACK.md ROOT [-o patch.md]
32
32
  codecrate apply PATCH.md ROOT
@@ -36,11 +36,14 @@ Overview
36
36
  pack
37
37
  ----
38
38
 
39
- Create a packed Markdown context file from a repository.
39
+ Create a packed Markdown context file from one or more repositories.
40
40
 
41
41
  .. code-block:: console
42
42
 
43
43
  codecrate pack . -o context.md
44
+ codecrate pack --repo /path/to/repo1 --repo /path/to/repo2 -o multi.md
45
+
46
+ When using ``--repo``, omit the positional ``ROOT``. Specifying both is an error.
44
47
 
45
48
  Useful flags:
46
49
 
@@ -52,7 +55,8 @@ Useful flags:
52
55
  * ``--include GLOB`` (repeatable): include patterns
53
56
  * ``--exclude GLOB`` (repeatable): exclude patterns
54
57
  * ``--split-max-chars N``: additionally emit ``.partN.md`` files for LLMs (the
55
- main output stays
58
+ main output stays unsplit). For multi-repo packs, parts are named
59
+ ``output.<repo>.partN.md``
56
60
  * ``-o/--output PATH``: output path (defaults to config ``output`` or ``context.md``)
57
61
 
58
62
 
@@ -107,7 +111,7 @@ Overview
107
111
 
108
112
  .. code-block:: console
109
113
 
110
- codecrate pack ROOT [options]
114
+ codecrate pack [ROOT] [--repo REPO ...] [options]
111
115
  codecrate unpack PACK.md -o OUT_DIR
112
116
  codecrate patch OLD_PACK.md ROOT [-o patch.md]
113
117
  codecrate apply PATCH.md ROOT
@@ -117,11 +121,14 @@ Overview
117
121
  pack
118
122
  ----
119
123
 
120
- Create a packed Markdown context file from a repository.
124
+ Create a packed Markdown context file from one or more repositories.
121
125
 
122
126
  .. code-block:: console
123
127
 
124
128
  codecrate pack . -o context.md
129
+ codecrate pack --repo /path/to/repo1 --repo /path/to/repo2 -o multi.md
130
+
131
+ When using ``--repo``, omit the positional ``ROOT``. Specifying both is an error.
125
132
 
126
133
  Useful flags:
127
134
 
@@ -131,7 +138,9 @@ Useful flags:
131
138
  * ``--respect-gitignore / --no-respect-gitignore``: include ignored files or not
132
139
  * ``--include GLOB`` (repeatable): include patterns
133
140
  * ``--exclude GLOB`` (repeatable): exclude patterns
134
- * ``--split-max-chars N``: split output into parts
141
+ * ``--split-max-chars N``: additionally emit ``.partN.md`` files for LLMs (the
142
+ main output stays unsplit). For multi-repo packs, parts are named
143
+ ``output.<repo>.partN.md``
135
144
 
136
145
 
137
146
  unpack
@@ -41,6 +41,12 @@ Pack a repository into ``context.md``:
41
41
 
42
42
  codecrate pack /path/to/repo -o context.md
43
43
 
44
+ Pack multiple repositories into a single output:
45
+
46
+ .. code-block:: console
47
+
48
+ codecrate pack --repo /path/to/repo1 --repo /path/to/repo2 -o multi.md
49
+
44
50
  Common options:
45
51
 
46
52
  * ``--dedupe``: deduplicate identical function bodies (enables stub layout when effective)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from codecrate.cli import main
8
+
9
+
10
+ def _write_repo(root: Path, filename: str, content: str) -> None:
11
+ root.mkdir()
12
+ (root / filename).write_text(content, encoding="utf-8")
13
+
14
+
15
+ def test_pack_multi_repos(tmp_path: Path) -> None:
16
+ repo1 = tmp_path / "repo1"
17
+ repo2 = tmp_path / "repo2"
18
+ _write_repo(repo1, "a.py", "def alpha():\n return 1\n")
19
+ _write_repo(repo2, "b.py", "def beta():\n return 2\n")
20
+
21
+ out_path = tmp_path / "multi.md"
22
+ main(["pack", "--repo", str(repo1), "--repo", str(repo2), "-o", str(out_path)])
23
+
24
+ text = out_path.read_text(encoding="utf-8")
25
+ assert "# Repository: repo1" in text
26
+ assert "# Repository: repo2" in text
27
+ assert "def alpha()" in text
28
+ assert "def beta()" in text
29
+
30
+
31
+ def test_pack_rejects_root_and_repo(tmp_path: Path) -> None:
32
+ repo = tmp_path / "repo"
33
+ _write_repo(repo, "a.py", "def alpha():\n return 1\n")
34
+
35
+ out_path = tmp_path / "multi.md"
36
+ with pytest.raises(SystemExit) as excinfo:
37
+ main(["pack", str(repo), "--repo", str(repo), "-o", str(out_path)])
38
+
39
+ assert excinfo.value.code == 2
@@ -1,251 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- from pathlib import Path
5
-
6
- from .config import load_config
7
- from .diffgen import generate_patch_markdown
8
- from .discover import discover_files
9
- from .markdown import render_markdown
10
- from .packer import pack_repo
11
- from .token_budget import split_by_max_chars
12
- from .udiff import apply_file_diffs, parse_unified_diff
13
- from .unpacker import unpack_to_dir
14
- from .validate import validate_pack_markdown
15
-
16
-
17
- def build_parser() -> argparse.ArgumentParser:
18
- p = argparse.ArgumentParser(
19
- prog="codecrate",
20
- description="Pack/unpack/patch/apply for repositories (Python + text files).",
21
- )
22
- sub = p.add_subparsers(dest="cmd", required=True)
23
-
24
- # pack
25
- pack = sub.add_parser("pack", help="Pack a repository/directory into Markdown.")
26
- pack.add_argument("root", type=Path, help="Root directory to scan")
27
- pack.add_argument(
28
- "-o",
29
- "--output",
30
- type=Path,
31
- default=None,
32
- help="Output markdown path (default: config 'output' or context.md)",
33
- )
34
- pack.add_argument(
35
- "--dedupe", action="store_true", help="Deduplicate identical function bodies"
36
- )
37
- pack.add_argument(
38
- "--layout",
39
- choices=["auto", "stubs", "full"],
40
- default=None,
41
- help="Output layout: auto|stubs|full (default: auto via config)",
42
- )
43
- pack.add_argument(
44
- "--keep-docstrings",
45
- action=argparse.BooleanOptionalAction,
46
- default=None,
47
- help="Keep docstrings in stubbed file view (default: true via config)",
48
- )
49
- pack.add_argument(
50
- "--respect-gitignore",
51
- action=argparse.BooleanOptionalAction,
52
- default=None,
53
- help="Respect .gitignore (default: true via config)",
54
- )
55
- pack.add_argument(
56
- "--manifest",
57
- action=argparse.BooleanOptionalAction,
58
- default=None,
59
- help="Include Manifest section (default: true via config)",
60
- )
61
- pack.add_argument(
62
- "--include", action="append", default=None, help="Include glob (repeatable)"
63
- )
64
- pack.add_argument(
65
- "--exclude", action="append", default=None, help="Exclude glob (repeatable)"
66
- )
67
- pack.add_argument(
68
- "--split-max-chars",
69
- type=int,
70
- default=None,
71
- help="Split output into .partN.md files",
72
- )
73
-
74
- # unpack
75
- unpack = sub.add_parser(
76
- "unpack", help="Reconstruct files from a packed context Markdown."
77
- )
78
- unpack.add_argument("markdown", type=Path, help="Packed Markdown file from `pack`")
79
- unpack.add_argument(
80
- "-o",
81
- "--out-dir",
82
- type=Path,
83
- required=True,
84
- help="Output directory for reconstructed files",
85
- )
86
-
87
- # patch
88
- patch = sub.add_parser(
89
- "patch",
90
- help="Generate a diff-only patch Markdown from old pack + current repo.",
91
- )
92
- patch.add_argument(
93
- "old_markdown", type=Path, help="Older packed Markdown (baseline)"
94
- )
95
- patch.add_argument("root", type=Path, help="Current repo root to compare against")
96
- patch.add_argument(
97
- "-o",
98
- "--output",
99
- type=Path,
100
- default=Path("patch.md"),
101
- help="Output patch markdown",
102
- )
103
-
104
- # apply
105
- apply = sub.add_parser("apply", help="Apply a diff-only patch Markdown to a repo.")
106
- apply.add_argument(
107
- "patch_markdown", type=Path, help="Patch Markdown containing ```diff blocks"
108
- )
109
- apply.add_argument("root", type=Path, help="Repo root to apply patch to")
110
- # validate-pack
111
- vpack = sub.add_parser(
112
- "validate-pack",
113
- help="Validate a packed context Markdown (sha/markers/canonical consistency).",
114
- )
115
- vpack.add_argument("markdown", type=Path, help="Packed Markdown to validate")
116
- vpack.add_argument(
117
- "--root",
118
- type=Path,
119
- default=None,
120
- help="Optional repo root to compare reconstructed files against",
121
- )
122
-
123
- return p
124
-
125
-
126
- def _extract_diff_blocks(md_text: str) -> str:
127
- """
128
- Extract only diff fences from markdown and concatenate to a unified diff string.
129
- """
130
- lines = md_text.splitlines()
131
- out: list[str] = []
132
- i = 0
133
- while i < len(lines):
134
- if lines[i].strip() == "```diff":
135
- i += 1
136
- while i < len(lines) and lines[i].strip() != "```":
137
- out.append(lines[i])
138
- i += 1
139
- i += 1
140
- return "\n".join(out) + "\n"
141
-
142
-
143
- def main(argv: list[str] | None = None) -> None:
144
- parser = build_parser()
145
- args = parser.parse_args(argv)
146
-
147
- if args.cmd == "pack":
148
- root: Path = args.root.resolve()
149
- cfg = load_config(root)
150
-
151
- include = args.include if args.include is not None else cfg.include
152
- exclude = args.exclude if args.exclude is not None else cfg.exclude
153
-
154
- keep_docstrings = (
155
- cfg.keep_docstrings
156
- if args.keep_docstrings is None
157
- else bool(args.keep_docstrings)
158
- )
159
- include_manifest = (
160
- cfg.manifest if args.manifest is None else bool(args.manifest)
161
- )
162
- respect_gitignore = (
163
- cfg.respect_gitignore
164
- if args.respect_gitignore is None
165
- else bool(args.respect_gitignore)
166
- )
167
- dedupe = bool(args.dedupe) or bool(cfg.dedupe)
168
- split_max_chars = (
169
- cfg.split_max_chars
170
- if args.split_max_chars is None
171
- else int(args.split_max_chars or 0)
172
- )
173
- layout = (
174
- str(args.layout).strip().lower()
175
- if args.layout is not None
176
- else str(getattr(cfg, "layout", "auto")).strip().lower()
177
- )
178
- if args.output is not None:
179
- out_path = args.output
180
- else:
181
- out_path = Path(getattr(cfg, "output", "context.md"))
182
- if not out_path.is_absolute():
183
- out_path = root / out_path
184
- disc = discover_files(
185
- root=root,
186
- include=include,
187
- exclude=exclude,
188
- respect_gitignore=respect_gitignore,
189
- )
190
- pack, canonical = pack_repo(
191
- disc.root, disc.files, keep_docstrings=keep_docstrings, dedupe=dedupe
192
- )
193
- md = render_markdown(
194
- pack, canonical, layout=layout, include_manifest=include_manifest
195
- )
196
- # Always write the canonical, unsplit pack
197
- # for machine parsing (unpack/validate).
198
- out_path.write_text(md, encoding="utf-8")
199
-
200
- # Additionally, write split parts for LLM consumption, if requested.
201
- parts = split_by_max_chars(md, out_path, split_max_chars)
202
- extra = [p for p in parts if p.path != out_path]
203
- for part in extra:
204
- part.path.write_text(part.content, encoding="utf-8")
205
-
206
- if extra:
207
- print(f"Wrote {out_path} and {len(extra)} split part file(s).")
208
- else:
209
- print(f"Wrote {out_path}.")
210
- elif args.cmd == "unpack":
211
- md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
212
- unpack_to_dir(md_text, args.out_dir)
213
- print(f"Unpacked into {args.out_dir}")
214
-
215
- elif args.cmd == "patch":
216
- old_md = args.old_markdown.read_text(encoding="utf-8", errors="replace")
217
- cfg = load_config(args.root)
218
- patch_md = generate_patch_markdown(
219
- old_md,
220
- args.root,
221
- include=cfg.include,
222
- exclude=cfg.exclude,
223
- respect_gitignore=cfg.respect_gitignore,
224
- )
225
- args.output.write_text(patch_md, encoding="utf-8")
226
- print(f"Wrote {args.output}")
227
-
228
- elif args.cmd == "validate-pack":
229
- md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
230
- report = validate_pack_markdown(md_text, root=args.root)
231
- if report.warnings:
232
- print("Warnings:")
233
- for w in report.warnings:
234
- print(f"- {w}")
235
- if report.errors:
236
- print("Errors:")
237
- for e in report.errors:
238
- print(f"- {e}")
239
- raise SystemExit(1)
240
- print("OK: pack is internally consistent.")
241
-
242
- elif args.cmd == "apply":
243
- md_text = args.patch_markdown.read_text(encoding="utf-8", errors="replace")
244
- diff_text = _extract_diff_blocks(md_text)
245
- diffs = parse_unified_diff(diff_text)
246
- changed = apply_file_diffs(diffs, args.root)
247
- print(f"Applied patch to {len(changed)} file(s).")
248
-
249
-
250
- if __name__ == "__main__":
251
- main()
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