Skip to main content

packdiff

Pack a repository into a single, LLM-friendly Markdown file.

CI PyPI Python Dependencies License: MIT

English · 简体中文

You are about to paste a codebase into an LLM. Which files? How big is the prompt? Did you just include .env?

packdiff answers all three. It walks a repository, skips the noise and the credentials, and writes one clean Markdown document — directory tree, language overview, and every text file in a fenced code block — with a real token estimate in the header.

packdiff . -o context.md                # pack the current repo
packdiff --max-tokens 32k -o ctx.md     # ...and fit it in a 32k window
packdiff --diff -o review.md            # ...or just what you have not committed
packdiff src/ docs/ --include '*.py'    # ...or just two trees, Python only

Install

pip install packdiff

Or with pipx if you would rather not put it inside a virtualenv:

pipx install packdiff

Python 3.9 or newer. No dependencies — standard library only.

Quick start

cd ~/projects/my-service
packdiff
# packdiff · repository context pack

> Generated by packdiff v0.3.0 on 2026-10-13 10:24 UTC.
> Roots: `my-service`
> 5 files · 103 B · ~215 tokens (estimated)

## Overview

| Language | Files | Lines |
| --- | ---: | ---: |
| markdown | 1 | 3 |
| python | 2 | 3 |
| gitignore | 1 | 2 |
| text | 1 | 1 |

## Directory tree

```text
my-service/
├── src/
│   ├── __init__.py
│   └── core.py
├── .gitignore
├── README.md
└── requirements.txt
```

## Files

### `.gitignore`
```gitignore
.venv/
*.pyc
```

### `README.md`
```markdown
# Demo

A tiny service.
```

### `requirements.txt`
```text
requests>=2.31
```

### `src/__init__.py`
```python
from .core import main
```

### `src/core.py`
```python
def main():
    print("hi")
```

## Skipped as secret-like

Review these before sharing the pack; use `--include-secrets` to override.

- `.env`

That is the whole point: a document you can paste, diff, cache, and re-generate when the code changes.

What ends up in the pack

Included: every text file, including dotfiles such as .github/workflows/ci.yml and .gitignore.

Excluded by default:

Category Examples How to override
Ignored paths anything matched by .gitignore, nested ones included, plus .git/info/exclude --no-gitignore
Build noise node_modules/, dist/, build/, target/, __pycache__/, .venv/, vendor/, … --no-default-ignores
Lockfiles package-lock.json, poetry.lock, Cargo.lock, go.sum, … --no-default-ignores
Binary files images, archives, executables, fonts, PDFs, office documents, databases —
Binary content any file containing a NUL byte, or that fails to decode —
Large files text above --max-file-size (default 1 MiB) --max-file-size 8m
Empty files zero-byte leftovers —
Secret-like files .env, *.pem, *.key, id_rsa, .netrc, credentials.json, … --include-secrets

Every exclusion is reported on stderr, so nothing disappears silently:

$ packdiff
packdiff: 5 files, 103 B from my-service
packdiff: skipped .env: looks like a secret (pass --include-secrets)

Reviewing only what changed

The most common way to use this tool is not "here is my repository" but "here is what I just changed". --diff packs only the files that differ from a revision, and it includes uncommitted and untracked files, because a new file is part of the change:

packdiff --diff                      # what have I not committed yet?
packdiff --diff main                 # what does this branch add on top of main?
packdiff --diff HEAD~3 -o review.md  # the last three commits
packdiff src/ --diff                 # ...restricted to one subtree

It composes with everything else, so the change set still passes through .gitignore, the binary check and the secret filter:

packdiff --diff --max-tokens 16k | pbcopy

Deletion is handled the way you would hope: a deleted file appears in the change set from git, then fails to match anything on disk, so it simply is not packed. Nothing changed, or everything in the change set was filtered out, is a clear error rather than an empty pack:

$ packdiff --diff
packdiff: error: no files changed against 'HEAD'

Change detection shells out to git diff, so the answer is the same one git status just gave you — and if git is missing, or the revision is wrong, or you point --diff at a directory outside the repository, you get an error that says so instead of a silently empty pack.

A worked example

examples/demo-project.pack.md is a committed pack of a small demo project, so you can read the real output without installing anything. The demo directory is arranged to show the filters doing their job: cache/ and debug.log are gone via .gitignore, assets/logo.png is gone as binary, and .env.example survives because a template is not a secret.

python3 examples/regenerate.py    # rebuild it after changing the format

tests/test_examples.py regenerates the pack and compares it byte for byte against the committed file, so the example cannot quietly drift out of date.

Token budgets

--max-tokens keeps the pack inside a context window instead of blowing past it mid-paste.

packdiff --max-tokens 32k -o ctx.md

Files are admitted in a deliberate order: first the ones whose name suggests architecture — README, pyproject.toml / package.json, entry points such as main.py or index.ts — and then the rest largest-first, on the theory that a 400-line module tells you more about a repo than a 3-line __init__.py. Whatever does not fit is listed at the bottom of the document rather than dropped silently:

## Deferred by token budget

These files matched but did not fit; raise `--max-tokens` to include them.

- `src/packdiff/walk.py` (11.9 KiB, ~3,049 tokens)

A first pass holds back 3 % of the budget (at least 120 tokens) for the header and the tree. Per-file estimates cannot know how large those will turn out to be, though, so the finished document is then measured, and the weakest admitted file is deferred and the document re-rendered until the estimate actually fits. --max-tokens is a guarantee on the rendered output, not an aspiration.

How honest is the estimate?

There is no tokenizer in this repo, by design — a 200 MB wheel for a dependency-free CLI would be absurd. Instead the estimator charges ASCII characters at 1 / 4 tokens each (the familiar "~4 characters per token" rule) and non-ASCII characters at one token each, which covers CJK text properly instead of underestimating it fourfold. Expect to land within roughly ±10 % of a BPE tokenizer on ordinary source code. Tune it with --token-ratio if you know your model better than the rule of thumb does.

Ignore rules

Three layers, evaluated in this order:

  1. Built-in denylist — build artefacts and lockfiles (--no-default-ignores to disable).
  2. .gitignore — including nested files, each anchored to its own directory, and .git/info/exclude (--no-gitignore to disable).
  3. .packdiffignore — gitignore syntax, read from the root of the first path you pass, for exclusions you do not want to commit (--ignore-file to point elsewhere).

--include and --exclude accept the same glob syntax and are applied last:

packdiff --include '*.py'                     # only Python files
packdiff --include '*.py' --include '*.toml'  # repeatable, additive
packdiff --exclude 'tests/**' --exclude '*.snap'
packdiff --exclude 'src/**' --exclude '!src/public/**'   # negation re-includes

Supported gitignore syntax: # comments, \# / \! escapes, backslash escaping anywhere in a pattern (foo\ bar matches foo bar), trailing-space stripping, ! negation, trailing / for directory-only patterns, leading / for anchoring, *, ?, [a-z] within a path segment, and ** as a whole segment. A ** at the end of a pattern matches everything inside a directory but not the directory itself; anywhere else it matches zero or more directories. A pattern that matches a directory excludes everything inside it, with or without a trailing slash. Re-including a file whose parent directory is excluded does not work — that is git's rule too, and packdiff enforces it by pruning excluded directories rather than descending into them.

This is the one part of the tool where "close enough" would quietly produce wrong answers, so it ships with an oracle rather than only with unit tests. Run it against any repository you trust and it will tell you where packdiff and git disagree:

$ python3 tools/gitignore_oracle.py /path/to/repo
compared 301 paths: 0 mismatches

Across this repository, a synthetic corpus of edge cases, and the checked-out trees of Flask, Requests, Express and Click — over a thousand paths in total — it reported zero disagreements. It found three real bugs during development: escapes inside patterns, the trailing-** rule, and contents of a matched directory. All three now have tests that cite git as the authority.

Safety

The output of this tool is, by construction, something you paste into someone else's model. That makes the secret filter a feature, not a nicety: files such as .env, *.pem, *.key, id_rsa, .netrc and credentials.json are skipped by default and reported by name. Templates stay: .env.example, .env.sample, id_rsa.pub.

packdiff --include-secrets      # only when you really do want them

The filter is a heuristic. It is not a substitute for reading what you are about to send.

Command reference

Option Default Description
PATH... . Files or directories to pack. Multiple roots are merged into one document.
-o, --output FILE stdout Write the pack to FILE. - means stdout.
--max-tokens N unlimited Keep the pack under roughly N tokens. Accepts 32k, 1m.
--diff [REV] off Only pack files changed against REV (default HEAD), including uncommitted and untracked files.
--include GLOB — Only pack matching paths. Repeatable.
--exclude GLOB — Skip matching paths. Repeatable.
--ignore-file FILE .packdiffignore Extra ignore file, gitignore syntax.
--no-gitignore off Do not read .gitignore / .git/info/exclude.
--no-default-ignores off Do not apply the built-in denylist.
--include-secrets off Do not skip secret-like files.
--follow-symlinks off Descend into symlinked directories (loop-protected).
--max-file-size SIZE 1m Skip text files larger than SIZE. Accepts 512k, 8m.
--encoding ENC utf-8 Encoding used to read files.
--token-ratio R 4.0 ASCII characters per token used by the estimator.
--no-tree off Omit the directory tree.
--ascii off Use ASCII tree characters (`
--list off List the files that would be packed, without their content.
-q, --quiet off Suppress the stderr summary.
--version Print the version and exit.

Exit status: 0 success, 1 runtime error, 2 usage error.

Recipes

Into a prompt from the shell

packdiff src/ --max-tokens 16k | pbcopy            # macOS
packdiff src/ --max-tokens 16k | clip              # Windows

A code review pack — just the change, for pasting into a reviewer:

packdiff --diff --max-tokens 60k -o review.md       # uncommitted work
packdiff --diff main --max-tokens 60k -o review.md   # a whole branch

Implementation plus tests, nothing else:

packdiff src/ tests/ --include '*.py' --max-tokens 100k -o review.md

Reproducible output for CI artefacts or committed snapshots: set SOURCE_DATE_EPOCH and the timestamp in the header becomes deterministic.

SOURCE_DATE_EPOCH=0 packdiff . -o context.md

See the cost before you commit to it:

packdiff --list          # tokens and bytes per file
packdiff --list | sort -k2 -n | tail   # the heaviest files

Design notes

A few decisions worth knowing about, in case you want to change them.

  • No dependencies. Not even a TOML parser for config — .packdiffignore is gitignore syntax read by an engine that had to exist anyway.
  • Prune, don't filter. Excluded directories are never descended into, which keeps the walk fast on node_modules and matches git's own re-inclusion semantics for free.
  • Fences size themselves. A pack containing a Markdown file that talks about code fences would break with a fixed ```; the renderer emits one backtick longer than the longest run in the file.
  • Unicode output, ASCII fallback. If the output stream cannot encode box-drawing characters (a Windows console, say), the tree silently degrades to |-- / `--.
  • --diff asks git rather than guessing. Parsing the index by hand would be another thing to get wrong; git diff already knows, and the answer matches what the user saw in git status a second earlier.

Development

git clone https://github.com/muguang1224/packdiff
cd packdiff
python -m pip install -e ".[dev]"

python -m pytest            # the full suite; no network, no fixtures on disk
ruff check . && ruff format --check .

# Optional: check the ignore engine against real git (needs git on PATH)
python3 tools/gitignore_oracle.py .

# Optional: rebuild the committed example pack
python3 examples/regenerate.py

The test suite builds its fixtures in temporary directories, so it runs on Linux, macOS and Windows without any setup.

License

MIT — see LICENSE.

Metadata

Release files for packdiff 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for packdiff 0.3.0
File Size Uploaded
packdiff-0.3.0.tar.gz 57.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for packdiff 0.3.0
File Interpreter ABI Platform
packdiff-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 90.1 kB

Release files / packdiff-0.3.0.tar.gz

Download URL packdiff-0.3.0.tar.gz
Size 57.1 kB
Tags Source
SHA-256 checksum
How to use checksums
c08378f1ca6230681b4a0ca96695fbe173301bf852381d0087791997797a8f7c
BLAKE2b-256 checksum
How to use checksums
1b49adce2df1d83f00cac21da31745bda95454efb7aeefd5a1c335355d0442da
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log

Release files / packdiff-0.3.0-py3-none-any.whl

Download URL packdiff-0.3.0-py3-none-any.whl
Size 33.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bce1ef0d4db2cdfd8c6a90e65d25fcb4f818e1872960cd5c5787f885999e3f86
BLAKE2b-256 checksum
How to use checksums
0b4b9dda46fd6355122bee9796c8ec5113d8a4a2dad804af08a43475f72ece58
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page